Merge branch 'main' into 4-27-app-deploy

This commit is contained in:
Stephen Zhou 2026-04-29 12:45:41 +08:00
commit e5fa2c9aad
No known key found for this signature in database
23 changed files with 502 additions and 421 deletions

View File

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

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import base64 import base64
import json import json
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from uuid import uuid4 from uuid import uuid4
@ -17,7 +18,7 @@ from core.trigger.constants import (
) )
from extensions.ext_redis import redis_client from extensions.ext_redis import redis_client
from graphon.enums import BuiltinNodeTypes from graphon.enums import BuiltinNodeTypes
from models import Account, AppMode from models import Account, App, AppMode
from models.model import AppModelConfig, IconType from models.model import AppModelConfig, IconType
from services import app_dsl_service from services import app_dsl_service
from services.account_service import AccountService, TenantService 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() 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: class TestAppDslService:
"""Integration tests for AppDslService using testcontainers.""" """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): def test_check_dependencies_returns_empty_when_no_redis_data(self, db_session_with_containers):
service = AppDslService(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) result = service.check_dependencies(app_model=app_model)
assert result.leaked_dependencies == [] assert result.leaked_dependencies == []
@ -614,7 +631,7 @@ class TestAppDslService:
) )
service = AppDslService(db_session_with_containers) 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 assert len(result.leaked_dependencies) == 1
def test_check_dependencies_with_real_app(self, db_session_with_containers, mock_external_service_dependencies): 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"), lambda _m: SimpleNamespace(kind="conv"),
) )
app = SimpleNamespace( app = _app_stub(
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
mode=AppMode.WORKFLOW.value, mode=AppMode.WORKFLOW.value,
name="old", name="old",
description="old-desc", description="old-desc",
@ -667,7 +682,6 @@ class TestAppDslService:
icon_background="#111111", icon_background="#111111",
updated_by=None, updated_by=None,
updated_at=None, updated_at=None,
app_model_config=None,
) )
service = AppDslService(db_session_with_containers) service = AppDslService(db_session_with_containers)
updated = service._create_or_update_app( updated = service._create_or_update_app(
@ -745,15 +759,7 @@ class TestAppDslService:
service = AppDslService(db_session_with_containers) service = AppDslService(db_session_with_containers)
with pytest.raises(ValueError, match="Missing workflow data"): with pytest.raises(ValueError, match="Missing workflow data"):
service._create_or_update_app( service._create_or_update_app(
app=SimpleNamespace( app=_app_stub(mode=AppMode.WORKFLOW.value),
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
mode=AppMode.WORKFLOW.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
data={"app": {"mode": AppMode.WORKFLOW.value}}, data={"app": {"mode": AppMode.WORKFLOW.value}},
account=_account_mock(), account=_account_mock(),
) )
@ -762,15 +768,7 @@ class TestAppDslService:
service = AppDslService(db_session_with_containers) service = AppDslService(db_session_with_containers)
with pytest.raises(ValueError, match="Missing model_config"): with pytest.raises(ValueError, match="Missing model_config"):
service._create_or_update_app( service._create_or_update_app(
app=SimpleNamespace( app=_app_stub(mode=AppMode.CHAT.value),
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
mode=AppMode.CHAT.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
data={"app": {"mode": AppMode.CHAT.value}}, data={"app": {"mode": AppMode.CHAT.value}},
account=_account_mock(), account=_account_mock(),
) )
@ -799,15 +797,7 @@ class TestAppDslService:
service = AppDslService(db_session_with_containers) service = AppDslService(db_session_with_containers)
with pytest.raises(ValueError, match="Invalid app mode"): with pytest.raises(ValueError, match="Invalid app mode"):
service._create_or_update_app( service._create_or_update_app(
app=SimpleNamespace( app=_app_stub(mode=AppMode.RAG_PIPELINE.value),
id=str(uuid4()),
tenant_id=_DEFAULT_TENANT_ID,
mode=AppMode.RAG_PIPELINE.value,
name="n",
description="d",
icon_background="#fff",
app_model_config=None,
),
data={"app": {"mode": AppMode.RAG_PIPELINE.value}}, data={"app": {"mode": AppMode.RAG_PIPELINE.value}},
account=_account_mock(), account=_account_mock(),
) )
@ -828,29 +818,16 @@ class TestAppDslService:
lambda *_args, **_kwargs: model_calls.append(True), lambda *_args, **_kwargs: model_calls.append(True),
) )
workflow_app = SimpleNamespace( workflow_app = _app_stub(
mode=AppMode.WORKFLOW.value, mode=AppMode.WORKFLOW.value,
tenant_id=_DEFAULT_TENANT_ID,
name="n",
icon="i",
icon_type="emoji", icon_type="emoji",
icon_background="#fff",
description="d",
use_icon_as_answer_icon=False,
app_model_config=None,
) )
AppDslService.export_dsl(workflow_app) AppDslService.export_dsl(workflow_app)
assert workflow_calls == [True] assert workflow_calls == [True]
chat_app = SimpleNamespace( chat_app = _app_stub(
mode=AppMode.CHAT.value, mode=AppMode.CHAT.value,
tenant_id=_DEFAULT_TENANT_ID,
name="n",
icon="i",
icon_type="emoji", icon_type="emoji",
icon_background="#fff",
description="d",
use_icon_as_answer_icon=False,
app_model_config=SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": []}}), app_model_config=SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": []}}),
) )
AppDslService.export_dsl(chat_app) AppDslService.export_dsl(chat_app)
@ -863,16 +840,14 @@ class TestAppDslService:
lambda **_kwargs: None, lambda **_kwargs: None,
) )
emoji_app = SimpleNamespace( emoji_app = _app_stub(
mode=AppMode.WORKFLOW.value, mode=AppMode.WORKFLOW.value,
tenant_id=_DEFAULT_TENANT_ID,
name="Emoji App", name="Emoji App",
icon="🎨", icon="🎨",
icon_type=IconType.EMOJI, icon_type=IconType.EMOJI,
icon_background="#FF5733", icon_background="#FF5733",
description="App with emoji icon", description="App with emoji icon",
use_icon_as_answer_icon=True, use_icon_as_answer_icon=True,
app_model_config=None,
) )
yaml_output = AppDslService.export_dsl(emoji_app) yaml_output = AppDslService.export_dsl(emoji_app)
data = yaml.safe_load(yaml_output) data = yaml.safe_load(yaml_output)
@ -880,16 +855,14 @@ class TestAppDslService:
assert data["app"]["icon_type"] == "emoji" assert data["app"]["icon_type"] == "emoji"
assert data["app"]["icon_background"] == "#FF5733" assert data["app"]["icon_background"] == "#FF5733"
image_app = SimpleNamespace( image_app = _app_stub(
mode=AppMode.WORKFLOW.value, mode=AppMode.WORKFLOW.value,
tenant_id=_DEFAULT_TENANT_ID,
name="Image App", name="Image App",
icon="https://example.com/icon.png", icon="https://example.com/icon.png",
icon_type=IconType.IMAGE, icon_type=IconType.IMAGE,
icon_background="#FFEAD5", icon_background="#FFEAD5",
description="App with image icon", description="App with image icon",
use_icon_as_answer_icon=False, use_icon_as_answer_icon=False,
app_model_config=None,
) )
yaml_output = AppDslService.export_dsl(image_app) yaml_output = AppDslService.export_dsl(image_app)
data = yaml.safe_load(yaml_output) data = yaml.safe_load(yaml_output)
@ -1106,7 +1079,7 @@ class TestAppDslService:
export_data: dict = {} export_data: dict = {}
AppDslService._append_workflow_export_data( AppDslService._append_workflow_export_data(
export_data=export_data, export_data=export_data,
app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID), app_model=_app_stub(),
include_secret=False, include_secret=False,
workflow_id=None, workflow_id=None,
) )
@ -1132,7 +1105,7 @@ class TestAppDslService:
with pytest.raises(ValueError, match="Missing draft workflow configuration"): with pytest.raises(ValueError, match="Missing draft workflow configuration"):
AppDslService._append_workflow_export_data( AppDslService._append_workflow_export_data(
export_data={}, export_data={},
app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID), app_model=_app_stub(),
include_secret=False, include_secret=False,
workflow_id=None, workflow_id=None,
) )
@ -1160,7 +1133,7 @@ class TestAppDslService:
monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x) 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_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 = {} export_data: dict = {}
AppDslService._append_model_config_export_data(export_data, app_model) 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): def test_append_model_config_export_data_requires_app_config(self):
with pytest.raises(ValueError, match="Missing app configuration"): 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 ───────────────────────────────────────── # ── Dependency Extraction ─────────────────────────────────────────

View File

@ -1,14 +1,12 @@
"""Primarily used for testing merged cell scenarios""" """Primarily used for testing merged cell scenarios"""
import gc
import io import io
import os import os
import tempfile import tempfile
import warnings
from collections import UserDict from collections import UserDict
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import MagicMock
import pytest import pytest
from docx import Document from docx import Document
@ -377,23 +375,21 @@ def test_close_is_idempotent():
extractor.temp_file.close.assert_called_once() 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 = object.__new__(WordExtractor)
extractor._closed = False extractor._closed = False
extractor.temp_file = MagicMock() 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: extractor.close()
warnings.simplefilter("always")
extractor.close()
gc.collect()
assert close_result.cr_frame is None
extractor.temp_file.close.assert_called_once() 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): def test_extract_images_handles_invalid_external_cases(monkeypatch):

2
api/uv.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@ -3820,21 +3820,6 @@
"count": 4 "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": { "web/app/components/workflow-app/components/workflow-children.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 3 "count": 3

View File

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

View File

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

View File

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

View File

@ -18,11 +18,10 @@ vi.mock('@/app/components/base/drawer-plus', () => ({
), ),
})) }))
vi.mock('@/app/components/base/emoji-picker', () => ({ vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => ( default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
<div data-testid="emoji-picker"> <div data-testid="emoji-picker">
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#000000')}>Emoji</button> <button data-testid="select-emoji" onClick={() => onSelect('🚀', '#000000')}>Emoji</button>
<button data-testid="close-emoji-picker" onClick={onClose}>Close</button>
</div> </div>
), ),
})) }))
@ -129,6 +128,7 @@ describe('WorkflowToolAsModal', () => {
await user.click(screen.getByTestId('append-label')) await user.click(screen.getByTestId('append-label'))
await user.click(screen.getByTestId('app-icon')) await user.click(screen.getByTestId('app-icon'))
await user.click(screen.getByTestId('select-emoji')) 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' })) await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import MethodSelector from '../method-selector' import MethodSelector from '../method-selector'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// Test utilities // Test utilities
const defaultProps: ComponentProps<typeof MethodSelector> = { const defaultProps: ComponentProps<typeof MethodSelector> = {
value: 'llm', value: 'llm',
@ -139,6 +141,24 @@ describe('MethodSelector', () => {
expect(onChange).toHaveBeenCalledWith('form') 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 () => { it('should toggle dropdown open state', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderComponent() renderComponent()
@ -235,10 +255,9 @@ describe('MethodSelector', () => {
await user.click(trigger) await user.click(trigger)
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
const dropdown = document.querySelector('.w-\\[320px\\]') const dropdown = document.querySelector('.w-\\[320px\\]')
expect(dropdown)!.toBeInTheDocument() expect(dropdown)!.toBeInTheDocument()
expect(dropdown)!.toHaveClass('rounded-lg')
expect(dropdown)!.toHaveClass('shadow-lg')
}) })
}) })

View File

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

View File

@ -2,11 +2,9 @@
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseLine } from '@remixicon/react' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { noop } from 'es-toolkit/function'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Modal from '@/app/components/base/modal'
type ConfirmModalProps = { type ConfirmModalProps = {
show: boolean show: boolean
@ -18,28 +16,29 @@ const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Modal <Dialog open={show} disablePointerDismissal>
className={cn('w-[600px] max-w-[600px] p-8')} <DialogContent
isShow={show} backdropProps={{ forceRender: true }}
onClose={noop} className={cn('w-[600px]! max-w-[600px]! p-8!')}
> >
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}> <div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" /> <span className="i-ri-close-line 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>
</div> </div>
</div> <div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-section p-3 shadow-xl">
</Modal> <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(onRefreshData).toHaveBeenCalled()
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled() expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) })
expect(result.current.showModal).toBe(false) expect(result.current.showModal).toBe(false)
}) })

View File

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

View File

@ -3,18 +3,18 @@ import type { FC } from 'react'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types' import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast' 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 { produce } from 'immer'
import * as React from 'react' import * as React from 'react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
import Drawer from '@/app/components/base/drawer-plus' import Divider from '@/app/components/base/divider'
import EmojiPicker from '@/app/components/base/emoji-picker' import EmojiPickerInner from '@/app/components/base/emoji-picker/Inner'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea' import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
import LabelSelector from '@/app/components/tools/labels/selector' import LabelSelector from '@/app/components/tools/labels/selector'
import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal' import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal'
import MethodSelector from '@/app/components/tools/workflow-tool/method-selector' import MethodSelector from '@/app/components/tools/workflow-tool/method-selector'
@ -53,6 +53,111 @@ type Props = {
workflow_tool_id: string workflow_tool_id: string
}>) => void }>) => 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 // Add and Edit
const WorkflowToolAsModal: FC<Props> = ({ const WorkflowToolAsModal: FC<Props> = ({
isAdd, isAdd,
@ -138,210 +243,201 @@ const WorkflowToolAsModal: FC<Props> = ({
return ( return (
<> <>
<Drawer <WorkflowToolDrawer
isShow
onHide={onHide} onHide={onHide}
title={t('common.workflowAsTool', { ns: 'workflow' })!} title={t('common.workflowAsTool', { ns: 'workflow' })!}
panelClassName="mt-2 w-[640px]!" >
maxWidthClassName="max-w-[640px]!" <div className="flex h-full flex-col">
height="calc(100vh - 16px)" <div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
headerClassName="!border-b-divider" {/* name & icon */}
body={( <div>
<div className="flex h-full flex-col"> <div className="py-2 system-sm-medium text-text-primary">
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3"> {t('createTool.name', { ns: 'tools' })}
{/* name & icon */} {' '}
<div> <span className="ml-1 text-red-500">*</span>
<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> </div>
{/* name for tool call */} <div className="flex items-center justify-between gap-3">
<div> <AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} />
<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>
<Input <Input
className="h-10" className="h-10 grow"
placeholder={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })!} placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
value={name} value={label}
onChange={e => setName(e.target.value)} onChange={e => setLabel(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' }) || ''}
/> />
</div> </div>
</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')}> {/* name for tool call */}
{!isAdd && onRemove && ( <div>
<Button variant="primary" tone="destructive" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button> <div className="flex items-center py-2 system-sm-medium text-text-primary">
)} {t('createTool.nameForToolCall', { ns: 'tools' })}
<div className="flex space-x-2"> {' '}
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button> <span className="ml-1 text-red-500">*</span>
<Button <InfoTooltip>
variant="primary" {t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
onClick={() => { </InfoTooltip>
if (isAdd)
onConfirm()
else
setShowModal(true)
}}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div> </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>
</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')}>
isShowMask={true} {!isAdd && onRemove && (
clickOutsideNotOpen={true} <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 && ( {showEmojiPicker && (
<EmojiPicker <WorkflowToolEmojiPicker
onSelect={(icon, icon_background) => { onSelect={(icon, icon_background) => {
setEmoji({ content: icon, background: icon_background }) setEmoji({ content: icon, background: icon_background })
setShowEmojiPicker(false) setShowEmojiPicker(false)

View File

@ -1,14 +1,14 @@
import type { FC } from 'react' import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react' import { RiArrowDownSLine } from '@remixicon/react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Check } from '@/app/components/base/icons/src/vender/line/general' 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 = { type MethodSelectorProps = {
value?: string value?: string
@ -20,37 +20,43 @@ const MethodSelector: FC<MethodSelectorProps> = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const handleSelect = (value: string) => {
onChange(value)
setOpen(false)
}
return ( return (
<PortalToFollowElem <Popover
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
placement="bottom-start"
offset={4}
> >
<div className="relative"> <div className="relative">
<PortalToFollowElemTrigger <PopoverTrigger
onClick={() => setOpen(v => !v)} nativeButton={false}
className="block" render={(
> <div className={cn(
<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',
'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',
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')}> <PopoverContent
{value === 'llm' ? t('createTool.toolInput.methodParameter', { ns: 'tools' }) : t('createTool.toolInput.methodSetting', { ns: 'tools' })} placement="bottom-start"
</div> sideOffset={4}
<div className="ml-1 shrink-0 text-text-secondary opacity-60"> positionerProps={{ style: { zIndex: 1040 } }}
<RiArrowDownSLine className="h-4 w-4" /> >
</div> <div className="relative w-[320px]">
</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">
<div className="p-1"> <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="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="item-center flex gap-1"> <div className="flex items-center gap-1">
<div className="h-4 w-4 shrink-0"> <div className="h-4 w-4 shrink-0">
{value === 'llm' && <Check className="h-4 w-4 shrink-0 text-text-accent" />} {value === 'llm' && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
</div> </div>
@ -58,8 +64,8 @@ const MethodSelector: FC<MethodSelectorProps> = ({
</div> </div>
<div className="pl-5 text-[13px] leading-[18px] text-text-tertiary">{t('createTool.toolInput.methodParameterTip', { ns: 'tools' })}</div> <div className="pl-5 text-[13px] leading-[18px] text-text-tertiary">{t('createTool.toolInput.methodParameterTip', { ns: 'tools' })}</div>
</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="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="item-center flex gap-1"> <div className="flex items-center gap-1">
<div className="h-4 w-4 shrink-0"> <div className="h-4 w-4 shrink-0">
{value === 'form' && <Check className="h-4 w-4 shrink-0 text-text-accent" />} {value === 'form' && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
</div> </div>
@ -69,9 +75,9 @@ const MethodSelector: FC<MethodSelectorProps> = ({
</div> </div>
</div> </div>
</div> </div>
</PortalToFollowElemContent> </PopoverContent>
</div> </div>
</PortalToFollowElem> </Popover>
) )
} }

View File

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

View File

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

View File

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

View File

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