mirror of
https://github.com/langgenius/dify.git
synced 2026-05-19 08:17:14 +08:00
Compare commits
7 Commits
copilot/ch
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d5186fb28 | |||
| 06f076e0ff | |||
| 5b79f7e99d | |||
| 1cee1a25b6 | |||
| c0f237bf35 | |||
| 75d7fc0526 | |||
| c057b5c5ff |
73
.github/scripts/check-hotfix-cherry-picks.sh
vendored
Normal file
73
.github/scripts/check-hotfix-cherry-picks.sh
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_SHA=${BASE_SHA:-}
|
||||
HEAD_SHA=${HEAD_SHA:-}
|
||||
MAIN_REF=${MAIN_REF:-origin/main}
|
||||
REMEDIATION_HINT="Changes should be made from the main branch using git cherry-pick -x."
|
||||
|
||||
error() {
|
||||
printf 'ERROR: %s\n' "$1" >&2
|
||||
}
|
||||
|
||||
if [[ -z "$BASE_SHA" || -z "$HEAD_SHA" ]]; then
|
||||
error "BASE_SHA and HEAD_SHA are required. $REMEDIATION_HINT"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$BASE_SHA^{commit}" > /dev/null 2>&1; then
|
||||
error "Base commit '$BASE_SHA' is not available in the local git checkout."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$HEAD_SHA^{commit}" > /dev/null 2>&1; then
|
||||
error "Head commit '$HEAD_SHA' is not available in the local git checkout."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$MAIN_REF^{commit}" > /dev/null 2>&1; then
|
||||
error "Main ref '$MAIN_REF' is not available in the local git checkout. $REMEDIATION_HINT"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
failed=0
|
||||
checked=0
|
||||
|
||||
while IFS= read -r commit_sha; do
|
||||
[[ -n "$commit_sha" ]] || continue
|
||||
|
||||
checked=$((checked + 1))
|
||||
subject=$(git log -1 --format=%s "$commit_sha")
|
||||
source_sha=$(
|
||||
git log -1 --format=%B "$commit_sha" \
|
||||
| sed -nE 's/^\(cherry picked from commit ([0-9a-fA-F]{7,64})\)$/\1/p' \
|
||||
| tail -n 1
|
||||
)
|
||||
|
||||
if [[ -z "$source_sha" ]]; then
|
||||
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "$source_sha^{commit}" 2> /dev/null; then
|
||||
error "Commit $commit_sha ($subject) references source $source_sha, but that commit is not available locally. $REMEDIATION_HINT"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! git merge-base --is-ancestor "$source_sha" "$MAIN_REF"; then
|
||||
error "Commit $commit_sha ($subject) references source $source_sha, but that source is not reachable from main ($MAIN_REF). $REMEDIATION_HINT"
|
||||
failed=1
|
||||
fi
|
||||
done < <(git rev-list --reverse "$BASE_SHA..$HEAD_SHA")
|
||||
|
||||
if [[ "$failed" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$checked" -eq 0 ]]; then
|
||||
echo "No PR commits to check."
|
||||
else
|
||||
echo "Verified $checked PR commit(s) include cherry-pick provenance from main."
|
||||
fi
|
||||
49
.github/workflows/hotfix-cherry-pick.yml
vendored
Normal file
49
.github/workflows/hotfix-cherry-pick.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
name: Hotfix Cherry-Pick Provenance
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'hotfix/**'
|
||||
- 'lts/**'
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- ready_for_review
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: hotfix-cherry-pick-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-cherry-pick-provenance:
|
||||
name: Require cherry-pick provenance
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch PR base, PR head, and main
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
git fetch --no-tags --prune origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" \
|
||||
"+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head"
|
||||
|
||||
- name: Load checker from main
|
||||
run: git show origin/main:.github/scripts/check-hotfix-cherry-picks.sh > "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
||||
|
||||
- name: Check PR commits
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
MAIN_REF: origin/main
|
||||
run: bash "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
||||
@ -874,6 +874,7 @@ class ToolBuiltinProviderSetDefaultApi(Resource):
|
||||
@console_ns.expect(console_ns.models[BuiltinProviderDefaultCredentialPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
def post(self, provider):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
@ -16,6 +16,7 @@ from pydantic import TypeAdapter
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.agent.entities import AgentToolEntity
|
||||
from core.helper import marketplace
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
@ -310,6 +311,8 @@ class PluginMigration:
|
||||
"""
|
||||
Fetch plugin unique identifier using plugin id.
|
||||
"""
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
return None
|
||||
plugin_manifest = marketplace.batch_fetch_plugin_manifests([plugin_id])
|
||||
if not plugin_manifest:
|
||||
return None
|
||||
@ -542,6 +545,11 @@ class PluginMigration:
|
||||
"""
|
||||
Install plugins for a tenant.
|
||||
"""
|
||||
if plugin_identifiers_map and not dify_config.MARKETPLACE_ENABLED:
|
||||
raise ValueError(
|
||||
"Marketplace disabled in offline mode; cannot bulk-install plugins. "
|
||||
"Pre-upload plugin packages via Console first."
|
||||
)
|
||||
manager = PluginInstaller()
|
||||
|
||||
# download all the plugins and upload
|
||||
|
||||
@ -73,35 +73,43 @@ class PluginService:
|
||||
cache_not_exists.append(plugin_id)
|
||||
|
||||
if cache_not_exists:
|
||||
manifests = {
|
||||
manifest.plugin_id: manifest
|
||||
for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists)
|
||||
}
|
||||
|
||||
for plugin_id, manifest in manifests.items():
|
||||
latest_plugin = PluginService.LatestPluginCache(
|
||||
plugin_id=plugin_id,
|
||||
version=manifest.latest_version,
|
||||
unique_identifier=manifest.latest_package_identifier,
|
||||
status=manifest.status,
|
||||
deprecated_reason=manifest.deprecated_reason,
|
||||
alternative_plugin_id=manifest.alternative_plugin_id,
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.info(
|
||||
"Marketplace disabled; skipping latest-plugins metadata fetch for %d ids",
|
||||
len(cache_not_exists),
|
||||
)
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
else:
|
||||
manifests = {
|
||||
manifest.plugin_id: manifest
|
||||
for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists)
|
||||
}
|
||||
|
||||
# Store in Redis
|
||||
redis_client.setex(
|
||||
f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}",
|
||||
PluginService.REDIS_TTL,
|
||||
latest_plugin.model_dump_json(),
|
||||
)
|
||||
for plugin_id, manifest in manifests.items():
|
||||
latest_plugin = PluginService.LatestPluginCache(
|
||||
plugin_id=plugin_id,
|
||||
version=manifest.latest_version,
|
||||
unique_identifier=manifest.latest_package_identifier,
|
||||
status=manifest.status,
|
||||
deprecated_reason=manifest.deprecated_reason,
|
||||
alternative_plugin_id=manifest.alternative_plugin_id,
|
||||
)
|
||||
|
||||
result[plugin_id] = latest_plugin
|
||||
# Store in Redis
|
||||
redis_client.setex(
|
||||
f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}",
|
||||
PluginService.REDIS_TTL,
|
||||
latest_plugin.model_dump_json(),
|
||||
)
|
||||
|
||||
# pop plugin_id from cache_not_exists
|
||||
cache_not_exists.remove(plugin_id)
|
||||
result[plugin_id] = latest_plugin
|
||||
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
# pop plugin_id from cache_not_exists
|
||||
cache_not_exists.remove(plugin_id)
|
||||
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
|
||||
@ -1350,6 +1350,12 @@ class RagPipelineService:
|
||||
)
|
||||
return workflow_node_execution_db_model
|
||||
|
||||
def _fetch_recommended_plugin_manifests(self, plugin_ids: list[str]) -> list[Any]:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.info("Marketplace disabled; recommended-plugins list empty")
|
||||
return []
|
||||
return marketplace.batch_fetch_plugin_by_ids(plugin_ids)
|
||||
|
||||
def get_recommended_plugins(self, type: str) -> dict[str, Any]:
|
||||
# Query active recommended plugins
|
||||
stmt = select(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
|
||||
@ -1372,7 +1378,7 @@ class RagPipelineService:
|
||||
)
|
||||
providers_map = {provider.plugin_id: provider.to_dict() for provider in providers}
|
||||
|
||||
plugin_manifests = marketplace.batch_fetch_plugin_by_ids(plugin_ids)
|
||||
plugin_manifests = self._fetch_recommended_plugin_manifests(plugin_ids)
|
||||
plugin_manifests_map = {manifest["plugin_id"]: manifest for manifest in plugin_manifests}
|
||||
|
||||
installed_plugin_list = []
|
||||
|
||||
@ -9,6 +9,7 @@ import yaml
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from constants import DOCUMENT_EXTENSIONS
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
|
||||
@ -273,6 +274,13 @@ class RagPipelineTransformService:
|
||||
plugin_unique_identifier = dependency.get("value", {}).get("plugin_unique_identifier")
|
||||
plugin_id = plugin_unique_identifier.split(":")[0]
|
||||
if plugin_id not in installed_plugins_ids:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.warning(
|
||||
"Marketplace disabled; skipping auto-install of %s. "
|
||||
"Pre-install via Console if pipeline requires it.",
|
||||
plugin_id,
|
||||
)
|
||||
continue
|
||||
plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(plugin_id) # type: ignore
|
||||
if plugin_unique_identifier:
|
||||
need_install_plugin_unique_identifiers.append(plugin_unique_identifier)
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
|
||||
MIGRATION_MODULE = "services.plugin.plugin_migration"
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_returns_none_when_disabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", False)
|
||||
batch_fetch = mocker.patch("services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests")
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
|
||||
assert result is None
|
||||
batch_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_calls_marketplace_when_enabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", True)
|
||||
manifest = mocker.MagicMock()
|
||||
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
||||
mocker.patch(
|
||||
"services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests",
|
||||
return_value=[manifest],
|
||||
)
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
|
||||
assert result == "langgenius/openai:1.0.0@abc"
|
||||
|
||||
|
||||
class TestHandlePluginInstanceInstall:
|
||||
def test_raises_when_disabled_and_map_nonempty(self) -> None:
|
||||
with patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg:
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
|
||||
with pytest.raises(ValueError, match="Marketplace disabled"):
|
||||
PluginMigration.handle_plugin_instance_install(
|
||||
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
|
||||
)
|
||||
|
||||
def test_no_raise_when_disabled_and_map_empty(self) -> None:
|
||||
with (
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_installer = MagicMock()
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True)
|
||||
|
||||
result = PluginMigration.handle_plugin_instance_install("tenant1", {})
|
||||
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_proceeds_when_enabled(self) -> None:
|
||||
with (
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.marketplace") as mock_marketplace,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_marketplace.download_plugin_pkg.return_value = b"pkg_data"
|
||||
mock_installer = MagicMock()
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True)
|
||||
|
||||
result = PluginMigration.handle_plugin_instance_install(
|
||||
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
|
||||
)
|
||||
|
||||
mock_marketplace.download_plugin_pkg.assert_called_once()
|
||||
assert "success" in result or "failed" in result
|
||||
50
api/tests/unit_tests/services/plugin/test_plugin_service.py
Normal file
50
api/tests/unit_tests/services/plugin/test_plugin_service.py
Normal file
@ -0,0 +1,50 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
MODULE = "services.plugin.plugin_service"
|
||||
|
||||
|
||||
class TestFetchLatestPluginVersion:
|
||||
def test_skips_marketplace_fetch_when_disabled(self) -> None:
|
||||
"""Cache misses stay None; marketplace is never called when disabled."""
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MODULE}.redis_client") as mock_redis,
|
||||
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_redis.get.return_value = None # all cache misses
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai", "langgenius/anthropic"])
|
||||
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_not_called()
|
||||
assert result == {"langgenius/openai": None, "langgenius/anthropic": None}
|
||||
|
||||
def test_calls_marketplace_fetch_when_enabled(self) -> None:
|
||||
"""Cache misses trigger marketplace fetch when enabled."""
|
||||
manifest = MagicMock()
|
||||
manifest.plugin_id = "langgenius/openai"
|
||||
manifest.latest_version = "1.0.0"
|
||||
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
||||
manifest.status = "active"
|
||||
manifest.deprecated_reason = ""
|
||||
manifest.alternative_plugin_id = ""
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MODULE}.redis_client") as mock_redis,
|
||||
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_redis.get.return_value = None
|
||||
mock_marketplace.batch_fetch_plugin_manifests.return_value = [manifest]
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai"])
|
||||
|
||||
# The list arg is mutated by remove() after the call, so check call count + result.
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_called_once()
|
||||
assert result["langgenius/openai"] is not None
|
||||
assert result["langgenius/openai"].version == "1.0.0"
|
||||
@ -0,0 +1,36 @@
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
|
||||
def _make_service() -> RagPipelineService:
|
||||
return RagPipelineService.__new__(RagPipelineService)
|
||||
|
||||
|
||||
def test_fetch_recommended_plugin_manifests_returns_empty_when_disabled(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", False)
|
||||
batch_fetch = mocker.patch("services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids")
|
||||
|
||||
service = _make_service()
|
||||
result = service._fetch_recommended_plugin_manifests(["langgenius/openai"])
|
||||
|
||||
assert result == []
|
||||
batch_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_recommended_plugin_manifests_returns_data_when_enabled(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", True)
|
||||
expected = [{"plugin_id": "langgenius/openai", "name": "OpenAI"}]
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids",
|
||||
return_value=expected,
|
||||
)
|
||||
|
||||
service = _make_service()
|
||||
result = service._fetch_recommended_plugin_manifests(["langgenius/openai"])
|
||||
|
||||
assert result == expected
|
||||
@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from models.dataset import Dataset
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import KnowledgeConfiguration
|
||||
@ -514,3 +516,64 @@ def test_deal_document_data_upload_file_with_existing_file(mocker) -> None:
|
||||
assert document.data_source_type == "local_file"
|
||||
assert "real_file_id" in document.data_source_info
|
||||
assert add_mock.call_count >= 2
|
||||
|
||||
|
||||
def _make_service():
|
||||
return RagPipelineTransformService.__new__(RagPipelineTransformService)
|
||||
|
||||
|
||||
def test_deal_dependencies_skips_marketplace_when_disabled(mocker: MockerFixture, caplog) -> None:
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED",
|
||||
False,
|
||||
)
|
||||
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
|
||||
installer.list_plugins.return_value = []
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration")
|
||||
install_call = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
)
|
||||
|
||||
pipeline_yaml = {
|
||||
"dependencies": [
|
||||
{
|
||||
"type": "marketplace",
|
||||
"value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
service._deal_dependencies(pipeline_yaml, "tenant-1")
|
||||
|
||||
install_call.assert_not_called()
|
||||
assert any("Marketplace disabled" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED",
|
||||
True,
|
||||
)
|
||||
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
|
||||
installer.list_plugins.return_value = []
|
||||
migration = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration").return_value
|
||||
migration._fetch_plugin_unique_identifier.return_value = "langgenius/openai:1.0.0@abc"
|
||||
install_call = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
)
|
||||
|
||||
pipeline_yaml = {
|
||||
"dependencies": [
|
||||
{
|
||||
"type": "marketplace",
|
||||
"value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service()
|
||||
service._deal_dependencies(pipeline_yaml, "tenant-1")
|
||||
|
||||
install_call.assert_called_once_with("tenant-1", ["langgenius/openai:1.0.0@abc"])
|
||||
|
||||
@ -5,7 +5,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
### What's Updated
|
||||
|
||||
- **Certbot Container**: `docker-compose.yaml` now contains `certbot` for managing SSL certificates. This container automatically renews certificates and ensures secure HTTPS connections.\
|
||||
For more information, refer `docker/certbot/README.md`.
|
||||
For more information, refer to `docker/certbot/README.md`.
|
||||
|
||||
- **Persistent Environment Variables**: Essential startup defaults are provided in `.env.example`, while local values are stored in `.env`, ensuring that your configurations persist across deployments.
|
||||
|
||||
@ -17,26 +17,26 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
### How to Deploy Dify with `docker-compose.yaml`
|
||||
|
||||
1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system.
|
||||
1. **Environment Setup**:
|
||||
2. **Environment Setup**:
|
||||
- Navigate to the `docker` directory.
|
||||
- Copy `.env.example` to `.env`.
|
||||
- Customize `.env` when you need to change essential startup defaults. Copy optional files from `envs/` without the `.example` suffix when you need advanced settings.
|
||||
- **Optional (for advanced deployments)**:
|
||||
If you maintain a full `.env` file copied from `.env.example`, you may use the environment synchronization tool to keep it aligned with the latest `.env.example` updates while preserving your custom settings.
|
||||
See the [Environment Variables Synchronization](#environment-variables-synchronization) section below.
|
||||
1. **Running the Services**:
|
||||
3. **Running the Services**:
|
||||
- Execute `docker compose up -d` from the `docker` directory to start the services.
|
||||
- To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`.
|
||||
- To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`. See `envs/vectorstores/` for the full list of supported options.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
1. **SSL Certificate Setup**:
|
||||
- Refer `docker/certbot/README.md` to set up SSL certificates using Certbot.
|
||||
1. **OpenTelemetry Collector Setup**:
|
||||
- Change `ENABLE_OTEL` to `true` in `.env`.
|
||||
- Configure `OTLP_BASE_ENDPOINT` properly.
|
||||
4. **SSL Certificate Setup**:
|
||||
- Refer to `docker/certbot/README.md` to set up SSL certificates using Certbot.
|
||||
5. **OpenTelemetry Collector Setup**:
|
||||
- Copy `envs/core-services/shared.env.example` to `envs/core-services/shared.env`.
|
||||
- Set `ENABLE_OTEL=true` and configure `OTLP_BASE_ENDPOINT`. Tune the other `OTEL_*` knobs in the same file if needed.
|
||||
|
||||
### How to Deploy Middleware for Developing Dify
|
||||
|
||||
@ -44,7 +44,7 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
- Use the `docker-compose.middleware.yaml` for setting up essential middleware services like databases and caches.
|
||||
- Navigate to the `docker` directory.
|
||||
- Ensure the `middleware.env` file is created by running `cp envs/middleware.env.example middleware.env` (refer to the `envs/middleware.env.example` file).
|
||||
1. **Running Middleware Services**:
|
||||
2. **Running Middleware Services**:
|
||||
- Navigate to the `docker` directory.
|
||||
- Execute `docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d` to start PostgreSQL/MySQL (per `DB_TYPE`) plus the bundled Weaviate instance.
|
||||
|
||||
@ -55,9 +55,9 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T
|
||||
For users migrating from the `docker-legacy` setup:
|
||||
|
||||
1. **Review Changes**: Familiarize yourself with the new `.env` configuration and Docker Compose setup.
|
||||
1. **Transfer Customizations**:
|
||||
2. **Transfer Customizations**:
|
||||
- If you have customized configurations such as `docker-compose.yaml`, `ssrf_proxy/squid.conf`, or `nginx/conf.d/default.conf`, you will need to reflect these changes in the `.env` file you create.
|
||||
1. **Data Migration**:
|
||||
3. **Data Migration**:
|
||||
- Ensure that data from services like databases and caches is backed up and migrated appropriately to the new structure if necessary.
|
||||
|
||||
### Overview of `.env`, `.env.example`, and `envs/`
|
||||
@ -80,49 +80,51 @@ The root `.env.example` file contains the essential startup settings. Optional a
|
||||
|
||||
1. **Common Variables**:
|
||||
|
||||
- `CONSOLE_API_URL`, `SERVICE_API_URL`: URLs for different API services.
|
||||
- `APP_WEB_URL`: Frontend application URL.
|
||||
- `FILES_URL`: Base URL for file downloads and previews.
|
||||
- `CONSOLE_API_URL`, `CONSOLE_WEB_URL`, `SERVICE_API_URL`, `APP_API_URL`, `APP_WEB_URL`: URLs for the API and frontend services.
|
||||
- `FILES_URL`, `INTERNAL_FILES_URL`: Public and internal base URLs for file downloads and previews.
|
||||
- `ENDPOINT_URL_TEMPLATE`, `NEXT_PUBLIC_SOCKET_URL`, `TRIGGER_URL`: Additional service URLs.
|
||||
|
||||
See `.env.example` for the full list.
|
||||
|
||||
1. **Server Configuration**:
|
||||
2. **Server Configuration**:
|
||||
|
||||
- `LOG_LEVEL`, `DEBUG`, `FLASK_DEBUG`: Logging and debug settings.
|
||||
- `SECRET_KEY`: A key for signing sessions, JWTs, and file URLs. Leave it empty to let Dify generate a persistent key in the storage directory, or set a unique value yourself.
|
||||
|
||||
1. **Database Configuration**:
|
||||
3. **Database Configuration**:
|
||||
|
||||
- `DB_USERNAME`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_DATABASE`: PostgreSQL database credentials and connection details.
|
||||
|
||||
1. **Redis Configuration**:
|
||||
4. **Redis Configuration**:
|
||||
|
||||
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`: Redis server connection settings.
|
||||
- `REDIS_KEY_PREFIX`: Optional global namespace prefix for Redis keys, topics, streams, and Celery Redis transport artifacts.
|
||||
|
||||
1. **Celery Configuration**:
|
||||
5. **Celery Configuration**:
|
||||
|
||||
- `CELERY_BROKER_URL`: Configuration for Celery message broker.
|
||||
|
||||
1. **Storage Configuration**:
|
||||
6. **Storage Configuration**:
|
||||
|
||||
- `STORAGE_TYPE`, `OPENDAL_SCHEME`, `OPENDAL_FS_ROOT`: Default local file storage settings. Optional storage backends are configured from the files under `envs/`.
|
||||
|
||||
1. **Vector Database Configuration**:
|
||||
7. **Vector Database Configuration**:
|
||||
|
||||
- `VECTOR_STORE`: Type of vector database (e.g., `weaviate`, `milvus`).
|
||||
- `VECTOR_STORE`: Type of vector database (e.g., `weaviate`, `milvus`). See `envs/vectorstores/` for the full list of supported options.
|
||||
- Specific settings for each vector store like `WEAVIATE_ENDPOINT`, `MILVUS_URI`.
|
||||
|
||||
1. **CORS Configuration**:
|
||||
8. **CORS Configuration**:
|
||||
|
||||
- `WEB_API_CORS_ALLOW_ORIGINS`, `CONSOLE_CORS_ALLOW_ORIGINS`: Settings for cross-origin resource sharing.
|
||||
|
||||
1. **OpenTelemetry Configuration**:
|
||||
9. **OpenTelemetry Configuration**:
|
||||
|
||||
- `ENABLE_OTEL`: Enable OpenTelemetry collector in api.
|
||||
- `OTLP_BASE_ENDPOINT`: Endpoint for your OTLP exporter.
|
||||
|
||||
1. **Other Service-Specific Environment Variables**:
|
||||
10. **Other Service-Specific Environment Variables**:
|
||||
|
||||
- Each service like `nginx`, `redis`, `db`, and vector databases have specific environment variables that are directly referenced in the `docker-compose.yaml`.
|
||||
- Each service like `nginx`, `redis`, `db`, and vector databases have specific environment variables that are directly referenced in the `docker-compose.yaml`.
|
||||
|
||||
### Environment Variables Synchronization
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@ vi.mock('../score-slider', () => ({
|
||||
<input
|
||||
role="slider"
|
||||
type="range"
|
||||
min={80}
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={e => onChange(Number((e.target as HTMLInputElement).value))}
|
||||
@ -272,7 +272,7 @@ describe('ConfigParamModal', () => {
|
||||
)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('min', '80')
|
||||
expect(slider).toHaveAttribute('min', '0')
|
||||
expect(slider).toHaveAttribute('max', '100')
|
||||
expect(slider).toHaveValue('90')
|
||||
})
|
||||
@ -375,7 +375,7 @@ describe('ConfigParamModal', () => {
|
||||
it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => {
|
||||
const configWithoutThreshold = {
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0,
|
||||
score_threshold: undefined as unknown as number,
|
||||
}
|
||||
render(
|
||||
<ConfigParamModal
|
||||
@ -390,6 +390,35 @@ describe('ConfigParamModal', () => {
|
||||
expect(screen.getByRole('slider')).toHaveValue('90')
|
||||
})
|
||||
|
||||
it('should preserve zero score threshold instead of falling back to default', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={{
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveValue('0')
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ embedding_provider_name: 'openai' }),
|
||||
0,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set loading state while saving', async () => {
|
||||
let resolveOnSave: () => void
|
||||
const onSave = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
|
||||
|
||||
@ -175,6 +175,22 @@ describe('AnnotationReply', () => {
|
||||
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show zero score threshold when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dash when score threshold is not set', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { queryAnnotationJobStatus } from '@/service/annotation'
|
||||
import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation'
|
||||
import { sleep } from '@/utils'
|
||||
import useAnnotationConfig from '../use-annotation-config'
|
||||
|
||||
@ -162,6 +162,35 @@ describe('useAnnotationConfig', () => {
|
||||
expect(updatedConfig.score_threshold).toBe(0.85)
|
||||
})
|
||||
|
||||
it('should preserve zero score threshold when enabling annotation', async () => {
|
||||
const zeroScoreConfig = { ...defaultConfig, score_threshold: 0 }
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: zeroScoreConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleEnableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}, 0)
|
||||
})
|
||||
|
||||
expect(updateAnnotationStatus).toHaveBeenCalledWith(
|
||||
'test-app',
|
||||
'enable',
|
||||
{
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
},
|
||||
0,
|
||||
)
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0]![0]
|
||||
expect(updatedConfig.score_threshold).toBe(0)
|
||||
})
|
||||
|
||||
it('should set score and embedding model together', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
|
||||
@ -75,7 +75,7 @@ const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, a
|
||||
<Item title={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })} tooltip={t('feature.annotation.scoreThreshold.description', { ns: 'appDebug' })}>
|
||||
<ScoreSlider
|
||||
className="mt-1"
|
||||
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
|
||||
value={(annotationConfig.score_threshold ?? ANNOTATION_DEFAULT.score_threshold) * 100}
|
||||
onChange={(val) => {
|
||||
setAnnotationConfig({
|
||||
...annotationConfig,
|
||||
|
||||
@ -100,7 +100,7 @@ const AnnotationReply = ({
|
||||
<div className="flex items-center gap-4 pt-0.5">
|
||||
<div className="">
|
||||
<div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}</div>
|
||||
<div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold || '-'}</div>
|
||||
<div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold ?? '-'}</div>
|
||||
</div>
|
||||
<div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div>
|
||||
<div className="">
|
||||
|
||||
@ -17,7 +17,7 @@ describe('ScoreSlider', () => {
|
||||
it('should display easy match and accurate match labels', () => {
|
||||
render(<ScoreSlider value={90} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('0.8')).toBeInTheDocument()
|
||||
expect(screen.getByText('0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.0')).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.easyMatch/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.accurateMatch/)).toBeInTheDocument()
|
||||
@ -36,4 +36,11 @@ describe('ScoreSlider', () => {
|
||||
expect(getSliderInput()).toHaveValue('95')
|
||||
expect(screen.getByText('0.95')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow zero as the minimum score threshold', () => {
|
||||
render(<ScoreSlider value={0} onChange={vi.fn()} />)
|
||||
|
||||
expect(getSliderInput()).toHaveValue('0')
|
||||
expect(screen.getByText('0.00')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -17,13 +17,16 @@ const clamp = (value: number, min: number, max: number) => {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
const SCORE_MIN = 0
|
||||
const SCORE_MAX = 100
|
||||
|
||||
const ScoreSlider: FC<Props> = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const safeValue = clamp(value, 80, 100)
|
||||
const safeValue = clamp(value, SCORE_MIN, SCORE_MAX)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@ -31,8 +34,8 @@ const ScoreSlider: FC<Props> = ({
|
||||
<Slider
|
||||
className="w-full"
|
||||
value={safeValue}
|
||||
min={80}
|
||||
max={100}
|
||||
min={SCORE_MIN}
|
||||
max={SCORE_MAX}
|
||||
step={1}
|
||||
onValueChange={onChange}
|
||||
aria-label={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}
|
||||
@ -40,7 +43,7 @@ const ScoreSlider: FC<Props> = ({
|
||||
<div
|
||||
className="pointer-events-none absolute top-[-16px] system-sm-semibold text-text-primary"
|
||||
style={{
|
||||
left: `calc(4px + ${(safeValue - 80) / 20} * (100% - 8px))`,
|
||||
left: `calc(4px + ${safeValue / SCORE_MAX} * (100% - 8px))`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
@ -49,7 +52,7 @@ const ScoreSlider: FC<Props> = ({
|
||||
</div>
|
||||
<div className="mt-[10px] flex items-center justify-between system-xs-semibold-uppercase">
|
||||
<div className="flex space-x-1 text-util-colors-cyan-cyan-500">
|
||||
<div>0.8</div>
|
||||
<div>0.0</div>
|
||||
<div>·</div>
|
||||
<div>{t('feature.annotation.scoreThreshold.easyMatch', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
|
||||
@ -53,7 +53,7 @@ const useAnnotationConfig = ({
|
||||
setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => {
|
||||
draft.enabled = true
|
||||
draft.embedding_model = embeddingModel
|
||||
if (!draft.score_threshold)
|
||||
if (draft.score_threshold === undefined || draft.score_threshold === null)
|
||||
draft.score_threshold = ANNOTATION_DEFAULT.score_threshold
|
||||
}))
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ let parameterRules: Array<Record<string, unknown>> | undefined = [
|
||||
},
|
||||
]
|
||||
let isRulesLoading = false
|
||||
let isRulesPending = false
|
||||
let currentProvider: Record<string, unknown> | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } }
|
||||
let currentModel: Record<string, unknown> | undefined = {
|
||||
model: 'gpt-3.5-turbo',
|
||||
@ -49,7 +50,7 @@ vi.mock('@/service/use-common', () => ({
|
||||
data: parameterRules,
|
||||
},
|
||||
isLoading: isRulesLoading,
|
||||
isPending: isRulesLoading,
|
||||
isPending: isRulesPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -92,9 +93,21 @@ vi.mock('../../model-selector', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../presets-parameter', () => ({
|
||||
default: ({ onSelect }: { onSelect: (id: number) => void }) => (
|
||||
<button onClick={() => onSelect(1)}>Preset 1</button>
|
||||
),
|
||||
default: ({ onSelect, supportedParameterNames }: { onSelect: (id: number) => void, supportedParameterNames?: string[] }) => {
|
||||
if (supportedParameterNames && !supportedParameterNames.includes('temperature'))
|
||||
return null
|
||||
|
||||
return <button onClick={() => onSelect(1)}>Preset 1</button>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../presets-parameter-utils', () => ({
|
||||
getSupportedPresetConfig: (_toneId: number, supportedParameterNames?: string[]) => {
|
||||
if (supportedParameterNames && !supportedParameterNames.includes('temperature'))
|
||||
return {}
|
||||
|
||||
return { temperature: 0.8 }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../trigger', () => ({
|
||||
@ -126,6 +139,7 @@ describe('ModelParameterModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isRulesLoading = false
|
||||
isRulesPending = false
|
||||
parameterRules = [
|
||||
{
|
||||
name: 'temperature',
|
||||
@ -194,7 +208,28 @@ describe('ModelParameterModal', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
fireEvent.click(screen.getByText('Preset 1'))
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
...defaultProps.completionParams,
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render preset control when visible parameters do not support preset keys', () => {
|
||||
parameterRules = [
|
||||
{
|
||||
name: 'max_tokens',
|
||||
label: { en_US: 'Max Tokens' },
|
||||
type: 'int',
|
||||
default: 256,
|
||||
min: 1,
|
||||
max: 4096,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
expect(screen.queryByText('Preset 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setModel when model selector picks another model', () => {
|
||||
@ -219,11 +254,29 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
it('should render loading state when parameter rules are loading', () => {
|
||||
isRulesLoading = true
|
||||
isRulesPending = true
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render parameter loading when model is not configured and parameter rules query is pending but disabled', () => {
|
||||
isRulesPending = true
|
||||
parameterRules = []
|
||||
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
provider=""
|
||||
modelId=""
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open content when readonly is true', () => {
|
||||
render(<ModelParameterModal {...defaultProps} readonly />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
@ -299,6 +352,7 @@ describe('ModelParameterModal', () => {
|
||||
it('should render the empty loading fallback when rules resolve to an empty list', () => {
|
||||
parameterRules = []
|
||||
isRulesLoading = true
|
||||
isRulesPending = true
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import PresetsParameter from '../presets-parameter'
|
||||
import { getSupportedPresetConfig } from '../presets-parameter-utils'
|
||||
|
||||
describe('PresetsParameter', () => {
|
||||
beforeEach(() => {
|
||||
@ -47,4 +48,22 @@ describe('PresetsParameter', () => {
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('should render presets when at least one preset parameter is supported', () => {
|
||||
render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['temperature']} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render presets when no preset parameters are supported', () => {
|
||||
render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['max_tokens']} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /common\.modelProvider\.loadPresets/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return only supported preset config keys', () => {
|
||||
expect(getSupportedPresetConfig(1, ['temperature'])).toEqual({
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,7 +24,7 @@ import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config'
|
||||
import { useModelParameterRules } from '@/service/use-common'
|
||||
import {
|
||||
useTextGenerationCurrentProviderAndModelAndModelList,
|
||||
@ -32,6 +32,7 @@ import {
|
||||
import ModelSelector from '../model-selector'
|
||||
import ParameterItem from './parameter-item'
|
||||
import PresetsParameter from './presets-parameter'
|
||||
import { getSupportedPresetConfig } from './presets-parameter-utils'
|
||||
import Trigger from './trigger'
|
||||
|
||||
export type ModelParameterModalProps = {
|
||||
@ -75,10 +76,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
const settingsIconRef = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
data: parameterRulesData,
|
||||
isPending,
|
||||
isLoading,
|
||||
} = useModelParameterRules(provider, modelId)
|
||||
const isRulesLoading = isPending || isLoading
|
||||
const isRulesLoading = !!provider && !!modelId && isLoading
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
@ -90,6 +90,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
const parameterRules: ModelParameterRule[] = useMemo(() => {
|
||||
return parameterRulesData?.data || []
|
||||
}, [parameterRulesData])
|
||||
const supportedPresetParameterNames = useMemo(() => {
|
||||
return parameterRules.map(parameterRule => parameterRule.name)
|
||||
}, [parameterRules])
|
||||
|
||||
const handleParamChange = (key: string, value: ParameterValue) => {
|
||||
onCompletionParamsChange({
|
||||
@ -125,13 +128,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
|
||||
const handleSelectPresetParameter = (toneId: number) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (tone) {
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...tone.config,
|
||||
})
|
||||
}
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...getSupportedPresetConfig(toneId, supportedPresetParameterNames),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@ -199,7 +199,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
<div className="flex flex-1 items-center system-sm-semibold-uppercase text-text-secondary">{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
<PresetsParameter
|
||||
onSelect={handleSelectPresetParameter}
|
||||
supportedParameterNames={supportedPresetParameterNames}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import { TONE_LIST } from '@/config'
|
||||
|
||||
export const getSupportedPresetConfig = (toneId: number, supportedParameterNames?: string[]) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (!tone?.config)
|
||||
return {}
|
||||
|
||||
if (!supportedParameterNames)
|
||||
return { ...tone.config }
|
||||
|
||||
const supportedParameterNameSet = new Set(supportedParameterNames)
|
||||
|
||||
return Object.entries(tone.config).reduce<Record<string, number>>((acc, [key, value]) => {
|
||||
if (supportedParameterNameSet.has(key))
|
||||
acc[key] = value
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
@ -12,6 +12,8 @@ import { Scales02 } from '@/app/components/base/icons/src/vender/solid/FinanceAn
|
||||
import { Target04 } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { TONE_LIST } from '@/config'
|
||||
|
||||
const PRESET_TONE_LIST = TONE_LIST.slice(0, 3)
|
||||
|
||||
const toneI18nKeyMap = {
|
||||
Creative: 'model.tone.Creative',
|
||||
Balanced: 'model.tone.Balanced',
|
||||
@ -27,10 +29,18 @@ const TONE_ICONS: Record<number, ReactNode> = {
|
||||
|
||||
type PresetsParameterProps = {
|
||||
onSelect: (toneId: number) => void
|
||||
supportedParameterNames?: string[]
|
||||
}
|
||||
|
||||
function PresetsParameter({ onSelect }: PresetsParameterProps) {
|
||||
function PresetsParameter({ onSelect, supportedParameterNames }: PresetsParameterProps) {
|
||||
const { t } = useTranslation()
|
||||
const supportedParameterNameSet = supportedParameterNames ? new Set(supportedParameterNames) : undefined
|
||||
const visiblePresetTones = supportedParameterNameSet
|
||||
? PRESET_TONE_LIST.filter(tone => Object.keys(tone.config ?? {}).some(key => supportedParameterNameSet.has(key)))
|
||||
: PRESET_TONE_LIST
|
||||
|
||||
if (!visiblePresetTones.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@ -47,7 +57,7 @@ function PresetsParameter({ onSelect }: PresetsParameterProps) {
|
||||
<span className="ml-0.5 i-ri-arrow-down-s-line h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{TONE_LIST.slice(0, 3).map(tone => (
|
||||
{visiblePresetTones.map(tone => (
|
||||
<DropdownMenuItem key={tone.id} onClick={() => onSelect(tone.id)}>
|
||||
{TONE_ICONS[tone.id]}
|
||||
{t(toneI18nKeyMap[tone.name], { ns: 'common' })}
|
||||
|
||||
@ -75,14 +75,58 @@ vi.mock('@/config', () => ({
|
||||
|
||||
// Mock PresetsParameter component
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter', () => ({
|
||||
default: ({ onSelect }: { onSelect: (toneId: number) => void }) => (
|
||||
<div data-testid="presets-parameter">
|
||||
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
|
||||
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
|
||||
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
|
||||
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
|
||||
</div>
|
||||
),
|
||||
default: ({ onSelect, supportedParameterNames }: { onSelect: (toneId: number) => void, supportedParameterNames?: string[] }) => {
|
||||
const hasSupportedParameter = !supportedParameterNames || supportedParameterNames.some(name => ['temperature', 'top_p', 'presence_penalty', 'frequency_penalty'].includes(name))
|
||||
if (!hasSupportedParameter)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="presets-parameter">
|
||||
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
|
||||
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
|
||||
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
|
||||
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils', () => ({
|
||||
getSupportedPresetConfig: (toneId: number, supportedParameterNames?: string[]) => {
|
||||
const toneConfigMap: Record<number, Record<string, number> | undefined> = {
|
||||
1: {
|
||||
temperature: 0.8,
|
||||
top_p: 0.9,
|
||||
presence_penalty: 0.1,
|
||||
frequency_penalty: 0.1,
|
||||
},
|
||||
2: {
|
||||
temperature: 0.5,
|
||||
top_p: 0.85,
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: 0.3,
|
||||
},
|
||||
3: {
|
||||
temperature: 0.2,
|
||||
top_p: 0.75,
|
||||
presence_penalty: 0.5,
|
||||
frequency_penalty: 0.5,
|
||||
},
|
||||
}
|
||||
const toneConfig = toneConfigMap[toneId]
|
||||
if (!toneConfig)
|
||||
return {}
|
||||
|
||||
if (!supportedParameterNames)
|
||||
return toneConfig
|
||||
|
||||
return Object.entries(toneConfig).reduce<Record<string, number>>((acc, [key, value]) => {
|
||||
if (supportedParameterNames.includes(key))
|
||||
acc[key] = value
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock ParameterItem component
|
||||
@ -148,10 +192,12 @@ const createDefaultProps = (overrides: Partial<{
|
||||
const setupModelParameterRulesMock = (config: {
|
||||
data?: ModelParameterRule[]
|
||||
isPending?: boolean
|
||||
isLoading?: boolean
|
||||
} = {}) => {
|
||||
mockUseModelParameterRules.mockReturnValue({
|
||||
data: config.data ? { data: config.data } : undefined,
|
||||
isPending: config.isPending ?? false,
|
||||
isLoading: config.isLoading ?? config.isPending ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
@ -188,6 +234,19 @@ describe('LLMParamsPanel', () => {
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render loading state when model is not configured and parameter rules query is pending but disabled', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ isPending: true, isLoading: false })
|
||||
const props = createDefaultProps({ provider: '', modelId: '' })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.parameters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render parameters header', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
@ -202,7 +261,7 @@ describe('LLMParamsPanel', () => {
|
||||
|
||||
it('should render PresetsParameter for openai provider', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
|
||||
|
||||
// Act
|
||||
@ -214,7 +273,7 @@ describe('LLMParamsPanel', () => {
|
||||
|
||||
it('should render PresetsParameter for azure_openai provider', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/azure_openai/azure_openai' })
|
||||
|
||||
// Act
|
||||
@ -224,6 +283,18 @@ describe('LLMParamsPanel', () => {
|
||||
expect(screen.getByTestId('presets-parameter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render PresetsParameter when no visible parameter supports presets', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'max_tokens', type: 'int' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('presets-parameter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render PresetsParameter for non-preset providers', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
@ -360,7 +431,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Creative preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -384,7 +463,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Balanced preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -407,7 +494,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Precise preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -430,7 +525,7 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply empty config for Custom preset (spreads undefined)', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -444,6 +539,27 @@ describe('LLMParamsPanel', () => {
|
||||
// Assert - Custom preset has no config, so only existing params are kept
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({ existing: 'value' })
|
||||
})
|
||||
|
||||
it('should apply only preset config keys supported by visible parameters', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
completionParams: { existing: 'value' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preset-creative'))
|
||||
|
||||
// Assert
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
existing: 'value',
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleParamChange', () => {
|
||||
|
||||
@ -10,7 +10,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ParameterItem from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item'
|
||||
import PresetsParameter from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
|
||||
import { getSupportedPresetConfig } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config'
|
||||
import { useModelParameterRules } from '@/service/use-common'
|
||||
|
||||
type Props = {
|
||||
@ -29,20 +30,21 @@ const LLMParamsPanel = ({
|
||||
onCompletionParamsChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: parameterRulesData, isPending: isLoading } = useModelParameterRules(provider, modelId)
|
||||
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
|
||||
const isRulesLoading = !!provider && !!modelId && isLoading
|
||||
|
||||
const parameterRules: ModelParameterRule[] = useMemo(() => {
|
||||
return parameterRulesData?.data || []
|
||||
}, [parameterRulesData])
|
||||
const supportedPresetParameterNames = useMemo(() => {
|
||||
return parameterRules.map(parameterRule => parameterRule.name)
|
||||
}, [parameterRules])
|
||||
|
||||
const handleSelectPresetParameter = (toneId: number) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (tone) {
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...tone.config,
|
||||
})
|
||||
}
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...getSupportedPresetConfig(toneId, supportedPresetParameterNames),
|
||||
})
|
||||
}
|
||||
const handleParamChange = (key: string, value: ParameterValue) => {
|
||||
onCompletionParamsChange({
|
||||
@ -65,7 +67,7 @@ const LLMParamsPanel = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (isRulesLoading) {
|
||||
return (
|
||||
<div className="mt-5"><Loading /></div>
|
||||
)
|
||||
@ -77,7 +79,10 @@ const LLMParamsPanel = ({
|
||||
<div className={cn('flex h-6 items-center system-sm-semibold text-text-secondary')}>{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
<PresetsParameter
|
||||
onSelect={handleSelectPresetParameter}
|
||||
supportedParameterNames={supportedPresetParameterNames}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
28
web/service/annotation.spec.ts
Normal file
28
web/service/annotation.spec.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { AnnotationEnableStatus } from '@/app/components/app/annotation/type'
|
||||
import { updateAnnotationStatus } from './annotation'
|
||||
import { post } from './base'
|
||||
|
||||
vi.mock('./base', () => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('annotation service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should preserve zero score threshold when updating annotation status', () => {
|
||||
updateAnnotationStatus('app-1', AnnotationEnableStatus.enable, {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
}, 0)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('apps/app-1/annotation-reply/enable', {
|
||||
body: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
score_threshold: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -7,7 +7,7 @@ export const fetchAnnotationConfig = (appId: string) => {
|
||||
}
|
||||
export const updateAnnotationStatus = (appId: string, action: AnnotationEnableStatus, embeddingModel?: EmbeddingModelConfig, score?: number) => {
|
||||
let body: any = {
|
||||
score_threshold: score || ANNOTATION_DEFAULT.score_threshold,
|
||||
score_threshold: score ?? ANNOTATION_DEFAULT.score_threshold,
|
||||
}
|
||||
if (embeddingModel) {
|
||||
body = {
|
||||
|
||||
Reference in New Issue
Block a user