mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
merge evaluation fe
This commit is contained in:
commit
a0d8e84667
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/actions/setup-web/action.yml
vendored
2
.github/actions/setup-web/action.yml
vendored
@ -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
1
.github/labeler.yml
vendored
@ -6,5 +6,4 @@ web:
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.npmrc'
|
||||
- '.nvmrc'
|
||||
|
||||
1
.github/workflows/autofix.yml
vendored
1
.github/workflows/autofix.yml
vendored
@ -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'
|
||||
|
||||
8
.github/workflows/build-push.yml
vendored
8
.github/workflows/build-push.yml
vendored
@ -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 }}
|
||||
|
||||
8
.github/workflows/docker-build.yml
vendored
8
.github/workflows/docker-build.yml
vendored
@ -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 }}
|
||||
|
||||
2
.github/workflows/main-ci.yml
vendored
2
.github/workflows/main-ci.yml
vendored
@ -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'
|
||||
|
||||
3
.github/workflows/style.yml
vendored
3
.github/workflows/style.yml
vendored
@ -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
|
||||
|
||||
1
.github/workflows/tool-test-sdks.yaml
vendored
1
.github/workflows/tool-test-sdks.yaml
vendored
@ -9,7 +9,6 @@ on:
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .npmrc
|
||||
|
||||
concurrency:
|
||||
group: sdk-tests-${{ github.head_ref || github.run_id }}
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@ -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 }}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.13.3"
|
||||
version = "1.14.0"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@ -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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
2
api/uv.lock
generated
@ -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" },
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
2112
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -1 +0,0 @@
|
||||
save-exact=true
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
{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">
|
||||
|
||||
{datasetBindings?.length}
|
||||
{' '}
|
||||
{t('editExternalAPIFormWarning.end', { ns: 'dataset' })}
|
||||
|
||||
<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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 (
|
||||
|
||||
80
web/app/components/evaluation/components/config-actions.tsx
Normal file
80
web/app/components/evaluation/components/config-actions.tsx
Normal 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
|
||||
@ -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')} />
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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({})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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!')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -206,7 +206,6 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
onRefreshData?.()
|
||||
invalidateAllWorkflowTools()
|
||||
invalidateDetail(workflowAppId)
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
setShowModal(false)
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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-код",
|
||||
|
||||
@ -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": "Абзацы",
|
||||
|
||||
@ -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": "Повторная попытка не удалась",
|
||||
|
||||
@ -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": "节点范围",
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.13.3",
|
||||
"version": "1.14.0",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#i18n": {
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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',
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user