Compare commits

..

17 Commits

Author SHA1 Message Date
1c692d7b0b fix: fix human input form logo replace (#37452) 2026-06-15 15:56:16 +08:00
33448bed6e fix: fix remove logo not work (#37435) 2026-06-15 13:08:28 +08:00
14bd643664 fix user token 2026-06-02 14:59:06 +08:00
34a17c3ce6 fix(security): reject path traversal sequences before plugin daemon forward (GHSA-gvc6-fh3x-89xh) (#35796)
Co-authored-by: Ido Shani <ido@zafran.io>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-28 18:10:00 +08:00
18f083607b fix: normalize summary_index_setting None to fix preview error (#36626) 2026-05-28 18:07:59 +08:00
2fe8dbd7ca fix: fix cannot extract elements from a scalar (#36769) 2026-05-28 15:50:27 +08:00
80cd289e87 fix: replace .distinct() with .group_by(Conversation.id) for PostgreSQL JSON compatibility (#36610)
Co-authored-by: cocoon <kuishou68@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
(cherry picked from commit e617435d03)
2026-05-28 13:19:27 +08:00
a14bc8a371 fix: fix DocumentSegment.keywords can not a valid json (#36715) 2026-05-27 17:11:06 +08:00
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
62 changed files with 1933 additions and 277 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

@ -16,7 +16,7 @@ class EnterpriseFeatureConfig(BaseSettings):
CAN_REPLACE_LOGO: bool = Field(
description="Allow customization of the enterprise logo.",
default=False,
default=True,
)
ENTERPRISE_REQUEST_TIMEOUT: int = Field(

View File

@ -137,7 +137,7 @@ class CompletionConversationApi(Resource):
.join( # type: ignore
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
)
.distinct()
.group_by(Conversation.id)
)
elif args.annotation_status == "not_annotated":
query = (
@ -275,7 +275,7 @@ class ChatConversationApi(Resource):
.join( # type: ignore
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
)
.distinct()
.group_by(Conversation.id)
)
case "not_annotated":
query = (

View File

@ -3,7 +3,7 @@ import uuid
from flask import request
from flask_restx import Resource, marshal
from pydantic import BaseModel, Field
from sqlalchemy import String, cast, func, or_, select
from sqlalchemy import String, case, cast, func, literal, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from werkzeug.exceptions import Forbidden, NotFound
@ -159,9 +159,17 @@ class DatasetDocumentSegmentListApi(Resource):
# Use database-specific methods for JSON array search
if dify_config.SQLALCHEMY_DATABASE_URI_SCHEME == "postgresql":
# PostgreSQL: Use jsonb_array_elements_text to properly handle Unicode/Chinese text
# Feed the set-returning function a JSON array in every row. Filtering in
# the subquery is not enough because PostgreSQL can still evaluate the
# SRF on scalar JSON before applying the predicate.
keywords_jsonb = cast(DocumentSegment.keywords, JSONB)
keywords_array = case(
(func.jsonb_typeof(keywords_jsonb) == "array", keywords_jsonb),
else_=cast(literal("[]"), JSONB),
)
keywords_condition = func.array_to_string(
func.array(
select(func.jsonb_array_elements_text(cast(DocumentSegment.keywords, JSONB)))
select(func.jsonb_array_elements_text(keywords_array))
.correlate(DocumentSegment)
.scalar_subquery()
),

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

@ -3,6 +3,7 @@ import json
import logging
from collections.abc import Callable, Generator
from typing import Any, cast
from urllib.parse import unquote
import httpx
from pydantic import BaseModel
@ -53,6 +54,9 @@ else:
logger = logging.getLogger(__name__)
PLUGIN_DAEMON_MAX_PATH_LENGTH = 4096
PLUGIN_DAEMON_MAX_PATH_DECODE_DEPTH = 8
_httpx_client: httpx.Client = get_pooled_http_client(
"plugin_daemon",
lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100), trust_env=False),
@ -103,6 +107,20 @@ class BasePluginClient:
params: dict[str, Any] | None,
files: dict[str, Any] | None,
) -> tuple[str, dict[str, str], bytes | dict[str, Any] | str | None, dict[str, Any] | None, dict[str, Any] | None]:
if len(path) > PLUGIN_DAEMON_MAX_PATH_LENGTH:
raise ValueError(f"Invalid plugin daemon path: path length exceeds {PLUGIN_DAEMON_MAX_PATH_LENGTH}")
decoded_path = path
for _ in range(PLUGIN_DAEMON_MAX_PATH_DECODE_DEPTH):
next_decoded_path = unquote(decoded_path)
if next_decoded_path == decoded_path:
break
decoded_path = next_decoded_path
else:
raise ValueError("Invalid plugin daemon path: path is too deeply encoded")
if any(seg == ".." for seg in decoded_path.split("/")):
raise ValueError(f"Invalid plugin daemon path: traversal sequence detected in {path!r}")
url = plugin_daemon_inner_api_baseurl / path
prepared_headers = dict(headers or {})
prepared_headers["X-Api-Key"] = dify_config.PLUGIN_DAEMON_KEY

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

@ -110,6 +110,8 @@ class TokenPair(BaseModel):
REFRESH_TOKEN_PREFIX = "refresh_token:"
ACCOUNT_REFRESH_TOKEN_PREFIX = "account_refresh_token:"
REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS)
ACCOUNT_LAST_ACTIVE_REFRESH_PREFIX = "account_last_active_refresh:"
ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL = timedelta(minutes=10)
class AccountService:
@ -146,6 +148,40 @@ class AccountService:
def _get_account_refresh_token_key(account_id: str) -> str:
return f"{ACCOUNT_REFRESH_TOKEN_PREFIX}{account_id}"
@staticmethod
def _get_account_last_active_refresh_key(account_id: str) -> str:
return f"{ACCOUNT_LAST_ACTIVE_REFRESH_PREFIX}{account_id}"
@staticmethod
@redis_fallback(default_return=True)
def _should_refresh_account_last_active(account_id: str) -> bool:
return bool(
redis_client.set(
AccountService._get_account_last_active_refresh_key(account_id),
1,
ex=int(ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL.total_seconds()),
nx=True,
)
)
@staticmethod
def _refresh_account_last_active(account: Account) -> None:
now = naive_utc_now()
refresh_before = now - ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL
if account.last_active_at >= refresh_before:
return
if not AccountService._should_refresh_account_last_active(account.id):
return
db.session.execute(
update(Account)
.where(Account.id == account.id, Account.last_active_at < refresh_before)
.values(last_active_at=now, updated_at=func.current_timestamp())
)
db.session.commit()
@staticmethod
def _store_refresh_token(refresh_token: str, account_id: str):
redis_client.setex(AccountService._get_refresh_token_key(refresh_token), REFRESH_TOKEN_EXPIRY, account_id)
@ -188,9 +224,7 @@ class AccountService:
available_ta.current = True
db.session.commit()
if naive_utc_now() - account.last_active_at > timedelta(minutes=10):
account.last_active_at = naive_utc_now()
db.session.commit()
AccountService._refresh_account_last_active(account)
# NOTE: make sure account is accessible outside of a db session
# This ensures that it will work correctly after upgrading to Flask version 3.1.2
db.session.refresh(account)

View File

@ -170,6 +170,16 @@ class _AutomaticProcessRule(BaseModel):
mode: Literal[ProcessRuleMode.AUTOMATIC]
summary_index_setting: _SummaryIndexSetting | None = None
@field_validator("summary_index_setting", mode="before")
@classmethod
def _normalize_summary_index_setting(cls, v: Any) -> Any:
"""Treat dicts with enable=None (or missing enable) as None (#36602)."""
if v is None:
return None
if isinstance(v, dict) and v.get("enable") is None:
return None
return v
class _CustomProcessRule(BaseModel):
model_config = ConfigDict(extra="allow")
@ -178,6 +188,16 @@ class _CustomProcessRule(BaseModel):
rules: _EstimateRules
summary_index_setting: _SummaryIndexSetting | None = None
@field_validator("summary_index_setting", mode="before")
@classmethod
def _normalize_summary_index_setting(cls, v: Any) -> Any:
"""Treat dicts with enable=None (or missing enable) as None (#36602)."""
if v is None:
return None
if isinstance(v, dict) and v.get("enable") is None:
return None
return v
class _HierarchicalProcessRule(BaseModel):
model_config = ConfigDict(extra="allow")
@ -186,6 +206,16 @@ class _HierarchicalProcessRule(BaseModel):
rules: _EstimateRules
summary_index_setting: _SummaryIndexSetting | None = None
@field_validator("summary_index_setting", mode="before")
@classmethod
def _normalize_summary_index_setting(cls, v: Any) -> Any:
"""Treat dicts with enable=None (or missing enable) as None (#36602)."""
if v is None:
return None
if isinstance(v, dict) and v.get("enable") is None:
return None
return v
_EstimateProcessRule = Annotated[
_AutomaticProcessRule | _CustomProcessRule | _HierarchicalProcessRule,

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

@ -1036,6 +1036,48 @@ class TestSegmentListAdvancedCases:
assert status == 200
assert response["total"] == 1
def test_segment_list_postgres_keyword_filter_handles_scalar_keywords(self, app: Flask):
api = DatasetDocumentSegmentListApi()
method = unwrap(api.get)
dataset = MagicMock()
document = MagicMock()
pagination = MagicMock(items=[], total=0, pages=0)
with (
app.test_request_context("/?keyword=test"),
patch(
"controllers.console.datasets.datasets_segments.current_account_with_tenant",
return_value=(MagicMock(), "11111111-1111-1111-1111-111111111111"),
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.get_dataset",
return_value=dataset,
),
patch(
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
return_value=None,
),
patch(
"controllers.console.datasets.datasets_segments.DocumentService.get_document",
return_value=document,
),
patch(
"controllers.console.datasets.datasets_segments.dify_config",
SimpleNamespace(SQLALCHEMY_DATABASE_URI_SCHEME="postgresql"),
),
patch(
"controllers.console.datasets.datasets_segments.db.paginate",
return_value=pagination,
) as paginate_mock,
):
method(api, "22222222-2222-2222-2222-222222222222", "33333333-3333-3333-3333-333333333333")
query = paginate_mock.call_args.kwargs["select"]
sql = str(query.compile(compile_kwargs={"literal_binds": True}))
assert "jsonb_array_elements_text(CASE" in sql
assert "ELSE CAST('[]' AS JSONB)" in sql
def test_segment_list_permission_denied(self, app: Flask):
"""Test segment list with permission denied"""
api = DatasetDocumentSegmentListApi()

View File

@ -1,11 +1,12 @@
import json
from urllib.parse import quote
import pytest
from pytest_mock import MockerFixture
from core.plugin.endpoint.exc import EndpointSetupFailedError
from core.plugin.entities.plugin_daemon import PluginDaemonInnerError
from core.plugin.impl.base import BasePluginClient
from core.plugin.impl.base import PLUGIN_DAEMON_MAX_PATH_LENGTH, BasePluginClient
from core.trigger.errors import (
EventIgnoreError,
TriggerInvokeError,
@ -67,6 +68,36 @@ class TestBasePluginClientImpl:
assert result == ["hello", "world"]
assert stream_mock.call_args.kwargs["data"] == {"k": "v"}
@pytest.mark.parametrize(
"path",
[
"plugin/tenant/%252e%252e%252ftarget",
"plugin/tenant/%2e%2e%252ftarget",
],
)
def test_prepare_request_rejects_encoded_traversal_with_encoded_separator(self, path: str):
client = BasePluginClient()
with pytest.raises(ValueError, match="traversal sequence detected"):
client._prepare_request(path, None, None, None, None)
def test_prepare_request_rejects_path_exceeding_max_length(self):
client = BasePluginClient()
path = "a" * (PLUGIN_DAEMON_MAX_PATH_LENGTH + 1)
with pytest.raises(ValueError, match="path length exceeds"):
client._prepare_request(path, None, None, None, None)
def test_prepare_request_rejects_excessively_encoded_path(self):
client = BasePluginClient()
segment = "..%2Ftarget"
for _ in range(9):
segment = quote(segment, safe="")
path = f"plugin/tenant/{segment}"
with pytest.raises(ValueError, match="too deeply encoded"):
client._prepare_request(path, None, None, None, None)
def test_request_with_plugin_daemon_response_handles_request_exception(self, mocker: MockerFixture):
client = BasePluginClient()
mocker.patch.object(client, "_request", side_effect=RuntimeError("boom"))

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

View File

@ -436,7 +436,10 @@ class TestAccountService:
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant_join
# Mock datetime
with patch("services.account_service.datetime") as mock_datetime:
with (
patch("services.account_service.datetime") as mock_datetime,
patch.object(AccountService, "_refresh_account_last_active") as mock_refresh_last_active,
):
mock_now = datetime.now()
mock_datetime.now.return_value = mock_now
mock_datetime.UTC = "UTC"
@ -447,6 +450,7 @@ class TestAccountService:
# Verify results
assert result == mock_account
assert mock_account.set_tenant_id.called
mock_refresh_last_active.assert_called_once_with(mock_account)
def test_load_user_not_found(self, mock_db_dependencies):
"""Test user loading when user does not exist."""
@ -483,7 +487,10 @@ class TestAccountService:
mock_db_dependencies["db"].session.scalar.side_effect = [None, mock_available_tenant]
# Mock datetime
with patch("services.account_service.datetime") as mock_datetime:
with (
patch("services.account_service.datetime") as mock_datetime,
patch.object(AccountService, "_refresh_account_last_active") as mock_refresh_last_active,
):
mock_now = datetime.now()
mock_datetime.now.return_value = mock_now
mock_datetime.UTC = "UTC"
@ -495,6 +502,7 @@ class TestAccountService:
assert result == mock_account
assert mock_available_tenant.current is True
self._assert_database_operations_called(mock_db_dependencies["db"])
mock_refresh_last_active.assert_called_once_with(mock_account)
def test_load_user_no_tenants(self, mock_db_dependencies):
"""Test user loading when user has no tenants at all."""
@ -517,6 +525,68 @@ class TestAccountService:
# Verify results
assert result is None
def test_refresh_account_last_active_uses_redis_gate_and_conditional_update(self, mock_db_dependencies):
"""Test last-active refresh is gated in Redis and conditionally written to DB."""
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
now = datetime(2026, 6, 2, 2, 45, 49)
mock_account.last_active_at = now - timedelta(minutes=15)
with (
patch("services.account_service.naive_utc_now", return_value=now),
patch("services.account_service.redis_client") as mock_redis_client,
):
mock_redis_client.set.return_value = True
AccountService._refresh_account_last_active(mock_account)
mock_redis_client.set.assert_called_once_with(
"account_last_active_refresh:user-123",
1,
ex=600,
nx=True,
)
mock_db_dependencies["db"].session.execute.assert_called_once()
mock_db_dependencies["db"].session.commit.assert_called_once()
def test_refresh_account_last_active_skips_db_when_redis_gate_exists(self, mock_db_dependencies):
"""Test concurrent refresh attempts do not enqueue duplicate DB updates."""
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
now = datetime(2026, 6, 2, 2, 45, 49)
mock_account.last_active_at = now - timedelta(minutes=15)
with (
patch("services.account_service.naive_utc_now", return_value=now),
patch("services.account_service.redis_client") as mock_redis_client,
):
mock_redis_client.set.return_value = None
AccountService._refresh_account_last_active(mock_account)
mock_redis_client.set.assert_called_once_with(
"account_last_active_refresh:user-123",
1,
ex=600,
nx=True,
)
mock_db_dependencies["db"].session.execute.assert_not_called()
mock_db_dependencies["db"].session.commit.assert_not_called()
def test_refresh_account_last_active_skips_recent_account(self, mock_db_dependencies):
"""Test recent activity does not touch Redis or DB."""
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
now = datetime(2026, 6, 2, 2, 45, 49)
mock_account.last_active_at = now - timedelta(minutes=5)
with (
patch("services.account_service.naive_utc_now", return_value=now),
patch("services.account_service.redis_client") as mock_redis_client,
):
AccountService._refresh_account_last_active(mock_account)
mock_redis_client.set.assert_not_called()
mock_db_dependencies["db"].session.execute.assert_not_called()
mock_db_dependencies["db"].session.commit.assert_not_called()
class TestTenantService:
"""

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

@ -0,0 +1,402 @@
import type { FormData } from '../form'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import { act, render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import FormContent from '../form'
import { useFormSubmit } from '../use-form-submit'
const mockSubmitForm = vi.hoisted(() => vi.fn())
const mockUseGetHumanInputForm = vi.hoisted(() => vi.fn())
const mockContentItemState = vi.hoisted(() => ({
staleAttachmentInputChange: undefined as ((name: string, value: unknown) => void) | undefined,
uploadedFile: {
id: 'file-1',
name: 'review.pdf',
size: 128,
type: 'document',
progress: 100,
transferMethod: 'local_file',
supportFileType: 'document',
uploadedId: 'upload-file-1',
},
uploadingFile: {
id: 'file-1',
name: 'review.pdf',
size: 128,
type: 'document',
progress: 50,
transferMethod: 'local_file',
supportFileType: 'document',
uploadedId: undefined,
},
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: 'token-123' }),
}))
vi.mock('@/service/use-share', () => ({
useGetHumanInputForm: (...args: unknown[]) => mockUseGetHumanInputForm(...args),
useSubmitHumanInputForm: () => ({
mutate: mockSubmitForm,
isPending: false,
}),
}))
vi.mock('@/hooks/use-document-title', () => ({
__esModule: true,
default: vi.fn(),
}))
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({
__esModule: true,
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => {
const isSummaryField = content.includes('summary')
const isAttachmentField = content.includes('attachments')
if (isAttachmentField && !mockContentItemState.staleAttachmentInputChange)
mockContentItemState.staleAttachmentInputChange = onInputChange
return (
<div data-testid="share-form-content-item">
{content}
{isSummaryField && (
<>
<button type="button" onClick={() => onInputChange('summary', '')}>
share-clear-summary
</button>
<button type="button" onClick={() => onInputChange('summary', 'updated summary')}>
share-update-summary
</button>
</>
)}
{isAttachmentField && (
<>
<button
type="button"
onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadingFile])}
>
share-uploading-attachments
</button>
<button
type="button"
onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadedFile])}
>
share-update-attachments
</button>
</>
)}
</div>
)
},
}))
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/expiration-time', () => ({
__esModule: true,
default: () => <div>expiration-time</div>,
}))
vi.mock('@/app/components/base/loading', () => ({
__esModule: true,
default: () => <div>loading</div>,
}))
vi.mock('@/app/components/base/logo/dify-logo', () => ({
__esModule: true,
default: () => <div>dify-logo</div>,
}))
vi.mock('@/app/components/base/app-icon', () => ({
__esModule: true,
default: () => <div>app-icon</div>,
}))
describe('Human input share form', () => {
const formData: FormData = {
site: {
site: {
title: 'Review App',
icon_type: 'emoji',
icon: 'R',
icon_background: '#fff',
icon_url: '',
default_language: 'en-US',
description: '',
copyright: '',
privacy_policy: '',
custom_disclaimer: '',
prompt_public: false,
use_icon_as_answer_icon: false,
},
},
form_content: '{{#$output.summary#}} {{#$output.attachments#}}',
inputs: [
{
type: InputVarType.paragraph,
output_variable_name: 'summary',
default: {
type: 'constant',
value: 'initial summary',
selector: [],
},
},
{
type: InputVarType.multiFiles,
output_variable_name: 'attachments',
allowed_file_extensions: ['.pdf'],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 3,
},
],
resolved_default_values: {},
user_actions: [
{
id: 'approve',
title: 'Approve',
button_style: UserActionButtonType.Primary,
},
],
expiration_time: 60,
}
beforeEach(() => {
vi.clearAllMocks()
mockContentItemState.staleAttachmentInputChange = undefined
mockUseGetHumanInputForm.mockReturnValue({
data: formData,
isLoading: false,
error: null,
})
})
it('should render the loading state while the form is being fetched', () => {
mockUseGetHumanInputForm.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
})
render(<FormContent />)
expect(screen.getByText('loading')).toBeInTheDocument()
})
it('should render status cards for terminal fetch states', () => {
const cases = [
{
error: { code: 'human_input_form_expired' },
title: 'share.humanInput.sorry',
subtitle: 'share.humanInput.expired',
submissionID: true,
},
{
error: { code: 'human_input_form_submitted' },
title: 'share.humanInput.sorry',
subtitle: 'share.humanInput.completed',
submissionID: true,
},
{
error: { code: 'web_form_rate_limit_exceeded' },
title: 'share.humanInput.rateLimitExceeded',
subtitle: undefined,
submissionID: false,
},
{
error: null,
title: 'share.humanInput.formNotFound',
subtitle: undefined,
submissionID: false,
},
]
cases.forEach(({ error, title, subtitle, submissionID }) => {
mockUseGetHumanInputForm.mockReturnValue({
data: undefined,
isLoading: false,
error,
})
const { unmount } = render(<FormContent />)
expect(screen.getByText(title)).toBeInTheDocument()
if (subtitle)
expect(screen.getByText(subtitle)).toBeInTheDocument()
else
expect(screen.queryByText('share.humanInput.expired')).not.toBeInTheDocument()
if (submissionID)
expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument()
else
expect(screen.queryByText(/share\.humanInput\.submissionID/)).not.toBeInTheDocument()
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
expect(screen.getByText('dify-logo')).toBeInTheDocument()
unmount()
})
})
it('submits typed human input values through the share form mutation', async () => {
const user = userEvent.setup()
render(<FormContent />)
await user.click(screen.getByRole('button', { name: 'share-update-summary' }))
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
await user.click(screen.getByRole('button', { name: 'Approve' }))
expect(mockSubmitForm).toHaveBeenCalledWith({
token: 'token-123',
data: {
action: 'approve',
inputs: {
summary: 'updated summary',
attachments: [{
type: 'document',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
})
it('should show the success status after the submit mutation succeeds', async () => {
const user = userEvent.setup()
render(<FormContent />)
await user.click(screen.getByRole('button', { name: 'share-update-summary' }))
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
await user.click(screen.getByRole('button', { name: 'Approve' }))
const options = mockSubmitForm.mock.calls[0]![1] as { onSuccess: () => void }
act(() => {
options.onSuccess()
})
expect(screen.getByText('share.humanInput.thanks')).toBeInTheDocument()
expect(screen.getByText('share.humanInput.recorded')).toBeInTheDocument()
expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument()
})
it('should submit empty inputs when there are no form values to process', () => {
const { result } = renderHook(() => useFormSubmit('token-empty'))
act(() => {
result.current.submit(
undefined as unknown as Record<string, HumanInputFieldValue>,
'reject',
[],
)
})
expect(mockSubmitForm).toHaveBeenCalledWith({
token: 'token-empty',
data: {
action: 'reject',
inputs: {},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
})
it('should keep initialized defaults when file upload uses the initial change callback', async () => {
const user = userEvent.setup()
render(<FormContent />)
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
await user.click(screen.getByRole('button', { name: 'Approve' }))
expect(mockSubmitForm).toHaveBeenCalledWith({
token: 'token-123',
data: {
action: 'approve',
inputs: {
summary: 'initial summary',
attachments: [{
type: 'document',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'upload-file-1',
}],
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
}))
})
it('should disable action buttons until every required field is filled and files are uploaded', async () => {
const user = userEvent.setup()
render(<FormContent />)
const approveButton = screen.getByRole('button', { name: 'Approve' })
expect(approveButton).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'share-uploading-attachments' }))
expect(approveButton).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
expect(approveButton).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'share-clear-summary' }))
expect(approveButton).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'share-update-summary' }))
expect(approveButton).toBeEnabled()
})
it('should hide branding when remove_webapp_brand is enabled', () => {
mockUseGetHumanInputForm.mockReturnValue({
data: {
...formData,
site: {
...formData.site,
custom_config: {
remove_webapp_brand: true,
replace_webapp_logo: null,
},
},
},
isLoading: false,
error: null,
})
render(<FormContent />)
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
expect(screen.queryByText('dify-logo')).not.toBeInTheDocument()
})
it('should render the custom branding logo when replace_webapp_logo is provided', () => {
mockUseGetHumanInputForm.mockReturnValue({
data: {
...formData,
site: {
...formData.site,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: 'https://example.com/custom-logo.png',
},
},
},
isLoading: false,
error: null,
})
render(<FormContent />)
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'logo' })).toHaveAttribute('src', 'https://example.com/custom-logo.png')
expect(screen.queryByText('dify-logo')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next'
import DifyLogo from '@/app/components/base/logo/dify-logo'
type BrandingFooterProps = {
removeWebappBrand?: boolean
replaceWebappLogo?: string | null
}
const BrandingFooter = ({
removeWebappBrand,
replaceWebappLogo,
}: BrandingFooterProps) => {
const { t } = useTranslation()
if (removeWebappBrand)
return null
return (
<div className="flex flex-row-reverse px-2 py-3">
<div className="flex shrink-0 items-center gap-1.5 px-1">
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
{replaceWebappLogo
? <img src={replaceWebappLogo} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />}
</div>
</div>
)
}
export default BrandingFooter

View File

@ -0,0 +1,53 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import BrandingFooter from './branding-footer'
type FormStatusCardProps = {
iconClassName: string
title: ReactNode
subtitle?: ReactNode
submissionID?: string
removeWebappBrand?: boolean
replaceWebappLogo?: string | null
}
const FormStatusCard = ({
iconClassName,
title,
subtitle,
submissionID,
removeWebappBrand,
replaceWebappLogo,
}: FormStatusCardProps) => {
const { t } = useTranslation()
return (
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-160 min-w-120">
<div className="flex h-80 flex-col gap-4 rounded-[20px] border border-divider-subtle bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<span className={cn('size-8', iconClassName)} />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{title}</div>
{!!subtitle && (
<div className="title-4xl-semi-bold text-text-primary">{subtitle}</div>
)}
</div>
{submissionID && (
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">
{t('humanInput.submissionID', { id: submissionID, ns: 'share' })}
</div>
)}
</div>
<BrandingFooter
removeWebappBrand={removeWebappBrand}
replaceWebappLogo={replaceWebappLogo}
/>
</div>
</div>
)
}
export default FormStatusCard

View File

@ -1,7 +1,7 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { SiteInfo } from '@/models/share'
import type { CustomConfigValueType, SiteInfo } from '@/models/share'
import type { HumanInputFormError } from '@/service/use-share'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@ -25,7 +25,10 @@ import { useParams } from '@/next/navigation'
import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share'
export type FormData = {
site: { site: SiteInfo }
site: {
site: SiteInfo
custom_config?: Record<string, CustomConfigValueType> | null
}
form_content: string
inputs: FormInputItem[]
resolved_default_values: Record<string, string>
@ -46,6 +49,11 @@ const FormContent = () => {
const { data: formData, isLoading, error } = useGetHumanInputForm(token)
const removeWebappBrand = formData?.site?.custom_config?.remove_webapp_brand === true
const replaceWebappLogo = typeof formData?.site?.custom_config?.replace_webapp_logo === 'string'
? formData.site.custom_config.replace_webapp_logo
: null
const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired'
const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted'
const rateLimitExceeded = (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded'
@ -99,29 +107,14 @@ const FormContent = () => {
if (success) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiCheckboxCircleFill className="h-8 w-8 text-text-success" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.thanks', { ns: 'share' })}</div>
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.recorded', { ns: 'share' })}</div>
</div>
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<FormStatusCard
iconClassName="i-ri-checkbox-circle-fill text-text-success"
title={t('humanInput.thanks', { ns: 'share' })}
subtitle={t('humanInput.recorded', { ns: 'share' })}
submissionID={token}
removeWebappBrand={removeWebappBrand}
replaceWebappLogo={replaceWebappLogo}
/>
)
}
@ -236,53 +229,14 @@ const FormContent = () => {
const site = formData.site.site
return (
<div className={cn('mx-auto flex h-full w-full max-w-[720px] flex-col items-center')}>
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
<AppIcon
size="large"
iconType={site.icon_type}
icon={site.icon}
background={site.icon_background}
imageUrl={site.icon_url}
/>
<div className="grow system-xl-semibold text-text-primary">{site.title}</div>
</div>
<div className="h-0 w-full grow overflow-y-auto">
<div className="border-components-divider-subtle rounded-[20px] border bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-xs">
{contentList.map((content, index) => (
<ContentItem
key={index}
content={content}
formInputFields={formData.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className="flex flex-wrap gap-1 py-1">
{formData.user_actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(action.id)}
>
{action.title}
</Button>
))}
</div>
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
</div>
<div className="flex flex-row-reverse px-2 py-3">
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-1',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<DifyLogo size="small" />
</div>
</div>
</div>
</div>
<LoadedFormContent
key={token}
formData={formData}
isSubmitting={isSubmitting}
onSubmit={submit}
removeWebappBrand={removeWebappBrand}
replaceWebappLogo={replaceWebappLogo}
/>
)
}

View File

@ -0,0 +1,108 @@
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { FormData } from './form'
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { produce } from 'immer'
import { useMemo, useState } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time'
import { getButtonStyle, getRenderedFormInputs, hasInvalidRequiredHumanInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import BrandingFooter from './branding-footer'
type LoadedFormContentProps = {
formData: FormData
isSubmitting: boolean
onSubmit: (inputs: Record<string, HumanInputFieldValue>, actionID: string, formInputs: FormData['inputs']) => void
removeWebappBrand?: boolean
replaceWebappLogo?: string | null
}
const LoadedFormContent = ({
formData,
isSubmitting,
onSubmit,
removeWebappBrand,
replaceWebappLogo,
}: LoadedFormContentProps) => {
const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content)
const [inputs, setInputs] = useState<Record<string, HumanInputFieldValue>>(() =>
initializeInputs(renderedFormInputs, formData.resolved_default_values),
)
const contentList = useMemo(() => {
const contentCounts = new Map<string, number>()
return splitByOutputVar(formData.form_content).map((content) => {
const occurrence = (contentCounts.get(content) || 0) + 1
contentCounts.set(content, occurrence)
return {
key: `${content}-${occurrence}`,
content,
}
})
}, [formData.form_content])
const handleInputsChange = (name: string, value: HumanInputFieldValue) => {
setInputs(prevInputs => produce(prevInputs, (draft) => {
draft[name] = value
}))
}
const submit = (actionID: string) => {
onSubmit(inputs, actionID, formData.inputs)
}
const isActionDisabled = isSubmitting || hasInvalidRequiredHumanInput(renderedFormInputs, inputs)
const site = formData.site.site
return (
<div className={cn('mx-auto flex size-full max-w-180 flex-col items-center')}>
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
<AppIcon
size="large"
iconType={site.icon_type}
icon={site.icon}
background={site.icon_background}
imageUrl={site.icon_url}
/>
<div className="grow system-xl-semibold text-text-primary">{site.title}</div>
</div>
<div className="h-0 w-full grow overflow-y-auto">
<div className="rounded-[20px] border border-divider-subtle bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-xs">
{contentList.map(({ key, content }) => (
<ContentItem
key={key}
content={content}
formInputFields={formData.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className="flex flex-wrap gap-1 py-1">
{formData.user_actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isActionDisabled}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(action.id)}
>
{action.title}
</Button>
))}
</div>
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
</div>
<BrandingFooter
removeWebappBrand={removeWebappBrand}
replaceWebappLogo={replaceWebappLogo}
/>
</div>
</div>
)
}
export default LoadedFormContent

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