Compare commits

..

9 Commits

Author SHA1 Message Date
7f392b6950 chore(release): bump version to 1.14.2 (#36313)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-19 13:27:26 +08:00
b0a3399774 feat: enhance app creation tracking with source and template ID (#36369)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-19 05:02:17 +00:00
2d5186fb28 fix(offline): guard marketplace I/O paths for ENG-421 (#36335)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-18 13:53:42 +00:00
06f076e0ff fix: no model selected but params keep loading (#36342) 2026-05-18 10:19:52 +00:00
5b79f7e99d docs: fix docker README numbering and refresh stale references (#36303) 2026-05-18 10:17:49 +00:00
1cee1a25b6 fix(console): require admin/owner to set default builtin tool credential (#36264)
Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
2026-05-18 10:15:51 +00:00
c0f237bf35 feat(web): allow annotation reply score threshold below 0.8 (#36337) 2026-05-18 10:05:13 +00:00
75d7fc0526 ci: add hotfix cherry-pick provenance check (#36340) 2026-05-18 10:03:56 +00:00
c057b5c5ff chore: Filter model presets by supported parameters (#36339) 2026-05-18 10:03:46 +00:00
48 changed files with 1070 additions and 194 deletions

View 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

View 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"

View File

@ -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()

View File

@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.14.1"
version = "1.14.2"
requires-python = "~=3.12.0"
dependencies = [

View File

@ -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

View File

@ -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:

View File

@ -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 = []

View File

@ -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)

View File

@ -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

View 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"

View File

@ -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

View File

@ -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"])

2
api/uv.lock generated
View File

@ -1323,7 +1323,7 @@ docs = [
[[package]]
name = "dify-api"
version = "1.14.1"
version = "1.14.2"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },

View File

@ -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

View File

@ -220,7 +220,7 @@ services:
# API service
api:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-}
@ -264,7 +264,7 @@ services:
# WebSocket service for workflow collaboration.
api_websocket:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
profiles:
- collaboration
environment:
@ -290,7 +290,7 @@ services:
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
<<: *shared-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: worker
SENTRY_DSN: ${API_SENTRY_DSN:-}
@ -333,7 +333,7 @@ services:
# Celery beat for scheduling periodic tasks.
worker_beat:
<<: *shared-worker-beat-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: beat
depends_on:
@ -366,7 +366,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.14.1
image: langgenius/dify-web:1.14.2
restart: always
env_file:
- path: ./envs/core-services/web.env

View File

@ -226,7 +226,7 @@ services:
# API service
api:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-}
@ -270,7 +270,7 @@ services:
# WebSocket service for workflow collaboration.
api_websocket:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
profiles:
- collaboration
environment:
@ -296,7 +296,7 @@ services:
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
<<: *shared-worker-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: worker
SENTRY_DSN: ${API_SENTRY_DSN:-}
@ -339,7 +339,7 @@ services:
# Celery beat for scheduling periodic tasks.
worker_beat:
<<: *shared-worker-beat-config
image: langgenius/dify-api:1.14.1
image: langgenius/dify-api:1.14.2
environment:
MODE: beat
depends_on:
@ -372,7 +372,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.14.1
image: langgenius/dify-web:1.14.2
restart: always
env_file:
- path: ./envs/core-services/web.env

View File

@ -236,8 +236,8 @@ describe('Explore App List Flow', () => {
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
options.onPending?.()
})
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
options.onSuccess?.()
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderAppList(true, onSuccess)

View File

@ -247,7 +247,9 @@ describe('Apps', () => {
})
expect(mockTrackCreateApp).toHaveBeenCalledWith({
source: 'studio_template_list',
appMode: AppModeEnum.CHAT,
templateId: 'Alpha',
})
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
expect(onSuccess).toHaveBeenCalled()

View File

@ -127,7 +127,7 @@ const Apps = ({
icon_background,
description,
})
trackCreateApp({ appMode: mode })
trackCreateApp({ source: 'studio_template_list', appMode: mode, templateId: currApp?.app_id })
setIsShowCreateModal(false)
toast.success(t('newApp.appCreated', { ns: 'app' }))

View File

@ -170,7 +170,7 @@ describe('CreateAppModal', () => {
mode: AppModeEnum.ADVANCED_CHAT,
}))
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.ADVANCED_CHAT })
expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_blank', appMode: AppModeEnum.ADVANCED_CHAT })
expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated')
expect(onSuccess).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()

View File

@ -79,7 +79,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
mode: appMode,
})
trackCreateApp({ appMode: app.mode })
trackCreateApp({ source: 'studio_blank', appMode: app.mode })
toast.success(t('newApp.appCreated', { ns: 'app' }))
onSuccess()

View File

@ -197,7 +197,7 @@ describe('CreateFromDSLModal', () => {
mode: DSLImportMode.YAML_URL,
yaml_url: 'https://example.com/app.yml',
})
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.CHAT })
expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload', appMode: AppModeEnum.CHAT })
expect(handleSuccess).toHaveBeenCalledTimes(1)
expect(handleClose).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1')
@ -304,7 +304,7 @@ describe('CreateFromDSLModal', () => {
expect(mockImportDSLConfirm).toHaveBeenCalledWith({
import_id: 'import-3',
})
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.WORKFLOW })
expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload', appMode: AppModeEnum.WORKFLOW })
})
it('should close the DSL mismatch modal when dialog requests close', async () => {

View File

@ -110,7 +110,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
return
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
trackCreateApp({ appMode: app_mode })
trackCreateApp({ source: 'studio_upload', appMode: app_mode })
if (onSuccess)
onSuccess()
@ -171,7 +171,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
const { status, app_id, app_mode } = response
if (status === DSLImportStatus.COMPLETED) {
trackCreateApp({ appMode: app_mode })
trackCreateApp({ source: 'studio_upload', appMode: app_mode })
if (onSuccess)
onSuccess()
if (onClose)

View File

@ -262,8 +262,8 @@ describe('Apps', () => {
})
it('should track template preview creation after a successful import', async () => {
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
options.onSuccess?.()
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderWithClient(<Apps />)
@ -275,7 +275,9 @@ describe('Apps', () => {
await waitFor(() => {
expect(mockFetchAppDetail).toHaveBeenCalledWith('template-1')
expect(mockTrackCreateApp).toHaveBeenCalledWith({
source: 'studio_template_preview',
appMode: AppModeEnum.CHAT,
templateId: 'template-1',
})
})
})
@ -284,8 +286,8 @@ describe('Apps', () => {
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
options.onPending?.()
})
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
options.onSuccess?.()
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW })
})
renderWithClient(<Apps />)
@ -299,7 +301,9 @@ describe('Apps', () => {
await waitFor(() => {
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
expect(mockTrackCreateApp).toHaveBeenCalledWith({
appMode: AppModeEnum.CHAT,
source: 'studio_template_preview',
appMode: AppModeEnum.WORKFLOW,
templateId: 'template-1',
})
})
})
@ -365,8 +369,8 @@ describe('Apps', () => {
})
it('should import DSL from marketplace template on confirm', async () => {
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
options.onSuccess?.()
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
mockSearchParams = new URLSearchParams('template-id=tpl-42')
renderWithClient(<Apps />)
@ -378,14 +382,22 @@ describe('Apps', () => {
{ mode: 'yaml-content', yaml_content: 'yaml-dsl-content' },
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
expect(mockTrackCreateApp).toHaveBeenCalledWith({
source: 'external',
appMode: AppModeEnum.CHAT,
templateId: 'tpl-42',
})
expect(mockReplace).toHaveBeenCalled()
})
})
it('should show DSL confirm modal when marketplace import is pending', async () => {
it('should track marketplace template creation after confirming a pending import', async () => {
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
options.onPending?.()
})
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.WORKFLOW })
})
mockSearchParams = new URLSearchParams('template-id=tpl-42')
renderWithClient(<Apps />)
@ -395,6 +407,16 @@ describe('Apps', () => {
expect(screen.getByTestId('dsl-confirm-modal')).toBeInTheDocument()
expect(mockReplace).toHaveBeenCalled()
})
fireEvent.click(screen.getByTestId('confirm-dsl'))
await waitFor(() => {
expect(mockTrackCreateApp).toHaveBeenCalledWith({
source: 'external',
appMode: AppModeEnum.WORKFLOW,
templateId: 'tpl-42',
})
})
})
})

View File

@ -1,6 +1,7 @@
'use client'
import type { CreateAppModalProps } from '../explore/create-app-modal'
import type { TryAppSelection } from '@/types/try-app'
import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEducationInit } from '@/app/education-apply/hooks'
@ -31,6 +32,7 @@ const Apps = () => {
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
const currentCreateAppModeRef = useRef<TryAppSelection['app']['app']['mode'] | null>(null)
const currentCreateAppTrackingRef = useRef<Pick<TrackCreateAppParams, 'source' | 'templateId'> | null>(null)
const currApp = currentTryAppParams?.app
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
const hideTryAppPanel = useCallback(() => {
@ -46,13 +48,24 @@ const Apps = () => {
const [isShowCreateModal, setIsShowCreateModal] = useState(false)
const handleShowFromTryApp = useCallback(() => {
currentCreateAppTrackingRef.current = {
source: 'studio_template_preview',
templateId: currentTryAppParams?.appId || currentTryAppParams?.app.app_id,
}
setIsShowCreateModal(true)
}, [])
const trackCurrentCreateApp = useCallback(() => {
if (!currentCreateAppModeRef.current)
}, [currentTryAppParams?.app.app_id, currentTryAppParams?.appId])
const trackCurrentCreateApp = useCallback((appMode?: TryAppSelection['app']['app']['mode'] | null) => {
const currentCreateAppTracking = currentCreateAppTrackingRef.current
const resolvedAppMode = appMode ?? currentCreateAppModeRef.current
if (!resolvedAppMode || !currentCreateAppTracking)
return
trackCreateApp({ appMode: currentCreateAppModeRef.current })
trackCreateApp({
...currentCreateAppTracking,
appMode: resolvedAppMode,
})
currentCreateAppTrackingRef.current = null
currentCreateAppModeRef.current = null
}, [])
const [controlRefreshList, setControlRefreshList] = useState(0)
@ -81,19 +94,25 @@ const Apps = () => {
const onConfirmDSL = useCallback(async () => {
await handleImportDSLConfirm({
onSuccess: () => {
trackCurrentCreateApp()
onSuccess: (response) => {
trackCurrentCreateApp(response.app_mode)
onSuccess()
},
})
}, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
const handleMarketplaceTemplateConfirm = useCallback(async (dslContent: string) => {
currentCreateAppModeRef.current = null
currentCreateAppTrackingRef.current = {
source: 'external',
templateId: templateId || undefined,
}
await handleImportDSL({
mode: DSLImportMode.YAML_CONTENT,
yaml_content: dslContent,
}, {
onSuccess: () => {
onSuccess: (response) => {
trackCurrentCreateApp(response.app_mode)
handleCloseTemplateModal()
onSuccess()
},
@ -102,7 +121,7 @@ const Apps = () => {
setShowDSLConfirmModal(true)
},
})
}, [handleImportDSL, handleCloseTemplateModal, onSuccess])
}, [handleImportDSL, handleCloseTemplateModal, onSuccess, templateId, trackCurrentCreateApp])
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
@ -127,8 +146,8 @@ const Apps = () => {
description,
}
await handleImportDSL(payload, {
onSuccess: () => {
trackCurrentCreateApp()
onSuccess: (response) => {
trackCurrentCreateApp(response.app_mode)
setIsShowCreateModal(false)
},
onPending: () => {

View File

@ -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) => {

View File

@ -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: {

View File

@ -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({

View File

@ -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,

View File

@ -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="">

View File

@ -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()
})
})

View File

@ -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>

View File

@ -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
}))
}

View File

@ -239,8 +239,8 @@ describe('AppList', () => {
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
options.onPending?.()
})
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
options.onSuccess?.()
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderAppList(true, onSuccess)
@ -257,7 +257,9 @@ describe('AppList', () => {
await waitFor(() => {
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
expect(mockTrackCreateApp).toHaveBeenCalledWith({
source: 'explore_template_list',
appMode: AppModeEnum.CHAT,
templateId: 'app-1',
})
expect(onSuccess).toHaveBeenCalledTimes(1)
})
@ -351,8 +353,8 @@ describe('AppList', () => {
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
options.onSuccess?.()
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderAppList(true)
@ -417,8 +419,8 @@ describe('AppList', () => {
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => {
options.onSuccess?.()
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderAppList(true)
@ -429,7 +431,9 @@ describe('AppList', () => {
await waitFor(() => {
expect(mockTrackCreateApp).toHaveBeenCalledWith({
source: 'explore_template_preview',
appMode: AppModeEnum.CHAT,
templateId: 'app-1',
})
})
})

View File

@ -3,6 +3,7 @@
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app'
import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
@ -107,6 +108,7 @@ const Apps = ({
const [currentTryApp, setCurrentTryApp] = useState<TryAppSelection | undefined>(undefined)
const currentCreateAppModeRef = useRef<App['app']['mode'] | null>(null)
const currentCreateAppTrackingRef = useRef<Pick<TrackCreateAppParams, 'source' | 'templateId'> | null>(null)
const isShowTryAppPanel = !!currentTryApp
const hideTryAppPanel = useCallback(() => {
setCurrentTryApp(undefined)
@ -116,13 +118,24 @@ const Apps = ({
}, [])
const handleShowFromTryApp = useCallback(() => {
setCurrApp(currentTryApp?.app || null)
currentCreateAppTrackingRef.current = {
source: 'explore_template_preview',
templateId: currentTryApp?.appId || currentTryApp?.app.app_id,
}
setIsShowCreateModal(true)
}, [currentTryApp?.app])
const trackCurrentCreateApp = useCallback(() => {
if (!currentCreateAppModeRef.current)
}, [currentTryApp?.app, currentTryApp?.appId])
const trackCurrentCreateApp = useCallback((appMode?: App['app']['mode'] | null) => {
const currentCreateAppTracking = currentCreateAppTrackingRef.current
const resolvedAppMode = appMode ?? currentCreateAppModeRef.current
if (!resolvedAppMode || !currentCreateAppTracking)
return
trackCreateApp({ appMode: currentCreateAppModeRef.current })
trackCreateApp({
...currentCreateAppTracking,
appMode: resolvedAppMode,
})
currentCreateAppTrackingRef.current = null
currentCreateAppModeRef.current = null
}, [])
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({
@ -148,8 +161,8 @@ const Apps = ({
description,
}
await handleImportDSL(payload, {
onSuccess: () => {
trackCurrentCreateApp()
onSuccess: (response) => {
trackCurrentCreateApp(response.app_mode)
setIsShowCreateModal(false)
},
onPending: () => {
@ -160,8 +173,8 @@ const Apps = ({
const onConfirmDSL = useCallback(async () => {
await handleImportDSLConfirm({
onSuccess: () => {
trackCurrentCreateApp()
onSuccess: (response) => {
trackCurrentCreateApp(response.app_mode)
onSuccess?.()
},
})
@ -242,6 +255,10 @@ const Apps = ({
app={app}
canCreate={hasEditPermission}
onCreate={() => {
currentCreateAppTrackingRef.current = {
source: 'explore_template_list',
templateId: app.app_id,
}
setCurrApp(app)
setIsShowCreateModal(true)
}}

View File

@ -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'))

View File

@ -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,
})
})
})

View File

@ -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>

View File

@ -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
}, {})
}

View File

@ -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' })}

View File

@ -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', () => {

View File

@ -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>

View File

@ -32,7 +32,7 @@ type DSLPayload = {
description?: string
}
type ResponseCallback = {
onSuccess?: () => void
onSuccess?: (payload: DSLImportResponse) => void
onPending?: (payload: DSLImportResponse) => void
onFailed?: () => void
}
@ -85,7 +85,7 @@ export const useImportDSL = () => {
toast.success(message)
else
toast.warning(message, { description })
onSuccess?.()
onSuccess?.(response)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
await handleCheckPluginDependencies(app_id)
getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push)
@ -134,7 +134,7 @@ export const useImportDSL = () => {
return
if (status === DSLImportStatus.COMPLETED) {
onSuccess?.()
onSuccess?.(response)
toast.success(t('newApp.appCreated', { ns: 'app' }))
await handleCheckPluginDependencies(app_id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')

View File

@ -1,7 +1,7 @@
{
"name": "dify-web",
"type": "module",
"version": "1.14.1",
"version": "1.14.2",
"private": true,
"imports": {
"#i18n": {

View 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,
},
})
})
})

View File

@ -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 = {

View File

@ -55,11 +55,12 @@ describe('create-app-tracking', () => {
})
describe('buildCreateAppEventPayload', () => {
it('should build original payloads with normalized app mode and timestamp', () => {
it('should build payloads with source, normalized app mode, and timestamp', () => {
expect(buildCreateAppEventPayload({
source: 'studio_blank',
appMode: AppModeEnum.ADVANCED_CHAT,
}, null, new Date(2026, 3, 13, 14, 5, 9))).toEqual({
source: 'original',
source: 'studio_blank',
app_mode: 'chatflow',
time: '04-13-14:05:09',
})
@ -67,9 +68,10 @@ describe('create-app-tracking', () => {
it('should map agent mode into the canonical app mode bucket', () => {
expect(buildCreateAppEventPayload({
source: 'studio_blank',
appMode: AppModeEnum.AGENT_CHAT,
}, null, new Date(2026, 3, 13, 9, 8, 7))).toEqual({
source: 'original',
source: 'studio_blank',
app_mode: 'agent',
time: '04-13-09:08:07',
})
@ -77,17 +79,19 @@ describe('create-app-tracking', () => {
it('should fold legacy non-agent modes into chatflow', () => {
expect(buildCreateAppEventPayload({
source: 'studio_blank',
appMode: AppModeEnum.CHAT,
}, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({
source: 'original',
source: 'studio_blank',
app_mode: 'chatflow',
time: '04-13-08:00:01',
})
expect(buildCreateAppEventPayload({
source: 'studio_blank',
appMode: AppModeEnum.COMPLETION,
}, null, new Date(2026, 3, 13, 8, 0, 2))).toEqual({
source: 'original',
source: 'studio_blank',
app_mode: 'chatflow',
time: '04-13-08:00:02',
})
@ -95,29 +99,56 @@ describe('create-app-tracking', () => {
it('should map workflow mode into the workflow bucket', () => {
expect(buildCreateAppEventPayload({
source: 'studio_blank',
appMode: AppModeEnum.WORKFLOW,
}, null, new Date(2026, 3, 13, 7, 6, 5))).toEqual({
source: 'original',
source: 'studio_blank',
app_mode: 'workflow',
time: '04-13-07:06:05',
})
})
it('should include template_id for template sources', () => {
expect(buildCreateAppEventPayload({
source: 'studio_template_list',
appMode: AppModeEnum.CHAT,
templateId: 'template-1',
}, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({
source: 'studio_template_list',
app_mode: 'chatflow',
time: '04-13-08:00:01',
template_id: 'template-1',
})
})
it('should prefer external attribution when present', () => {
expect(buildCreateAppEventPayload(
{
source: 'studio_template_list',
appMode: AppModeEnum.WORKFLOW,
templateId: 'template-1',
},
{
utmSource: 'linkedin',
utmCampaign: 'agent-launch',
},
new Date(2026, 3, 13, 7, 6, 5),
)).toEqual({
source: 'external',
app_mode: 'workflow',
time: '04-13-07:06:05',
template_id: 'template-1',
utm_source: 'linkedin',
utm_campaign: 'agent-launch',
})
})
it('should not build external payloads without attribution', () => {
expect(buildCreateAppEventPayload({
source: 'external',
appMode: AppModeEnum.WORKFLOW,
}, null, new Date(2026, 3, 13, 7, 6, 5))).toBeNull()
})
})
describe('trackCreateApp', () => {
@ -126,20 +157,24 @@ describe('create-app-tracking', () => {
searchParams: new URLSearchParams('utm_source=newsletter&slug=how-to-build-rag-agent'),
})
trackCreateApp({ appMode: AppModeEnum.WORKFLOW })
trackCreateApp({ source: 'studio_template_list', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' })
expect(amplitude.trackEvent).toHaveBeenNthCalledWith(1, 'create_app', {
source: 'external',
app_mode: 'workflow',
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
template_id: 'template-1',
utm_source: 'blog',
utm_campaign: 'how-to-build-rag-agent',
})
trackCreateApp({ appMode: AppModeEnum.WORKFLOW })
trackCreateApp({ source: 'studio_template_list', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' })
expect(amplitude.trackEvent).toHaveBeenNthCalledWith(2, 'create_app', {
source: 'original',
source: 'studio_template_list',
app_mode: 'workflow',
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
template_id: 'template-1',
})
})
@ -152,16 +187,19 @@ describe('create-app-tracking', () => {
window.history.replaceState({}, '', '/explore')
trackCreateApp({ appMode: AppModeEnum.CHAT })
trackCreateApp({ source: 'explore_template_preview', appMode: AppModeEnum.CHAT, templateId: 'template-2' })
expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
source: 'external',
app_mode: 'chatflow',
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
template_id: 'template-2',
utm_source: 'linkedin',
utm_campaign: 'agent-launch',
})
})
it('should fall back to the original payload when window is unavailable', () => {
it('should fall back to the provided source when window is unavailable', () => {
const originalWindow = globalThis.window
try {
@ -170,10 +208,10 @@ describe('create-app-tracking', () => {
value: undefined,
})
trackCreateApp({ appMode: AppModeEnum.AGENT_CHAT })
trackCreateApp({ source: 'studio_blank', appMode: AppModeEnum.AGENT_CHAT })
expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
source: 'original',
source: 'studio_blank',
app_mode: 'agent',
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
})
@ -185,5 +223,29 @@ describe('create-app-tracking', () => {
})
}
})
it('should read, normalize, and consume snake_case sessionStorage attribution', () => {
window.sessionStorage.setItem('create_app_external_attribution', JSON.stringify({
utm_source: 'twitter',
utm_campaign: 'launch-week',
}))
trackCreateApp({ source: 'studio_blank', appMode: AppModeEnum.CHAT })
expect(amplitude.trackEvent).toHaveBeenCalledWith('create_app', {
source: 'external',
app_mode: 'chatflow',
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
utm_source: 'twitter/x',
utm_campaign: 'launch-week',
})
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
})
it('should not track external source without remembered attribution', () => {
trackCreateApp({ source: 'external', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' })
expect(amplitude.trackEvent).not.toHaveBeenCalled()
})
})
})

View File

@ -6,12 +6,13 @@ const CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY = 'create_app_external_attribu
const CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS = ['utm_source', 'utm_campaign', 'slug'] as const
const EXTERNAL_UTM_SOURCE_MAP = {
blog: 'blog',
dify_blog: 'blog',
linkedin: 'linkedin',
newsletter: 'blog',
twitter: 'twitter/x',
x: 'twitter/x',
'blog': 'blog',
'dify_blog': 'blog',
'linkedin': 'linkedin',
'newsletter': 'blog',
'twitter': 'twitter/x',
'twitter/x': 'twitter/x',
'x': 'twitter/x',
} as const
type SearchParamReader = {
@ -20,8 +21,19 @@ type SearchParamReader = {
type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent'
type TrackCreateAppParams = {
type CreateAppSource
= | 'external'
| 'explore_template_list'
| 'explore_template_preview'
| 'studio_blank'
| 'studio_template_list'
| 'studio_template_preview'
| 'studio_upload'
export type TrackCreateAppParams = {
source: CreateAppSource
appMode: AppModeEnum
templateId?: string
}
type ExternalCreateAppAttribution = {
@ -173,7 +185,20 @@ export const extractExternalCreateAppAttribution = ({
}
const readRememberedExternalCreateAppAttribution = (): ExternalCreateAppAttribution | null => {
return parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)) as ExternalCreateAppAttribution | null
const attribution = parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY))
const utmSource = mapExternalUtmSource(
getObjectStringValue(attribution?.utmSource) ?? getObjectStringValue(attribution?.utm_source),
)
if (!utmSource)
return null
const utmCampaign = getObjectStringValue(attribution?.utmCampaign) ?? getObjectStringValue(attribution?.utm_campaign)
return {
utmSource,
...(utmCampaign ? { utmCampaign } : {}),
}
}
const writeRememberedExternalCreateAppAttribution = (attribution: ExternalCreateAppAttribution) => {
@ -214,18 +239,22 @@ export const buildCreateAppEventPayload = (
externalAttribution?: ExternalCreateAppAttribution | null,
currentTime = new Date(),
) => {
if (externalAttribution) {
return {
source: 'external',
utm_source: externalAttribution.utmSource,
...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}),
} satisfies Record<string, string>
}
const source = externalAttribution ? 'external' : params.source
if (source === 'external' && !externalAttribution)
return null
return {
source: 'original',
source,
app_mode: mapOriginalCreateAppMode(params.appMode),
time: formatCreateAppTime(currentTime),
...(params.templateId ? { template_id: params.templateId } : {}),
...(externalAttribution
? {
utm_source: externalAttribution.utmSource,
...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}),
}
: {}),
} satisfies Record<string, string>
}
@ -233,6 +262,9 @@ export const trackCreateApp = (params: TrackCreateAppParams) => {
const externalAttribution = resolveCurrentExternalCreateAppAttribution()
const payload = buildCreateAppEventPayload(params, externalAttribution)
if (!payload)
return
if (externalAttribution)
clearRememberedExternalCreateAppAttribution()