mirror of
https://github.com/langgenius/dify.git
synced 2026-06-16 04:46:24 +08:00
Compare commits
17 Commits
copilot/ch
...
hotfix/1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c692d7b0b | |||
| 33448bed6e | |||
| 14bd643664 | |||
| 34a17c3ce6 | |||
| 18f083607b | |||
| 2fe8dbd7ca | |||
| 80cd289e87 | |||
| a14bc8a371 | |||
| 7f392b6950 | |||
| b0a3399774 | |||
| 2d5186fb28 | |||
| 06f076e0ff | |||
| 5b79f7e99d | |||
| 1cee1a25b6 | |||
| c0f237bf35 | |||
| 75d7fc0526 | |||
| c057b5c5ff |
73
.github/scripts/check-hotfix-cherry-picks.sh
vendored
Normal file
73
.github/scripts/check-hotfix-cherry-picks.sh
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_SHA=${BASE_SHA:-}
|
||||
HEAD_SHA=${HEAD_SHA:-}
|
||||
MAIN_REF=${MAIN_REF:-origin/main}
|
||||
REMEDIATION_HINT="Changes should be made from the main branch using git cherry-pick -x."
|
||||
|
||||
error() {
|
||||
printf 'ERROR: %s\n' "$1" >&2
|
||||
}
|
||||
|
||||
if [[ -z "$BASE_SHA" || -z "$HEAD_SHA" ]]; then
|
||||
error "BASE_SHA and HEAD_SHA are required. $REMEDIATION_HINT"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$BASE_SHA^{commit}" > /dev/null 2>&1; then
|
||||
error "Base commit '$BASE_SHA' is not available in the local git checkout."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$HEAD_SHA^{commit}" > /dev/null 2>&1; then
|
||||
error "Head commit '$HEAD_SHA' is not available in the local git checkout."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$MAIN_REF^{commit}" > /dev/null 2>&1; then
|
||||
error "Main ref '$MAIN_REF' is not available in the local git checkout. $REMEDIATION_HINT"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
failed=0
|
||||
checked=0
|
||||
|
||||
while IFS= read -r commit_sha; do
|
||||
[[ -n "$commit_sha" ]] || continue
|
||||
|
||||
checked=$((checked + 1))
|
||||
subject=$(git log -1 --format=%s "$commit_sha")
|
||||
source_sha=$(
|
||||
git log -1 --format=%B "$commit_sha" \
|
||||
| sed -nE 's/^\(cherry picked from commit ([0-9a-fA-F]{7,64})\)$/\1/p' \
|
||||
| tail -n 1
|
||||
)
|
||||
|
||||
if [[ -z "$source_sha" ]]; then
|
||||
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "$source_sha^{commit}" 2> /dev/null; then
|
||||
error "Commit $commit_sha ($subject) references source $source_sha, but that commit is not available locally. $REMEDIATION_HINT"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! git merge-base --is-ancestor "$source_sha" "$MAIN_REF"; then
|
||||
error "Commit $commit_sha ($subject) references source $source_sha, but that source is not reachable from main ($MAIN_REF). $REMEDIATION_HINT"
|
||||
failed=1
|
||||
fi
|
||||
done < <(git rev-list --reverse "$BASE_SHA..$HEAD_SHA")
|
||||
|
||||
if [[ "$failed" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$checked" -eq 0 ]]; then
|
||||
echo "No PR commits to check."
|
||||
else
|
||||
echo "Verified $checked PR commit(s) include cherry-pick provenance from main."
|
||||
fi
|
||||
49
.github/workflows/hotfix-cherry-pick.yml
vendored
Normal file
49
.github/workflows/hotfix-cherry-pick.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
name: Hotfix Cherry-Pick Provenance
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'hotfix/**'
|
||||
- 'lts/**'
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- ready_for_review
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: hotfix-cherry-pick-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-cherry-pick-provenance:
|
||||
name: Require cherry-pick provenance
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch PR base, PR head, and main
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
git fetch --no-tags --prune origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" \
|
||||
"+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head"
|
||||
|
||||
- name: Load checker from main
|
||||
run: git show origin/main:.github/scripts/check-hotfix-cherry-picks.sh > "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
||||
|
||||
- name: Check PR commits
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
MAIN_REF: origin/main
|
||||
run: bash "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
||||
@ -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(
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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()
|
||||
),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.14.1"
|
||||
version = "1.14.2"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -16,6 +16,7 @@ from pydantic import TypeAdapter
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.agent.entities import AgentToolEntity
|
||||
from core.helper import marketplace
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
@ -310,6 +311,8 @@ class PluginMigration:
|
||||
"""
|
||||
Fetch plugin unique identifier using plugin id.
|
||||
"""
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
return None
|
||||
plugin_manifest = marketplace.batch_fetch_plugin_manifests([plugin_id])
|
||||
if not plugin_manifest:
|
||||
return None
|
||||
@ -542,6 +545,11 @@ class PluginMigration:
|
||||
"""
|
||||
Install plugins for a tenant.
|
||||
"""
|
||||
if plugin_identifiers_map and not dify_config.MARKETPLACE_ENABLED:
|
||||
raise ValueError(
|
||||
"Marketplace disabled in offline mode; cannot bulk-install plugins. "
|
||||
"Pre-upload plugin packages via Console first."
|
||||
)
|
||||
manager = PluginInstaller()
|
||||
|
||||
# download all the plugins and upload
|
||||
|
||||
@ -73,35 +73,43 @@ class PluginService:
|
||||
cache_not_exists.append(plugin_id)
|
||||
|
||||
if cache_not_exists:
|
||||
manifests = {
|
||||
manifest.plugin_id: manifest
|
||||
for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists)
|
||||
}
|
||||
|
||||
for plugin_id, manifest in manifests.items():
|
||||
latest_plugin = PluginService.LatestPluginCache(
|
||||
plugin_id=plugin_id,
|
||||
version=manifest.latest_version,
|
||||
unique_identifier=manifest.latest_package_identifier,
|
||||
status=manifest.status,
|
||||
deprecated_reason=manifest.deprecated_reason,
|
||||
alternative_plugin_id=manifest.alternative_plugin_id,
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.info(
|
||||
"Marketplace disabled; skipping latest-plugins metadata fetch for %d ids",
|
||||
len(cache_not_exists),
|
||||
)
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
else:
|
||||
manifests = {
|
||||
manifest.plugin_id: manifest
|
||||
for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists)
|
||||
}
|
||||
|
||||
# Store in Redis
|
||||
redis_client.setex(
|
||||
f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}",
|
||||
PluginService.REDIS_TTL,
|
||||
latest_plugin.model_dump_json(),
|
||||
)
|
||||
for plugin_id, manifest in manifests.items():
|
||||
latest_plugin = PluginService.LatestPluginCache(
|
||||
plugin_id=plugin_id,
|
||||
version=manifest.latest_version,
|
||||
unique_identifier=manifest.latest_package_identifier,
|
||||
status=manifest.status,
|
||||
deprecated_reason=manifest.deprecated_reason,
|
||||
alternative_plugin_id=manifest.alternative_plugin_id,
|
||||
)
|
||||
|
||||
result[plugin_id] = latest_plugin
|
||||
# Store in Redis
|
||||
redis_client.setex(
|
||||
f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}",
|
||||
PluginService.REDIS_TTL,
|
||||
latest_plugin.model_dump_json(),
|
||||
)
|
||||
|
||||
# pop plugin_id from cache_not_exists
|
||||
cache_not_exists.remove(plugin_id)
|
||||
result[plugin_id] = latest_plugin
|
||||
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
# pop plugin_id from cache_not_exists
|
||||
cache_not_exists.remove(plugin_id)
|
||||
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
|
||||
@ -1350,6 +1350,12 @@ class RagPipelineService:
|
||||
)
|
||||
return workflow_node_execution_db_model
|
||||
|
||||
def _fetch_recommended_plugin_manifests(self, plugin_ids: list[str]) -> list[Any]:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.info("Marketplace disabled; recommended-plugins list empty")
|
||||
return []
|
||||
return marketplace.batch_fetch_plugin_by_ids(plugin_ids)
|
||||
|
||||
def get_recommended_plugins(self, type: str) -> dict[str, Any]:
|
||||
# Query active recommended plugins
|
||||
stmt = select(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
|
||||
@ -1372,7 +1378,7 @@ class RagPipelineService:
|
||||
)
|
||||
providers_map = {provider.plugin_id: provider.to_dict() for provider in providers}
|
||||
|
||||
plugin_manifests = marketplace.batch_fetch_plugin_by_ids(plugin_ids)
|
||||
plugin_manifests = self._fetch_recommended_plugin_manifests(plugin_ids)
|
||||
plugin_manifests_map = {manifest["plugin_id"]: manifest for manifest in plugin_manifests}
|
||||
|
||||
installed_plugin_list = []
|
||||
|
||||
@ -9,6 +9,7 @@ import yaml
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from constants import DOCUMENT_EXTENSIONS
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
|
||||
@ -273,6 +274,13 @@ class RagPipelineTransformService:
|
||||
plugin_unique_identifier = dependency.get("value", {}).get("plugin_unique_identifier")
|
||||
plugin_id = plugin_unique_identifier.split(":")[0]
|
||||
if plugin_id not in installed_plugins_ids:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.warning(
|
||||
"Marketplace disabled; skipping auto-install of %s. "
|
||||
"Pre-install via Console if pipeline requires it.",
|
||||
plugin_id,
|
||||
)
|
||||
continue
|
||||
plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(plugin_id) # type: ignore
|
||||
if plugin_unique_identifier:
|
||||
need_install_plugin_unique_identifiers.append(plugin_unique_identifier)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
|
||||
MIGRATION_MODULE = "services.plugin.plugin_migration"
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_returns_none_when_disabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", False)
|
||||
batch_fetch = mocker.patch("services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests")
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
|
||||
assert result is None
|
||||
batch_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_calls_marketplace_when_enabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", True)
|
||||
manifest = mocker.MagicMock()
|
||||
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
||||
mocker.patch(
|
||||
"services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests",
|
||||
return_value=[manifest],
|
||||
)
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
|
||||
assert result == "langgenius/openai:1.0.0@abc"
|
||||
|
||||
|
||||
class TestHandlePluginInstanceInstall:
|
||||
def test_raises_when_disabled_and_map_nonempty(self) -> None:
|
||||
with patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg:
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
|
||||
with pytest.raises(ValueError, match="Marketplace disabled"):
|
||||
PluginMigration.handle_plugin_instance_install(
|
||||
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
|
||||
)
|
||||
|
||||
def test_no_raise_when_disabled_and_map_empty(self) -> None:
|
||||
with (
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_installer = MagicMock()
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True)
|
||||
|
||||
result = PluginMigration.handle_plugin_instance_install("tenant1", {})
|
||||
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_proceeds_when_enabled(self) -> None:
|
||||
with (
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.marketplace") as mock_marketplace,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_marketplace.download_plugin_pkg.return_value = b"pkg_data"
|
||||
mock_installer = MagicMock()
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True)
|
||||
|
||||
result = PluginMigration.handle_plugin_instance_install(
|
||||
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
|
||||
)
|
||||
|
||||
mock_marketplace.download_plugin_pkg.assert_called_once()
|
||||
assert "success" in result or "failed" in result
|
||||
50
api/tests/unit_tests/services/plugin/test_plugin_service.py
Normal file
50
api/tests/unit_tests/services/plugin/test_plugin_service.py
Normal file
@ -0,0 +1,50 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
MODULE = "services.plugin.plugin_service"
|
||||
|
||||
|
||||
class TestFetchLatestPluginVersion:
|
||||
def test_skips_marketplace_fetch_when_disabled(self) -> None:
|
||||
"""Cache misses stay None; marketplace is never called when disabled."""
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MODULE}.redis_client") as mock_redis,
|
||||
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_redis.get.return_value = None # all cache misses
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai", "langgenius/anthropic"])
|
||||
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_not_called()
|
||||
assert result == {"langgenius/openai": None, "langgenius/anthropic": None}
|
||||
|
||||
def test_calls_marketplace_fetch_when_enabled(self) -> None:
|
||||
"""Cache misses trigger marketplace fetch when enabled."""
|
||||
manifest = MagicMock()
|
||||
manifest.plugin_id = "langgenius/openai"
|
||||
manifest.latest_version = "1.0.0"
|
||||
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
||||
manifest.status = "active"
|
||||
manifest.deprecated_reason = ""
|
||||
manifest.alternative_plugin_id = ""
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MODULE}.redis_client") as mock_redis,
|
||||
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_redis.get.return_value = None
|
||||
mock_marketplace.batch_fetch_plugin_manifests.return_value = [manifest]
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai"])
|
||||
|
||||
# The list arg is mutated by remove() after the call, so check call count + result.
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_called_once()
|
||||
assert result["langgenius/openai"] is not None
|
||||
assert result["langgenius/openai"].version == "1.0.0"
|
||||
@ -0,0 +1,36 @@
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
|
||||
def _make_service() -> RagPipelineService:
|
||||
return RagPipelineService.__new__(RagPipelineService)
|
||||
|
||||
|
||||
def test_fetch_recommended_plugin_manifests_returns_empty_when_disabled(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", False)
|
||||
batch_fetch = mocker.patch("services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids")
|
||||
|
||||
service = _make_service()
|
||||
result = service._fetch_recommended_plugin_manifests(["langgenius/openai"])
|
||||
|
||||
assert result == []
|
||||
batch_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_recommended_plugin_manifests_returns_data_when_enabled(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", True)
|
||||
expected = [{"plugin_id": "langgenius/openai", "name": "OpenAI"}]
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids",
|
||||
return_value=expected,
|
||||
)
|
||||
|
||||
service = _make_service()
|
||||
result = service._fetch_recommended_plugin_manifests(["langgenius/openai"])
|
||||
|
||||
assert result == expected
|
||||
@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from models.dataset import Dataset
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import KnowledgeConfiguration
|
||||
@ -514,3 +516,64 @@ def test_deal_document_data_upload_file_with_existing_file(mocker) -> None:
|
||||
assert document.data_source_type == "local_file"
|
||||
assert "real_file_id" in document.data_source_info
|
||||
assert add_mock.call_count >= 2
|
||||
|
||||
|
||||
def _make_service():
|
||||
return RagPipelineTransformService.__new__(RagPipelineTransformService)
|
||||
|
||||
|
||||
def test_deal_dependencies_skips_marketplace_when_disabled(mocker: MockerFixture, caplog) -> None:
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED",
|
||||
False,
|
||||
)
|
||||
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
|
||||
installer.list_plugins.return_value = []
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration")
|
||||
install_call = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
)
|
||||
|
||||
pipeline_yaml = {
|
||||
"dependencies": [
|
||||
{
|
||||
"type": "marketplace",
|
||||
"value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
service._deal_dependencies(pipeline_yaml, "tenant-1")
|
||||
|
||||
install_call.assert_not_called()
|
||||
assert any("Marketplace disabled" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED",
|
||||
True,
|
||||
)
|
||||
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
|
||||
installer.list_plugins.return_value = []
|
||||
migration = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration").return_value
|
||||
migration._fetch_plugin_unique_identifier.return_value = "langgenius/openai:1.0.0@abc"
|
||||
install_call = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
)
|
||||
|
||||
pipeline_yaml = {
|
||||
"dependencies": [
|
||||
{
|
||||
"type": "marketplace",
|
||||
"value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service()
|
||||
service._deal_dependencies(pipeline_yaml, "tenant-1")
|
||||
|
||||
install_call.assert_called_once_with("tenant-1", ["langgenius/openai:1.0.0@abc"])
|
||||
|
||||
@ -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
2
api/uv.lock
generated
@ -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" },
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
402
web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx
Normal file
402
web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
30
web/app/(humanInputLayout)/form/[token]/branding-footer.tsx
Normal file
30
web/app/(humanInputLayout)/form/[token]/branding-footer.tsx
Normal 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
|
||||
53
web/app/(humanInputLayout)/form/[token]/form-status-card.tsx
Normal file
53
web/app/(humanInputLayout)/form/[token]/form-status-card.tsx
Normal 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
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
108
web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx
Normal file
108
web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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: () => {
|
||||
|
||||
@ -40,7 +40,7 @@ vi.mock('../score-slider', () => ({
|
||||
<input
|
||||
role="slider"
|
||||
type="range"
|
||||
min={80}
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={e => onChange(Number((e.target as HTMLInputElement).value))}
|
||||
@ -272,7 +272,7 @@ describe('ConfigParamModal', () => {
|
||||
)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('min', '80')
|
||||
expect(slider).toHaveAttribute('min', '0')
|
||||
expect(slider).toHaveAttribute('max', '100')
|
||||
expect(slider).toHaveValue('90')
|
||||
})
|
||||
@ -375,7 +375,7 @@ describe('ConfigParamModal', () => {
|
||||
it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => {
|
||||
const configWithoutThreshold = {
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0,
|
||||
score_threshold: undefined as unknown as number,
|
||||
}
|
||||
render(
|
||||
<ConfigParamModal
|
||||
@ -390,6 +390,35 @@ describe('ConfigParamModal', () => {
|
||||
expect(screen.getByRole('slider')).toHaveValue('90')
|
||||
})
|
||||
|
||||
it('should preserve zero score threshold instead of falling back to default', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={{
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveValue('0')
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ embedding_provider_name: 'openai' }),
|
||||
0,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set loading state while saving', async () => {
|
||||
let resolveOnSave: () => void
|
||||
const onSave = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
|
||||
|
||||
@ -175,6 +175,22 @@ describe('AnnotationReply', () => {
|
||||
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show zero score threshold when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dash when score threshold is not set', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { queryAnnotationJobStatus } from '@/service/annotation'
|
||||
import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation'
|
||||
import { sleep } from '@/utils'
|
||||
import useAnnotationConfig from '../use-annotation-config'
|
||||
|
||||
@ -162,6 +162,35 @@ describe('useAnnotationConfig', () => {
|
||||
expect(updatedConfig.score_threshold).toBe(0.85)
|
||||
})
|
||||
|
||||
it('should preserve zero score threshold when enabling annotation', async () => {
|
||||
const zeroScoreConfig = { ...defaultConfig, score_threshold: 0 }
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: zeroScoreConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleEnableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}, 0)
|
||||
})
|
||||
|
||||
expect(updateAnnotationStatus).toHaveBeenCalledWith(
|
||||
'test-app',
|
||||
'enable',
|
||||
{
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
},
|
||||
0,
|
||||
)
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0]![0]
|
||||
expect(updatedConfig.score_threshold).toBe(0)
|
||||
})
|
||||
|
||||
it('should set score and embedding model together', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
|
||||
@ -75,7 +75,7 @@ const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, a
|
||||
<Item title={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })} tooltip={t('feature.annotation.scoreThreshold.description', { ns: 'appDebug' })}>
|
||||
<ScoreSlider
|
||||
className="mt-1"
|
||||
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
|
||||
value={(annotationConfig.score_threshold ?? ANNOTATION_DEFAULT.score_threshold) * 100}
|
||||
onChange={(val) => {
|
||||
setAnnotationConfig({
|
||||
...annotationConfig,
|
||||
|
||||
@ -100,7 +100,7 @@ const AnnotationReply = ({
|
||||
<div className="flex items-center gap-4 pt-0.5">
|
||||
<div className="">
|
||||
<div className="mb-0.5 system-2xs-medium-uppercase text-text-tertiary">{t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}</div>
|
||||
<div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold || '-'}</div>
|
||||
<div className="system-xs-regular text-text-secondary">{annotationReply.score_threshold ?? '-'}</div>
|
||||
</div>
|
||||
<div className="h-[27px] w-px rotate-12 bg-divider-subtle"></div>
|
||||
<div className="">
|
||||
|
||||
@ -17,7 +17,7 @@ describe('ScoreSlider', () => {
|
||||
it('should display easy match and accurate match labels', () => {
|
||||
render(<ScoreSlider value={90} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('0.8')).toBeInTheDocument()
|
||||
expect(screen.getByText('0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.0')).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.easyMatch/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.accurateMatch/)).toBeInTheDocument()
|
||||
@ -36,4 +36,11 @@ describe('ScoreSlider', () => {
|
||||
expect(getSliderInput()).toHaveValue('95')
|
||||
expect(screen.getByText('0.95')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow zero as the minimum score threshold', () => {
|
||||
render(<ScoreSlider value={0} onChange={vi.fn()} />)
|
||||
|
||||
expect(getSliderInput()).toHaveValue('0')
|
||||
expect(screen.getByText('0.00')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -17,13 +17,16 @@ const clamp = (value: number, min: number, max: number) => {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
const SCORE_MIN = 0
|
||||
const SCORE_MAX = 100
|
||||
|
||||
const ScoreSlider: FC<Props> = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const safeValue = clamp(value, 80, 100)
|
||||
const safeValue = clamp(value, SCORE_MIN, SCORE_MAX)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@ -31,8 +34,8 @@ const ScoreSlider: FC<Props> = ({
|
||||
<Slider
|
||||
className="w-full"
|
||||
value={safeValue}
|
||||
min={80}
|
||||
max={100}
|
||||
min={SCORE_MIN}
|
||||
max={SCORE_MAX}
|
||||
step={1}
|
||||
onValueChange={onChange}
|
||||
aria-label={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}
|
||||
@ -40,7 +43,7 @@ const ScoreSlider: FC<Props> = ({
|
||||
<div
|
||||
className="pointer-events-none absolute top-[-16px] system-sm-semibold text-text-primary"
|
||||
style={{
|
||||
left: `calc(4px + ${(safeValue - 80) / 20} * (100% - 8px))`,
|
||||
left: `calc(4px + ${safeValue / SCORE_MAX} * (100% - 8px))`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
@ -49,7 +52,7 @@ const ScoreSlider: FC<Props> = ({
|
||||
</div>
|
||||
<div className="mt-[10px] flex items-center justify-between system-xs-semibold-uppercase">
|
||||
<div className="flex space-x-1 text-util-colors-cyan-cyan-500">
|
||||
<div>0.8</div>
|
||||
<div>0.0</div>
|
||||
<div>·</div>
|
||||
<div>{t('feature.annotation.scoreThreshold.easyMatch', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
|
||||
@ -53,7 +53,7 @@ const useAnnotationConfig = ({
|
||||
setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => {
|
||||
draft.enabled = true
|
||||
draft.embedding_model = embeddingModel
|
||||
if (!draft.score_threshold)
|
||||
if (draft.score_threshold === undefined || draft.score_threshold === null)
|
||||
draft.score_threshold = ANNOTATION_DEFAULT.score_threshold
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
|
||||
@ -13,6 +13,7 @@ let parameterRules: Array<Record<string, unknown>> | undefined = [
|
||||
},
|
||||
]
|
||||
let isRulesLoading = false
|
||||
let isRulesPending = false
|
||||
let currentProvider: Record<string, unknown> | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } }
|
||||
let currentModel: Record<string, unknown> | undefined = {
|
||||
model: 'gpt-3.5-turbo',
|
||||
@ -49,7 +50,7 @@ vi.mock('@/service/use-common', () => ({
|
||||
data: parameterRules,
|
||||
},
|
||||
isLoading: isRulesLoading,
|
||||
isPending: isRulesLoading,
|
||||
isPending: isRulesPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -92,9 +93,21 @@ vi.mock('../../model-selector', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../presets-parameter', () => ({
|
||||
default: ({ onSelect }: { onSelect: (id: number) => void }) => (
|
||||
<button onClick={() => onSelect(1)}>Preset 1</button>
|
||||
),
|
||||
default: ({ onSelect, supportedParameterNames }: { onSelect: (id: number) => void, supportedParameterNames?: string[] }) => {
|
||||
if (supportedParameterNames && !supportedParameterNames.includes('temperature'))
|
||||
return null
|
||||
|
||||
return <button onClick={() => onSelect(1)}>Preset 1</button>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../presets-parameter-utils', () => ({
|
||||
getSupportedPresetConfig: (_toneId: number, supportedParameterNames?: string[]) => {
|
||||
if (supportedParameterNames && !supportedParameterNames.includes('temperature'))
|
||||
return {}
|
||||
|
||||
return { temperature: 0.8 }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../trigger', () => ({
|
||||
@ -126,6 +139,7 @@ describe('ModelParameterModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isRulesLoading = false
|
||||
isRulesPending = false
|
||||
parameterRules = [
|
||||
{
|
||||
name: 'temperature',
|
||||
@ -194,7 +208,28 @@ describe('ModelParameterModal', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
fireEvent.click(screen.getByText('Preset 1'))
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
...defaultProps.completionParams,
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render preset control when visible parameters do not support preset keys', () => {
|
||||
parameterRules = [
|
||||
{
|
||||
name: 'max_tokens',
|
||||
label: { en_US: 'Max Tokens' },
|
||||
type: 'int',
|
||||
default: 256,
|
||||
min: 1,
|
||||
max: 4096,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
expect(screen.queryByText('Preset 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setModel when model selector picks another model', () => {
|
||||
@ -219,11 +254,29 @@ describe('ModelParameterModal', () => {
|
||||
|
||||
it('should render loading state when parameter rules are loading', () => {
|
||||
isRulesLoading = true
|
||||
isRulesPending = true
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render parameter loading when model is not configured and parameter rules query is pending but disabled', () => {
|
||||
isRulesPending = true
|
||||
parameterRules = []
|
||||
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
provider=""
|
||||
modelId=""
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open content when readonly is true', () => {
|
||||
render(<ModelParameterModal {...defaultProps} readonly />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
@ -299,6 +352,7 @@ describe('ModelParameterModal', () => {
|
||||
it('should render the empty loading fallback when rules resolve to an empty list', () => {
|
||||
parameterRules = []
|
||||
isRulesLoading = true
|
||||
isRulesPending = true
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Open Settings'))
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import PresetsParameter from '../presets-parameter'
|
||||
import { getSupportedPresetConfig } from '../presets-parameter-utils'
|
||||
|
||||
describe('PresetsParameter', () => {
|
||||
beforeEach(() => {
|
||||
@ -47,4 +48,22 @@ describe('PresetsParameter', () => {
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('should render presets when at least one preset parameter is supported', () => {
|
||||
render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['temperature']} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render presets when no preset parameters are supported', () => {
|
||||
render(<PresetsParameter onSelect={vi.fn()} supportedParameterNames={['max_tokens']} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /common\.modelProvider\.loadPresets/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return only supported preset config keys', () => {
|
||||
expect(getSupportedPresetConfig(1, ['temperature'])).toEqual({
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,7 +24,7 @@ import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config'
|
||||
import { useModelParameterRules } from '@/service/use-common'
|
||||
import {
|
||||
useTextGenerationCurrentProviderAndModelAndModelList,
|
||||
@ -32,6 +32,7 @@ import {
|
||||
import ModelSelector from '../model-selector'
|
||||
import ParameterItem from './parameter-item'
|
||||
import PresetsParameter from './presets-parameter'
|
||||
import { getSupportedPresetConfig } from './presets-parameter-utils'
|
||||
import Trigger from './trigger'
|
||||
|
||||
export type ModelParameterModalProps = {
|
||||
@ -75,10 +76,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
const settingsIconRef = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
data: parameterRulesData,
|
||||
isPending,
|
||||
isLoading,
|
||||
} = useModelParameterRules(provider, modelId)
|
||||
const isRulesLoading = isPending || isLoading
|
||||
const isRulesLoading = !!provider && !!modelId && isLoading
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
@ -90,6 +90,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
const parameterRules: ModelParameterRule[] = useMemo(() => {
|
||||
return parameterRulesData?.data || []
|
||||
}, [parameterRulesData])
|
||||
const supportedPresetParameterNames = useMemo(() => {
|
||||
return parameterRules.map(parameterRule => parameterRule.name)
|
||||
}, [parameterRules])
|
||||
|
||||
const handleParamChange = (key: string, value: ParameterValue) => {
|
||||
onCompletionParamsChange({
|
||||
@ -125,13 +128,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
|
||||
const handleSelectPresetParameter = (toneId: number) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (tone) {
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...tone.config,
|
||||
})
|
||||
}
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...getSupportedPresetConfig(toneId, supportedPresetParameterNames),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@ -199,7 +199,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
<div className="flex flex-1 items-center system-sm-semibold-uppercase text-text-secondary">{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
<PresetsParameter
|
||||
onSelect={handleSelectPresetParameter}
|
||||
supportedParameterNames={supportedPresetParameterNames}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import { TONE_LIST } from '@/config'
|
||||
|
||||
export const getSupportedPresetConfig = (toneId: number, supportedParameterNames?: string[]) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (!tone?.config)
|
||||
return {}
|
||||
|
||||
if (!supportedParameterNames)
|
||||
return { ...tone.config }
|
||||
|
||||
const supportedParameterNameSet = new Set(supportedParameterNames)
|
||||
|
||||
return Object.entries(tone.config).reduce<Record<string, number>>((acc, [key, value]) => {
|
||||
if (supportedParameterNameSet.has(key))
|
||||
acc[key] = value
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
@ -12,6 +12,8 @@ import { Scales02 } from '@/app/components/base/icons/src/vender/solid/FinanceAn
|
||||
import { Target04 } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { TONE_LIST } from '@/config'
|
||||
|
||||
const PRESET_TONE_LIST = TONE_LIST.slice(0, 3)
|
||||
|
||||
const toneI18nKeyMap = {
|
||||
Creative: 'model.tone.Creative',
|
||||
Balanced: 'model.tone.Balanced',
|
||||
@ -27,10 +29,18 @@ const TONE_ICONS: Record<number, ReactNode> = {
|
||||
|
||||
type PresetsParameterProps = {
|
||||
onSelect: (toneId: number) => void
|
||||
supportedParameterNames?: string[]
|
||||
}
|
||||
|
||||
function PresetsParameter({ onSelect }: PresetsParameterProps) {
|
||||
function PresetsParameter({ onSelect, supportedParameterNames }: PresetsParameterProps) {
|
||||
const { t } = useTranslation()
|
||||
const supportedParameterNameSet = supportedParameterNames ? new Set(supportedParameterNames) : undefined
|
||||
const visiblePresetTones = supportedParameterNameSet
|
||||
? PRESET_TONE_LIST.filter(tone => Object.keys(tone.config ?? {}).some(key => supportedParameterNameSet.has(key)))
|
||||
: PRESET_TONE_LIST
|
||||
|
||||
if (!visiblePresetTones.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@ -47,7 +57,7 @@ function PresetsParameter({ onSelect }: PresetsParameterProps) {
|
||||
<span className="ml-0.5 i-ri-arrow-down-s-line h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{TONE_LIST.slice(0, 3).map(tone => (
|
||||
{visiblePresetTones.map(tone => (
|
||||
<DropdownMenuItem key={tone.id} onClick={() => onSelect(tone.id)}>
|
||||
{TONE_ICONS[tone.id]}
|
||||
{t(toneI18nKeyMap[tone.name], { ns: 'common' })}
|
||||
|
||||
@ -75,14 +75,58 @@ vi.mock('@/config', () => ({
|
||||
|
||||
// Mock PresetsParameter component
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter', () => ({
|
||||
default: ({ onSelect }: { onSelect: (toneId: number) => void }) => (
|
||||
<div data-testid="presets-parameter">
|
||||
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
|
||||
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
|
||||
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
|
||||
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
|
||||
</div>
|
||||
),
|
||||
default: ({ onSelect, supportedParameterNames }: { onSelect: (toneId: number) => void, supportedParameterNames?: string[] }) => {
|
||||
const hasSupportedParameter = !supportedParameterNames || supportedParameterNames.some(name => ['temperature', 'top_p', 'presence_penalty', 'frequency_penalty'].includes(name))
|
||||
if (!hasSupportedParameter)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="presets-parameter">
|
||||
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
|
||||
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
|
||||
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
|
||||
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils', () => ({
|
||||
getSupportedPresetConfig: (toneId: number, supportedParameterNames?: string[]) => {
|
||||
const toneConfigMap: Record<number, Record<string, number> | undefined> = {
|
||||
1: {
|
||||
temperature: 0.8,
|
||||
top_p: 0.9,
|
||||
presence_penalty: 0.1,
|
||||
frequency_penalty: 0.1,
|
||||
},
|
||||
2: {
|
||||
temperature: 0.5,
|
||||
top_p: 0.85,
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: 0.3,
|
||||
},
|
||||
3: {
|
||||
temperature: 0.2,
|
||||
top_p: 0.75,
|
||||
presence_penalty: 0.5,
|
||||
frequency_penalty: 0.5,
|
||||
},
|
||||
}
|
||||
const toneConfig = toneConfigMap[toneId]
|
||||
if (!toneConfig)
|
||||
return {}
|
||||
|
||||
if (!supportedParameterNames)
|
||||
return toneConfig
|
||||
|
||||
return Object.entries(toneConfig).reduce<Record<string, number>>((acc, [key, value]) => {
|
||||
if (supportedParameterNames.includes(key))
|
||||
acc[key] = value
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock ParameterItem component
|
||||
@ -148,10 +192,12 @@ const createDefaultProps = (overrides: Partial<{
|
||||
const setupModelParameterRulesMock = (config: {
|
||||
data?: ModelParameterRule[]
|
||||
isPending?: boolean
|
||||
isLoading?: boolean
|
||||
} = {}) => {
|
||||
mockUseModelParameterRules.mockReturnValue({
|
||||
data: config.data ? { data: config.data } : undefined,
|
||||
isPending: config.isPending ?? false,
|
||||
isLoading: config.isLoading ?? config.isPending ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
@ -188,6 +234,19 @@ describe('LLMParamsPanel', () => {
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render loading state when model is not configured and parameter rules query is pending but disabled', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ isPending: true, isLoading: false })
|
||||
const props = createDefaultProps({ provider: '', modelId: '' })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.parameters')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render parameters header', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
@ -202,7 +261,7 @@ describe('LLMParamsPanel', () => {
|
||||
|
||||
it('should render PresetsParameter for openai provider', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
|
||||
|
||||
// Act
|
||||
@ -214,7 +273,7 @@ describe('LLMParamsPanel', () => {
|
||||
|
||||
it('should render PresetsParameter for azure_openai provider', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/azure_openai/azure_openai' })
|
||||
|
||||
// Act
|
||||
@ -224,6 +283,18 @@ describe('LLMParamsPanel', () => {
|
||||
expect(screen.getByTestId('presets-parameter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render PresetsParameter when no visible parameter supports presets', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'max_tokens', type: 'int' })], isPending: false })
|
||||
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('presets-parameter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render PresetsParameter for non-preset providers', () => {
|
||||
// Arrange
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
@ -360,7 +431,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Creative preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -384,7 +463,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Balanced preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -407,7 +494,15 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply Precise preset config', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({
|
||||
data: [
|
||||
createParameterRule({ name: 'temperature' }),
|
||||
createParameterRule({ name: 'top_p' }),
|
||||
createParameterRule({ name: 'presence_penalty' }),
|
||||
createParameterRule({ name: 'frequency_penalty' }),
|
||||
],
|
||||
isPending: false,
|
||||
})
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -430,7 +525,7 @@ describe('LLMParamsPanel', () => {
|
||||
it('should apply empty config for Custom preset (spreads undefined)', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [], isPending: false })
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
@ -444,6 +539,27 @@ describe('LLMParamsPanel', () => {
|
||||
// Assert - Custom preset has no config, so only existing params are kept
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({ existing: 'value' })
|
||||
})
|
||||
|
||||
it('should apply only preset config keys supported by visible parameters', () => {
|
||||
// Arrange
|
||||
const onCompletionParamsChange = vi.fn()
|
||||
setupModelParameterRulesMock({ data: [createParameterRule({ name: 'temperature' })], isPending: false })
|
||||
const props = createDefaultProps({
|
||||
provider: 'langgenius/openai/openai',
|
||||
onCompletionParamsChange,
|
||||
completionParams: { existing: 'value' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<LLMParamsPanel {...props} />)
|
||||
fireEvent.click(screen.getByTestId('preset-creative'))
|
||||
|
||||
// Assert
|
||||
expect(onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
existing: 'value',
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleParamChange', () => {
|
||||
|
||||
@ -10,7 +10,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ParameterItem from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item'
|
||||
import PresetsParameter from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
|
||||
import { getSupportedPresetConfig } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter-utils'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config'
|
||||
import { useModelParameterRules } from '@/service/use-common'
|
||||
|
||||
type Props = {
|
||||
@ -29,20 +30,21 @@ const LLMParamsPanel = ({
|
||||
onCompletionParamsChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: parameterRulesData, isPending: isLoading } = useModelParameterRules(provider, modelId)
|
||||
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
|
||||
const isRulesLoading = !!provider && !!modelId && isLoading
|
||||
|
||||
const parameterRules: ModelParameterRule[] = useMemo(() => {
|
||||
return parameterRulesData?.data || []
|
||||
}, [parameterRulesData])
|
||||
const supportedPresetParameterNames = useMemo(() => {
|
||||
return parameterRules.map(parameterRule => parameterRule.name)
|
||||
}, [parameterRules])
|
||||
|
||||
const handleSelectPresetParameter = (toneId: number) => {
|
||||
const tone = TONE_LIST.find(tone => tone.id === toneId)
|
||||
if (tone) {
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...tone.config,
|
||||
})
|
||||
}
|
||||
onCompletionParamsChange({
|
||||
...completionParams,
|
||||
...getSupportedPresetConfig(toneId, supportedPresetParameterNames),
|
||||
})
|
||||
}
|
||||
const handleParamChange = (key: string, value: ParameterValue) => {
|
||||
onCompletionParamsChange({
|
||||
@ -65,7 +67,7 @@ const LLMParamsPanel = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (isRulesLoading) {
|
||||
return (
|
||||
<div className="mt-5"><Loading /></div>
|
||||
)
|
||||
@ -77,7 +79,10 @@ const LLMParamsPanel = ({
|
||||
<div className={cn('flex h-6 items-center system-sm-semibold text-text-secondary')}>{t('modelProvider.parameters', { ns: 'common' })}</div>
|
||||
{
|
||||
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
|
||||
<PresetsParameter onSelect={handleSelectPresetParameter} />
|
||||
<PresetsParameter
|
||||
onSelect={handleSelectPresetParameter}
|
||||
supportedParameterNames={supportedPresetParameterNames}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.14.1",
|
||||
"version": "1.14.2",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#i18n": {
|
||||
|
||||
28
web/service/annotation.spec.ts
Normal file
28
web/service/annotation.spec.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { AnnotationEnableStatus } from '@/app/components/app/annotation/type'
|
||||
import { updateAnnotationStatus } from './annotation'
|
||||
import { post } from './base'
|
||||
|
||||
vi.mock('./base', () => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('annotation service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should preserve zero score threshold when updating annotation status', () => {
|
||||
updateAnnotationStatus('app-1', AnnotationEnableStatus.enable, {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
}, 0)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('apps/app-1/annotation-reply/enable', {
|
||||
body: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
score_threshold: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -7,7 +7,7 @@ export const fetchAnnotationConfig = (appId: string) => {
|
||||
}
|
||||
export const updateAnnotationStatus = (appId: string, action: AnnotationEnableStatus, embeddingModel?: EmbeddingModelConfig, score?: number) => {
|
||||
let body: any = {
|
||||
score_threshold: score || ANNOTATION_DEFAULT.score_threshold,
|
||||
score_threshold: score ?? ANNOTATION_DEFAULT.score_threshold,
|
||||
}
|
||||
if (embeddingModel) {
|
||||
body = {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user