Merge branch 'main' into jzh

This commit is contained in:
JzoNg 2026-04-29 12:06:29 +08:00
commit 31e74371ef
26 changed files with 637 additions and 422 deletions

View File

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

View File

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

View File

@ -3,6 +3,7 @@ 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
@ -17,7 +18,7 @@ from core.trigger.constants import (
)
from extensions.ext_redis import redis_client
from graphon.enums import BuiltinNodeTypes
from models import Account, AppMode
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 +68,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 +602,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 +631,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 +673,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 +682,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 +759,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 +768,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 +797,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 +818,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 +840,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 +855,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 +1079,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 +1105,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 +1133,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 +1142,7 @@ class TestAppDslService:
def test_append_model_config_export_data_requires_app_config(self):
with pytest.raises(ValueError, match="Missing app configuration"):
AppDslService._append_model_config_export_data({}, SimpleNamespace(app_model_config=None))
AppDslService._append_model_config_export_data({}, _app_stub(app_model_config=None))
# ── Dependency Extraction ─────────────────────────────────────────

View File

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

View File

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

View File

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

2
api/uv.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@ -3820,21 +3820,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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MethodSelector from '../method-selector'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// Test utilities
const defaultProps: ComponentProps<typeof MethodSelector> = {
value: 'llm',
@ -139,6 +141,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()
@ -235,10 +255,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')
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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