merge evaluation fe

This commit is contained in:
JzoNg 2026-04-29 15:42:20 +08:00
commit a0d8e84667
91 changed files with 3076 additions and 2806 deletions

3
.github/CODEOWNERS vendored
View File

@ -6,6 +6,9 @@
* @crazywoola @laipz8200 @Yeuoly
# ESLint suppression file is maintained by autofix.ci pruning.
/eslint-suppressions.json
# CODEOWNERS file
/.github/CODEOWNERS @laipz8200 @crazywoola

View File

@ -4,7 +4,7 @@ runs:
using: composite
steps:
- name: Setup Vite+
uses: voidzero-dev/setup-vp@20553a7a7429c429a74894104a2835d7fed28a72 # v1.3.0
uses: voidzero-dev/setup-vp@4f5aa3e38c781f1b01e78fb9255527cee8a6efa6 # v1.8.0
with:
node-version-file: .nvmrc
cache: true

1
.github/labeler.yml vendored
View File

@ -6,5 +6,4 @@ web:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.npmrc'
- '.nvmrc'

View File

@ -43,7 +43,6 @@ jobs:
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
.nvmrc
- name: Check api inputs
if: github.event_name != 'merge_group'

View File

@ -74,7 +74,7 @@ jobs:
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Set up Depot CLI
uses: depot/setup-action@v1
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Extract metadata for Docker
id: meta
@ -84,7 +84,7 @@ jobs:
- name: Build Docker image
id: build
uses: depot/build-push-action@v1
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: ${{ matrix.build_context }}
@ -124,10 +124,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@98e3b2c9eab4f4f98a95c0c0a3ea5e5e672fd2a8 # v3.10.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Validate Docker image
uses: docker/build-push-action@5cd29d66b4a8d8e6f4d5dfe2e9329f0b1d446289 # v6.18.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.build_context }}

View File

@ -44,10 +44,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Depot CLI
uses: depot/setup-action@v1
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Build Docker Image
uses: depot/build-push-action@v1
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
push: false
@ -71,10 +71,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@98e3b2c9eab4f4f98a95c0c0a3ea5e5e672fd2a8 # v3.10.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build Docker Image
uses: docker/build-push-action@5cd29d66b4a8d8e6f4d5dfe2e9329f0b1d446289 # v6.18.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.context }}

View File

@ -69,7 +69,6 @@ jobs:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.npmrc'
- '.nvmrc'
- '.github/workflows/web-tests.yml'
- '.github/actions/setup-web/**'
@ -83,7 +82,6 @@ jobs:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.npmrc'
- '.nvmrc'
- 'docker/docker-compose.middleware.yaml'
- 'docker/middleware.env.example'

View File

@ -81,7 +81,6 @@ jobs:
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
.nvmrc
.github/workflows/style.yml
.github/actions/setup-web/**
@ -108,8 +107,6 @@ jobs:
- name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
env:
NODE_OPTIONS: --max-old-space-size=4096
run: vp run lint:tss
- name: Web type check

View File

@ -9,7 +9,6 @@ on:
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .npmrc
concurrency:
group: sdk-tests-${{ github.head_ref || github.run_id }}

View File

@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1.0.107
uses: anthropics/claude-code-action@ef50f123a3a9be95b60040d042717517407c7256 # v1.0.110
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

1
.npmrc
View File

@ -1 +0,0 @@
save-exact=true

View File

@ -113,8 +113,18 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
# Validates name encoding for non-Latin characters.
name = name.strip().encode("utf-8").decode("utf-8") if name else None
# generate random password
new_password = secrets.token_urlsafe(16)
# Generate a random password that satisfies the password policy.
# The iteration limit guards against infinite loops caused by unexpected bugs in valid_password.
for _ in range(100):
new_password = secrets.token_urlsafe(16)
try:
valid_password(new_password)
break
except Exception:
continue
else:
click.echo(click.style("Failed to generate a valid password. Please try again.", fg="red"))
return
# register account
account = RegisterService.register(

View File

@ -38,6 +38,48 @@ class HitTestingPayload(BaseModel):
class DatasetsHitTestingBase:
@staticmethod
def _normalize_hit_testing_query(query: Any) -> str:
"""Return the user-visible query string from legacy and current response shapes."""
if isinstance(query, str):
return query
if isinstance(query, dict):
content = query.get("content")
if isinstance(content, str):
return content
raise ValueError("Invalid hit testing query response")
@staticmethod
def _normalize_hit_testing_records(records: Any) -> list[dict[str, Any]]:
"""Coerce nullable collection fields into lists before response validation."""
if not isinstance(records, list):
return []
normalized_records: list[dict[str, Any]] = []
for record in records:
if not isinstance(record, dict):
continue
normalized_record = dict(record)
segment = normalized_record.get("segment")
if isinstance(segment, dict):
normalized_segment = dict(segment)
if normalized_segment.get("keywords") is None:
normalized_segment["keywords"] = []
normalized_record["segment"] = normalized_segment
if normalized_record.get("child_chunks") is None:
normalized_record["child_chunks"] = []
if normalized_record.get("files") is None:
normalized_record["files"] = []
normalized_records.append(normalized_record)
return normalized_records
@staticmethod
def get_and_validate_dataset(dataset_id: str):
assert isinstance(current_user, Account)
@ -75,7 +117,12 @@ class DatasetsHitTestingBase:
attachment_ids=args.get("attachment_ids"),
limit=10,
)
return {"query": response["query"], "records": marshal(response["records"], hit_testing_record_fields)}
return {
"query": DatasetsHitTestingBase._normalize_hit_testing_query(response.get("query")),
"records": DatasetsHitTestingBase._normalize_hit_testing_records(
marshal(response.get("records", []), hit_testing_record_fields)
),
}
except services.errors.index.IndexNotInitializedError:
raise DatasetNotInitializedError()
except ProviderTokenNotInitError as ex:

View File

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

View File

@ -3,21 +3,21 @@ from __future__ import annotations
import base64
import json
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
import yaml
from faker import Faker
from graphon.enums import BuiltinNodeTypes
from core.trigger.constants import (
TRIGGER_PLUGIN_NODE_TYPE,
TRIGGER_SCHEDULE_NODE_TYPE,
TRIGGER_WEBHOOK_NODE_TYPE,
)
from extensions.ext_redis import redis_client
from models import Account, AppMode
from graphon.enums import BuiltinNodeTypes
from models import Account, App, AppMode
from models.model import AppModelConfig, IconType
from services import app_dsl_service
from services.account_service import AccountService, TenantService
@ -67,6 +67,22 @@ def _pending_yaml_content(version: str = "99.0.0") -> bytes:
return (f'version: "{version}"\nkind: app\napp:\n name: Loop Test\n mode: workflow\n').encode()
def _app_stub(**overrides: Any) -> App:
defaults = {
"id": str(uuid4()),
"tenant_id": _DEFAULT_TENANT_ID,
"mode": AppMode.WORKFLOW.value,
"name": "n",
"description": "d",
"icon_type": IconType.EMOJI,
"icon": "i",
"icon_background": "#fff",
"use_icon_as_answer_icon": False,
"app_model_config": None,
}
return cast(App, SimpleNamespace(**(defaults | overrides)))
class TestAppDslService:
"""Integration tests for AppDslService using testcontainers."""
@ -585,7 +601,7 @@ class TestAppDslService:
def test_check_dependencies_returns_empty_when_no_redis_data(self, db_session_with_containers):
service = AppDslService(db_session_with_containers)
app_model = SimpleNamespace(id=str(uuid4()), tenant_id=_DEFAULT_TENANT_ID)
app_model = _app_stub()
result = service.check_dependencies(app_model=app_model)
assert result.leaked_dependencies == []
@ -614,7 +630,7 @@ class TestAppDslService:
)
service = AppDslService(db_session_with_containers)
result = service.check_dependencies(app_model=SimpleNamespace(id=app_id, tenant_id=_DEFAULT_TENANT_ID))
result = service.check_dependencies(app_model=_app_stub(id=app_id))
assert len(result.leaked_dependencies) == 1
def test_check_dependencies_with_real_app(self, db_session_with_containers, mock_external_service_dependencies):
@ -656,9 +672,7 @@ class TestAppDslService:
lambda _m: SimpleNamespace(kind="conv"),
)
app = SimpleNamespace(
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
app = _app_stub(
mode=AppMode.WORKFLOW.value,
name="old",
description="old-desc",
@ -667,7 +681,6 @@ class TestAppDslService:
icon_background="#111111",
updated_by=None,
updated_at=None,
app_model_config=None,
)
service = AppDslService(db_session_with_containers)
updated = service._create_or_update_app(
@ -745,15 +758,7 @@ class TestAppDslService:
service = AppDslService(db_session_with_containers)
with pytest.raises(ValueError, match="Missing workflow data"):
service._create_or_update_app(
app=SimpleNamespace(
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
mode=AppMode.WORKFLOW.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
app=_app_stub(mode=AppMode.WORKFLOW.value),
data={"app": {"mode": AppMode.WORKFLOW.value}},
account=_account_mock(),
)
@ -762,15 +767,7 @@ class TestAppDslService:
service = AppDslService(db_session_with_containers)
with pytest.raises(ValueError, match="Missing model_config"):
service._create_or_update_app(
app=SimpleNamespace(
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
mode=AppMode.CHAT.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
app=_app_stub(mode=AppMode.CHAT.value),
data={"app": {"mode": AppMode.CHAT.value}},
account=_account_mock(),
)
@ -799,15 +796,7 @@ class TestAppDslService:
service = AppDslService(db_session_with_containers)
with pytest.raises(ValueError, match="Invalid app mode"):
service._create_or_update_app(
app=SimpleNamespace(
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
mode=AppMode.RAG_PIPELINE.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
app=_app_stub(mode=AppMode.RAG_PIPELINE.value),
data={"app": {"mode": AppMode.RAG_PIPELINE.value}},
account=_account_mock(),
)
@ -828,29 +817,16 @@ class TestAppDslService:
lambda *_args, **_kwargs: model_calls.append(True),
)
workflow_app = SimpleNamespace(
workflow_app = _app_stub(
mode=AppMode.WORKFLOW.value,
tenant_id=_DEFAULT_TENANT_ID,
name="n",
icon="i",
icon_type="emoji",
icon_background="#fff",
description="d",
use_icon_as_answer_icon=False,
app_model_config=None,
)
AppDslService.export_dsl(workflow_app)
assert workflow_calls == [True]
chat_app = SimpleNamespace(
chat_app = _app_stub(
mode=AppMode.CHAT.value,
tenant_id=_DEFAULT_TENANT_ID,
name="n",
icon="i",
icon_type="emoji",
icon_background="#fff",
description="d",
use_icon_as_answer_icon=False,
app_model_config=SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": []}}),
)
AppDslService.export_dsl(chat_app)
@ -863,16 +839,14 @@ class TestAppDslService:
lambda **_kwargs: None,
)
emoji_app = SimpleNamespace(
emoji_app = _app_stub(
mode=AppMode.WORKFLOW.value,
tenant_id=_DEFAULT_TENANT_ID,
name="Emoji App",
icon="🎨",
icon_type=IconType.EMOJI,
icon_background="#FF5733",
description="App with emoji icon",
use_icon_as_answer_icon=True,
app_model_config=None,
)
yaml_output = AppDslService.export_dsl(emoji_app)
data = yaml.safe_load(yaml_output)
@ -880,16 +854,14 @@ class TestAppDslService:
assert data["app"]["icon_type"] == "emoji"
assert data["app"]["icon_background"] == "#FF5733"
image_app = SimpleNamespace(
image_app = _app_stub(
mode=AppMode.WORKFLOW.value,
tenant_id=_DEFAULT_TENANT_ID,
name="Image App",
icon="https://example.com/icon.png",
icon_type=IconType.IMAGE,
icon_background="#FFEAD5",
description="App with image icon",
use_icon_as_answer_icon=False,
app_model_config=None,
)
yaml_output = AppDslService.export_dsl(image_app)
data = yaml.safe_load(yaml_output)
@ -1106,7 +1078,7 @@ class TestAppDslService:
export_data: dict = {}
AppDslService._append_workflow_export_data(
export_data=export_data,
app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID),
app_model=_app_stub(),
include_secret=False,
workflow_id=None,
)
@ -1132,7 +1104,7 @@ class TestAppDslService:
with pytest.raises(ValueError, match="Missing draft workflow configuration"):
AppDslService._append_workflow_export_data(
export_data={},
app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID),
app_model=_app_stub(),
include_secret=False,
workflow_id=None,
)
@ -1160,7 +1132,7 @@ class TestAppDslService:
monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x)
app_model_config = SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": [{"credential_id": "secret"}]}})
app_model = SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID, app_model_config=app_model_config)
app_model = _app_stub(app_model_config=app_model_config)
export_data: dict = {}
AppDslService._append_model_config_export_data(export_data, app_model)
@ -1169,7 +1141,7 @@ class TestAppDslService:
def test_append_model_config_export_data_requires_app_config(self):
with pytest.raises(ValueError, match="Missing app configuration"):
AppDslService._append_model_config_export_data({}, SimpleNamespace(app_model_config=None))
AppDslService._append_model_config_export_data({}, _app_stub(app_model_config=None))
# ── Dependency Extraction ─────────────────────────────────────────

View File

@ -134,6 +134,42 @@ class TestPerformHitTesting:
assert result["query"] == "hello"
assert result["records"] == []
def test_success_normalizes_legacy_query_and_nullable_list_fields(self, dataset):
response = {
"query": {"content": "hello"},
"records": [
{
"segment": {"id": "segment-1", "keywords": None},
"child_chunks": None,
"files": None,
"score": 0.8,
}
],
}
with (
patch.object(
HitTestingService,
"retrieve",
return_value=response,
),
patch(
"controllers.console.datasets.hit_testing_base.marshal",
return_value=response["records"],
),
):
result = DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"})
assert result["query"] == "hello"
assert result["records"] == [
{
"segment": {"id": "segment-1", "keywords": []},
"child_chunks": [],
"files": [],
"score": 0.8,
}
]
def test_index_not_initialized(self, dataset):
with patch.object(
HitTestingService,

View File

@ -171,6 +171,57 @@ class TestHitTestingApiPost:
assert passed_retrieval_model["search_method"] == "semantic_search"
assert passed_retrieval_model["top_k"] == 10
@patch("controllers.service_api.dataset.hit_testing.service_api_ns")
@patch("controllers.console.datasets.hit_testing_base.marshal")
@patch("controllers.console.datasets.hit_testing_base.HitTestingService")
@patch("controllers.console.datasets.hit_testing_base.DatasetService")
@patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))
def test_post_normalizes_legacy_query_and_nullable_list_fields(
self,
mock_current_user,
mock_dataset_svc,
mock_hit_svc,
mock_marshal,
mock_ns,
app,
):
"""Test service API normalizes legacy query shape and nullable list fields."""
dataset_id = str(uuid.uuid4())
tenant_id = str(uuid.uuid4())
mock_dataset = Mock()
mock_dataset.id = dataset_id
mock_dataset_svc.get_dataset.return_value = mock_dataset
mock_dataset_svc.check_dataset_permission.return_value = None
mock_hit_svc.retrieve.return_value = {"query": {"content": "legacy query"}, "records": ["placeholder"]}
mock_hit_svc.hit_testing_args_check.return_value = None
mock_marshal.return_value = [
{
"segment": {"id": "segment-1", "keywords": None},
"child_chunks": None,
"files": None,
"score": 0.9,
}
]
mock_ns.payload = {"query": "legacy query"}
with app.test_request_context():
api = HitTestingApi()
response = HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id)
assert response["query"] == "legacy query"
assert response["records"] == [
{
"segment": {"id": "segment-1", "keywords": []},
"child_chunks": [],
"files": [],
"score": 0.9,
}
]
@patch("controllers.service_api.dataset.hit_testing.service_api_ns")
@patch("controllers.console.datasets.hit_testing_base.DatasetService")
@patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))

View File

@ -1,14 +1,12 @@
"""Primarily used for testing merged cell scenarios"""
import gc
import io
import os
import tempfile
import warnings
from collections import UserDict
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import MagicMock
import pytest
from docx import Document
@ -377,23 +375,21 @@ def test_close_is_idempotent():
extractor.temp_file.close.assert_called_once()
def test_close_handles_async_close_mock():
async def _async_close() -> None:
return None
def test_close_closes_awaitable_close_result():
extractor = object.__new__(WordExtractor)
extractor._closed = False
extractor.temp_file = MagicMock()
extractor.temp_file.close = AsyncMock()
close_result = _async_close()
extractor.temp_file.close = MagicMock(return_value=close_result)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
extractor.close()
gc.collect()
extractor.close()
assert close_result.cr_frame is None
extractor.temp_file.close.assert_called_once()
assert not [
warning
for warning in caught
if issubclass(warning.category, RuntimeWarning) and "AsyncMockMixin._execute_mock_call" in str(warning.message)
]
def test_extract_images_handles_invalid_external_cases(monkeypatch):

2
api/uv.lock generated
View File

@ -1376,7 +1376,7 @@ wheels = [
[[package]]
name = "dify-api"
version = "1.13.3"
version = "1.14.0"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },

View File

@ -21,7 +21,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.13.3
image: langgenius/dify-api:1.14.0
restart: always
environment:
# Use the shared environment variables.
@ -69,7 +69,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.13.3
image: langgenius/dify-api:1.14.0
restart: always
environment:
# Use the shared environment variables.
@ -115,7 +115,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.13.3
image: langgenius/dify-api:1.14.0
restart: always
environment:
# Use the shared environment variables.
@ -152,7 +152,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.13.3
image: langgenius/dify-web:1.14.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@ -268,7 +268,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.14
image: langgenius/dify-sandbox:0.2.15
restart: always
environment:
# The DifySandbox configurations
@ -292,7 +292,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.3-local
image: langgenius/dify-plugin-daemon:0.6.0-local
restart: always
environment:
# Use the shared environment variables.

View File

@ -103,7 +103,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.14
image: langgenius/dify-sandbox:0.2.15
restart: always
env_file:
- ./middleware.env
@ -129,7 +129,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.3-local
image: langgenius/dify-plugin-daemon:0.6.0-local
restart: always
env_file:
- ./middleware.env

View File

@ -745,7 +745,7 @@ services:
# API service
api:
image: langgenius/dify-api:1.13.3
image: langgenius/dify-api:1.14.0
restart: always
environment:
# Use the shared environment variables.
@ -793,7 +793,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.13.3
image: langgenius/dify-api:1.14.0
restart: always
environment:
# Use the shared environment variables.
@ -839,7 +839,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.13.3
image: langgenius/dify-api:1.14.0
restart: always
environment:
# Use the shared environment variables.
@ -876,7 +876,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.13.3
image: langgenius/dify-web:1.14.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@ -992,7 +992,7 @@ services:
# The DifySandbox
sandbox:
image: langgenius/dify-sandbox:0.2.14
image: langgenius/dify-sandbox:0.2.15
restart: always
environment:
# The DifySandbox configurations
@ -1016,7 +1016,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.3-local
image: langgenius/dify-plugin-daemon:0.6.0-local
restart: always
environment:
# Use the shared environment variables.

View File

@ -385,9 +385,6 @@
}
},
"web/app/components/app/configuration/config/agent/agent-tools/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 9
}
@ -641,11 +638,6 @@
"count": 2
}
},
"web/app/components/app/overview/app-card.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/overview/customize/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -2607,14 +2599,6 @@
"count": 1
}
},
"web/app/components/datasets/external-api/external-api-modal/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -2625,11 +2609,6 @@
"count": 1
}
},
"web/app/components/datasets/extra-info/statistics.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/formatted-text/flavours/type.ts": {
"ts/no-empty-object-type": {
"count": 1
@ -3299,9 +3278,6 @@
}
},
"web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -3764,11 +3740,6 @@
"count": 1
}
},
"web/app/components/tools/mcp/detail/tool-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/mcp/mcp-server-modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -3782,11 +3753,6 @@
"count": 1
}
},
"web/app/components/tools/mcp/mcp-service-card.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/mcp/modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -3820,21 +3786,6 @@
"count": 4
}
},
"web/app/components/tools/workflow-tool/confirm-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/workflow-tool/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/workflow-tool/method-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow-app/components/workflow-children.tsx": {
"ts/no-explicit-any": {
"count": 3
@ -4266,9 +4217,6 @@
}
},
"web/app/components/workflow/nodes/_base/components/prompt/editor.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
@ -5736,9 +5684,6 @@
}
},
"web/app/signin/one-more-step.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}

View File

@ -2,11 +2,12 @@
"name": "dify",
"type": "module",
"private": true,
"packageManager": "pnpm@10.33.2",
"packageManager": "pnpm@11.0.0",
"engines": {
"node": "^22.22.1"
},
"scripts": {
"dev": "concurrently -k -n vinext,proxy \"vp run dify-web#dev:vinext\" \"vp run dify-web#dev:proxy\"",
"prepare": "vp config",
"type-check": "vp run -r type-check",
"lint": "eslint --cache --concurrency=auto",
@ -16,6 +17,7 @@
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"concurrently": "catalog:",
"eslint": "catalog:",
"eslint-markdown": "catalog:",
"eslint-plugin-markdown-preferences": "catalog:",

2112
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,11 @@
saveExact: true
catalogMode: prefer
dedupeDirectDeps: true
engineStrict: true
minimumReleaseAge: 1440
optimisticRepeatInstall: true
verifyDepsBeforeRun: install
resolutionMode: time-based
allowBuilds:
'@parcel/watcher': false
canvas: false
@ -5,7 +13,6 @@ allowBuilds:
sharp: false
autoInstallPeers: false
blockExoticSubdeps: true
catalogMode: prefer
shellEmulator: true
strictDepBuilds: true
trustPolicy: no-downgrade
@ -42,13 +49,13 @@ overrides:
svgo@>=3.0.0 <3.3.3: 3.3.3
tar@<=7.5.10: 7.5.11
undici@>=7.0.0 <7.24.0: 7.24.0
vite: npm:@voidzero-dev/vite-plus-core@0.1.19
vitest: npm:@voidzero-dev/vite-plus-test@0.1.19
vite: npm:@voidzero-dev/vite-plus-core@0.1.20
vitest: npm:@voidzero-dev/vite-plus-test@0.1.20
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
catalog:
'@amplitude/analytics-browser': 2.41.1
'@amplitude/plugin-session-replay-browser': 1.28.0
'@amplitude/analytics-browser': 2.42.0
'@amplitude/plugin-session-replay-browser': 1.28.1
'@antfu/eslint-config': 8.2.0
'@base-ui/react': 1.4.1
'@chromatic-com/storybook': 5.1.2
@ -61,16 +68,16 @@ catalog:
'@formatjs/intl-localematcher': 0.8.4
'@headlessui/react': 2.2.10
'@heroicons/react': 2.2.0
'@hono/node-server': 1.19.14
'@hono/node-server': 2.0.0
'@iconify-json/heroicons': 1.2.3
'@iconify-json/ri': 1.2.10
'@lexical/code': 0.43.0
'@lexical/link': 0.43.0
'@lexical/list': 0.43.0
'@lexical/react': 0.43.0
'@lexical/selection': 0.43.0
'@lexical/text': 0.43.0
'@lexical/utils': 0.43.0
'@lexical/code': 0.44.0
'@lexical/link': 0.44.0
'@lexical/list': 0.44.0
'@lexical/react': 0.44.0
'@lexical/selection': 0.44.0
'@lexical/text': 0.44.0
'@lexical/utils': 0.44.0
'@mdx-js/loader': 3.1.1
'@mdx-js/react': 3.1.1
'@mdx-js/rollup': 3.1.1
@ -98,20 +105,20 @@ catalog:
'@tailwindcss/postcss': 4.2.4
'@tailwindcss/typography': 0.5.19
'@tailwindcss/vite': 4.2.4
'@tanstack/eslint-plugin-query': 5.100.5
'@tanstack/eslint-plugin-query': 5.100.6
'@tanstack/react-devtools': 0.10.2
'@tanstack/react-form': 1.29.1
'@tanstack/react-form-devtools': 0.2.22
'@tanstack/react-query': 5.100.5
'@tanstack/react-query-devtools': 5.100.5
'@tanstack/react-query': 5.100.6
'@tanstack/react-query-devtools': 5.100.6
'@tanstack/react-virtual': 3.13.24
'@testing-library/dom': 10.4.1
'@testing-library/jest-dom': 6.9.1
'@testing-library/react': 16.3.2
'@testing-library/user-event': 14.6.1
'@tsslint/cli': 3.0.4
'@tsslint/compat-eslint': 3.0.4
'@tsslint/config': 3.0.4
'@tsslint/cli': 3.1.0
'@tsslint/compat-eslint': 3.1.0
'@tsslint/config': 3.1.0
'@types/js-cookie': 3.0.6
'@types/js-yaml': 4.0.9
'@types/negotiator': 0.6.4
@ -120,9 +127,9 @@ catalog:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3
'@types/sortablejs': 1.15.9
'@typescript-eslint/eslint-plugin': 8.59.0
'@typescript-eslint/parser': 8.59.0
'@typescript/native-preview': 7.0.0-dev.20260426.1
'@typescript-eslint/eslint-plugin': 8.59.1
'@typescript-eslint/parser': 8.59.1
'@typescript/native-preview': 7.0.0-dev.20260428.1
'@vitejs/plugin-react': 6.0.1
'@vitejs/plugin-rsc': 0.5.25
'@vitest/coverage-v8': 4.1.5
@ -134,7 +141,8 @@ catalog:
clsx: 2.1.1
cmdk: 1.1.1
code-inspector-plugin: 1.5.1
copy-to-clipboard: 3.3.3
concurrently: ^9.2.1
copy-to-clipboard: 4.0.2
cron-parser: 5.5.0
dayjs: 1.11.20
decimal.js: 10.6.0
@ -147,8 +155,8 @@ catalog:
emoji-mart: 5.6.0
es-toolkit: 1.46.0
eslint: 10.2.1
eslint-markdown: 0.6.1
eslint-plugin-better-tailwindcss: 4.4.1
eslint-markdown: 0.7.0
eslint-plugin-better-tailwindcss: 4.5.0
eslint-plugin-hyoban: 0.14.1
eslint-plugin-markdown-preferences: 0.41.1
eslint-plugin-no-barrel-files: 1.3.1
@ -161,7 +169,7 @@ catalog:
hono: 4.12.15
html-entities: 2.6.0
html-to-image: 1.11.13
i18next: 26.0.6
i18next: 26.0.8
i18next-resources-to-backend: 1.2.1
iconify-import-svg: 0.2.0
immer: 11.1.4
@ -174,7 +182,7 @@ catalog:
knip: 6.7.0
ky: 2.0.2
lamejs: 1.2.1
lexical: 0.43.0
lexical: 0.44.0
loro-crdt: 1.12.0
mermaid: 11.14.0
mime: 4.1.0
@ -214,18 +222,18 @@ catalog:
string-ts: 2.3.1
tailwind-merge: 3.5.0
tailwindcss: 4.2.4
tldts: 7.0.28
tldts: 7.0.29
tsx: 4.21.0
typescript: 6.0.3
uglify-js: 3.19.3
unist-util-visit: 5.1.0
use-context-selector: 2.0.0
uuid: 13.0.0
vinext: 0.0.41
vite: npm:@voidzero-dev/vite-plus-core@0.1.19
uuid: 14.0.0
vinext: 0.0.45
vite: npm:@voidzero-dev/vite-plus-core@0.1.20
vite-plugin-inspect: 12.0.0-beta.1
vite-plus: 0.1.19
vitest: npm:@voidzero-dev/vite-plus-test@0.1.19
vite-plus: 0.1.20
vitest: npm:@voidzero-dev/vite-plus-test@0.1.20
vitest-browser-react: 2.2.0
vitest-canvas-mock: 1.1.4
zod: 4.3.6

View File

@ -1 +0,0 @@
save-exact=true

View File

@ -195,9 +195,19 @@ describe('Header Nav Flow', () => {
renderNav()
fireEvent.click(screen.getByRole('button', { name: /Alpha/i }))
fireEvent.click(await screen.findByText('menus.newApp'))
const openCreateMenu = async () => {
fireEvent.click(await screen.findByText('menus.newApp'))
return screen.findByText('newApp.startFromBlank')
}
await openCreateMenu()
fireEvent.click(await screen.findByText('newApp.startFromBlank'))
await openCreateMenu()
fireEvent.click(await screen.findByText('newApp.startFromTemplate'))
await openCreateMenu()
fireEvent.click(await screen.findByText('importDSL'))
expect(mockOnCreate).toHaveBeenNthCalledWith(1, 'blank')

View File

@ -6,7 +6,9 @@ import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { AgentTool } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiDeleteBinLine,
RiEqualizer2Line,
@ -23,7 +25,6 @@ import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
import AppIcon from '@/app/components/base/app-icon'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import { CollectionType } from '@/app/components/tools/types'
import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
@ -154,13 +155,23 @@ const AgentTools: FC = () => {
title={(
<div className="flex items-center">
<div className="mr-1">{t('agent.tools.name', { ns: 'appDebug' })}</div>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('agent.tools.description', { ns: 'appDebug' })}
</div>
)}
/>
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('agent.tools.description', { ns: 'appDebug' })}
render={(
<button
type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm p-px outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</button>
)}
/>
<PopoverContent popupClassName="w-[180px] px-3 py-2 system-xs-regular text-text-tertiary">
{t('agent.tools.description', { ns: 'appDebug' })}
</PopoverContent>
</Popover>
</div>
)}
headerRight={(
@ -216,34 +227,59 @@ const AgentTools: FC = () => {
<span className="pr-1.5 system-xs-medium text-text-secondary">{getProviderShowName(item)}</span>
<span className="text-text-tertiary">{item.tool_label}</span>
{!item.isDeleted && !readonly && (
<Tooltip
popupContent={(
<Popover>
<span className="h-4 w-4">
<PopoverTrigger
openOnHover
aria-label={item.tool_name}
render={(
<button
type="button"
className="ml-0.5 hidden h-4 w-4 items-center justify-center rounded-sm outline-hidden group-hover:inline-flex hover:bg-state-base-hover focus-visible:inline-flex focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
data-testid="tool-info-tooltip"
>
<RiInformation2Line className="h-4 w-4 text-text-tertiary" />
</button>
)}
/>
</span>
<PopoverContent popupClassName="w-[180px] px-3 py-2 system-xs-regular">
<div className="w-[180px]">
<div className="mb-1.5 text-text-secondary">{item.tool_name}</div>
<div className="mb-1.5 text-text-tertiary">{t('toolNameUsageTip', { ns: 'tools' })}</div>
<div className="cursor-pointer text-text-accent" onClick={() => copy(item.tool_name)}>{t('copyToolName', { ns: 'tools' })}</div>
<button
type="button"
className="cursor-pointer rounded-sm text-text-accent outline-hidden hover:underline focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
onClick={() => copy(item.tool_name)}
>
{t('copyToolName', { ns: 'tools' })}
</button>
</div>
)}
>
<div className="h-4 w-4">
<div className="ml-0.5 hidden group-hover:inline-block" data-testid="tool-info-tooltip">
<RiInformation2Line className="h-4 w-4 text-text-tertiary" />
</div>
</div>
</Tooltip>
</PopoverContent>
</Popover>
)}
</div>
</div>
<div className="ml-1 flex shrink-0 items-center">
{item.isDeleted && (
<div className="mr-2 flex items-center">
<Tooltip
popupContent={t('toolRemoved', { ns: 'tools' })}
>
<div className="mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5">
<AlertTriangle className="h-4 w-4 text-[#F79009]" />
</div>
</Tooltip>
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('toolRemoved', { ns: 'tools' })}
render={(
<button
type="button"
className="mr-1 cursor-pointer rounded-md p-1 outline-hidden hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
<AlertTriangle className="h-4 w-4 text-[#F79009]" />
</button>
)}
/>
<PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary">
{t('toolRemoved', { ns: 'tools' })}
</PopoverContent>
</Popover>
<div
className="cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive"
onClick={() => {
@ -263,19 +299,25 @@ const AgentTools: FC = () => {
{!item.isDeleted && !readonly && (
<div className="mr-2 hidden items-center gap-1 group-hover:flex">
{!item.notAuthor && (
<Tooltip
popupContent={t('setBuiltInTools.infoAndSetting', { ns: 'tools' })}
needsDelay={false}
>
<div
className="cursor-pointer rounded-md p-1 hover:bg-black/5"
onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}
>
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
className="cursor-pointer rounded-md p-1 outline-hidden hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
aria-label={t('setBuiltInTools.infoAndSetting', { ns: 'tools' })}
onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}
>
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</button>
)}
/>
<TooltipContent>
{t('setBuiltInTools.infoAndSetting', { ns: 'tools' })}
</TooltipContent>
</Tooltip>
)}
<div

View File

@ -447,6 +447,7 @@ describe('AppCard', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.enableTooltip.description' }))
fireEvent.click(screen.getByText('overview.appInfo.enableTooltip.learnMore'))
expect(mockWindowOpen).toHaveBeenCalledWith('https://docs.example.com/use-dify/nodes/user-input', '_blank')

View File

@ -3,6 +3,7 @@ import type { WorkflowLaunchInputValue } from './app-card-utils'
import type { ConfigParams } from './settings'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Switch } from '@langgenius/dify-ui/switch'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
@ -10,7 +11,6 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppBasic from '@/app/components/app-sidebar/basic'
import { useStore as useAppStore } from '@/app/components/app/store'
import Tooltip from '@/app/components/base/tooltip'
import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button'
import Indicator from '@/app/components/header/indicator'
import { useAppContext } from '@/context/app-context'
@ -232,6 +232,31 @@ function AppCard({
triggerModeDisabled,
])
const missingStartNodeContent = cardState.appUnpublished || cardState.missingStartNode
? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<button
type="button"
className="cursor-pointer rounded-sm text-xs font-normal text-text-accent outline-hidden hover:underline focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</button>
</>
)
: ''
const statusPopoverContent = cardState.toggleDisabled
? (
triggerModeDisabled && triggerModeMessage
? triggerModeMessage
: missingStartNodeContent
)
: ''
return (
<div
className={`${isInPanel ? 'border-t border-l-[0.5px]' : 'border-[0.5px] shadow-xs'} w-full max-w-full rounded-xl border-effects-highlight ${className ?? ''} ${cardState.isMinimalState ? 'h-12' : ''}`}
@ -240,13 +265,19 @@ function AppCard({
{triggerModeDisabled && (
triggerModeMessage
? (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true" />
</Tooltip>
<Popover>
<PopoverTrigger
openOnHover
aria-label={typeof triggerModeMessage === 'string' ? triggerModeMessage : basicName}
render={<button type="button" className="absolute inset-0 z-10 cursor-not-allowed rounded-xl outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover" />}
/>
<PopoverContent
placement="right"
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
>
{triggerModeMessage}
</PopoverContent>
</Popover>
)
: <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true" />
)}
@ -272,38 +303,31 @@ function AppCard({
: t('overview.status.disable', { ns: 'appOverview' })}
</div>
</div>
<Tooltip
popupContent={
cardState.toggleDisabled
? (
triggerModeDisabled && triggerModeMessage
? triggerModeMessage
: (cardState.appUnpublished || cardState.missingStartNode)
? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</>
)
: ''
)
: ''
}
position="right"
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
offset={24}
>
<div>
<Switch checked={cardState.runningStatus} onCheckedChange={onChangeStatus} disabled={cardState.toggleDisabled} />
</div>
</Tooltip>
{cardState.toggleDisabled && statusPopoverContent
? (
<Popover>
<PopoverTrigger
openOnHover
nativeButton={false}
aria-label={typeof statusPopoverContent === 'string' ? statusPopoverContent : t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
render={(
<div>
<Switch checked={cardState.runningStatus} onCheckedChange={onChangeStatus} disabled={cardState.toggleDisabled} />
</div>
)}
/>
<PopoverContent
placement="right"
sideOffset={24}
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
>
{statusPopoverContent}
</PopoverContent>
</Popover>
)
: (
<Switch checked={cardState.runningStatus} onCheckedChange={onChangeStatus} disabled={cardState.toggleDisabled} />
)}
</div>
{!cardState.isMinimalState && (
<AppCardUrlSection

View File

@ -9,7 +9,8 @@ import {
} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getNodeVisual, getToneClasses } from '@/app/components/evaluation/components/metric-selector/utils'
import { getEvaluationNodeBlockType } from '@/app/components/evaluation/components/metric-selector/utils'
import BlockIcon from '@/app/components/workflow/block-icon'
type EvaluationCellProps = {
evaluation: EvaluationLogItem[]
@ -60,37 +61,34 @@ const EvaluationCell = ({
popupClassName="w-[320px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
<div data-testid="workflow-log-evaluation-popover" className="max-h-[320px] overflow-y-auto bg-components-panel-bg">
{evaluation.map((item, index) => {
const nodeVisual = item.nodeInfo ? getNodeVisual(item.nodeInfo) : null
const nodeToneClasses = nodeVisual ? getToneClasses(nodeVisual.tone) : null
return (
<div
key={`${item.name}-${index}`}
className={cn(
'grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3',
index !== evaluation.length - 1 && 'border-b border-divider-subtle',
{evaluation.map((item, index) => (
<div
key={item.nodeInfo ? `${item.name}-${item.nodeInfo.node_id}` : item.name}
className={cn(
'grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3',
index !== evaluation.length - 1 && 'border-b border-divider-subtle',
)}
>
<div className="min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{item.name}</div>
{item.nodeInfo && (
<div className="mt-1 flex min-w-0 items-center gap-1.5">
<BlockIcon
type={getEvaluationNodeBlockType(item.nodeInfo)}
size="xs"
className="h-[18px] w-[18px] shrink-0"
/>
<span className="truncate system-xs-regular text-text-tertiary">
{item.nodeInfo.title}
</span>
</div>
)}
>
<div className="min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{item.name}</div>
{item.nodeInfo && nodeVisual && nodeToneClasses && (
<div className="mt-1 flex min-w-0 items-center gap-1.5">
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
</div>
<span className="truncate system-xs-regular text-text-tertiary">
{item.nodeInfo.title}
</span>
</div>
)}
</div>
<div className="max-w-[120px] text-right system-sm-regular wrap-break-word text-text-secondary">
{formatEvaluationValue(item.value)}
</div>
</div>
)
})}
<div className="max-w-[120px] text-right system-sm-regular wrap-break-word text-text-secondary">
{formatEvaluationValue(item.value)}
</div>
</div>
))}
</div>
</PopoverContent>
</Popover>

View File

@ -10,13 +10,13 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { RiBook2Line, RiCloseLine, RiInformation2Line, RiLock2Fill } from '@remixicon/react'
import { memo, useEffect, useState } from 'react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { PortalToFollowElem, PortalToFollowElemContent } from '@/app/components/base/portal-to-follow-elem'
import Tooltip from '@/app/components/base/tooltip'
import { createExternalAPI } from '@/service/datasets'
import Form from './Form'
@ -57,15 +57,20 @@ const formSchemas: FormSchema[] = [
required: true,
},
]
const emptyExternalAPIFormData: CreateExternalAPIReq = {
name: '',
settings: {
endpoint: '',
api_key: '',
},
}
const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCancel, datasetBindings, isEditMode, onEdit }) => {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [formData, setFormData] = useState<CreateExternalAPIReq>({ name: '', settings: { endpoint: '', api_key: '' } })
useEffect(() => {
if (isEditMode && data)
setFormData(data)
}, [isEditMode, data])
const [formData, setFormData] = useState<CreateExternalAPIReq>(() => isEditMode && data ? data : emptyExternalAPIFormData)
const hasEmptyInputs = Object.values(formData).some(value => typeof value === 'string' ? value.trim() === '' : Object.values(value).some(v => v.trim() === ''))
const handleDataChange = (val: CreateExternalAPIReq) => {
setFormData(val)
@ -108,106 +113,121 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
}
}
return (
<PortalToFollowElem open>
<PortalToFollowElemContent className="z-60 h-full w-full">
<div className="fixed inset-0 flex items-center justify-center bg-black/25">
<div className="shadows-shadow-xl relative flex w-[480px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg">
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
<div className="grow self-stretch title-2xl-semi-bold text-text-primary">
{isEditMode ? t('editExternalAPIFormTitle', { ns: 'dataset' }) : t('createExternalAPI', { ns: 'dataset' })}
</div>
{isEditMode && (datasetBindings?.length ?? 0) > 0 && (
<div className="flex items-center system-xs-regular text-text-tertiary">
{t('editExternalAPIFormWarning.front', { ns: 'dataset' })}
<span className="flex cursor-pointer items-center text-text-accent">
&nbsp;
{datasetBindings?.length}
{' '}
{t('editExternalAPIFormWarning.end', { ns: 'dataset' })}
<Dialog
open
disablePointerDismissal
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent className="w-[480px]! max-w-none! overflow-visible! rounded-2xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! shadow-xl!">
<div className="relative flex w-full flex-col items-start">
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="grow self-stretch title-2xl-semi-bold text-text-primary">
{isEditMode ? t('editExternalAPIFormTitle', { ns: 'dataset' }) : t('createExternalAPI', { ns: 'dataset' })}
</DialogTitle>
{isEditMode && (datasetBindings?.length ?? 0) > 0 && (
<div className="flex items-center system-xs-regular text-text-tertiary">
{t('editExternalAPIFormWarning.front', { ns: 'dataset' })}
<span className="flex cursor-pointer items-center text-text-accent">
&nbsp;
{datasetBindings?.length}
{' '}
{t('editExternalAPIFormWarning.end', { ns: 'dataset' })}
&nbsp;
<Tooltip
popupClassName="flex items-center self-stretch w-[320px]"
popupContent={(
<div className="p-1">
<div className="flex items-start self-stretch pt-1 pr-3 pb-0.5 pl-2">
<div className="system-xs-medium-uppercase text-text-tertiary">{`${datasetBindings?.length} ${t('editExternalAPITooltipTitle', { ns: 'dataset' })}`}</div>
</div>
{datasetBindings?.map(binding => (
<div key={binding.id} className="flex items-center gap-1 self-stretch px-2 py-1">
<RiBook2Line className="h-4 w-4 text-text-secondary" />
<div className="system-sm-medium text-text-secondary">{binding.name}</div>
</div>
))}
</div>
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('editExternalAPIFormWarning.end', { ns: 'dataset' })}
render={(
<button
type="button"
className="flex h-3.5 w-3.5 items-center justify-center rounded-sm outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
<RiInformation2Line className="h-3.5 w-3.5" />
</button>
)}
asChild={false}
position="bottom"
/>
<PopoverContent
placement="bottom"
popupClassName="flex w-[320px] items-center self-stretch px-3 py-2"
>
<RiInformation2Line className="h-3.5 w-3.5" />
</Tooltip>
</span>
</div>
)}
</div>
<ActionButton className="absolute top-5 right-5" onClick={onCancel}>
<RiCloseLine className="h-[18px] w-[18px] shrink-0 text-text-tertiary" />
</ActionButton>
<Form value={formData} onChange={handleDataChange} formSchemas={formSchemas} className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3" />
<div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5">
<Button type="button" variant="secondary" onClick={onCancel}>
{t('externalAPIForm.cancel', { ns: 'dataset' })}
</Button>
<Button
type="submit"
variant="primary"
onClick={() => {
if (isEditMode && (datasetBindings?.length ?? 0) > 0)
setShowConfirm(true)
else if (isEditMode && onEdit)
onEdit(formData)
else
handleSave()
}}
disabled={hasEmptyInputs || loading}
>
{t('externalAPIForm.save', { ns: 'dataset' })}
</Button>
</div>
<div className="flex items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px] border-divider-subtle
bg-background-soft px-2 py-3 system-xs-regular text-text-tertiary"
>
<RiLock2Fill className="h-3 w-3 text-text-quaternary" />
{t('externalAPIForm.encrypted.front', { ns: 'dataset' })}
<a className="text-text-accent" target="_blank" rel="noopener noreferrer" href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html">
PKCS1_OAEP
</a>
{t('externalAPIForm.encrypted.end', { ns: 'dataset' })}
</div>
</div>
<AlertDialog
open={showConfirm && (datasetBindings?.length ?? 0) > 0}
onOpenChange={open => !open && setShowConfirm(false)}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
Warning
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{`${t('editExternalAPIConfirmWarningContent.front', { ns: 'dataset' })} ${datasetBindings?.length} ${t('editExternalAPIConfirmWarningContent.end', { ns: 'dataset' })}`}
</AlertDialogDescription>
<div className="p-1">
<div className="flex items-start self-stretch pt-1 pr-3 pb-0.5 pl-2">
<div className="system-xs-medium-uppercase text-text-tertiary">{`${datasetBindings?.length} ${t('editExternalAPITooltipTitle', { ns: 'dataset' })}`}</div>
</div>
{datasetBindings?.map(binding => (
<div key={binding.id} className="flex items-center gap-1 self-stretch px-2 py-1">
<RiBook2Line className="h-4 w-4 text-text-secondary" />
<div className="system-sm-medium text-text-secondary">{binding.name}</div>
</div>
))}
</div>
</PopoverContent>
</Popover>
</span>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleSave}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<ActionButton className="absolute top-5 right-5" onClick={onCancel}>
<RiCloseLine className="h-[18px] w-[18px] shrink-0 text-text-tertiary" />
</ActionButton>
<Form value={formData} onChange={handleDataChange} formSchemas={formSchemas} className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3" />
<div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5">
<Button type="button" variant="secondary" onClick={onCancel}>
{t('externalAPIForm.cancel', { ns: 'dataset' })}
</Button>
<Button
type="submit"
variant="primary"
onClick={() => {
if (isEditMode && (datasetBindings?.length ?? 0) > 0)
setShowConfirm(true)
else if (isEditMode && onEdit)
onEdit(formData)
else
handleSave()
}}
disabled={hasEmptyInputs || loading}
>
{t('externalAPIForm.save', { ns: 'dataset' })}
</Button>
</div>
<div className="flex items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px] border-divider-subtle
bg-background-soft px-2 py-3 system-xs-regular text-text-tertiary"
>
<RiLock2Fill className="h-3 w-3 text-text-quaternary" />
{t('externalAPIForm.encrypted.front', { ns: 'dataset' })}
<a className="text-text-accent" target="_blank" rel="noopener noreferrer" href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html">
PKCS1_OAEP
</a>
{t('externalAPIForm.encrypted.end', { ns: 'dataset' })}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<AlertDialog
open={showConfirm && (datasetBindings?.length ?? 0) > 0}
onOpenChange={open => !open && setShowConfirm(false)}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
Warning
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{`${t('editExternalAPIConfirmWarningContent.front', { ns: 'dataset' })} ${datasetBindings?.length} ${t('editExternalAPIConfirmWarningContent.end', { ns: 'dataset' })}`}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleSave}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</DialogContent>
</Dialog>
)
}
export default memo(AddExternalAPIModal)

View File

@ -1,10 +1,10 @@
import type { RelatedAppResponse } from '@/models/datasets'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiInformation2Line } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import LinkedAppsPanel from '@/app/components/base/linked-apps-panel'
import Tooltip from '@/app/components/base/tooltip'
import NoLinkedAppsPanel from '../no-linked-apps-panel'
type StatisticsProps = {
@ -40,26 +40,34 @@ const Statistics = ({
<div className="system-md-semibold-uppercase text-text-secondary">
{relatedAppsTotal ?? '--'}
</div>
<Tooltip
position="top-start"
noDecoration
needsDelay
popupContent={
hasRelatedApps
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('datasetMenus.relatedApp', { ns: 'common' })}
render={(
<button
type="button"
className="flex cursor-pointer items-center gap-x-0.5 rounded-sm system-2xs-medium-uppercase text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
<span>{t('datasetMenus.relatedApp', { ns: 'common' })}</span>
<RiInformation2Line className="size-3" />
</button>
)}
/>
<PopoverContent
placement="top-start"
popupClassName="border-0 bg-transparent p-0 shadow-none"
>
{hasRelatedApps
? (
<LinkedAppsPanel
relatedApps={relatedApps.data}
isMobile={!expand}
/>
)
: <NoLinkedAppsPanel />
}
>
<div className="flex cursor-pointer items-center gap-x-0.5 system-2xs-medium-uppercase text-text-tertiary">
<span>{t('datasetMenus.relatedApp', { ns: 'common' })}</span>
<RiInformation2Line className="size-3" />
</div>
</Tooltip>
: <NoLinkedAppsPanel />}
</PopoverContent>
</Popover>
</div>
</div>
)

View File

@ -7,8 +7,8 @@ import { useEvaluationStore } from '../store'
const mockUpload = vi.hoisted(() => vi.fn())
const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn())
const mockUseDefaultEvaluationMetrics = vi.hoisted(() => vi.fn())
const mockUseEvaluationConfig = vi.hoisted(() => vi.fn())
const mockUseEvaluationNodeInfoMutation = vi.hoisted(() => vi.fn())
const mockUseSaveEvaluationConfigMutation = vi.hoisted(() => vi.fn())
const mockUseStartEvaluationRunMutation = vi.hoisted(() => vi.fn())
const mockUsePublishedPipelineInfo = vi.hoisted(() => vi.fn())
@ -51,7 +51,7 @@ vi.mock('@/service/base', () => ({
vi.mock('@/service/use-evaluation', () => ({
useEvaluationConfig: (...args: unknown[]) => mockUseEvaluationConfig(...args),
useAvailableEvaluationMetrics: (...args: unknown[]) => mockUseAvailableEvaluationMetrics(...args),
useEvaluationNodeInfoMutation: (...args: unknown[]) => mockUseEvaluationNodeInfoMutation(...args),
useDefaultEvaluationMetrics: (...args: unknown[]) => mockUseDefaultEvaluationMetrics(...args),
useSaveEvaluationConfigMutation: (...args: unknown[]) => mockUseSaveEvaluationConfigMutation(...args),
useStartEvaluationRunMutation: (...args: unknown[]) => mockUseStartEvaluationRunMutation(...args),
}))
@ -128,7 +128,7 @@ const renderWithQueryClient = (ui: ReactNode) => {
describe('Evaluation', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
useEvaluationStore.setState({ resources: {}, initialResources: {} })
vi.clearAllMocks()
mockUseEvaluationConfig.mockReturnValue({
data: null,
@ -141,18 +141,41 @@ describe('Evaluation', () => {
isLoading: false,
})
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
'faithfulness': [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
],
})
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
{
metric: 'faithfulness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
],
},
{
metric: 'context-precision',
value_type: 'number',
node_info_list: [],
},
{
metric: 'context-recall',
value_type: 'number',
node_info_list: [],
},
{
metric: 'context-relevance',
value_type: 'number',
node_info_list: [],
},
],
},
isLoading: false,
})
mockUseSaveEvaluationConfigMutation.mockReturnValue({
isPending: false,
@ -251,6 +274,37 @@ describe('Evaluation', () => {
})
})
it('should reset unsaved non-pipeline config changes to the hydrated config', () => {
mockUseEvaluationConfig.mockReturnValue({
data: {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [],
customized_metrics: null,
judgment_config: null,
},
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-reset" />)
const resetButton = screen.getByRole('button', { name: 'common.operation.reset' })
expect(resetButton).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: 'faith' },
})
fireEvent.click(screen.getByTestId('evaluation-metric-node-faithfulness-node-faithfulness'))
expect(useEvaluationStore.getState().resources['apps:app-reset']!.metrics).toHaveLength(1)
expect(resetButton).toBeEnabled()
fireEvent.click(resetButton)
expect(useEvaluationStore.getState().resources['apps:app-reset']!.metrics).toHaveLength(0)
expect(resetButton).toBeDisabled()
})
it('should hide the value row for empty operators', () => {
const resourceType = 'apps'
const resourceId = 'app-2'
@ -330,22 +384,19 @@ describe('Evaluation', () => {
})
it('should render the metric no-node empty state', () => {
mockUseAvailableEvaluationMetrics.mockReturnValue({
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
metrics: ['context-precision'],
default_metrics: [
{
metric: 'context-precision',
value_type: 'number',
node_info_list: [],
},
],
},
isLoading: false,
})
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'context-precision': [],
})
},
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-3" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
@ -353,10 +404,49 @@ describe('Evaluation', () => {
expect(screen.getByText('evaluation.metrics.noNodesInWorkflow')).toBeInTheDocument()
})
it('should render the global empty state when no metrics are available', () => {
mockUseAvailableEvaluationMetrics.mockReturnValue({
it('should add a node from a dynamically returned metric option', () => {
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
metrics: [],
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
{
metric: 'context-precision',
value_type: 'number',
node_info_list: [
{ node_id: 'node-context', title: 'Context Node', type: 'knowledge-retrieval' },
],
},
],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-dynamic-metric" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
fireEvent.click(screen.getByTestId('evaluation-metric-node-context-precision-node-context'))
const metrics = useEvaluationStore.getState().resources['apps:app-dynamic-metric']!.metrics
expect(metrics).toHaveLength(1)
expect(metrics[0]).toMatchObject({
optionId: 'context-precision',
label: 'Context Precision',
nodeInfoList: [
{ node_id: 'node-context', title: 'Context Node', type: 'knowledge-retrieval' },
],
})
})
it('should render the global empty state when no metrics are available', () => {
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [],
},
isLoading: false,
})
@ -369,27 +459,24 @@ describe('Evaluation', () => {
})
it('should show more nodes when a metric has more than three nodes', () => {
mockUseAvailableEvaluationMetrics.mockReturnValue({
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
metrics: ['answer-correctness'],
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
{ node_id: 'node-3', title: 'LLM 3', type: 'llm' },
{ node_id: 'node-4', title: 'LLM 4', type: 'llm' },
],
},
],
},
isLoading: false,
})
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
{ node_id: 'node-3', title: 'LLM 3', type: 'llm' },
{ node_id: 'node-4', title: 'LLM 4', type: 'llm' },
],
})
},
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-5" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))

View File

@ -1,5 +1,4 @@
import type { EvaluationConfig } from '@/types/evaluation'
import { getEvaluationMockConfig } from '../mock'
import {
getAllowedOperators,
isCustomMetricConfigured,
@ -8,16 +7,21 @@ import {
} from '../store'
import { buildEvaluationConfigPayload, buildEvaluationRunRequest } from '../store-utils'
const customWorkflow = {
id: 'workflow-precision-review',
appId: 'custom-workflow-app-id',
name: 'Precision Review Workflow',
}
describe('evaluation store', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
useEvaluationStore.setState({ resources: {}, initialResources: {} })
})
it('should configure a custom metric mapping to a valid state', () => {
const resourceType = 'apps'
const resourceId = 'app-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
@ -27,9 +31,9 @@ describe('evaluation store', () => {
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, {
workflowId: config.workflowOptions[0].id,
workflowAppId: 'custom-workflow-app-id',
workflowName: config.workflowOptions[0].label,
workflowId: customWorkflow.id,
workflowAppId: customWorkflow.appId,
workflowName: customWorkflow.name,
})
store.syncCustomMetricMappings(resourceType, resourceId, initialMetric!.id, ['query'])
store.syncCustomMetricOutputs(resourceType, resourceId, initialMetric!.id, [{
@ -44,8 +48,8 @@ describe('evaluation store', () => {
const configuredMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
expect(configuredMetric!.customConfig!.workflowAppId).toBe('custom-workflow-app-id')
expect(configuredMetric!.customConfig!.workflowName).toBe(config.workflowOptions[0].label)
expect(configuredMetric!.customConfig!.workflowAppId).toBe(customWorkflow.appId)
expect(configuredMetric!.customConfig!.workflowName).toBe(customWorkflow.name)
expect(configuredMetric!.customConfig!.outputs).toEqual([{ id: 'score', valueType: 'number' }])
})
@ -71,12 +75,11 @@ describe('evaluation store', () => {
const resourceType = 'apps'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[1].id)
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness')
const addedMetric = useEvaluationStore.getState().resources['apps:app-2'].metrics.find(metric => metric.optionId === config.builtinMetrics[1].id)
const addedMetric = useEvaluationStore.getState().resources['apps:app-2'].metrics.find(metric => metric.optionId === 'faithfulness')
expect(addedMetric).toBeDefined()
store.removeMetric(resourceType, resourceId, addedMetric!.id)
@ -88,8 +91,7 @@ describe('evaluation store', () => {
const resourceType = 'apps'
const resourceId = 'app-4'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
const metricId = config.builtinMetrics[0].id
const metricId = 'answer-correctness'
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, metricId, [
@ -115,10 +117,9 @@ describe('evaluation store', () => {
const resourceType = 'apps'
const resourceId = 'app-conditions'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[0].id, [
store.addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
store.setConditionLogicalOperator(resourceType, resourceId, 'or')
@ -137,27 +138,26 @@ describe('evaluation store', () => {
const resourceType = 'apps'
const resourceId = 'app-condition-selector'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-condition-selector'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: config.workflowOptions[0].id,
workflowAppId: 'custom-workflow-app-id',
workflowName: config.workflowOptions[0].label,
workflowId: customWorkflow.id,
workflowAppId: customWorkflow.appId,
workflowName: customWorkflow.name,
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
valueType: 'string',
}])
store.addCondition(resourceType, resourceId, [config.workflowOptions[0].id, 'reason'])
store.addCondition(resourceType, resourceId, [customWorkflow.id, 'reason'])
const condition = useEvaluationStore.getState().resources['apps:app-condition-selector'].judgmentConfig.conditions[0]
expect(condition.variableSelector).toEqual([config.workflowOptions[0].id, 'reason'])
expect(condition.variableSelector).toEqual([customWorkflow.id, 'reason'])
expect(condition.comparisonOperator).toBe('contains')
expect(condition.value).toBeNull()
})
@ -166,16 +166,15 @@ describe('evaluation store', () => {
const resourceType = 'apps'
const resourceId = 'app-3'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-3'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: config.workflowOptions[0].id,
workflowAppId: 'custom-workflow-app-id',
workflowName: config.workflowOptions[0].label,
workflowId: customWorkflow.id,
workflowAppId: customWorkflow.appId,
workflowName: customWorkflow.name,
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
@ -185,7 +184,7 @@ describe('evaluation store', () => {
const condition = useEvaluationStore.getState().resources['apps:app-3'].judgmentConfig.conditions[0]
store.updateConditionMetric(resourceType, resourceId, condition.id, [config.workflowOptions[0].id, 'reason'])
store.updateConditionMetric(resourceType, resourceId, condition.id, [customWorkflow.id, 'reason'])
store.updateConditionValue(resourceType, resourceId, condition.id, 'needs follow-up')
store.updateConditionOperator(resourceType, resourceId, condition.id, 'empty')

View File

@ -1,13 +1,9 @@
'use client'
import type { BatchTestTab, EvaluationResourceProps } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useTranslation } from 'react-i18next'
import { useSaveEvaluationConfigMutation } from '@/service/use-evaluation'
import { isEvaluationRunnable, useEvaluationResource, useEvaluationStore } from '../../store'
import { buildEvaluationConfigPayload } from '../../store-utils'
import { TAB_CLASS_NAME } from '../../utils'
import HistoryTab from './history-tab'
import InputFieldsTab from './input-fields-tab'
@ -19,63 +15,21 @@ const BatchTestPanel = ({
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const tabLabels: Record<BatchTestTab, string> = {
'input-fields': t('batch.tabs.input-fields'),
'history': t('batch.tabs.history'),
}
const resource = useEvaluationResource(resourceType, resourceId)
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
const saveConfigMutation = useSaveEvaluationConfigMutation()
const isRunnable = isEvaluationRunnable(resource)
const isPanelReady = !!resource.judgeModelId && resource.metrics.length > 0
const handleSave = () => {
if (!isRunnable) {
toast.warning(t('batch.validation'))
return
}
const body = buildEvaluationConfigPayload(resource, resourceType)
if (!body) {
toast.warning(t('batch.validation'))
return
}
saveConfigMutation.mutate({
params: {
targetType: resourceType,
targetId: resourceId,
},
body,
}, {
onSuccess: () => {
toast.success(tCommon('api.saved'))
},
onError: () => {
toast.error(t('config.saveFailed'))
},
})
}
return (
<div className="flex h-full min-h-0 flex-col bg-background-default">
<div className="px-6 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="system-xl-semibold text-text-primary">{t('batch.title')}</div>
<div className="mt-1 system-sm-regular text-text-tertiary">{t('batch.description')}</div>
</div>
<Button
className="shrink-0"
variant="primary"
disabled={!isRunnable}
loading={saveConfigMutation.isPending}
onClick={handleSave}
>
{tCommon('operation.save')}
</Button>
<div className="min-w-0">
<div className="system-xl-semibold text-text-primary">{t('batch.title')}</div>
<div className="mt-1 system-sm-regular text-text-tertiary">{t('batch.description')}</div>
</div>
<div className="mt-4 rounded-xl border border-divider-subtle bg-components-card-bg p-3">
<div className="flex items-start gap-3">

View File

@ -1,7 +1,7 @@
import type { EvaluationResourceProps } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import { getEvaluationMockConfig } from '../../mock'
import { EVALUATION_TEMPLATE_FILE_NAMES } from '../../store-utils'
import InputFieldsRequirements from './input-fields/input-fields-requirements'
import UploadRunPopover from './input-fields/upload-run-popover'
import { useInputFieldsActions } from './input-fields/use-input-fields-actions'
@ -19,7 +19,6 @@ const InputFieldsTab = ({
isRunnable,
}: InputFieldsTabProps) => {
const { t } = useTranslation('evaluation')
const config = getEvaluationMockConfig(resourceType)
const { inputFields, isInputFieldsLoading } = usePublishedInputFields(resourceType, resourceId)
const actions = useInputFieldsActions({
resourceType,
@ -28,7 +27,7 @@ const InputFieldsTab = ({
isInputFieldsLoading,
isPanelReady,
isRunnable,
templateFileName: config.templateFileName,
templateFileName: EVALUATION_TEMPLATE_FILE_NAMES[resourceType],
})
return (

View File

@ -0,0 +1,80 @@
'use client'
import type { EvaluationResourceProps } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useTranslation } from 'react-i18next'
import { useSaveEvaluationConfigMutation } from '@/service/use-evaluation'
import {
isEvaluationRunnable,
useEvaluationResource,
useEvaluationStore,
useIsEvaluationConfigDirty,
} from '../store'
import { buildEvaluationConfigPayload } from '../store-utils'
const EvaluationConfigActions = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const resource = useEvaluationResource(resourceType, resourceId)
const isDirty = useIsEvaluationConfigDirty(resourceType, resourceId)
const resetResourceConfig = useEvaluationStore(state => state.resetResourceConfig)
const markResourceConfigSaved = useEvaluationStore(state => state.markResourceConfigSaved)
const saveConfigMutation = useSaveEvaluationConfigMutation()
const isRunnable = isEvaluationRunnable(resource)
const handleSave = () => {
if (!isRunnable) {
toast.warning(t('batch.validation'))
return
}
const body = buildEvaluationConfigPayload(resource, resourceType)
if (!body) {
toast.warning(t('batch.validation'))
return
}
saveConfigMutation.mutate({
params: {
targetType: resourceType,
targetId: resourceId,
},
body,
}, {
onSuccess: () => {
markResourceConfigSaved(resourceType, resourceId)
toast.success(tCommon('api.saved'))
},
onError: () => {
toast.error(t('config.saveFailed'))
},
})
}
return (
<div className="flex shrink-0 items-center gap-2">
<Button
variant="secondary"
disabled={!isDirty || saveConfigMutation.isPending}
onClick={() => resetResourceConfig(resourceType, resourceId)}
>
{tCommon('operation.reset')}
</Button>
<Button
variant="primary"
disabled={!isRunnable}
loading={saveConfigMutation.isPending}
onClick={handleSave}
>
{tCommon('operation.save')}
</Button>
</div>
)
}
export default EvaluationConfigActions

View File

@ -1,10 +1,11 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import type { NonPipelineEvaluationResourceProps } from '../../types'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import BatchTestPanel from '../batch-test-panel'
import ConditionsSection from '../conditions-section'
import EvaluationConfigActions from '../config-actions'
import JudgeModelSelector from '../judge-model-selector'
import MetricSection from '../metric-section'
import SectionHeader, { InlineSectionHeader } from '../section-header'
@ -12,7 +13,7 @@ import SectionHeader, { InlineSectionHeader } from '../section-header'
const NonPipelineEvaluation = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
}: NonPipelineEvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const docLink = useDocLink()
@ -38,6 +39,7 @@ const NonPipelineEvaluation = ({
</>
)}
descriptionClassName="max-w-[700px]"
action={<EvaluationConfigActions resourceType={resourceType} resourceId={resourceId} />}
/>
<section className="max-w-[700px] py-4">
<InlineSectionHeader title={t('judgeModel.title')} tooltip={t('judgeModel.description')} />

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import { useEvaluationStore } from '../../store'
import HistoryTab from '../batch-test-panel/history-tab'
import EvaluationConfigActions from '../config-actions'
import JudgeModelSelector from '../judge-model-selector'
import PipelineBatchActions from '../pipeline/pipeline-batch-actions'
import PipelineMetricsSection from '../pipeline/pipeline-metrics-section'
@ -45,6 +46,7 @@ const PipelineEvaluation = ({
</a>
</>
)}
action={<EvaluationConfigActions resourceType={resourceType} resourceId={resourceId} />}
/>
</div>

View File

@ -4,13 +4,11 @@ import MetricSection from '..'
import { useEvaluationStore } from '../../../store'
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn())
const mockUseEvaluationNodeInfoMutation = vi.hoisted(() => vi.fn())
const mockUseDefaultEvaluationMetrics = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-evaluation', () => ({
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
useAvailableEvaluationMetrics: (...args: unknown[]) => mockUseAvailableEvaluationMetrics(...args),
useEvaluationNodeInfoMutation: (...args: unknown[]) => mockUseEvaluationNodeInfoMutation(...args),
useDefaultEvaluationMetrics: (...args: unknown[]) => mockUseDefaultEvaluationMetrics(...args),
}))
const resourceType = 'apps' as const
@ -37,9 +35,17 @@ describe('MetricSection', () => {
vi.clearAllMocks()
useEvaluationStore.setState({ resources: {} })
mockUseAvailableEvaluationMetrics.mockReturnValue({
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
metrics: ['answer-correctness'],
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
],
},
isLoading: false,
})
@ -54,17 +60,6 @@ describe('MetricSection', () => {
isFetchingNextPage: false,
isLoading: false,
})
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
})
},
})
})
// Verify the empty state block extracted from MetricSection.
@ -138,16 +133,20 @@ describe('MetricSection', () => {
it('should show only unselected nodes in the add-node dropdown and append the selected node', () => {
// Arrange
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
})
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
},
],
},
isLoading: false,
})
act(() => {
@ -171,16 +170,20 @@ describe('MetricSection', () => {
it('should hide the add-node button when the builtin metric already targets all nodes', () => {
// Arrange
mockUseEvaluationNodeInfoMutation.mockReturnValue({
isPending: false,
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
options?.onSuccess?.({
'answer-correctness': [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
})
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
},
],
},
isLoading: false,
})
act(() => {

View File

@ -12,8 +12,9 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useEvaluationStore } from '../../store'
import { dedupeNodeInfoList, getMetricVisual, getNodeVisual, getToneClasses } from '../metric-selector/utils'
import { dedupeNodeInfoList, getEvaluationNodeBlockType, getMetricVisual, getToneClasses } from '../metric-selector/utils'
type BuiltinMetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
@ -41,7 +42,7 @@ const BuiltinMetricCard = ({
return (
<div className="group overflow-hidden rounded-xl border border-components-panel-border hover:bg-background-section">
<div className="flex items-center justify-between gap-3 px-3 pt-3 pb-1">
<div className={cn('flex items-center justify-between gap-3 px-3 pt-3', isExpanded ? 'pb-1' : 'pb-3')}>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-1 text-left"
@ -76,17 +77,16 @@ const BuiltinMetricCard = ({
<div className="flex flex-wrap gap-1 px-3 pt-1 pb-3">
{selectedNodeInfoList.length
? selectedNodeInfoList.map((nodeInfo) => {
const nodeVisual = getNodeVisual(nodeInfo)
const nodeToneClasses = getToneClasses(nodeVisual.tone)
return (
<div
key={nodeInfo.node_id}
className="inline-flex min-w-[18px] items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1.5 shadow-xs"
>
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
</div>
<BlockIcon
type={getEvaluationNodeBlockType(nodeInfo)}
size="xs"
className="h-[18px] w-[18px] shrink-0"
/>
<span className="px-1 system-xs-regular text-text-primary">{nodeInfo.title}</span>
<button
type="button"
@ -99,7 +99,7 @@ const BuiltinMetricCard = ({
selectedNodeInfoList.filter(item => item.node_id !== nodeInfo.node_id),
)}
>
<span aria-hidden="true" className="i-ri-close-line h-3.5 w-3.5" />
<span aria-hidden="true" className="i-custom-vender-solid-general-x-circle h-3.5 w-3.5" />
</button>
</div>
)
@ -126,9 +126,6 @@ const BuiltinMetricCard = ({
popupClassName="w-[252px] rounded-md border-[0.5px] border-components-panel-border py-1 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
{selectableNodeInfoList.map((nodeInfo) => {
const nodeVisual = getNodeVisual(nodeInfo)
const nodeToneClasses = getToneClasses(nodeVisual.tone)
return (
<DropdownMenuItem
key={nodeInfo.node_id}
@ -141,9 +138,11 @@ const BuiltinMetricCard = ({
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2.5 pr-1">
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
</div>
<BlockIcon
type={getEvaluationNodeBlockType(nodeInfo)}
size="xs"
className="h-[18px] w-[18px] shrink-0"
/>
<span className="truncate system-sm-medium text-text-secondary">{nodeInfo.title}</span>
</div>
</DropdownMenuItem>

View File

@ -1,13 +1,11 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
import { useEffect, useMemo, useState } from 'react'
import type { NonPipelineEvaluationResourceProps } from '../../types'
import { useTranslation } from 'react-i18next'
import { useAvailableEvaluationMetrics, useEvaluationNodeInfoMutation } from '@/service/use-evaluation'
import { useDefaultEvaluationMetrics } from '@/service/use-evaluation'
import { useEvaluationResource } from '../../store'
import MetricSelector from '../metric-selector'
import { toEvaluationTargetType } from '../metric-selector/utils'
import { getDefaultMetricNodeInfoMap } from '../metric-selector/utils'
import { InlineSectionHeader } from '../section-header'
import MetricCard from './metric-card'
import MetricSectionEmptyState from './metric-section-empty-state'
@ -15,55 +13,13 @@ import MetricSectionEmptyState from './metric-section-empty-state'
const MetricSection = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
}: NonPipelineEvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const [nodeInfoMap, setNodeInfoMap] = useState<Record<string, NodeInfo[]>>({})
const hasMetrics = resource.metrics.length > 0
const hasBuiltinMetrics = resource.metrics.some(metric => metric.kind === 'builtin')
const shouldLoadNodeInfo = resourceType !== 'datasets' && !!resourceId && hasBuiltinMetrics
const { data: availableMetricsData } = useAvailableEvaluationMetrics(shouldLoadNodeInfo)
const { mutate: loadNodeInfo } = useEvaluationNodeInfoMutation()
const availableMetricIds = useMemo(() => availableMetricsData?.metrics ?? [], [availableMetricsData?.metrics])
const availableMetricIdsKey = availableMetricIds.join(',')
const resolvedNodeInfoMap = shouldLoadNodeInfo ? nodeInfoMap : {}
useEffect(() => {
if (!shouldLoadNodeInfo || availableMetricIds.length === 0)
return
let isActive = true
loadNodeInfo(
{
params: {
targetType: toEvaluationTargetType(resourceType),
targetId: resourceId,
},
body: {
metrics: availableMetricIds,
},
},
{
onSuccess: (data) => {
if (!isActive)
return
setNodeInfoMap(data)
},
onError: () => {
if (!isActive)
return
setNodeInfoMap({})
},
},
)
return () => {
isActive = false
}
}, [availableMetricIds, availableMetricIdsKey, loadNodeInfo, resourceId, resourceType, shouldLoadNodeInfo])
const { data: defaultMetricsData } = useDefaultEvaluationMetrics(resourceType, resourceId, hasBuiltinMetrics)
const nodeInfoMap = getDefaultMetricNodeInfoMap(defaultMetricsData?.default_metrics ?? [])
return (
<section className="max-w-[700px] py-4">
@ -79,7 +35,7 @@ const MetricSection = ({
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
availableNodeInfoList={metric.kind === 'builtin' ? (resolvedNodeInfoMap[metric.optionId] ?? []) : undefined}
availableNodeInfoList={metric.kind === 'builtin' ? (nodeInfoMap[metric.optionId] ?? []) : undefined}
/>
))}
<MetricSelector

View File

@ -29,7 +29,6 @@ const MetricSelector = ({
const addCustomMetric = useEvaluationStore(state => state.addCustomMetric)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [nodeInfoMap, setNodeInfoMap] = useState<Record<string, Array<{ node_id: string, title: string, type: string }>>>({})
const [collapsedMetricMap, setCollapsedMetricMap] = useState<Record<string, boolean>>({})
const [expandedMetricNodesMap, setExpandedMetricNodesMap] = useState<Record<string, boolean>>({})
const hasCustomMetric = resource.metrics.some(metric => metric.kind === 'custom-workflow')
@ -44,8 +43,6 @@ const MetricSelector = ({
query,
resourceType,
resourceId,
nodeInfoMap,
setNodeInfoMap,
})
const handleOpenChange = (nextOpen: boolean) => {

View File

@ -5,9 +5,9 @@ type SelectorEmptyStateProps = {
const EmptySearchStateIcon = () => {
return (
<div className="relative h-8 w-8 text-text-quaternary">
<span aria-hidden="true" className="i-ri-search-line absolute bottom-0 right-0 h-6 w-6" />
<span aria-hidden="true" className="absolute left-0 top-[9px] h-[2px] w-[7px] rounded-full bg-current opacity-80" />
<span aria-hidden="true" className="absolute left-0 top-[16px] h-[2px] w-[4px] rounded-full bg-current opacity-80" />
<span aria-hidden="true" className="absolute right-0 bottom-0 i-ri-search-line h-6 w-6" />
<span aria-hidden="true" className="absolute top-[9px] left-0 h-[2px] w-[7px] rounded-full bg-current opacity-80" />
<span aria-hidden="true" className="absolute top-[16px] left-0 h-[2px] w-[4px] rounded-full bg-current opacity-80" />
</div>
)
}
@ -18,7 +18,7 @@ const SelectorEmptyState = ({
return (
<div className="flex h-full min-h-[524px] flex-col items-center justify-center gap-2 px-4 pb-20 text-center">
<EmptySearchStateIcon />
<div className="text-text-secondary system-sm-regular">{message}</div>
<div className="system-sm-regular text-text-secondary">{message}</div>
</div>
)
}

View File

@ -18,13 +18,13 @@ const SelectorFooter = ({
className="relative flex items-center gap-3 overflow-hidden border-t border-divider-subtle bg-background-default-subtle px-4 py-5 text-left enabled:hover:bg-state-base-hover-alt disabled:cursor-not-allowed disabled:opacity-60"
onClick={onClick}
>
<div className="absolute -left-6 -top-6 h-28 w-28 rounded-full bg-util-colors-indigo-indigo-100 opacity-50 blur-2xl" />
<div className="absolute -top-6 -left-6 h-28 w-28 rounded-full bg-util-colors-indigo-indigo-100 opacity-50 blur-2xl" />
<div className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-[0px_3px_10px_-2px_rgba(9,9,11,0.08),0px_2px_4px_-2px_rgba(9,9,11,0.06)]">
<span aria-hidden="true" className="i-ri-add-line h-[18px] w-[18px] text-text-tertiary" />
</div>
<div className="relative min-w-0">
<div className="text-text-secondary system-sm-semibold">{title}</div>
<div className="mt-0.5 text-text-tertiary system-xs-regular">{description}</div>
<div className="system-sm-semibold text-text-secondary">{title}</div>
<div className="mt-0.5 system-xs-regular text-text-tertiary">{description}</div>
</div>
</button>
)

View File

@ -2,7 +2,9 @@ import type { TFunction } from 'i18next'
import type { EvaluationMetric } from '../../types'
import type { MetricSelectorSection } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { getMetricVisual, getNodeVisual, getToneClasses } from './utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import BlockIcon from '@/app/components/workflow/block-icon'
import { getEvaluationNodeBlockType, getMetricVisual, getToneClasses } from './utils'
type SelectorMetricSectionProps = {
section: MetricSelectorSection
@ -12,7 +14,7 @@ type SelectorMetricSectionProps = {
isShowingAllNodes: boolean
onToggleExpanded: () => void
onToggleShowAllNodes: () => void
onToggleNodeSelection: (metricId: string, nodeInfo: MetricSelectorSection['visibleNodes'][number]) => void
onToggleNodeSelection: (metric: MetricSelectorSection['metric'], nodeInfo: MetricSelectorSection['visibleNodes'][number]) => void
t: TFunction<'evaluation'>
}
@ -56,7 +58,7 @@ const SelectorMetricSection = ({
<span aria-hidden="true" className={cn(metricVisual.icon, 'h-3.5 w-3.5')} />
</div>
<div className="flex items-center gap-1">
<span className="system-xs-medium-uppercase truncate text-text-secondary">{metric.label}</span>
<span className="truncate system-xs-medium-uppercase text-text-secondary">{metric.label}</span>
<span
aria-hidden="true"
className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary transition-transform', !isExpanded && '-rotate-90')}
@ -64,21 +66,34 @@ const SelectorMetricSection = ({
</div>
</button>
<button type="button" className="p-px text-text-quaternary">
<span aria-hidden="true" className="i-ri-question-line h-[14px] w-[14px]" />
</button>
{metric.description && (
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
className="p-px text-text-quaternary transition-colors hover:text-text-tertiary"
aria-label={metric.label}
>
<span aria-hidden="true" className="i-ri-question-line h-[14px] w-[14px]" />
</button>
)}
/>
<TooltipContent className="max-w-[260px]">
{metric.description}
</TooltipContent>
</Tooltip>
)}
</div>
{isExpanded && (
<div className="px-1 py-1">
{hasNoNodeInfo && (
<div className="system-sm-regular px-3 pt-0.5 pb-2 text-text-tertiary">
<div className="px-3 pt-0.5 pb-2 system-sm-regular text-text-tertiary">
{t('metrics.noNodesInWorkflow')}
</div>
)}
{shownNodes.map((nodeInfo) => {
const nodeVisual = getNodeVisual(nodeInfo)
const nodeToneClasses = getToneClasses(nodeVisual.tone)
const isAdded = addedMetric
? addedMetric.nodeInfoList?.length
? selectedNodeIds.has(nodeInfo.node_id)
@ -91,21 +106,23 @@ const SelectorMetricSection = ({
data-testid={`evaluation-metric-node-${metric.id}-${nodeInfo.node_id}`}
type="button"
className={cn(
'flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-state-base-hover-alt',
'flex w-full items-center gap-1 rounded-md px-3 py-1.5 text-left transition-colors hover:bg-state-base-hover-alt',
isAdded && 'opacity-50',
)}
onClick={() => onToggleNodeSelection(metric.id, nodeInfo)}
onClick={() => onToggleNodeSelection(metric, nodeInfo)}
>
<div className="flex min-w-0 flex-1 items-center gap-2.5 pr-1">
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
</div>
<BlockIcon
type={getEvaluationNodeBlockType(nodeInfo)}
size="xs"
className="h-[18px] w-[18px] shrink-0"
/>
<span className="truncate text-[13px] leading-4 font-medium text-text-secondary">
{nodeInfo.title}
</span>
</div>
{isAdded && (
<span className="system-xs-regular shrink-0 px-1 text-text-quaternary">{t('metrics.added')}</span>
<span className="shrink-0 px-1 system-xs-regular text-text-quaternary">{t('metrics.added')}</span>
)}
</button>
)
@ -120,7 +137,7 @@ const SelectorMetricSection = ({
<div className="flex items-center px-1 text-text-tertiary">
<span aria-hidden="true" className={cn(isShowingAllNodes ? 'i-ri-subtract-line' : 'i-ri-more-line', 'h-4 w-4')} />
</div>
<span className="system-xs-regular truncate text-text-tertiary">
<span className="truncate system-xs-regular text-text-tertiary">
{isShowingAllNodes ? t('metrics.showLess') : t('metrics.showMore')}
</span>
</div>

View File

@ -1,7 +1,7 @@
import type { EvaluationMetric, EvaluationResourceProps, MetricOption } from '../../types'
import type { EvaluationMetric, MetricOption, NonPipelineEvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
export type MetricSelectorProps = EvaluationResourceProps & {
export type MetricSelectorProps = NonPipelineEvaluationResourceProps & {
triggerClassName?: string
triggerStyle?: 'button' | 'text'
}

View File

@ -1,31 +1,29 @@
import type { MetricOption, NonPipelineEvaluationResourceType } from '../../types'
import type { BuiltinMetricMap, MetricSelectorSection } from './types'
import type { NodeInfo } from '@/types/evaluation'
import { useEffect, useMemo } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAvailableEvaluationMetrics, useEvaluationNodeInfoMutation } from '@/service/use-evaluation'
import { useDefaultEvaluationMetrics } from '@/service/use-evaluation'
import { getTranslatedMetricDescription } from '../../default-metric-descriptions'
import { getEvaluationMockConfig } from '../../mock'
import { useEvaluationResource, useEvaluationStore } from '../../store'
import {
buildMetricOption,
dedupeNodeInfoList,
toEvaluationTargetType,
getDefaultMetricNodeInfoMap,
} from './utils'
type UseMetricSelectorDataOptions = {
open: boolean
query: string
resourceType: 'apps' | 'datasets' | 'snippets'
resourceType: NonPipelineEvaluationResourceType
resourceId: string
nodeInfoMap: Record<string, NodeInfo[]>
setNodeInfoMap: (value: Record<string, NodeInfo[]>) => void
}
type UseMetricSelectorDataResult = {
builtinMetricMap: BuiltinMetricMap
filteredSections: MetricSelectorSection[]
isRemoteLoading: boolean
toggleNodeSelection: (metricId: string, nodeInfo: NodeInfo) => void
toggleNodeSelection: (metric: MetricOption, nodeInfo: NodeInfo) => void
}
export const useMetricSelectorData = ({
@ -33,16 +31,12 @@ export const useMetricSelectorData = ({
query,
resourceType,
resourceId,
nodeInfoMap,
setNodeInfoMap,
}: UseMetricSelectorDataOptions): UseMetricSelectorDataResult => {
const { t } = useTranslation('evaluation')
const config = getEvaluationMockConfig(resourceType)
const metrics = useEvaluationResource(resourceType, resourceId).metrics
const addBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
const removeMetric = useEvaluationStore(state => state.removeMetric)
const { data: availableMetricsData, isLoading: isAvailableMetricsLoading } = useAvailableEvaluationMetrics(open)
const { mutate: loadNodeInfo, isPending: isNodeInfoLoading } = useEvaluationNodeInfoMutation()
const { data: defaultMetricsData, isLoading: isDefaultMetricsLoading } = useDefaultEvaluationMetrics(resourceType, resourceId, open)
const builtinMetrics = useMemo(() => {
return metrics.filter(metric => metric.kind === 'builtin')
@ -52,54 +46,19 @@ export const useMetricSelectorData = ({
return new Map(builtinMetrics.map(metric => [metric.optionId, metric] as const))
}, [builtinMetrics])
const availableMetricIds = useMemo(() => availableMetricsData?.metrics ?? [], [availableMetricsData?.metrics])
const availableMetricIdsKey = availableMetricIds.join(',')
const defaultMetrics = useMemo(() => defaultMetricsData?.default_metrics ?? [], [defaultMetricsData?.default_metrics])
const nodeInfoMap = useMemo(() => getDefaultMetricNodeInfoMap(defaultMetrics), [defaultMetrics])
const resolvedMetrics = useMemo(() => {
const metricsMap = new Map(config.builtinMetrics.map(metric => [metric.id, metric] as const))
return defaultMetrics
.map((defaultMetric) => {
if (!defaultMetric.metric)
return null
return availableMetricIds.map(metricId => metricsMap.get(metricId) ?? buildMetricOption(metricId))
}, [availableMetricIds, config.builtinMetrics])
useEffect(() => {
if (!open)
return
if (resourceType === 'datasets' || !resourceId || availableMetricIds.length === 0)
return
let isActive = true
loadNodeInfo(
{
params: {
targetType: toEvaluationTargetType(resourceType),
targetId: resourceId,
},
body: {
metrics: availableMetricIds,
},
},
{
onSuccess: (data) => {
if (!isActive)
return
setNodeInfoMap(data)
},
onError: () => {
if (!isActive)
return
setNodeInfoMap({})
},
},
)
return () => {
isActive = false
}
}, [availableMetricIds, availableMetricIdsKey, loadNodeInfo, open, resourceId, resourceType, setNodeInfoMap])
return buildMetricOption(defaultMetric.metric, defaultMetric.value_type)
})
.filter((metric): metric is MetricOption => !!metric)
}, [defaultMetrics])
const filteredSections = useMemo(() => {
const keyword = query.trim().toLowerCase()
@ -110,8 +69,7 @@ export const useMetricSelectorData = ({
|| metric.label.toLowerCase().includes(keyword)
|| metricDescription.toLowerCase().includes(keyword)
const metricNodes = nodeInfoMap[metric.id] ?? []
const supportsNodeSelection = resourceType !== 'datasets'
const hasNoNodeInfo = supportsNodeSelection && metricNodes.length === 0
const hasNoNodeInfo = metricNodes.length === 0
if (hasNoNodeInfo) {
if (!metricMatches)
@ -146,10 +104,11 @@ export const useMetricSelectorData = ({
hasNoNodeInfo: false,
visibleNodes,
}
}).filter(section => !!section)
}, [nodeInfoMap, query, resolvedMetrics, resourceType, t])
}).filter((section): section is MetricSelectorSection => !!section)
}, [nodeInfoMap, query, resolvedMetrics, t])
const toggleNodeSelection = (metricId: string, nodeInfo: NodeInfo) => {
const toggleNodeSelection = (metric: MetricOption, nodeInfo: NodeInfo) => {
const metricId = metric.id
const addedMetric = builtinMetricMap.get(metricId)
const currentSelectedNodes = addedMetric?.nodeInfoList ?? []
@ -164,13 +123,13 @@ export const useMetricSelectorData = ({
return
}
addBuiltinMetric(resourceType, resourceId, metricId, nextSelectedNodes)
addBuiltinMetric(resourceType, resourceId, metricId, nextSelectedNodes, metric)
}
return {
builtinMetricMap,
filteredSections,
isRemoteLoading: isAvailableMetricsLoading || isNodeInfoLoading,
isRemoteLoading: isDefaultMetricsLoading,
toggleNodeSelection,
}
}

View File

@ -1,10 +1,16 @@
import type { MetricOption } from '../../types'
import type { ConditionMetricValueType, MetricOption } from '../../types'
import type { MetricVisualTone } from './types'
import type { EvaluationTargetType, NodeInfo } from '@/types/evaluation'
import type { EvaluationDefaultMetric, NodeInfo } from '@/types/evaluation'
import { BlockEnum } from '@/app/components/workflow/types'
import { getDefaultMetricDescription } from '../../default-metric-descriptions'
export const toEvaluationTargetType = (resourceType: 'apps' | 'snippets'): EvaluationTargetType => {
return resourceType === 'snippets' ? 'snippets' : 'apps'
const defaultConditionMetricValueType: ConditionMetricValueType = 'number'
export const normalizeMetricValueType = (valueType: string | undefined): ConditionMetricValueType => {
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean')
return valueType
return defaultConditionMetricValueType
}
const humanizeMetricId = (metricId: string) => {
@ -15,17 +21,37 @@ const humanizeMetricId = (metricId: string) => {
.join(' ')
}
export const buildMetricOption = (metricId: string): MetricOption => ({
export const buildMetricOption = (metricId: string, valueType?: string): MetricOption => ({
id: metricId,
label: humanizeMetricId(metricId),
description: getDefaultMetricDescription(metricId),
valueType: 'number',
valueType: normalizeMetricValueType(valueType),
})
export const dedupeNodeInfoList = (nodeInfoList: NodeInfo[]) => {
return Array.from(new Map(nodeInfoList.map(nodeInfo => [nodeInfo.node_id, nodeInfo])).values())
}
export const getDefaultMetricNodeInfoMap = (defaultMetrics: EvaluationDefaultMetric[]) => {
const nodeInfoMap: Record<string, NodeInfo[]> = {}
defaultMetrics.forEach((defaultMetric) => {
if (!defaultMetric.metric)
return
nodeInfoMap[defaultMetric.metric] = dedupeNodeInfoList([
...(nodeInfoMap[defaultMetric.metric] ?? []),
...(defaultMetric.node_info_list ?? []),
])
})
return nodeInfoMap
}
export const getMetricVisual = (metricId: string): { icon: string, tone: MetricVisualTone } => {
if (['context-precision', 'context-recall'].includes(metricId)) {
if (['context_precision', 'context_recall'].includes(metricId)) {
return {
icon: metricId === 'context-recall' ? 'i-ri-arrow-go-back-line' : 'i-ri-focus-2-line',
icon: metricId === 'context_recall' ? 'i-ri-arrow-go-back-line' : 'i-ri-focus-2-line',
tone: 'green',
}
}
@ -33,29 +59,25 @@ export const getMetricVisual = (metricId: string): { icon: string, tone: MetricV
if (metricId === 'faithfulness')
return { icon: 'i-ri-anchor-line', tone: 'indigo' }
if (metricId === 'tool-correctness')
if (metricId === 'tool_correctness')
return { icon: 'i-ri-tools-line', tone: 'indigo' }
if (metricId === 'task-completion')
if (metricId === 'task_completion')
return { icon: 'i-ri-task-line', tone: 'indigo' }
if (metricId === 'argument-correctness')
if (metricId === 'argument_correctness')
return { icon: 'i-ri-scales-3-line', tone: 'indigo' }
return { icon: 'i-ri-checkbox-circle-line', tone: 'indigo' }
}
export const getNodeVisual = (nodeInfo: NodeInfo): { icon: string, tone: MetricVisualTone } => {
const normalizedType = nodeInfo.type.toLowerCase()
const normalizedTitle = nodeInfo.title.toLowerCase()
const workflowBlockTypeSet = new Set<string>(Object.values(BlockEnum))
if (normalizedType.includes('retriev') || normalizedTitle.includes('retriev') || normalizedTitle.includes('knowledge'))
return { icon: 'i-ri-book-open-line', tone: 'green' }
export const getEvaluationNodeBlockType = (nodeInfo: Pick<NodeInfo, 'type'>): BlockEnum => {
if (workflowBlockTypeSet.has(nodeInfo.type))
return nodeInfo.type as BlockEnum
if (normalizedType.includes('agent') || normalizedTitle.includes('agent'))
return { icon: 'i-ri-user-star-line', tone: 'indigo' }
return { icon: 'i-ri-ai-generate-2', tone: 'indigo' }
return BlockEnum.LLM
}
export const getToneClasses = (tone: MetricVisualTone) => {
@ -71,7 +93,3 @@ export const getToneClasses = (tone: MetricVisualTone) => {
solid: 'bg-util-colors-indigo-indigo-500 text-white',
}
}
export const dedupeNodeInfoList = (nodeInfoList: NodeInfo[]) => {
return Array.from(new Map(nodeInfoList.map(nodeInfo => [nodeInfo.node_id, nodeInfo])).values())
}

View File

@ -4,8 +4,8 @@ import type { EvaluationResourceProps } from '../../types'
import type { InputField } from '../batch-test-panel/input-fields/input-fields-utils'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import { getEvaluationMockConfig } from '../../mock'
import { isEvaluationRunnable, useEvaluationResource } from '../../store'
import { EVALUATION_TEMPLATE_FILE_NAMES } from '../../store-utils'
import UploadRunPopover from '../batch-test-panel/input-fields/upload-run-popover'
import { useInputFieldsActions } from '../batch-test-panel/input-fields/use-input-fields-actions'
@ -20,7 +20,6 @@ const PipelineBatchActions = ({
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const config = getEvaluationMockConfig(resourceType)
const isConfigReady = !!resource.judgeModelId && resource.metrics.some(metric => metric.kind === 'builtin')
const isRunnable = isEvaluationRunnable(resource)
const actions = useInputFieldsActions({
@ -30,7 +29,7 @@ const PipelineBatchActions = ({
isInputFieldsLoading: false,
isPanelReady: isConfigReady,
isRunnable,
templateFileName: config.templateFileName,
templateFileName: EVALUATION_TEMPLATE_FILE_NAMES[resourceType],
})
return (

View File

@ -9,8 +9,8 @@ import { BlockEnum } from '@/app/components/workflow/types'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useAvailableEvaluationMetrics } from '@/service/use-evaluation'
import { usePublishedPipelineInfo } from '@/service/use-pipeline'
import { getEvaluationMockConfig } from '../../mock'
import { useEvaluationResource, useEvaluationStore } from '../../store'
import { buildMetricOption } from '../metric-selector/utils'
import { InlineSectionHeader } from '../section-header'
import PipelineMetricItem from './pipeline-metric-item'
@ -52,7 +52,6 @@ const PipelineMetricsSection = ({
const { data: availableMetricsData } = useAvailableEvaluationMetrics()
const { data: publishedPipeline } = usePublishedPipelineInfo(pipelineId || '')
const resource = useEvaluationResource(resourceType, resourceId)
const config = getEvaluationMockConfig(resourceType)
const knowledgeIndexNodeInfoList = useMemo(
() => getKnowledgeIndexNodeInfo(publishedPipeline?.graph.nodes),
[publishedPipeline?.graph.nodes],
@ -62,12 +61,14 @@ const PipelineMetricsSection = ({
.filter(metric => metric.kind === 'builtin')
.map(metric => [metric.optionId, metric]),
), [resource.metrics])
const availableMetricIds = useMemo(() => new Set(availableMetricsData?.metrics ?? []), [availableMetricsData?.metrics])
const availableBuiltinMetrics = useMemo(() => {
return config.builtinMetrics.filter(metric =>
availableMetricIds.has(metric.id) || builtinMetricMap.has(metric.id),
)
}, [availableMetricIds, builtinMetricMap, config.builtinMetrics])
const metricIds = new Set([
...(availableMetricsData?.metrics ?? []),
...builtinMetricMap.keys(),
])
return Array.from(metricIds).map(metricId => buildMetricOption(metricId))
}, [availableMetricsData?.metrics, builtinMetricMap])
useEffect(() => {
if (!knowledgeIndexNodeInfoList.length)
@ -77,7 +78,7 @@ const PipelineMetricsSection = ({
if (metric.kind !== 'builtin' || isSameNodeInfoList(metric.nodeInfoList, knowledgeIndexNodeInfoList))
return
addBuiltinMetric(resourceType, resourceId, metric.optionId, knowledgeIndexNodeInfoList)
addBuiltinMetric(resourceType, resourceId, metric.optionId, knowledgeIndexNodeInfoList, metric)
})
}, [addBuiltinMetric, knowledgeIndexNodeInfoList, resource.metrics, resourceId, resourceType])
@ -88,7 +89,8 @@ const PipelineMetricsSection = ({
return
}
addBuiltinMetric(resourceType, resourceId, metricId, knowledgeIndexNodeInfoList)
const metricOption = availableBuiltinMetrics.find(metric => metric.id === metricId)
addBuiltinMetric(resourceType, resourceId, metricId, knowledgeIndexNodeInfoList, metricOption)
}
return (

View File

@ -1,179 +0,0 @@
import type {
EvaluationFieldOption,
EvaluationMockConfig,
EvaluationResourceType,
MetricOption,
} from './types'
import { getDefaultMetricDescription } from './default-metric-descriptions'
const judgeModels = [
{
id: 'gpt-4.1-mini',
label: 'GPT-4.1 mini',
provider: 'OpenAI',
},
{
id: 'claude-3-7-sonnet',
label: 'Claude 3.7 Sonnet',
provider: 'Anthropic',
},
{
id: 'gemini-2.0-flash',
label: 'Gemini 2.0 Flash',
provider: 'Google',
},
]
const builtinMetrics: MetricOption[] = [
{
id: 'answer-correctness',
label: 'Answer Correctness',
description: getDefaultMetricDescription('answer-correctness'),
valueType: 'number',
},
{
id: 'faithfulness',
label: 'Faithfulness',
description: getDefaultMetricDescription('faithfulness'),
valueType: 'number',
},
{
id: 'relevance',
label: 'Relevance',
description: getDefaultMetricDescription('relevance'),
valueType: 'number',
},
{
id: 'latency',
label: 'Latency',
description: 'Captures runtime responsiveness for the full execution path.',
valueType: 'number',
},
{
id: 'token-usage',
label: 'Token Usage',
description: 'Tracks prompt and completion token consumption for the run.',
valueType: 'number',
},
{
id: 'tool-success-rate',
label: 'Tool Success Rate',
description: 'Measures whether each required tool invocation finishes without failure.',
valueType: 'number',
},
]
const pipelineBuiltinMetrics: MetricOption[] = [
{
id: 'context-precision',
label: 'Context Precision',
description: getDefaultMetricDescription('context-precision'),
valueType: 'number',
},
{
id: 'context-recall',
label: 'Context Recall',
description: getDefaultMetricDescription('context-recall'),
valueType: 'number',
},
{
id: 'context-relevance',
label: 'Context Relevance',
description: getDefaultMetricDescription('context-relevance'),
valueType: 'number',
},
]
const workflowOptions = [
{
id: 'workflow-precision-review',
label: 'Precision Review Workflow',
description: 'Custom evaluator for nuanced quality review.',
targetVariables: [
{ id: 'query', label: 'query' },
{ id: 'answer', label: 'answer' },
{ id: 'reference', label: 'reference' },
],
},
{
id: 'workflow-risk-review',
label: 'Risk Review Workflow',
description: 'Custom evaluator for policy and escalation checks.',
targetVariables: [
{ id: 'input', label: 'input' },
{ id: 'output', label: 'output' },
],
},
]
const workflowFields: EvaluationFieldOption[] = [
{ id: 'app.input.query', label: 'Query', group: 'App Input', type: 'string' },
{ id: 'app.input.locale', label: 'Locale', group: 'App Input', type: 'enum', options: [{ value: 'en-US', label: 'en-US' }, { value: 'zh-Hans', label: 'zh-Hans' }] },
{ id: 'app.output.answer', label: 'Answer', group: 'App Output', type: 'string' },
{ id: 'app.output.score', label: 'Score', group: 'App Output', type: 'number' },
{ id: 'system.has_context', label: 'Has Context', group: 'System', type: 'boolean' },
]
const pipelineFields: EvaluationFieldOption[] = [
{ id: 'dataset.input.document_id', label: 'Document ID', group: 'Dataset', type: 'string' },
{ id: 'dataset.input.chunk_count', label: 'Chunk Count', group: 'Dataset', type: 'number' },
{ id: 'retrieval.output.hit_rate', label: 'Hit Rate', group: 'Retrieval', type: 'number' },
{ id: 'retrieval.output.source', label: 'Source', group: 'Retrieval', type: 'enum', options: [{ value: 'bm25', label: 'BM25' }, { value: 'hybrid', label: 'Hybrid' }] },
{ id: 'pipeline.output.published', label: 'Published', group: 'Output', type: 'boolean' },
]
const snippetFields: EvaluationFieldOption[] = [
{ id: 'snippet.input.blog_url', label: 'Blog URL', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.input.platforms', label: 'Platforms', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.output.content', label: 'Generated Content', group: 'Snippet Output', type: 'string' },
{ id: 'snippet.output.length', label: 'Output Length', group: 'Snippet Output', type: 'number' },
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
]
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
if (resourceType === 'datasets') {
return {
judgeModels,
builtinMetrics: pipelineBuiltinMetrics,
workflowOptions,
fieldOptions: pipelineFields,
templateFileName: 'pipeline-evaluation-template.csv',
batchRequirements: [
'Include one row per retrieval scenario.',
'Provide the expected source or target chunk for each case.',
'Keep numeric metrics in plain number format.',
],
historySummaryLabel: 'Pipeline evaluation batch',
}
}
if (resourceType === 'snippets') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: snippetFields,
templateFileName: 'snippet-evaluation-template.csv',
batchRequirements: [
'Include one row per snippet execution case.',
'Provide the expected final content or acceptance rule.',
'Keep optional fields empty when not used.',
],
historySummaryLabel: 'Snippet evaluation batch',
}
}
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: workflowFields,
templateFileName: 'workflow-evaluation-template.csv',
batchRequirements: [
'Include one row per workflow test case.',
'Provide both user input and expected answer when available.',
'Keep boolean columns as true or false.',
],
historySummaryLabel: 'Workflow evaluation batch',
}
}

View File

@ -21,7 +21,6 @@ import type {
NodeInfo,
} from '@/types/evaluation'
import { getDefaultMetricDescription } from './default-metric-descriptions'
import { getEvaluationMockConfig } from './mock'
import {
buildConditionMetricOptions,
decodeModelSelection,
@ -34,6 +33,19 @@ import {
type EvaluationStoreResources = Record<string, EvaluationResourceState>
export const DEFAULT_PIPELINE_METRIC_THRESHOLD = 0.85
export const EVALUATION_TEMPLATE_FILE_NAMES: Record<EvaluationResourceType, string> = {
apps: 'workflow-evaluation-template.csv',
snippets: 'snippet-evaluation-template.csv',
datasets: 'pipeline-evaluation-template.csv',
}
const BATCH_HISTORY_SUMMARY_LABELS: Record<EvaluationResourceType, string> = {
apps: 'Workflow evaluation batch',
snippets: 'Snippet evaluation batch',
datasets: 'Pipeline evaluation batch',
}
const PIPELINE_METRIC_IDS = new Set(['context-precision', 'context-recall', 'context-relevance'])
const PIPELINE_LOGICAL_OPERATOR: JudgmentConfig['logicalOperator'] = 'and'
@ -47,9 +59,8 @@ const humanizeMetricId = (metricId: string) => {
.join(' ')
}
const resolveMetricOption = (resourceType: EvaluationResourceType, metricId: string): MetricOption => {
const config = getEvaluationMockConfig(resourceType)
return config.builtinMetrics.find(metric => metric.id === metricId) ?? {
export const resolveMetricOption = (metricId: string): MetricOption => {
return {
id: metricId,
label: humanizeMetricId(metricId),
description: getDefaultMetricDescription(metricId),
@ -57,13 +68,11 @@ const resolveMetricOption = (resourceType: EvaluationResourceType, metricId: str
}
}
const pipelineMetricIds = new Set(getEvaluationMockConfig('datasets').builtinMetrics.map(metric => metric.id))
const isPipelineResourceType = (resourceType: EvaluationResourceType) => resourceType === 'datasets'
const isPipelineResourceState = (resource: EvaluationResourceState) => {
return resource.metrics.length > 0
&& resource.metrics.every(metric => metric.kind === 'builtin' && pipelineMetricIds.has(metric.optionId))
&& resource.metrics.every(metric => metric.kind === 'builtin' && PIPELINE_METRIC_IDS.has(metric.optionId))
}
const normalizeNodeInfoList = (value: NodeInfo[] | undefined): NodeInfo[] => {
@ -88,10 +97,7 @@ const normalizeNodeInfoList = (value: NodeInfo[] | undefined): NodeInfo[] => {
.filter((item): item is NodeInfo => !!item)
}
const normalizeDefaultMetrics = (
resourceType: EvaluationResourceType,
value: EvaluationDefaultMetric[] | null | undefined,
): EvaluationMetric[] => {
const normalizeDefaultMetrics = (value: EvaluationDefaultMetric[] | null | undefined): EvaluationMetric[] => {
if (!value?.length)
return []
@ -101,7 +107,7 @@ const normalizeDefaultMetrics = (
if (!metricId)
return null
const metricOption = resolveMetricOption(resourceType, metricId)
const metricOption = resolveMetricOption(metricId)
return createBuiltinMetric(metricOption, normalizeNodeInfoList(item.node_info_list ?? []))
})
.filter((item): item is EvaluationMetric => !!item)
@ -455,7 +461,7 @@ export const buildStateFromEvaluationConfig = (
resourceType: EvaluationResourceType,
config: EvaluationConfig,
): EvaluationResourceState => {
const defaultMetrics = normalizeDefaultMetrics(resourceType, config.default_metrics)
const defaultMetrics = normalizeDefaultMetrics(config.default_metrics)
const customMetrics = isPipelineResourceType(resourceType) ? [] : normalizeCustomMetric(config.customized_metrics)
const metrics = isPipelineResourceType(resourceType)
? normalizePipelineMetrics(config, defaultMetrics)
@ -652,14 +658,12 @@ export const createBatchTestRecord = (
resourceType: EvaluationResourceType,
uploadedFileName: string | null | undefined,
): BatchTestRecord => {
const config = getEvaluationMockConfig(resourceType)
return {
id: createId('batch'),
fileName: uploadedFileName ?? config.templateFileName,
fileName: uploadedFileName ?? EVALUATION_TEMPLATE_FILE_NAMES[resourceType],
status: 'running',
startedAt: new Date().toLocaleTimeString(),
summary: config.historySummaryLabel,
summary: BATCH_HISTORY_SUMMARY_LABELS[resourceType],
}
}

View File

@ -2,10 +2,11 @@ import type {
ComparisonOperator,
EvaluationResourceState,
EvaluationResourceType,
MetricOption,
} from './types'
import type { EvaluationConfig, NodeInfo } from '@/types/evaluation'
import { isEqual } from 'es-toolkit/predicate'
import { create } from 'zustand'
import { getEvaluationMockConfig } from './mock'
import {
buildConditionItem,
buildInitialState,
@ -19,6 +20,7 @@ import {
isCustomMetricConfigured as isCustomMetricConfiguredFromUtils,
isEvaluationRunnable as isEvaluationRunnableFromUtils,
requiresConditionValue as requiresConditionValueFromUtils,
resolveMetricOption,
syncCustomMetricMappings as syncCustomMetricMappingsFromUtils,
syncJudgmentConfigWithMetrics,
updateMetric,
@ -28,10 +30,13 @@ import { buildConditionMetricOptions } from './utils'
type EvaluationStore = {
resources: Record<string, EvaluationResourceState>
initialResources: Record<string, EvaluationResourceState>
ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void
hydrateResource: (resourceType: EvaluationResourceType, resourceId: string, config: EvaluationConfig) => void
resetResourceConfig: (resourceType: EvaluationResourceType, resourceId: string) => void
markResourceConfigSaved: (resourceType: EvaluationResourceType, resourceId: string) => void
setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string, nodeInfoList?: NodeInfo[]) => void
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string, nodeInfoList?: NodeInfo[], metricOption?: MetricOption) => void
updateMetricThreshold: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, threshold: number) => void
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
@ -88,8 +93,68 @@ type EvaluationStore = {
const initialResourceCache: Record<string, EvaluationResourceState> = {}
const cloneEvaluationResourceState = (resource: EvaluationResourceState): EvaluationResourceState => ({
...resource,
metrics: resource.metrics.map(metric => ({
...metric,
nodeInfoList: metric.nodeInfoList?.map(nodeInfo => ({ ...nodeInfo })),
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.map(mapping => ({ ...mapping })),
outputs: metric.customConfig.outputs.map(output => ({ ...output })),
}
: undefined,
})),
judgmentConfig: {
...resource.judgmentConfig,
conditions: resource.judgmentConfig.conditions.map(condition => ({ ...condition })),
},
batchRecords: resource.batchRecords.map(record => ({ ...record })),
})
const preserveBatchState = (
configState: EvaluationResourceState,
currentResource: EvaluationResourceState | undefined,
resourceType: EvaluationResourceType,
): EvaluationResourceState => {
const initialState = buildInitialState(resourceType)
return {
...cloneEvaluationResourceState(configState),
activeBatchTab: currentResource?.activeBatchTab ?? initialState.activeBatchTab,
uploadedFileId: currentResource?.uploadedFileId ?? initialState.uploadedFileId,
uploadedFileName: currentResource?.uploadedFileName ?? initialState.uploadedFileName,
selectedRunId: currentResource?.selectedRunId ?? initialState.selectedRunId,
batchRecords: currentResource?.batchRecords.map(record => ({ ...record })) ?? initialState.batchRecords,
}
}
const createConfigSnapshot = (
resourceType: EvaluationResourceType,
resource: EvaluationResourceState,
): EvaluationResourceState => {
const initialState = buildInitialState(resourceType)
return {
...cloneEvaluationResourceState(resource),
activeBatchTab: initialState.activeBatchTab,
uploadedFileId: initialState.uploadedFileId,
uploadedFileName: initialState.uploadedFileName,
selectedRunId: initialState.selectedRunId,
batchRecords: initialState.batchRecords,
}
}
const pickConfigComparableState = (resource: EvaluationResourceState) => ({
judgeModelId: resource.judgeModelId,
metrics: resource.metrics,
judgmentConfig: resource.judgmentConfig,
})
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
resources: {},
initialResources: {},
ensureResource: (resourceType, resourceId) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
if (get().resources[resourceKey])
@ -103,17 +168,42 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
}))
},
hydrateResource: (resourceType, resourceId, config) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
const configState = buildStateFromEvaluationConfig(resourceType, config)
set(state => ({
resources: {
...state.resources,
[buildResourceKey(resourceType, resourceId)]: {
...buildStateFromEvaluationConfig(resourceType, config),
activeBatchTab: state.resources[buildResourceKey(resourceType, resourceId)]?.activeBatchTab ?? 'input-fields',
uploadedFileId: state.resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileId ?? null,
uploadedFileName: state.resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? null,
selectedRunId: state.resources[buildResourceKey(resourceType, resourceId)]?.selectedRunId ?? null,
batchRecords: state.resources[buildResourceKey(resourceType, resourceId)]?.batchRecords ?? [],
},
[resourceKey]: preserveBatchState(configState, state.resources[resourceKey], resourceType),
},
initialResources: {
...state.initialResources,
[resourceKey]: createConfigSnapshot(resourceType, configState),
},
}))
},
resetResourceConfig: (resourceType, resourceId) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
set(state => ({
resources: {
...state.resources,
[resourceKey]: preserveBatchState(
state.initialResources[resourceKey] ?? buildInitialState(resourceType),
state.resources[resourceKey],
resourceType,
),
},
}))
},
markResourceConfigSaved: (resourceType, resourceId) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
const resource = get().resources[resourceKey] ?? buildInitialState(resourceType)
set(state => ({
initialResources: {
...state.initialResources,
[resourceKey]: createConfigSnapshot(resourceType, resource),
},
}))
},
@ -125,11 +215,8 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
})),
}))
},
addBuiltinMetric: (resourceType, resourceId, optionId, nodeInfoList = []) => {
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
if (!option)
return
addBuiltinMetric: (resourceType, resourceId, optionId, nodeInfoList = [], metricOption) => {
const option = metricOption ?? resolveMetricOption(optionId)
set((state) => {
return {
resources: updateResourceState(state.resources, resourceType, resourceId, (currentResource) => {
@ -436,6 +523,20 @@ export const useEvaluationResource = (resourceType: EvaluationResourceType, reso
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
}
export const useIsEvaluationConfigDirty = (resourceType: EvaluationResourceType, resourceId: string) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return useEvaluationStore((state) => {
const resource = state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType))
const initialResource = state.initialResources[resourceKey] ?? buildInitialState(resourceType)
return !isEqual(
pickConfigComparableState(resource),
pickConfigComparableState(initialResource),
)
})
}
export const getAllowedOperators = (
metrics: EvaluationResourceState['metrics'],
variableSelector: [string, string] | null,

View File

@ -1,18 +1,22 @@
import type { NodeInfo } from '@/types/evaluation'
export type EvaluationResourceType = 'apps' | 'datasets' | 'snippets'
export type NonPipelineEvaluationResourceType = Exclude<EvaluationResourceType, 'datasets'>
export type EvaluationResourceProps = {
resourceType: EvaluationResourceType
resourceId: string
}
export type NonPipelineEvaluationResourceProps = {
resourceType: NonPipelineEvaluationResourceType
resourceId: string
}
export type MetricKind = 'builtin' | 'custom-workflow'
export type BatchTestTab = 'input-fields' | 'history'
export type FieldType = 'string' | 'number' | 'boolean' | 'enum'
export type ConditionMetricValueType = 'string' | 'number' | 'boolean'
export type ComparisonOperator
@ -35,12 +39,6 @@ export type ComparisonOperator
| 'is null'
| 'is not null'
export type JudgeModelOption = {
id: string
label: string
provider: string
}
export type MetricOption = {
id: string
label: string
@ -48,27 +46,6 @@ export type MetricOption = {
valueType: ConditionMetricValueType
}
export type EvaluationWorkflowOption = {
id: string
label: string
description: string
targetVariables: Array<{
id: string
label: string
}>
}
export type EvaluationFieldOption = {
id: string
label: string
group: string
type: FieldType
options?: Array<{
value: string
label: string
}>
}
export type CustomMetricMapping = {
id: string
inputVariableId: string | null
@ -141,13 +118,3 @@ export type EvaluationResourceState = {
selectedRunId: string | null
batchRecords: BatchTestRecord[]
}
export type EvaluationMockConfig = {
judgeModels: JudgeModelOption[]
builtinMetrics: MetricOption[]
workflowOptions: EvaluationWorkflowOption[]
fieldOptions: EvaluationFieldOption[]
templateFileName: string
batchRequirements: string[]
historySummaryLabel: string
}

View File

@ -307,11 +307,15 @@ describe('Nav Component', () => {
fireEvent.click(selectorButton)
})
const createButton = await screen.findByText('Create New')
await act(async () => {
fireEvent.click(createButton)
})
const openCreateMenu = async () => {
const createButton = await screen.findByText('Create New')
await act(async () => {
fireEvent.click(createButton)
})
return screen.findByText(/app\.newApp\.startFromBlank/i)
}
await openCreateMenu()
const blankOption = await screen.findByText(
/app\.newApp\.startFromBlank/i,
)
@ -320,6 +324,7 @@ describe('Nav Component', () => {
})
expect(mockOnCreate).toHaveBeenCalledWith('blank')
await openCreateMenu()
const templateOption = await screen.findByText(
/app\.newApp\.startFromTemplate/i,
)
@ -328,6 +333,7 @@ describe('Nav Component', () => {
})
expect(mockOnCreate).toHaveBeenCalledWith('template')
await openCreateMenu()
const dslOption = await screen.findByText(/app\.importDSL/i)
await act(async () => {
fireEvent.click(dslOption)

View File

@ -203,23 +203,30 @@ describe('NavSelector Component', () => {
await act(async () => {
fireEvent.click(button)
})
const createBtn = screen.getByText('Create New')
await act(async () => {
fireEvent.click(createBtn)
})
const openCreateMenu = async () => {
const createBtn = screen.getByText('Create New')
await act(async () => {
fireEvent.click(createBtn)
})
return screen.findByText(/app\.newApp\.startFromBlank/i)
}
await openCreateMenu()
const blank = await screen.findByText(/app\.newApp\.startFromBlank/i)
await act(async () => {
fireEvent.click(blank)
})
expect(mockOnCreate).toHaveBeenCalledWith('blank')
await openCreateMenu()
const template = await screen.findByText(/app\.newApp\.startFromTemplate/i)
await act(async () => {
fireEvent.click(template)
})
expect(mockOnCreate).toHaveBeenCalledWith('template')
await openCreateMenu()
const dsl = await screen.findByText(/app\.importDSL/i)
await act(async () => {
fireEvent.click(dsl)
@ -227,6 +234,21 @@ describe('NavSelector Component', () => {
expect(mockOnCreate).toHaveBeenCalledWith('dsl')
})
it('should open extended create menu on hover in app mode', async () => {
render(<NavSelector {...defaultProps} isApp />)
const button = screen.getByRole('button')
await act(async () => {
fireEvent.click(button)
})
const createBtn = screen.getByText('Create New')
await act(async () => {
fireEvent.mouseEnter(createBtn)
})
expect(await screen.findByText(/app\.newApp\.startFromBlank/i))!.toBeInTheDocument()
})
it('should not show create button for non-editors', async () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceEditor: false,

View File

@ -1,6 +1,6 @@
'use client'
import type { AppIconType, AppModeEnum } from '@/types/app'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiAddLine,
@ -8,7 +8,7 @@ import {
RiArrowRightSLine,
} from '@remixicon/react'
import { debounce } from 'es-toolkit/compat'
import { Fragment, useCallback } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { AppTypeIcon } from '@/app/components/app/type-selector'
@ -38,6 +38,75 @@ export type INavSelectorProps = {
isLoadingMore?: boolean
}
type AppCreateMenuProps = {
createText: string
startFromBlankText: string
startFromTemplateText: string
importDSLText: string
onCreate: (state: string) => void
}
const AppCreateMenu = ({
createText,
startFromBlankText,
startFromTemplateText,
importDSLText,
onCreate,
}: AppCreateMenuProps) => {
const [open, setOpen] = useState(false)
const handleCreate = (state: string) => {
setOpen(false)
onCreate(state)
}
return (
<div className="relative h-full w-full" onMouseLeave={() => setOpen(false)}>
<button
type="button"
className="w-full p-1 text-left"
onClick={() => setOpen(value => !value)}
onMouseEnter={() => setOpen(true)}
>
<div className={cn(
'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover',
open && 'bg-state-base-hover!',
)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-background-default">
<RiAddLine className="h-4 w-4 text-text-primary" />
</div>
<div className="grow text-left text-[14px] font-normal text-text-secondary">{createText}</div>
<RiArrowRightSLine className="h-3.5 w-3.5 shrink-0 text-text-primary" />
</div>
</button>
{open && (
<div
className="absolute top-[3px] right-[-198px] z-10 min-w-[200px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg"
onMouseEnter={() => setOpen(true)}
>
<div className="p-1">
<button type="button" className={cn('flex w-full cursor-pointer items-center rounded-lg px-3 py-[6px] text-left font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => handleCreate('blank')}>
<FilePlus01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{startFromBlankText}
</button>
<button type="button" className={cn('flex w-full cursor-pointer items-center rounded-lg px-3 py-[6px] text-left font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => handleCreate('template')}>
<FilePlus02 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{startFromTemplateText}
</button>
</div>
<div className="border-t border-divider-regular p-1">
<button type="button" className={cn('flex w-full cursor-pointer items-center rounded-lg px-3 py-[6px] text-left font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => handleCreate('dsl')}>
<FileArrow01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{importDSLText}
</button>
</div>
</div>
)}
</div>
)
}
const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onLoadMore, isLoadingMore }: INavSelectorProps) => {
const { t } = useTranslation()
const router = useRouter()
@ -72,7 +141,7 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
className="
absolute right-0 -left-11 mt-1.5 w-60 max-w-80
origin-top-right divide-y divide-divider-regular rounded-lg bg-components-panel-bg-blur
shadow-lg
shadow-lg outline-none
"
>
<div className="overflow-auto px-1 py-1" style={{ maxHeight: '50vh' }} onScroll={handleScroll}>
@ -130,56 +199,13 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
</MenuItem>
)}
{isApp && isCurrentWorkspaceEditor && (
<Menu as="div" className="relative h-full w-full">
{({ open }) => (
<>
<MenuButton className="w-full p-1">
<div className={cn(
'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover',
open && 'bg-state-base-hover!',
)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-background-default">
<RiAddLine className="h-4 w-4 text-text-primary" />
</div>
<div className="grow text-left text-[14px] font-normal text-text-secondary">{createText}</div>
<RiArrowRightSLine className="h-3.5 w-3.5 shrink-0 text-text-primary" />
</div>
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems className={cn(
'absolute top-[3px] right-[-198px] z-10 min-w-[200px] rounded-lg bg-components-panel-bg-blur shadow-lg',
)}
>
<div className="p-1">
<div className={cn('flex cursor-pointer items-center rounded-lg px-3 py-[6px] font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => onCreate('blank')}>
<FilePlus01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{t('newApp.startFromBlank', { ns: 'app' })}
</div>
<div className={cn('flex cursor-pointer items-center rounded-lg px-3 py-[6px] font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => onCreate('template')}>
<FilePlus02 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{t('newApp.startFromTemplate', { ns: 'app' })}
</div>
</div>
<div className="border-t border-divider-regular p-1">
<div className={cn('flex cursor-pointer items-center rounded-lg px-3 py-[6px] font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => onCreate('dsl')}>
<FileArrow01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{t('importDSL', { ns: 'app' })}
</div>
</div>
</MenuItems>
</Transition>
</>
)}
</Menu>
<AppCreateMenu
createText={createText}
startFromBlankText={t('newApp.startFromBlank', { ns: 'app' })}
startFromTemplateText={t('newApp.startFromTemplate', { ns: 'app' })}
importDSLText={t('importDSL', { ns: 'app' })}
onCreate={onCreate}
/>
)}
</MenuItems>
</>

View File

@ -76,6 +76,8 @@ const createPluginDetail = (): PluginDetail => ({
})
describe('EndpointList', () => {
const getAddButton = () => screen.getByRole('button', { name: 'plugin.detailPanel.endpointModalTitle' })
beforeEach(() => {
vi.clearAllMocks()
mockEndpointListData = { endpoints: mockEndpoints }
@ -112,7 +114,7 @@ describe('EndpointList', () => {
it('should render add button', () => {
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
expect(getAddButton()).toBeInTheDocument()
})
})
@ -120,8 +122,7 @@ describe('EndpointList', () => {
it('should show modal when add button clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton!)
fireEvent.click(getAddButton())
expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument()
})
@ -129,8 +130,7 @@ describe('EndpointList', () => {
it('should hide modal when cancel clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton!)
fireEvent.click(getAddButton())
expect(screen.getByTestId('endpoint-modal'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-cancel'))
@ -140,8 +140,7 @@ describe('EndpointList', () => {
it('should call createEndpoint when save clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton!)
fireEvent.click(getAddButton())
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalled()
@ -176,8 +175,7 @@ describe('EndpointList', () => {
it('should invalidate endpoint list after successful create', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton!)
fireEvent.click(getAddButton())
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin')
@ -186,8 +184,7 @@ describe('EndpointList', () => {
it('should pass correct params to createEndpoint', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button')[0]
fireEvent.click(addButton!)
fireEvent.click(getAddButton())
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalledWith({

View File

@ -1,5 +1,6 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiAddLine,
@ -11,7 +12,6 @@ import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import { toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { useDocLink } from '@/context/i18n'
import {
@ -67,10 +67,23 @@ const EndpointList = ({ detail }: Props) => {
<div className="mb-1 flex h-6 items-center justify-between system-sm-semibold-uppercase text-text-secondary">
<div className="flex items-center gap-0.5">
{t('detailPanel.endpoints', { ns: 'plugin' })}
<Tooltip
position="right"
popupClassName="w-[240px] p-4 rounded-xl bg-components-panel-bg-blur border-[0.5px] border-components-panel-border"
popupContent={(
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('detailPanel.endpointsTip', { ns: 'plugin' })}
render={(
<button
type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm p-px outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</button>
)}
/>
<PopoverContent
placement="right"
popupClassName="w-[240px] p-4 rounded-xl bg-components-panel-bg-blur border-[0.5px] border-components-panel-border"
>
<div className="flex flex-col gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<RiApps2AddLine className="h-4 w-4 text-text-tertiary" />
@ -80,17 +93,19 @@ const EndpointList = ({ detail }: Props) => {
href={docLink('/develop-plugin/getting-started/getting-started-dify-plugin')}
target="_blank"
rel="noopener noreferrer"
className="inline-flex cursor-pointer items-center gap-1 system-xs-regular text-text-accent"
>
<div className="inline-flex cursor-pointer items-center gap-1 system-xs-regular text-text-accent">
<RiBookOpenLine className="h-3 w-3" />
{t('detailPanel.endpointsDocLink', { ns: 'plugin' })}
</div>
<RiBookOpenLine className="h-3 w-3" />
{t('detailPanel.endpointsDocLink', { ns: 'plugin' })}
</a>
</div>
)}
/>
</PopoverContent>
</Popover>
</div>
<ActionButton onClick={showEndpointModal}>
<ActionButton
aria-label={t('detailPanel.endpointModalTitle', { ns: 'plugin' })}
onClick={showEndpointModal}
>
<RiAddLine className="h-4 w-4" />
</ActionButton>
</div>

View File

@ -1,10 +1,11 @@
import type { SnippetDetailPayload } from '@/models/snippet'
import type { Snippet } from '@/types/snippet'
import { render, screen } from '@testing-library/react'
import SnippetEvaluationPage from '../snippet-evaluation-page'
const mockUseSnippetApiDetail = vi.fn()
const mockGetSnippetDetailMock = vi.fn()
const mockSetAppSidebarExpand = vi.fn()
const mockUseDocumentTitle = vi.fn()
vi.mock('@/service/use-snippets', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/use-snippets')>()
@ -34,15 +35,15 @@ vi.mock('@/next/navigation', () => ({
}),
}))
vi.mock('@/service/use-snippets.mock', () => ({
getSnippetDetailMock: (snippetId: string) => mockGetSnippetDetailMock(snippetId),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
vi.mock('@/hooks/use-document-title', () => ({
default: (title: string) => mockUseDocumentTitle(title),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
setAppSidebarExpand: mockSetAppSidebarExpand,
@ -101,21 +102,39 @@ const mockSnippetDetail: SnippetDetailPayload = {
},
}
const mockSnippetApiDetail: Snippet = {
id: mockSnippetDetail.snippet.id,
name: mockSnippetDetail.snippet.name,
description: mockSnippetDetail.snippet.description,
type: 'node',
version: '1',
use_count: 19,
icon_info: {
icon: mockSnippetDetail.snippet.icon,
icon_background: mockSnippetDetail.snippet.iconBackground,
icon_type: 'emoji',
},
input_fields: [],
created_at: 1711267200,
created_by: 'user-1',
updated_at: 1711267200,
updated_by: 'user-1',
is_published: true,
}
describe('SnippetEvaluationPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSnippetApiDetail.mockReturnValue({
data: undefined,
data: mockSnippetApiDetail,
isLoading: false,
})
mockGetSnippetDetailMock.mockReturnValue(mockSnippetDetail)
})
it('should render evaluation with mock snippet detail data', () => {
it('should render evaluation with snippet detail data from api', () => {
render(<SnippetEvaluationPage snippetId="snippet-1" />)
expect(mockGetSnippetDetailMock).toHaveBeenCalledWith('snippet-1')
expect(mockUseSnippetApiDetail).not.toHaveBeenCalled()
expect(mockUseSnippetApiDetail).toHaveBeenCalledWith('snippet-1')
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
expect(screen.getByTestId('evaluation')).toHaveTextContent('snippet-1')
})

View File

@ -1,9 +1,13 @@
'use client'
import { useMemo } from 'react'
import Loading from '@/app/components/base/loading'
import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard'
import Evaluation from '@/app/components/evaluation'
import { getSnippetDetailMock } from '@/service/use-snippets.mock'
import {
buildSnippetDetailPayload,
useSnippetApiDetail,
} from '@/service/use-snippets'
import SnippetLayout from './components/snippet-layout'
type SnippetEvaluationPageProps = {
@ -11,8 +15,21 @@ type SnippetEvaluationPageProps = {
}
const SnippetEvaluationPage = ({ snippetId }: SnippetEvaluationPageProps) => {
const mockSnippet = useMemo(() => getSnippetDetailMock(snippetId)?.snippet, [snippetId])
const snippet = mockSnippet
const { data, isLoading } = useSnippetApiDetail(snippetId)
const snippet = useMemo(() => {
if (!data)
return undefined
return buildSnippetDetailPayload(data).snippet
}, [data])
if (isLoading) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
)
}
if (!snippet)
return null

View File

@ -12,6 +12,7 @@ const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' })
const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' })
const mockInvalidateMCPTools = vi.fn()
const mockInvalidateAllMCPTools = vi.fn()
const mockOpenOAuthPopup = vi.fn()
// Mutable mock state
@ -33,6 +34,7 @@ vi.mock('@/service/use-tools', () => ({
isFetching: mockIsFetching,
}),
useInvalidateMCPTools: () => mockInvalidateMCPTools,
useInvalidateAllMCPTools: () => mockInvalidateAllMCPTools,
useUpdateMCPTools: () => ({
mutateAsync: mockUpdateTools,
isPending: mockIsUpdating,
@ -180,6 +182,7 @@ describe('MCPDetailContent', () => {
mockUpdateMCP.mockClear()
mockDeleteMCP.mockClear()
mockInvalidateMCPTools.mockClear()
mockInvalidateAllMCPTools.mockClear()
mockOpenOAuthPopup.mockClear()
// Reset mock return values
@ -512,6 +515,7 @@ describe('MCPDetailContent', () => {
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1')
expect(mockInvalidateAllMCPTools).toHaveBeenCalled()
expect(onUpdate).toHaveBeenCalled()
})
})
@ -529,6 +533,7 @@ describe('MCPDetailContent', () => {
await waitFor(() => {
expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1')
expect(mockInvalidateAllMCPTools).toHaveBeenCalled()
})
})
})

View File

@ -26,6 +26,7 @@ import { openOAuthPopup } from '@/hooks/use-oauth'
import {
useAuthorizeMCP,
useDeleteMCP,
useInvalidateAllMCPTools,
useInvalidateMCPTools,
useMCPTools,
useUpdateMCP,
@ -61,6 +62,7 @@ const MCPDetailContent: FC<Props> = ({
const { data, isFetching: isGettingTools } = useMCPTools(detail.is_team_authorization ? detail.id : '')
const invalidateMCPTools = useInvalidateMCPTools()
const invalidateAllMCPTools = useInvalidateAllMCPTools()
const { mutateAsync: updateTools, isPending: isUpdating } = useUpdateMCPTools()
const { mutateAsync: authorizeMcp, isPending: isAuthorizing } = useAuthorizeMCP()
const toolList = data?.tools || []
@ -76,8 +78,9 @@ const MCPDetailContent: FC<Props> = ({
return
await updateTools(detail.id)
invalidateMCPTools(detail.id)
invalidateAllMCPTools()
onUpdate()
}, [detail, hideUpdateConfirm, invalidateMCPTools, onUpdate, updateTools])
}, [detail, hideUpdateConfirm, invalidateAllMCPTools, invalidateMCPTools, onUpdate, updateTools])
const { mutateAsync: updateMCP } = useUpdateMCP({})
const { mutateAsync: deleteMCP } = useDeleteMCP({})

View File

@ -1,9 +1,9 @@
'use client'
import type { Tool } from '@/app/components/tools/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { useLocale } from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
@ -51,25 +51,31 @@ const MCPToolItem = ({
}
return (
<Tooltip
key={tool.name}
position="left"
popupClassName="p-0! px-4! py-3.5! w-[360px]! border-[0.5px]! border-components-panel-border! rounded-xl! shadow-lg!"
popupContent={(
<Popover key={tool.name}>
<PopoverTrigger
openOnHover
aria-label={tool.label[language]}
render={(
<button
type="button"
className={cn('bg-components-panel-item-bg w-full cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 text-left shadow-xs outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover')}
>
<div className="pb-0.5 system-md-semibold text-text-secondary">{tool.label[language]}</div>
<div className="line-clamp-2 system-xs-regular text-text-tertiary" title={tool.description[language]}>{tool.description[language]}</div>
</button>
)}
/>
<PopoverContent
placement="left"
popupClassName="w-[360px]! rounded-xl! border-[0.5px]! border-components-panel-border! px-4! py-3.5! shadow-lg!"
>
<div>
<div className="mb-1 title-xs-semi-bold text-text-primary">{tool.label[language]}</div>
<div className="body-xs-regular text-text-secondary">{tool.description[language]}</div>
{renderParameters()}
</div>
)}
>
<div
className={cn('bg-components-panel-item-bg cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover')}
>
<div className="pb-0.5 system-md-semibold text-text-secondary">{tool.label[language]}</div>
<div className="line-clamp-2 system-xs-regular text-text-tertiary" title={tool.description[language]}>{tool.description[language]}</div>
</div>
</Tooltip>
</PopoverContent>
</Popover>
)
}
export default MCPToolItem

View File

@ -15,14 +15,15 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
@ -81,13 +82,22 @@ const ServerURLSection: FC<ServerURLSectionProps> = ({
<CopyFeedback content={serverURL} className="size-6!" />
<Divider type="vertical" className="mx-0.5! h-3.5! shrink-0" />
{isCurrentWorkspaceManager && (
<Tooltip popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}>
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={onRegenerate}
>
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
className="cursor-pointer rounded-md p-1 outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
aria-label={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}
onClick={onRegenerate}
>
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
</button>
)}
/>
<TooltipContent>
{t('overview.appInfo.regenerate', { ns: 'appOverview' })}
</TooltipContent>
</Tooltip>
)}
</>
@ -104,13 +114,19 @@ type TriggerModeOverlayProps = {
const TriggerModeOverlay: FC<TriggerModeOverlayProps> = ({ triggerModeMessage }) => {
if (triggerModeMessage) {
return (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
</Tooltip>
<Popover>
<PopoverTrigger
openOnHover
aria-label={typeof triggerModeMessage === 'string' ? triggerModeMessage : 'Disabled'}
render={<button type="button" className="absolute inset-0 z-10 cursor-not-allowed rounded-xl outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover" />}
/>
<PopoverContent
placement="right"
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
>
{triggerModeMessage}
</PopoverContent>
</Popover>
)
}
return <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
@ -146,12 +162,13 @@ function getTooltipContent({
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
<button
type="button"
className="cursor-pointer rounded-sm text-xs font-normal text-text-accent outline-hidden hover:underline focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
</button>
</>
)
}
@ -316,16 +333,31 @@ const MCPServiceCard: FC<IAppCardProps> = ({
</div>
</div>
<StatusIndicator serverActivated={serverActivated} />
<Tooltip
popupContent={tooltipContent}
position="right"
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
offset={24}
>
<div>
<Switch checked={activated} onCheckedChange={onChangeStatus} disabled={toggleDisabled} />
</div>
</Tooltip>
{toggleDisabled && tooltipContent
? (
<Popover>
<PopoverTrigger
openOnHover
nativeButton={false}
aria-label={typeof tooltipContent === 'string' ? tooltipContent : t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
render={(
<div>
<Switch checked={activated} onCheckedChange={onChangeStatus} disabled={toggleDisabled} />
</div>
)}
/>
<PopoverContent
placement="right"
sideOffset={24}
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
>
{tooltipContent}
</PopoverContent>
</Popover>
)
: (
<Switch checked={activated} onCheckedChange={onChangeStatus} disabled={toggleDisabled} />
)}
</div>
{!isMinimalState && (
<ServerURLSection

View File

@ -9,6 +9,8 @@ import WorkflowToolConfigureButton from '../configure-button'
import WorkflowToolAsModal from '../index'
import MethodSelector from '../method-selector'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// Mock Next.js navigation
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
@ -83,12 +85,11 @@ vi.mock('@/app/components/base/drawer-plus', () => ({
},
}))
// Mock EmojiPicker - simplified for testing
vi.mock('@/app/components/base/emoji-picker', () => ({
default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => (
// Mock EmojiPickerInner - simplified for testing
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
<div data-testid="emoji-picker">
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#f0f0f0')}>Select Emoji</button>
<button data-testid="close-emoji-picker" onClick={onClose}>Close</button>
</div>
),
}))
@ -978,6 +979,7 @@ describe('WorkflowToolAsModal', () => {
// Select emoji
await user.click(screen.getByTestId('select-emoji'))
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
// Assert
const updatedIcon = screen.getByTestId('app-icon')
@ -1002,7 +1004,7 @@ describe('WorkflowToolAsModal', () => {
expect(screen.getByTestId('emoji-picker'))!.toBeInTheDocument()
await user.click(screen.getByTestId('close-emoji-picker'))
await user.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
// Assert
// Assert
@ -1501,7 +1503,7 @@ describe('MethodSelector', () => {
// Assert
// Assert
expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
})
it('should display parameter method text when value is llm', () => {
@ -1562,11 +1564,11 @@ describe('MethodSelector', () => {
// Act
render(<MethodSelector {...props} />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByTestId('popover-trigger'))
// Assert
// Assert
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
it('should call onChange with llm when parameter option clicked', async () => {
@ -1580,7 +1582,7 @@ describe('MethodSelector', () => {
// Act
render(<MethodSelector {...props} />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByTestId('popover-trigger'))
const paramOption = screen.getAllByText('tools.createTool.toolInput.methodParameter')[0]
await user.click(paramOption!)
@ -1600,7 +1602,7 @@ describe('MethodSelector', () => {
// Act
render(<MethodSelector {...props} />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByTestId('popover-trigger'))
const settingOption = screen.getByText('tools.createTool.toolInput.methodSetting')
await user.click(settingOption)
@ -1621,12 +1623,12 @@ describe('MethodSelector', () => {
render(<MethodSelector {...props} />)
// First click - open
await user.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
await user.click(screen.getByTestId('popover-trigger'))
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
// Second click - close
await user.click(screen.getByTestId('portal-trigger'))
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
await user.click(screen.getByTestId('popover-trigger'))
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
})
@ -1642,10 +1644,10 @@ describe('MethodSelector', () => {
// Act
render(<MethodSelector {...props} />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByTestId('popover-trigger'))
// Assert - the first option (llm) should have a check icon container
const content = screen.getByTestId('portal-content')
const content = screen.getByTestId('popover-content')
expect(content)!.toBeInTheDocument()
})
@ -1659,10 +1661,10 @@ describe('MethodSelector', () => {
// Act
render(<MethodSelector {...props} />)
await user.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByTestId('popover-trigger'))
// Assert
const content = screen.getByTestId('portal-content')
const content = screen.getByTestId('popover-content')
expect(content)!.toBeInTheDocument()
})
})

View File

@ -18,11 +18,10 @@ vi.mock('@/app/components/base/drawer-plus', () => ({
),
}))
vi.mock('@/app/components/base/emoji-picker', () => ({
default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => (
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
<div data-testid="emoji-picker">
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#000000')}>Emoji</button>
<button data-testid="close-emoji-picker" onClick={onClose}>Close</button>
</div>
),
}))
@ -129,6 +128,7 @@ describe('WorkflowToolAsModal', () => {
await user.click(screen.getByTestId('append-label'))
await user.click(screen.getByTestId('app-icon'))
await user.click(screen.getByTestId('select-emoji'))
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({
@ -195,6 +195,6 @@ describe('WorkflowToolAsModal', () => {
/>,
)
expect(screen.getAllByText('tools.createTool.toolOutput.reservedParameterDuplicateTip').length).toBeGreaterThan(0)
expect(screen.getAllByTestId('reserved-output-warning').length).toBeGreaterThan(0)
})
})

View File

@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MethodSelector from '../method-selector'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// Test utilities
const defaultProps: ComponentProps<typeof MethodSelector> = {
value: 'llm',
@ -137,6 +139,24 @@ describe('MethodSelector', () => {
expect(onChange).toHaveBeenCalledWith('form')
})
it('should close dropdown after an option is clicked', async () => {
const user = userEvent.setup()
renderComponent({ value: 'llm' })
const trigger = screen.getByText('tools.createTool.toolInput.methodParameter')
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText('tools.createTool.toolInput.methodSettingTip'))!.toBeInTheDocument()
})
await user.click(screen.getByText('tools.createTool.toolInput.methodSettingTip'))
await waitFor(() => {
expect(screen.queryByText('tools.createTool.toolInput.methodSettingTip')).not.toBeInTheDocument()
})
})
it('should toggle dropdown open state', async () => {
const user = userEvent.setup()
renderComponent()
@ -232,10 +252,9 @@ describe('MethodSelector', () => {
await user.click(trigger)
await waitFor(() => {
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
const dropdown = document.querySelector('.w-\\[320px\\]')
expect(dropdown).toBeInTheDocument()
expect(dropdown).toHaveClass('rounded-lg')
expect(dropdown).toHaveClass('shadow-lg')
expect(dropdown)!.toBeInTheDocument()
})
})

View File

@ -93,13 +93,12 @@ describe('ConfirmModal', () => {
// Arrange & Act
renderComponent()
// Assert - Check for the dialog panel with modal content
// The real modal structure has nested divs, we need to find the one with our classes
const dialogContent = document.querySelector('.relative.rounded-2xl')
// Assert
const dialogContent = screen.getByRole('dialog')
expect(dialogContent).toBeInTheDocument()
expect(dialogContent).toHaveClass('w-[600px]')
expect(dialogContent).toHaveClass('max-w-[600px]')
expect(dialogContent).toHaveClass('p-8')
expect(dialogContent).toHaveClass('w-[600px]!')
expect(dialogContent).toHaveClass('max-w-[600px]!')
expect(dialogContent).toHaveClass('p-8!')
})
})

View File

@ -2,11 +2,9 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Modal from '@/app/components/base/modal'
type ConfirmModalProps = {
show: boolean
@ -18,28 +16,29 @@ const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => {
const { t } = useTranslation()
return (
<Modal
className={cn('w-[600px] max-w-[600px] p-8')}
isShow={show}
onClose={noop}
>
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
<div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-section p-3 shadow-xl">
<AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" />
</div>
<div className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t('createTool.confirmTitle', { ns: 'tools' })}</div>
<div className="my-1 text-sm leading-5 text-text-tertiary">
{t('createTool.confirmTip', { ns: 'tools' })}
</div>
<div className="flex items-center justify-end pt-6">
<div className="flex items-center">
<Button className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" tone="destructive" onClick={onConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
<Dialog open={show} disablePointerDismissal>
<DialogContent
backdropProps={{ forceRender: true }}
className={cn('w-[600px]! max-w-[600px]! p-8!')}
>
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</div>
</div>
</Modal>
<div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-section p-3 shadow-xl">
<AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" />
</div>
<DialogTitle className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t('createTool.confirmTitle', { ns: 'tools' })}</DialogTitle>
<div className="my-1 text-sm leading-5 text-text-tertiary">
{t('createTool.confirmTip', { ns: 'tools' })}
</div>
<div className="flex items-center justify-end pt-6">
<div className="flex items-center">
<Button className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" tone="destructive" onClick={onConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -437,7 +437,6 @@ describe('useConfigureButton', () => {
expect(onRefreshData).toHaveBeenCalled()
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) })
expect(result.current.showModal).toBe(false)
})

View File

@ -206,7 +206,6 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
onRefreshData?.()
invalidateAllWorkflowTools()
invalidateDetail(workflowAppId)
toast.success(t('api.actionSuccess', { ns: 'common' }))
setShowModal(false)
}
catch (e) {

View File

@ -3,18 +3,18 @@ import type { FC } from 'react'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiErrorWarningLine } from '@remixicon/react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { produce } from 'immer'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Drawer from '@/app/components/base/drawer-plus'
import EmojiPicker from '@/app/components/base/emoji-picker'
import Divider from '@/app/components/base/divider'
import EmojiPickerInner from '@/app/components/base/emoji-picker/Inner'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
import LabelSelector from '@/app/components/tools/labels/selector'
import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal'
import MethodSelector from '@/app/components/tools/workflow-tool/method-selector'
@ -53,6 +53,111 @@ type Props = {
workflow_tool_id: string
}>) => void
}
type WorkflowToolDrawerProps = {
title: string
onHide: () => void
children: React.ReactNode
}
const InfoTooltip = ({ children }: { children: React.ReactNode }) => {
return (
<Tooltip>
<TooltipTrigger
render={(
<span className="i-ri-question-line h-3.5 w-3.5 shrink-0 cursor-help text-text-quaternary hover:text-text-tertiary" />
)}
/>
<TooltipContent>
<div className="w-[180px]">
{children}
</div>
</TooltipContent>
</Tooltip>
)
}
const WorkflowToolDrawer = ({ title, onHide, children }: WorkflowToolDrawerProps) => {
return (
<Dialog open disablePointerDismissal>
<DialogContent
className={cn(
'top-2 right-2 bottom-2 left-auto h-[calc(100dvh-16px)] max-h-[calc(100dvh-16px)] w-[640px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! overflow-hidden rounded-xl border-none bg-transparent p-0 shadow-none',
'data-ending-style:translate-x-4 data-ending-style:scale-100 data-starting-style:translate-x-4 data-starting-style:scale-100',
)}
backdropClassName="bg-background-overlay"
>
<div data-testid="drawer" className="flex h-full w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl">
<div className="shrink-0 border-b border-divider-subtle py-4">
<div className="flex h-6 items-center justify-between pr-5 pl-6">
<DialogTitle data-testid="drawer-title" className="system-xl-semibold text-text-primary">
{title}
</DialogTitle>
<button
type="button"
data-testid="drawer-close"
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover"
aria-label="Close"
onClick={onHide}
>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</button>
</div>
</div>
<div className="grow overflow-hidden">
{children}
</div>
</div>
</DialogContent>
</Dialog>
)
}
type WorkflowToolEmojiPickerProps = {
onSelect: (icon: string, background: string) => void
onClose: () => void
}
const WorkflowToolEmojiPicker = ({ onSelect, onClose }: WorkflowToolEmojiPickerProps) => {
const { t } = useTranslation()
const [selectedEmoji, setSelectedEmoji] = useState('')
const [selectedBackground, setSelectedBackground] = useState<string>()
return (
<Dialog open disablePointerDismissal>
<DialogContent
backdropProps={{ forceRender: true }}
className="flex max-h-[552px] w-[480px]! flex-col overflow-hidden rounded-xl border-[0.5px] border-divider-subtle p-0! shadow-xl"
>
<DialogTitle className="sr-only">
{t('iconPicker.emoji', { ns: 'app' })}
</DialogTitle>
<EmojiPickerInner
className="pt-3"
onSelect={(emoji, background) => {
setSelectedEmoji(emoji)
setSelectedBackground(background)
}}
/>
<Divider className="mt-3 mb-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={onClose}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button
disabled={selectedEmoji === '' || !selectedBackground}
variant="primary"
className="w-full"
onClick={() => onSelect(selectedEmoji, selectedBackground!)}
>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
// Add and Edit
const WorkflowToolAsModal: FC<Props> = ({
isAdd,
@ -138,210 +243,201 @@ const WorkflowToolAsModal: FC<Props> = ({
return (
<>
<Drawer
isShow
<WorkflowToolDrawer
onHide={onHide}
title={t('common.workflowAsTool', { ns: 'workflow' })!}
panelClassName="mt-2 w-[640px]!"
maxWidthClassName="max-w-[640px]!"
height="calc(100vh - 16px)"
headerClassName="!border-b-divider"
body={(
<div className="flex h-full flex-col">
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
{/* name & icon */}
<div>
<div className="py-2 system-sm-medium text-text-primary">
{t('createTool.name', { ns: 'tools' })}
{' '}
<span className="ml-1 text-red-500">*</span>
</div>
<div className="flex items-center justify-between gap-3">
<AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} />
<Input
className="h-10 grow"
placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
value={label}
onChange={e => setLabel(e.target.value)}
/>
</div>
>
<div className="flex h-full flex-col">
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
{/* name & icon */}
<div>
<div className="py-2 system-sm-medium text-text-primary">
{t('createTool.name', { ns: 'tools' })}
{' '}
<span className="ml-1 text-red-500">*</span>
</div>
{/* name for tool call */}
<div>
<div className="flex items-center py-2 system-sm-medium text-text-primary">
{t('createTool.nameForToolCall', { ns: 'tools' })}
{' '}
<span className="ml-1 text-red-500">*</span>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
</div>
)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} />
<Input
className="h-10"
placeholder={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })!}
value={name}
onChange={e => setName(e.target.value)}
/>
{!isWorkflowToolNameValid(name) && (
<div className="text-xs leading-[18px] text-red-500">{t('createTool.nameForToolCallTip', { ns: 'tools' })}</div>
)}
</div>
{/* description */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.description', { ns: 'tools' })}</div>
<Textarea
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
{/* Tool Input */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.title', { ns: 'tools' })}</div>
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
<thead className="text-text-tertiary uppercase">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.toolInput.name', { ns: 'tools' })}</th>
<th className="w-[102px] p-2 pl-3 font-medium">{t('createTool.toolInput.method', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.toolInput.description', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{parameters.map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex">
<span className="truncate font-medium text-text-primary">{item.name}</span>
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.required ? t('createTool.toolInput.required', { ns: 'tools' }) : ''}</span>
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td>
{item.name === '__image' && (
<div className={cn(
'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2',
)}
>
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
{t('createTool.toolInput.methodParameter', { ns: 'tools' })}
</div>
</div>
)}
{item.name !== '__image' && (
<MethodSelector value={item.form} onChange={value => handleParameterChange('form', value, index)} />
)}
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<input
type="text"
className="w-full appearance-none bg-transparent text-[13px] leading-[18px] font-normal text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary"
placeholder={t('createTool.toolInput.descriptionPlaceholder', { ns: 'tools' })!}
value={item.description}
onChange={e => handleParameterChange('description', e.target.value, index)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Tool Output */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolOutput.title', { ns: 'tools' })}</div>
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
<thead className="text-text-tertiary uppercase">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.name', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.toolOutput.description', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{[...reservedOutputParameters, ...outputParameters].map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex items-center">
<span className="truncate font-medium text-text-primary">{item.name}</span>
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.reserved ? t('createTool.toolOutput.reserved', { ns: 'tools' }) : ''}</span>
{
!item.reserved && hasReservedWorkflowOutputConflict(reservedOutputParameters, item.name)
? (
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })}
</div>
)}
>
<RiErrorWarningLine className="h-3 w-3 text-text-warning-secondary" />
</Tooltip>
)
: null
}
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<span className="text-[13px] leading-[18px] font-normal text-text-secondary">{item.description}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Tags */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
<LabelSelector value={labels} onChange={handleLabelSelect} />
</div>
{/* Privacy Policy */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
<Input
className="h-10"
value={privacyPolicy}
onChange={e => setPrivacyPolicy(e.target.value)}
placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''}
className="h-10 grow"
placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
value={label}
onChange={e => setLabel(e.target.value)}
/>
</div>
</div>
<div className={cn((!isAdd && onRemove) ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}>
{!isAdd && onRemove && (
<Button variant="primary" tone="destructive" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button>
)}
<div className="flex space-x-2">
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
variant="primary"
onClick={() => {
if (isAdd)
onConfirm()
else
setShowModal(true)
}}
>
{t('operation.save', { ns: 'common' })}
</Button>
{/* name for tool call */}
<div>
<div className="flex items-center py-2 system-sm-medium text-text-primary">
{t('createTool.nameForToolCall', { ns: 'tools' })}
{' '}
<span className="ml-1 text-red-500">*</span>
<InfoTooltip>
{t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
</InfoTooltip>
</div>
<Input
className="h-10"
placeholder={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })!}
value={name}
onChange={e => setName(e.target.value)}
/>
{!isWorkflowToolNameValid(name) && (
<div className="text-xs leading-[18px] text-red-500">{t('createTool.nameForToolCallTip', { ns: 'tools' })}</div>
)}
</div>
{/* description */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.description', { ns: 'tools' })}</div>
<Textarea
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
{/* Tool Input */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.title', { ns: 'tools' })}</div>
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
<thead className="text-text-tertiary uppercase">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.toolInput.name', { ns: 'tools' })}</th>
<th className="w-[102px] p-2 pl-3 font-medium">{t('createTool.toolInput.method', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.toolInput.description', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{parameters.map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex">
<span className="truncate font-medium text-text-primary">{item.name}</span>
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.required ? t('createTool.toolInput.required', { ns: 'tools' }) : ''}</span>
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td>
{item.name === '__image' && (
<div className={cn(
'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2',
)}
>
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
{t('createTool.toolInput.methodParameter', { ns: 'tools' })}
</div>
</div>
)}
{item.name !== '__image' && (
<MethodSelector value={item.form} onChange={value => handleParameterChange('form', value, index)} />
)}
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<input
type="text"
className="w-full appearance-none bg-transparent text-[13px] leading-[18px] font-normal text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary"
placeholder={t('createTool.toolInput.descriptionPlaceholder', { ns: 'tools' })!}
value={item.description}
onChange={e => handleParameterChange('description', e.target.value, index)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Tool Output */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolOutput.title', { ns: 'tools' })}</div>
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
<thead className="text-text-tertiary uppercase">
<tr className="border-b border-divider-regular">
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.name', { ns: 'tools' })}</th>
<th className="p-2 pl-3 font-medium">{t('createTool.toolOutput.description', { ns: 'tools' })}</th>
</tr>
</thead>
<tbody>
{[...reservedOutputParameters, ...outputParameters].map((item, index) => (
<tr key={index} className="border-b border-divider-regular last:border-0">
<td className="max-w-[156px] p-2 pl-3">
<div className="text-[13px] leading-[18px]">
<div title={item.name} className="flex items-center">
<span className="truncate font-medium text-text-primary">{item.name}</span>
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.reserved ? t('createTool.toolOutput.reserved', { ns: 'tools' }) : ''}</span>
{
!item.reserved && hasReservedWorkflowOutputConflict(reservedOutputParameters, item.name)
? (
<Tooltip>
<TooltipTrigger
render={(
<span data-testid="reserved-output-warning" className="i-ri-error-warning-line h-3 w-3 text-text-warning-secondary" />
)}
/>
<TooltipContent>
<div className="w-[180px]">
{t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })}
</div>
</TooltipContent>
</Tooltip>
)
: null
}
</div>
<div className="text-text-tertiary">{item.type}</div>
</div>
</td>
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
<span className="text-[13px] leading-[18px] font-normal text-text-secondary">{item.description}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Tags */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
<LabelSelector value={labels} onChange={handleLabelSelect} />
</div>
{/* Privacy Policy */}
<div>
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
<Input
className="h-10"
value={privacyPolicy}
onChange={e => setPrivacyPolicy(e.target.value)}
placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''}
/>
</div>
</div>
)}
isShowMask={true}
clickOutsideNotOpen={true}
/>
<div className={cn((!isAdd && onRemove) ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}>
{!isAdd && onRemove && (
<Button variant="primary" tone="destructive" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button>
)}
<div className="flex space-x-2">
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
variant="primary"
onClick={() => {
if (isAdd)
onConfirm()
else
setShowModal(true)
}}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</div>
</div>
</WorkflowToolDrawer>
{showEmojiPicker && (
<EmojiPicker
<WorkflowToolEmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ content: icon, background: icon_background })
setShowEmojiPicker(false)

View File

@ -1,14 +1,14 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type MethodSelectorProps = {
value?: string
@ -20,37 +20,43 @@ const MethodSelector: FC<MethodSelectorProps> = ({
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleSelect = (value: string) => {
onChange(value)
setOpen(false)
}
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'flex h-9 min-h-[56px] cursor-pointer items-center gap-1 bg-transparent px-3 py-2 hover:bg-background-section-burn',
open && 'bg-background-section-burn! hover:bg-background-section-burn',
<PopoverTrigger
nativeButton={false}
render={(
<div className={cn(
'flex h-9 min-h-[56px] cursor-pointer items-center gap-1 bg-transparent px-3 py-2 hover:bg-background-section-burn',
open && 'bg-background-section-burn! hover:bg-background-section-burn',
)}
>
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
{value === 'llm' ? t('createTool.toolInput.methodParameter', { ns: 'tools' }) : t('createTool.toolInput.methodSetting', { ns: 'tools' })}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<RiArrowDownSLine className="h-4 w-4" />
</div>
</div>
)}
>
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
{value === 'llm' ? t('createTool.toolInput.methodParameter', { ns: 'tools' }) : t('createTool.toolInput.methodSetting', { ns: 'tools' })}
</div>
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
<RiArrowDownSLine className="h-4 w-4" />
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-1040">
<div className="relative w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
positionerProps={{ style: { zIndex: 1040 } }}
>
<div className="relative w-[320px]">
<div className="p-1">
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => onChange('llm')}>
<div className="item-center flex gap-1">
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => handleSelect('llm')}>
<div className="flex items-center gap-1">
<div className="h-4 w-4 shrink-0">
{value === 'llm' && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
</div>
@ -58,8 +64,8 @@ const MethodSelector: FC<MethodSelectorProps> = ({
</div>
<div className="pl-5 text-[13px] leading-[18px] text-text-tertiary">{t('createTool.toolInput.methodParameterTip', { ns: 'tools' })}</div>
</div>
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => onChange('form')}>
<div className="item-center flex gap-1">
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => handleSelect('form')}>
<div className="flex items-center gap-1">
<div className="h-4 w-4 shrink-0">
{value === 'form' && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
</div>
@ -69,9 +75,9 @@ const MethodSelector: FC<MethodSelectorProps> = ({
</div>
</div>
</div>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}

View File

@ -7,7 +7,9 @@ import type {
Variable,
} from '../../../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiDeleteBinLine,
} from '@remixicon/react'
@ -26,7 +28,6 @@ import { Variable02 } from '@/app/components/base/icons/src/vender/solid/develop
import { Jinja } from '@/app/components/base/icons/src/vender/workflow'
import PromptEditor from '@/app/components/base/prompt-editor'
import { PROMPT_EDITOR_INSERT_QUICKLY } from '@/app/components/base/prompt-editor/plugins/update-block'
import Tooltip from '@/app/components/base/tooltip'
import { useWorkflowVariableType } from '@/app/components/workflow/hooks'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars'
import ToggleExpandBtn from '@/app/components/workflow/nodes/_base/components/toggle-expand-btn'
@ -165,7 +166,25 @@ const Editor: FC<Props> = ({
{' '}
{required && <span className="text-text-destructive">*</span>}
</div>
{!!titleTooltip && <Tooltip popupContent={titleTooltip} />}
{!!titleTooltip && (
<Popover>
<PopoverTrigger
openOnHover
aria-label={typeof titleTooltip === 'string' ? titleTooltip : typeof title === 'string' ? title : 'Help'}
render={(
<button
type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm p-px outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</button>
)}
/>
<PopoverContent popupClassName="max-w-[300px] px-3 py-2 system-xs-regular text-text-tertiary">
{titleTooltip}
</PopoverContent>
</Popover>
)}
</div>
<div className="flex items-center">
<div className="text-xs leading-[18px] font-medium text-text-tertiary">{value?.length || 0}</div>
@ -184,34 +203,48 @@ const Editor: FC<Props> = ({
{/* Operations */}
<div className="flex items-center space-x-[2px]">
{isSupportJinja && (
<Tooltip
popupContent={(
<div>
<div>{t('common.enableJinja', { ns: 'workflow' })}</div>
<a className="text-text-accent" target="_blank" href="https://jinja.palletsprojects.com/en/2.10.x/">{t('common.learnMore', { ns: 'workflow' })}</a>
</div>
)}
>
<div className={cn(editionType === EditionType.jinja2 && 'border-components-button-ghost-bg-hover bg-components-button-ghost-bg-hover', 'flex h-[22px] items-center space-x-0.5 rounded-[5px] border border-transparent px-1.5 hover:border-components-button-ghost-bg-hover')}>
<Jinja className="h-3 w-6 text-text-quaternary" />
<Switch
size="sm"
checked={editionType === EditionType.jinja2}
onCheckedChange={(checked) => {
onEditionTypeChange?.(checked ? EditionType.jinja2 : EditionType.basic)
}}
<div className={cn(editionType === EditionType.jinja2 && 'border-components-button-ghost-bg-hover bg-components-button-ghost-bg-hover', 'flex h-[22px] items-center space-x-0.5 rounded-[5px] border border-transparent px-1.5 hover:border-components-button-ghost-bg-hover')}>
<Popover>
<PopoverTrigger
openOnHover
aria-label={t('common.enableJinja', { ns: 'workflow' })}
render={(
<button
type="button"
className="flex h-4 w-7 items-center justify-center rounded-sm outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
<Jinja className="h-3 w-6 text-text-quaternary" />
</button>
)}
/>
</div>
</Tooltip>
<PopoverContent popupClassName="px-3 py-2 system-xs-regular text-text-tertiary">
<div>
<div>{t('common.enableJinja', { ns: 'workflow' })}</div>
<a className="text-text-accent hover:underline" target="_blank" rel="noopener noreferrer" href="https://jinja.palletsprojects.com/en/2.10.x/">{t('common.learnMore', { ns: 'workflow' })}</a>
</div>
</PopoverContent>
</Popover>
<Switch
size="sm"
checked={editionType === EditionType.jinja2}
onCheckedChange={(checked) => {
onEditionTypeChange?.(checked ? EditionType.jinja2 : EditionType.basic)
}}
/>
</div>
)}
{!readOnly && (
<Tooltip
popupContent={`${t('common.insertVarTip', { ns: 'workflow' })}`}
>
<ActionButton onClick={handleInsertVariable}>
<Variable02 className="h-4 w-4" />
</ActionButton>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton onClick={handleInsertVariable}>
<Variable02 className="h-4 w-4" />
</ActionButton>
)}
/>
<TooltipContent>
{t('common.insertVarTip', { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
)}
{showRemove && (

View File

@ -2,11 +2,11 @@
import type { Reducer } from 'react'
import type { LanguagesSupported } from '@/i18n-config/language'
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useReducer } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { LICENSE_LINK } from '@/constants/link'
import { languages } from '@/i18n-config/language'
import Link from '@/next/link'
@ -94,21 +94,35 @@ const OneMoreStep = () => {
<div className="mx-auto mt-6 w-full">
<div className="relative">
<div className="mb-5">
<label className="my-2 flex items-center justify-between system-md-semibold text-text-secondary">
{t('invitationCode', { ns: 'login' })}
<Tooltip
popupContent={(
<div className="w-[256px] text-xs font-medium">
<div className="my-2 flex items-center justify-between system-md-semibold text-text-secondary">
<label htmlFor="invitation_code">
{t('invitationCode', { ns: 'login' })}
</label>
<Popover>
<PopoverTrigger
openOnHover
render={(
<button
type="button"
className="cursor-pointer rounded-sm text-text-accent-secondary outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-hover"
>
{t('dontHave', { ns: 'login' })}
</button>
)}
/>
<PopoverContent
placement="top"
popupClassName="w-[256px] px-3 py-2 text-xs font-medium text-text-tertiary"
>
<div>
<div className="font-medium">{t('sendUsMail', { ns: 'login' })}</div>
<div className="cursor-pointer text-xs font-medium text-text-accent-secondary">
<a href="mailto:request-invitation@langgenius.ai">request-invitation@langgenius.ai</a>
</div>
</div>
)}
>
<span className="cursor-pointer text-text-accent-secondary">{t('dontHave', { ns: 'login' })}</span>
</Tooltip>
</label>
</PopoverContent>
</Popover>
</div>
<div className="mt-1">
<Input
id="invitation_code"

View File

@ -2,6 +2,8 @@ import type {
AvailableEvaluationWorkflowsResponse,
EvaluationConfig,
EvaluationConfigData,
EvaluationDefaultMetricsResponse,
EvaluationDefaultMetricsTargetType,
EvaluationFileInfo,
EvaluationLogsResponse,
EvaluationMetricsListResponse,
@ -255,6 +257,19 @@ export const evaluationMetricsContract = base
}>())
.output(type<EvaluationMetricsMapResponse>())
export const evaluationDefaultMetricsContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/default-metrics',
method: 'GET',
})
.input(type<{
params: {
targetType: EvaluationDefaultMetricsTargetType
targetId: string
}
}>())
.output(type<EvaluationDefaultMetricsResponse>())
export const evaluationNodeInfoContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/node-info',

View File

@ -14,6 +14,7 @@ import {
datasetEvaluationRunDetailContract,
datasetEvaluationTemplateDownloadContract,
evaluationConfigContract,
evaluationDefaultMetricsContract,
evaluationFileContract,
evaluationLogsContract,
evaluationMetricsContract,
@ -145,6 +146,7 @@ export const consoleRouterContract = {
runDetail: evaluationRunDetailContract,
cancelRun: cancelEvaluationRunContract,
metrics: evaluationMetricsContract,
defaultMetrics: evaluationDefaultMetricsContract,
nodeInfo: evaluationNodeInfoContract,
availableMetrics: availableEvaluationMetricsContract,
availableWorkflows: availableEvaluationWorkflowsContract,

View File

@ -110,7 +110,7 @@
"metrics.groups.operations": "Operations",
"metrics.groups.other": "Other",
"metrics.groups.quality": "Quality",
"metrics.noNodesInWorkflow": "No LLM nodes in this workflow",
"metrics.noNodesInWorkflow": "No selectable nodes",
"metrics.noResults": "No metrics or nodes were found",
"metrics.nodesAll": "All nodes",
"metrics.nodesLabel": "Node Scope",

View File

@ -10,7 +10,7 @@
"analysis.ms": "мс",
"analysis.title": "Анализ",
"analysis.tokenPS": "Токен/с",
"analysis.tokenUsage.consumed": "Потрачено",
"analysis.tokenUsage.consumed": "Потреблено",
"analysis.tokenUsage.explanation": "Отражает ежедневное использование токенов языковой модели для приложения, полезно для целей контроля затрат.",
"analysis.tokenUsage.title": "Использование токенов",
"analysis.totalConversations.explanation": "Ежедневное количество чатов с LLM; проектирование/отладка не учитываются.",
@ -62,7 +62,7 @@
"overview.appInfo.enableTooltip.description": "Чтобы включить эту функцию, добавьте на холст узел ввода пользователя. (Может уже существовать в черновике, вступает в силу после публикации)",
"overview.appInfo.enableTooltip.learnMore": "Узнать больше",
"overview.appInfo.explanation": "Готовое к использованию веб-приложение ИИ",
"overview.appInfo.launch": "Баркас",
"overview.appInfo.launch": "Запустить",
"overview.appInfo.preUseReminder": "Пожалуйста, включите веб-приложение перед продолжением.",
"overview.appInfo.preview": "Предварительный просмотр",
"overview.appInfo.qrcode.download": "Скачать QR-код",

View File

@ -1,13 +1,13 @@
{
"embedding.automatic": "Автоматически",
"embedding.childMaxTokens": "Ребёнок",
"embedding.childMaxTokens": "Наследник",
"embedding.completed": "Встраивание завершено",
"embedding.custom": "Пользовательский",
"embedding.docName": "Предварительная обработка документа",
"embedding.docName": "Имя документа",
"embedding.economy": "Экономичный режим",
"embedding.error": "Ошибка расчета эмбеддингов",
"embedding.estimate": "Оценочное потребление",
"embedding.hierarchical": "Родитель-дочерний",
"embedding.estimate": "Оценка",
"embedding.hierarchical": "Иерархический",
"embedding.highQuality": "Режим высокого качества",
"embedding.mode": "Правило сегментации",
"embedding.parentMaxTokens": "Родитель",
@ -16,7 +16,7 @@
"embedding.previewTip": "Предварительный просмотр абзацев будет доступен после завершения расчета эмбеддингов",
"embedding.processing": "Расчет эмбеддингов...",
"embedding.resume": "Возобновить обработку",
"embedding.segmentLength": "Длина фрагментов",
"embedding.segmentLength": "Длина сегментов",
"embedding.segments": "Абзацы",
"embedding.stop": "Остановить обработку",
"embedding.textCleaning": "Предварительная очистка текста",
@ -279,25 +279,25 @@
"metadata.type.webPage": "Веб-страница",
"metadata.type.wikipediaEntry": "Статья в Википедии",
"segment.addAnother": "Добавить еще один",
"segment.addChildChunk": "Добавить дочерний чанк",
"segment.addChunk": "Добавить чанк",
"segment.addChildChunk": "Добавить дочерний фрагмент",
"segment.addChunk": "Добавить фрагмент",
"segment.addKeyWord": "Добавить ключевое слово",
"segment.allFilesUploaded": "Все файлы должны быть загружены перед сохранением",
"segment.answerEmpty": "Ответ не может быть пустым",
"segment.answerPlaceholder": "добавьте ответ здесь",
"segment.characters_one": "характер",
"segment.characters_other": "письмена",
"segment.childChunk": "Чайлд-Чанк",
"segment.childChunkAdded": "Добавлен 1 дочерний чанк",
"segment.childChunks_one": "ДОЧЕРНИЙ ЧАНК",
"segment.childChunks_other": ЕТСКИЕ КУСОЧКИ",
"segment.chunk": "Ломоть",
"segment.chunkAdded": "Добавлен 1 блок",
"segment.chunkDetail": "Деталь Чанка",
"segment.chunks_one": "ЛОМОТЬ",
"segment.chunks_other": "КУСКИ",
"segment.characters_one": "символ",
"segment.characters_other": "символы",
"segment.childChunk": "Дочерний фрагмент",
"segment.childChunkAdded": "Добавлен 1 дочерний фрагмент",
"segment.childChunks_one": "ДОЧЕРНИЙ ФРАГМЕНТ",
"segment.childChunks_other": ОЧЕРНИЕ ФРАГМЕНТЫ",
"segment.chunk": "Фрагмент",
"segment.chunkAdded": "Добавлен 1 фрагмент",
"segment.chunkDetail": "Детали фрагмента",
"segment.chunks_one": "ФРАГМЕНТ",
"segment.chunks_other": "ФРАГМЕНТЫ",
"segment.clearFilter": "Очистить фильтр",
"segment.collapseChunks": "Сворачивание кусков",
"segment.collapseChunks": "Свернуть фрагменты",
"segment.contentEmpty": "Содержимое не может быть пустым",
"segment.contentPlaceholder": "добавьте содержимое здесь",
"segment.dateTimeFormat": "MM/DD/YYYY HH:mm",
@ -307,15 +307,15 @@
"segment.editParentChunk": "Редактирование родительского блока",
"segment.edited": "ОТРЕДАКТИРОВАНЫ",
"segment.editedAt": "Отредактировано в",
"segment.empty": "Чанк не найден",
"segment.expandChunks": "Развернуть чанки",
"segment.empty": "Фрагмент не найден",
"segment.expandChunks": "Развернуть фрагменты",
"segment.hitCount": "Количество обращений",
"segment.keywordDuplicate": "Ключевое слово уже существует",
"segment.keywordEmpty": "Ключевое слово не может быть пустым",
"segment.keywordError": "Максимальная длина ключевого слова - 20",
"segment.keywords": "Ключевые слова",
"segment.newChildChunk": "Новый дочерний чанк",
"segment.newChunk": "Новый чанк",
"segment.newChildChunk": "Новый дочерний фрагмент",
"segment.newChunk": "Новый фрагмент",
"segment.newQaSegment": "Новый сегмент вопрос-ответ",
"segment.newTextSegment": "Новый текстовый сегмент",
"segment.paragraphs": "Абзацы",

View File

@ -1,7 +1,7 @@
{
"blocks.agent": "Агент",
"blocks.answer": "Ответ",
"blocks.assigner": "Назначение переменной",
"blocks.assigner": "Назначение переменных",
"blocks.code": "Код",
"blocks.datasource": "Источник данных",
"blocks.datasource-empty": "Пустой источник данных",
@ -17,10 +17,10 @@
"blocks.list-operator": "Оператор списка",
"blocks.llm": "LLM",
"blocks.loop": "Цикл",
"blocks.loop-end": "Выйти из цикла",
"blocks.loop-end": "Конец цикла",
"blocks.loop-start": "Начало цикла",
"blocks.originalStartNode": "исходный начальный узел",
"blocks.parameter-extractor": "Извлечение параметров",
"blocks.parameter-extractor": "Экстрактор параметров",
"blocks.question-classifier": "Классификатор вопросов",
"blocks.start": "Начало",
"blocks.template-transform": "Шаблон",
@ -29,7 +29,7 @@
"blocks.trigger-schedule": "Триггер расписания",
"blocks.trigger-webhook": "Вебхук-триггер",
"blocks.variable-aggregator": "Агрегатор переменных",
"blocks.variable-assigner": "Агрегатор переменных",
"blocks.variable-assigner": "Назначение переменных",
"blocksAbout.agent": "Вызов больших языковых моделей для ответа на вопросы или обработки естественного языка",
"blocksAbout.answer": "Определите содержимое ответа в чате",
"blocksAbout.assigner": "Узел назначения переменной используется для назначения значений записываемым переменным (например, переменным разговора).",
@ -485,7 +485,7 @@
"nodes.common.pluginNotInstalled": "Плагин не установлен",
"nodes.common.pluginsNotInstalled": "{{count}} плагинов не установлено",
"nodes.common.retry.maxRetries": "максимальное количество повторных попыток",
"nodes.common.retry.ms": "госпожа",
"nodes.common.retry.ms": "мс",
"nodes.common.retry.retries": "{{num}} Повторных попыток",
"nodes.common.retry.retry": "Снова пробовать",
"nodes.common.retry.retryFailed": "Повторная попытка не удалась",

View File

@ -93,8 +93,13 @@
"metrics.builtin.description.taskCompletion": "衡量 Agent 是否最终完成了用户明确提出的目标。它会整体评估推理链路、中间步骤和最终输出;高分表示任务已被完整达成。",
"metrics.builtin.description.toolCorrectness": "衡量 Agent 在任务执行过程中发起的工具调用是否正确,包括工具选择本身以及传入参数是否合理。高分表示 Agent 的工具使用策略符合预期行为。",
"metrics.custom.description": "选择评测工作流并完成变量映射后即可运行测试。",
"metrics.custom.footerDescription": "连接已发布的评测工作流",
"metrics.custom.footerTitle": "自定义指标",
"metrics.custom.limitDescription": "只能添加一个自定义指标。",
"metrics.custom.mappingTitle": "变量映射",
"metrics.custom.mappingWarning": "请先完成工作流选择和所有变量映射,再运行批量测试。",
"metrics.custom.outputPlaceholder": "选择输出变量",
"metrics.custom.outputTitle": "输出",
"metrics.custom.title": "自定义评测器",
"metrics.custom.warningBadge": "待配置",
"metrics.custom.workflowLabel": "评测工作流",
@ -104,7 +109,7 @@
"metrics.groups.operations": "运行",
"metrics.groups.other": "其他",
"metrics.groups.quality": "质量",
"metrics.noNodesInWorkflow": "当前工作流中没有 LLM 节点",
"metrics.noNodesInWorkflow": "没有可选节点",
"metrics.noResults": "没有匹配的指标。",
"metrics.nodesAll": "全部节点",
"metrics.nodesLabel": "节点范围",

View File

@ -21,10 +21,18 @@ const config: KnipConfig = {
],
/// keep-sorted
rules: {
binaries: 'error',
// TODO: fix these warnings
// Unused devDependencies (3)
// @eslint-react/eslint-plugin package.json:160:6
// @next/eslint-plugin-next package.json:168:6
// eslint-plugin-react-refresh package.json:211:6
// Unlisted binaries (2)
// eslint package.json
// vp package.json
binaries: 'warn',
catalog: 'error',
dependencies: 'error',
devDependencies: 'error',
devDependencies: 'warn',
duplicates: 'error',
enumMembers: 'error',
exports: 'error',

View File

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

View File

@ -1,4 +1,4 @@
import type { EvaluationResourceType } from '@/app/components/evaluation/types'
import type { EvaluationResourceType, NonPipelineEvaluationResourceType } from '@/app/components/evaluation/types'
import type { AvailableEvaluationWorkflowsResponse, EvaluationConfig } from '@/types/evaluation'
import {
keepPreviousData,
@ -60,6 +60,23 @@ export const useAvailableEvaluationMetrics = (enabled = true) => {
}))
}
export const useDefaultEvaluationMetrics = (
resourceType: NonPipelineEvaluationResourceType,
resourceId: string,
enabled = true,
) => {
return useQuery(consoleQuery.evaluation.defaultMetrics.queryOptions({
input: {
params: {
targetType: resourceType,
targetId: resourceId,
},
},
enabled: !!resourceId && enabled,
refetchOnWindowFocus: false,
}))
}
export const useEvaluationWorkflowAssociatedTargets = (
workflowId: string | undefined,
options?: { enabled?: boolean },

View File

@ -1,203 +0,0 @@
import type { Node } from '@/app/components/workflow/types'
import type { SnippetDetailPayload, SnippetInputField, SnippetListItem } from '@/models/snippet'
import codeDefault from '@/app/components/workflow/nodes/code/default'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import httpDefault from '@/app/components/workflow/nodes/http/default'
import { Method } from '@/app/components/workflow/nodes/http/types'
import llmDefault from '@/app/components/workflow/nodes/llm/default'
import questionClassifierDefault from '@/app/components/workflow/nodes/question-classifier/default'
import { BlockEnum, PromptRole } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
import { AppModeEnum } from '@/types/app'
const getSnippetListMock = (): SnippetListItem[] => ([
{
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
updatedAt: 'Updated 2h ago',
usage: 'Used 19 times',
icon: '🪄',
iconBackground: '#E0EAFF',
status: 'Draft',
},
])
const createSnippetMock = (snippetId: string): SnippetListItem => ({
id: snippetId,
name: 'Tone Rewriter',
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
updatedAt: 'Updated 2h ago',
usage: 'Used 19 times',
icon: '🪄',
iconBackground: '#E0EAFF',
status: 'Draft',
})
const getSnippetInputFieldsMock = (): SnippetInputField[] => ([
{
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
placeholder: 'Paste a source article URL',
options: [],
max_length: 256,
},
{
type: PipelineInputVarType.textInput,
label: 'Target Platforms',
variable: 'platforms',
required: true,
placeholder: 'X, LinkedIn, Instagram',
options: [],
max_length: 128,
},
{
type: PipelineInputVarType.textInput,
label: 'Tone',
variable: 'tone',
required: false,
placeholder: 'Concise and executive-ready',
options: [],
max_length: 48,
},
{
type: PipelineInputVarType.textInput,
label: 'Max Length',
variable: 'max_length',
required: false,
placeholder: 'Set an ideal output length',
options: [],
max_length: 48,
},
])
const getSnippetGraphMock = (): SnippetDetailPayload['graph'] => ({
viewport: { x: 120, y: 30, zoom: 0.9 },
nodes: [
{
id: 'question-classifier',
position: { x: 280, y: 208 },
data: {
...questionClassifierDefault.defaultValue,
title: 'Question Classifier',
desc: 'After-sales related questions',
type: BlockEnum.QuestionClassifier,
query_variable_selector: ['sys', 'query'],
model: {
provider: 'openai',
name: 'gpt-4o',
mode: AppModeEnum.CHAT,
completion_params: {
temperature: 0.2,
},
},
classes: [
{
id: '1',
name: 'HTTP Request',
},
{
id: '2',
name: 'LLM',
},
{
id: '3',
name: 'Code',
},
],
} as unknown as Node['data'],
},
{
id: 'http-request',
position: { x: 670, y: 72 },
data: {
...httpDefault.defaultValue,
title: 'HTTP Request',
desc: 'POST https://api.example.com/content/rewrite',
type: BlockEnum.HttpRequest,
method: Method.post,
url: 'https://api.example.com/content/rewrite',
headers: 'Content-Type: application/json',
} as unknown as Node['data'],
},
{
id: 'llm',
position: { x: 670, y: 248 },
data: {
...llmDefault.defaultValue,
title: 'LLM',
desc: 'GPT-4o',
type: BlockEnum.LLM,
model: {
provider: 'openai',
name: 'gpt-4o',
mode: AppModeEnum.CHAT,
completion_params: {
temperature: 0.7,
},
},
prompt_template: [{
role: PromptRole.system,
text: 'Rewrite the content with the requested tone.',
}],
} as unknown as Node['data'],
},
{
id: 'code',
position: { x: 670, y: 424 },
data: {
...codeDefault.defaultValue,
title: 'Code',
desc: 'Python',
type: BlockEnum.Code,
code_language: CodeLanguage.python3,
code: 'def main(text: str) -> dict:\n return {"content": text.strip()}',
} as unknown as Node['data'],
},
],
edges: [
{
id: 'edge-question-http',
source: 'question-classifier',
sourceHandle: '1',
target: 'http-request',
targetHandle: 'target',
},
{
id: 'edge-question-llm',
source: 'question-classifier',
sourceHandle: '2',
target: 'llm',
targetHandle: 'target',
},
{
id: 'edge-question-code',
source: 'question-classifier',
sourceHandle: '3',
target: 'code',
targetHandle: 'target',
},
],
})
export const getSnippetDetailMock = (snippetId: string): SnippetDetailPayload | null => {
if (!snippetId)
return null
const snippet = getSnippetListMock().find(item => item.id === snippetId) ?? createSnippetMock(snippetId)
const inputFields = getSnippetInputFieldsMock()
return {
snippet,
graph: getSnippetGraphMock(),
inputFields,
uiMeta: {
inputFieldCount: inputFields.length,
checklistCount: 2,
autoSavedAt: 'Auto-saved · a few seconds ago',
},
}
}

View File

@ -1,4 +1,5 @@
export type EvaluationTargetType = 'apps' | 'snippets' | 'datasets'
export type EvaluationDefaultMetricsTargetType = 'apps' | 'snippets'
export type EvaluationJudgmentConditionValue = string | string[] | boolean
@ -33,6 +34,10 @@ export type EvaluationDefaultMetric = {
node_info_list?: NodeInfo[]
}
export type EvaluationDefaultMetricsResponse = {
default_metrics: EvaluationDefaultMetric[]
}
export type EvaluationCustomizedMetric = {
evaluation_workflow_id?: string
input_fields?: Record<string, string | null | undefined>