mirror of
https://github.com/langgenius/dify.git
synced 2026-06-01 06:28:14 +08:00
Compare commits
5 Commits
feat/s3-pu
...
1.14.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fafeec415 | |||
| d23cefe005 | |||
| 16d408d908 | |||
| 0536549f73 | |||
| d0956039e7 |
@ -116,14 +116,6 @@ S3_ACCESS_KEY=your-access-key
|
||||
S3_SECRET_KEY=your-secret-key
|
||||
S3_REGION=your-region
|
||||
S3_ADDRESS_STYLE=auto
|
||||
# Optional public base URL for objects in the bucket. When set, signed file
|
||||
# previews are served by 302-redirecting to "<base>/<object-key>" so that bytes
|
||||
# are delivered directly by the object store / CDN. Examples:
|
||||
# Cloudflare R2 custom domain: https://cdn.example.com
|
||||
# MinIO public endpoint: https://minio.example.com/your-bucket
|
||||
# Aliyun OSS public domain: https://your-bucket.oss-cn-hangzhou.aliyuncs.com
|
||||
# Leave empty to keep the default API-streamed behavior.
|
||||
S3_PUBLIC_BASE_URL=
|
||||
|
||||
# Workflow run and Conversation archive storage (S3-compatible)
|
||||
ARCHIVE_STORAGE_ENABLED=false
|
||||
|
||||
@ -43,16 +43,3 @@ class S3StorageConfig(BaseSettings):
|
||||
description="Use AWS managed IAM roles for authentication instead of access/secret keys",
|
||||
default=False,
|
||||
)
|
||||
|
||||
S3_PUBLIC_BASE_URL: str | None = Field(
|
||||
description=(
|
||||
"Optional public base URL for objects in the bucket "
|
||||
"(e.g., a Cloudflare R2 custom domain, MinIO public endpoint, or "
|
||||
"OSS public domain). When set, signed file previews are served via "
|
||||
"302 redirect to '<base>/<object-key>' so that bytes are delivered "
|
||||
"directly by the object store / CDN instead of proxied by Dify's API. "
|
||||
"Trailing slashes are ignored. Leave empty to keep the default "
|
||||
"API-streamed behavior."
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response, redirect, request
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import NotFound
|
||||
@ -64,7 +64,7 @@ class ImagePreviewApi(Resource):
|
||||
sign = args.sign
|
||||
|
||||
try:
|
||||
public_url, generator, mimetype = FileService(db.engine).get_image_preview(
|
||||
generator, mimetype = FileService(db.engine).get_image_preview(
|
||||
file_id=file_id,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
@ -73,9 +73,6 @@ class ImagePreviewApi(Resource):
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
if public_url:
|
||||
return redirect(public_url, code=302)
|
||||
|
||||
return Response(generator, mimetype=mimetype)
|
||||
|
||||
|
||||
@ -106,7 +103,7 @@ class FilePreviewApi(Resource):
|
||||
args = FilePreviewQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
|
||||
try:
|
||||
public_url, generator, upload_file = FileService(db.engine).get_file_generator_by_file_id(
|
||||
generator, upload_file = FileService(db.engine).get_file_generator_by_file_id(
|
||||
file_id=file_id,
|
||||
timestamp=args.timestamp,
|
||||
nonce=args.nonce,
|
||||
@ -115,9 +112,6 @@ class FilePreviewApi(Resource):
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
if public_url:
|
||||
return redirect(public_url, code=302)
|
||||
|
||||
response = Response(
|
||||
generator,
|
||||
mimetype=upload_file.mime_type,
|
||||
@ -181,13 +175,10 @@ class WorkspaceWebappLogoApi(Resource):
|
||||
raise NotFound("webapp logo is not found")
|
||||
|
||||
try:
|
||||
public_url, generator, mimetype = FileService(db.engine).get_public_image_preview(
|
||||
generator, mimetype = FileService(db.engine).get_public_image_preview(
|
||||
webapp_logo_file_id,
|
||||
)
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
if public_url:
|
||||
return redirect(public_url, code=302)
|
||||
|
||||
return Response(generator, mimetype=mimetype)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response, redirect, request
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
@ -57,10 +57,6 @@ class ToolFileApi(Resource):
|
||||
|
||||
try:
|
||||
tool_file_manager = ToolFileManager()
|
||||
public_url, tool_file = tool_file_manager.get_public_url_and_file_by_tool_file_id(file_id)
|
||||
if public_url and tool_file:
|
||||
return redirect(public_url, code=302)
|
||||
|
||||
stream, tool_file = tool_file_manager.get_file_generator_by_tool_file_id(
|
||||
file_id,
|
||||
)
|
||||
|
||||
@ -225,23 +225,6 @@ class ToolFileManager:
|
||||
|
||||
return stream, self._build_graph_file_reference(tool_file)
|
||||
|
||||
def get_public_url_and_file_by_tool_file_id(self, tool_file_id: str) -> tuple[str | None, File | None]:
|
||||
"""
|
||||
Resolve a tool file to a public URL when the storage backend exposes one.
|
||||
|
||||
Returns (public_url, file_reference). If the backend has no public URL
|
||||
configured, returns (None, file_reference) and callers should fall back
|
||||
to the streaming path.
|
||||
"""
|
||||
with session_factory.create_session() as session:
|
||||
tool_file: ToolFile | None = session.scalar(select(ToolFile).where(ToolFile.id == tool_file_id).limit(1))
|
||||
|
||||
if not tool_file:
|
||||
return None, None
|
||||
|
||||
public_url = storage.get_public_url(tool_file.file_key)
|
||||
return public_url, self._build_graph_file_reference(tool_file)
|
||||
|
||||
|
||||
# init tool_file_parser
|
||||
from graphon.file.tool_file_parser import set_tool_file_manager_factory
|
||||
|
||||
@ -119,9 +119,6 @@ class Storage:
|
||||
def delete(self, filename: str):
|
||||
return self.storage_runner.delete(filename)
|
||||
|
||||
def get_public_url(self, filename: str) -> str | None:
|
||||
return self.storage_runner.get_public_url(filename)
|
||||
|
||||
def scan(self, path: str, files: bool = True, directories: bool = False) -> list[str]:
|
||||
return self.storage_runner.scan(path, files=files, directories=directories)
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import logging
|
||||
from collections.abc import Generator
|
||||
from urllib.parse import quote
|
||||
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
@ -18,8 +17,6 @@ class AwsS3Storage(BaseStorage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.bucket_name = dify_config.S3_BUCKET_NAME
|
||||
public_base_url = dify_config.S3_PUBLIC_BASE_URL
|
||||
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
|
||||
if dify_config.S3_USE_AWS_MANAGED_IAM:
|
||||
logger.info("Using AWS managed IAM role for S3")
|
||||
|
||||
@ -88,8 +85,3 @@ class AwsS3Storage(BaseStorage):
|
||||
|
||||
def delete(self, filename: str):
|
||||
self.client.delete_object(Bucket=self.bucket_name, Key=filename)
|
||||
|
||||
def get_public_url(self, filename: str) -> str | None:
|
||||
if not self.public_base_url:
|
||||
return None
|
||||
return f"{self.public_base_url}/{quote(filename, safe='/')}"
|
||||
|
||||
@ -31,17 +31,6 @@ class BaseStorage(ABC):
|
||||
def delete(self, filename: str):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_public_url(self, filename: str) -> str | None:
|
||||
"""
|
||||
Return a publicly accessible URL for the given object, or None if the
|
||||
backend is not configured to serve content publicly.
|
||||
|
||||
When set, file controllers will 302-redirect signed preview requests to
|
||||
this URL after verifying the signature, so that the bytes themselves are
|
||||
served by the object store / CDN instead of streamed through Dify's API.
|
||||
"""
|
||||
return None
|
||||
|
||||
def scan(self, path, files=True, directories=False) -> list[str]:
|
||||
"""
|
||||
Scan files and directories in the given path.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.13.3"
|
||||
version = "1.14.0"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@ -210,12 +210,9 @@ class FileService:
|
||||
if extension.lower() not in IMAGE_EXTENSIONS:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
public_url = storage.get_public_url(upload_file.key)
|
||||
if public_url:
|
||||
return public_url, None, upload_file.mime_type
|
||||
|
||||
generator = storage.load(upload_file.key, stream=True)
|
||||
return None, generator, upload_file.mime_type
|
||||
|
||||
return generator, upload_file.mime_type
|
||||
|
||||
def get_file_generator_by_file_id(self, file_id: str, timestamp: str, nonce: str, sign: str):
|
||||
result = file_helpers.verify_file_signature(upload_file_id=file_id, timestamp=timestamp, nonce=nonce, sign=sign)
|
||||
@ -228,12 +225,9 @@ class FileService:
|
||||
if not upload_file:
|
||||
raise NotFound("File not found or signature is invalid")
|
||||
|
||||
public_url = storage.get_public_url(upload_file.key)
|
||||
if public_url:
|
||||
return public_url, None, upload_file
|
||||
|
||||
generator = storage.load(upload_file.key, stream=True)
|
||||
return None, generator, upload_file
|
||||
|
||||
return generator, upload_file
|
||||
|
||||
def get_public_image_preview(self, file_id: str):
|
||||
with self._session_maker(expire_on_commit=False) as session:
|
||||
@ -247,12 +241,9 @@ class FileService:
|
||||
if extension.lower() not in IMAGE_EXTENSIONS:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
public_url = storage.get_public_url(upload_file.key)
|
||||
if public_url:
|
||||
return public_url, None, upload_file.mime_type
|
||||
|
||||
generator = storage.load(upload_file.key)
|
||||
return None, generator, upload_file.mime_type
|
||||
|
||||
return generator, upload_file.mime_type
|
||||
|
||||
def get_file_content(self, file_id: str) -> str:
|
||||
with self._session_maker(expire_on_commit=False) as session:
|
||||
|
||||
@ -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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
@ -49,7 +49,6 @@ class TestImagePreviewApi:
|
||||
|
||||
generator = iter([b"img"])
|
||||
mock_file_service.return_value.get_image_preview.return_value = (
|
||||
None,
|
||||
generator,
|
||||
"image/png",
|
||||
)
|
||||
@ -61,30 +60,6 @@ class TestImagePreviewApi:
|
||||
|
||||
assert response.mimetype == "image/png"
|
||||
|
||||
@patch.object(module, "FileService")
|
||||
def test_redirects_to_public_url(self, mock_file_service):
|
||||
module.request = fake_request(
|
||||
{
|
||||
"timestamp": "123",
|
||||
"nonce": "abc",
|
||||
"sign": "sig",
|
||||
}
|
||||
)
|
||||
|
||||
mock_file_service.return_value.get_image_preview.return_value = (
|
||||
"https://cdn.example.com/upload_files/tenant/abc.png",
|
||||
None,
|
||||
"image/png",
|
||||
)
|
||||
|
||||
api = module.ImagePreviewApi()
|
||||
get_fn = unwrap(api.get)
|
||||
|
||||
response = get_fn("file-id")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"] == "https://cdn.example.com/upload_files/tenant/abc.png"
|
||||
|
||||
@patch.object(module, "FileService")
|
||||
def test_unsupported_file_type(self, mock_file_service):
|
||||
module.request = fake_request(
|
||||
@ -123,7 +98,6 @@ class TestFilePreviewApi:
|
||||
upload_file = DummyUploadFile(size=100)
|
||||
|
||||
mock_file_service.return_value.get_file_generator_by_file_id.return_value = (
|
||||
None,
|
||||
generator,
|
||||
upload_file,
|
||||
)
|
||||
@ -138,32 +112,6 @@ class TestFilePreviewApi:
|
||||
assert "Accept-Ranges" not in response.headers
|
||||
mock_enforce.assert_called_once()
|
||||
|
||||
@patch.object(module, "FileService")
|
||||
def test_redirects_to_public_url(self, mock_file_service):
|
||||
module.request = fake_request(
|
||||
{
|
||||
"timestamp": "123",
|
||||
"nonce": "abc",
|
||||
"sign": "sig",
|
||||
"as_attachment": False,
|
||||
}
|
||||
)
|
||||
|
||||
upload_file = DummyUploadFile(size=100)
|
||||
mock_file_service.return_value.get_file_generator_by_file_id.return_value = (
|
||||
"https://cdn.example.com/upload_files/tenant/abc.bin",
|
||||
None,
|
||||
upload_file,
|
||||
)
|
||||
|
||||
api = module.FilePreviewApi()
|
||||
get_fn = unwrap(api.get)
|
||||
|
||||
response = get_fn("file-id")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"] == "https://cdn.example.com/upload_files/tenant/abc.bin"
|
||||
|
||||
@patch.object(module, "enforce_download_for_html")
|
||||
@patch.object(module, "FileService")
|
||||
def test_as_attachment(self, mock_file_service, mock_enforce):
|
||||
@ -184,7 +132,6 @@ class TestFilePreviewApi:
|
||||
)
|
||||
|
||||
mock_file_service.return_value.get_file_generator_by_file_id.return_value = (
|
||||
None,
|
||||
generator,
|
||||
upload_file,
|
||||
)
|
||||
@ -228,7 +175,6 @@ class TestWorkspaceWebappLogoApi:
|
||||
generator = iter([b"logo"])
|
||||
|
||||
mock_file_service.return_value.get_public_image_preview.return_value = (
|
||||
None,
|
||||
generator,
|
||||
"image/png",
|
||||
)
|
||||
@ -240,24 +186,6 @@ class TestWorkspaceWebappLogoApi:
|
||||
|
||||
assert response.mimetype == "image/png"
|
||||
|
||||
@patch.object(module, "FileService")
|
||||
@patch.object(module.TenantService, "get_custom_config")
|
||||
def test_redirects_to_public_url(self, mock_config, mock_file_service):
|
||||
mock_config.return_value = {"replace_webapp_logo": "logo-id"}
|
||||
mock_file_service.return_value.get_public_image_preview.return_value = (
|
||||
"https://cdn.example.com/upload_files/tenant/logo.png",
|
||||
None,
|
||||
"image/png",
|
||||
)
|
||||
|
||||
api = module.WorkspaceWebappLogoApi()
|
||||
get_fn = unwrap(api.get)
|
||||
|
||||
response = get_fn("workspace-id")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"] == "https://cdn.example.com/upload_files/tenant/logo.png"
|
||||
|
||||
@patch.object(module.TenantService, "get_custom_config")
|
||||
def test_logo_not_configured(self, mock_config):
|
||||
mock_config.return_value = {}
|
||||
|
||||
@ -50,10 +50,6 @@ class TestToolFileApi:
|
||||
stream = iter([b"data"])
|
||||
tool_file = DummyToolFile(size=100)
|
||||
|
||||
mock_tool_file_manager.return_value.get_public_url_and_file_by_tool_file_id.return_value = (
|
||||
None,
|
||||
tool_file,
|
||||
)
|
||||
mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.return_value = (
|
||||
stream,
|
||||
tool_file,
|
||||
@ -73,37 +69,6 @@ class TestToolFileApi:
|
||||
sign="sig",
|
||||
)
|
||||
|
||||
@patch.object(module, "verify_tool_file_signature", return_value=True)
|
||||
@patch.object(module, "ToolFileManager")
|
||||
def test_redirects_to_public_url(
|
||||
self,
|
||||
mock_tool_file_manager,
|
||||
mock_verify,
|
||||
):
|
||||
module.request = fake_request(
|
||||
{
|
||||
"timestamp": "123",
|
||||
"nonce": "abc",
|
||||
"sign": "sig",
|
||||
"as_attachment": False,
|
||||
}
|
||||
)
|
||||
|
||||
tool_file = DummyToolFile(size=100)
|
||||
mock_tool_file_manager.return_value.get_public_url_and_file_by_tool_file_id.return_value = (
|
||||
"https://cdn.example.com/tool_files/abc.txt",
|
||||
tool_file,
|
||||
)
|
||||
|
||||
api = module.ToolFileApi()
|
||||
get_fn = unwrap(api.get)
|
||||
|
||||
response = get_fn("file-id", "txt")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"] == "https://cdn.example.com/tool_files/abc.txt"
|
||||
mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.assert_not_called()
|
||||
|
||||
@patch.object(module, "verify_tool_file_signature", return_value=True)
|
||||
@patch.object(module, "ToolFileManager")
|
||||
def test_as_attachment(
|
||||
@ -126,10 +91,6 @@ class TestToolFileApi:
|
||||
filename="doc.pdf",
|
||||
)
|
||||
|
||||
mock_tool_file_manager.return_value.get_public_url_and_file_by_tool_file_id.return_value = (
|
||||
None,
|
||||
tool_file,
|
||||
)
|
||||
mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.return_value = (
|
||||
stream,
|
||||
tool_file,
|
||||
@ -176,10 +137,6 @@ class TestToolFileApi:
|
||||
}
|
||||
)
|
||||
|
||||
mock_tool_file_manager.return_value.get_public_url_and_file_by_tool_file_id.return_value = (
|
||||
None,
|
||||
None,
|
||||
)
|
||||
mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.return_value = (
|
||||
None,
|
||||
None,
|
||||
@ -207,10 +164,6 @@ class TestToolFileApi:
|
||||
}
|
||||
)
|
||||
|
||||
mock_tool_file_manager.return_value.get_public_url_and_file_by_tool_file_id.return_value = (
|
||||
None,
|
||||
DummyToolFile(),
|
||||
)
|
||||
mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.side_effect = Exception("boom")
|
||||
|
||||
api = module.ToolFileApi()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from extensions.storage.aws_s3_storage import AwsS3Storage
|
||||
|
||||
|
||||
def _build_storage(public_base_url: str | None = None) -> AwsS3Storage:
|
||||
with patch("extensions.storage.aws_s3_storage.dify_config", autospec=True) as mock_config:
|
||||
mock_config.S3_BUCKET_NAME = "test-bucket"
|
||||
mock_config.S3_PUBLIC_BASE_URL = public_base_url
|
||||
mock_config.S3_USE_AWS_MANAGED_IAM = False
|
||||
mock_config.S3_ACCESS_KEY = "ak"
|
||||
mock_config.S3_SECRET_KEY = "sk"
|
||||
mock_config.S3_ENDPOINT = "https://example.com"
|
||||
mock_config.S3_REGION = "auto"
|
||||
mock_config.S3_ADDRESS_STYLE = "auto"
|
||||
|
||||
with patch("extensions.storage.aws_s3_storage.boto3") as mock_boto3:
|
||||
client = Mock()
|
||||
client.head_bucket.return_value = None
|
||||
mock_boto3.client.return_value = client
|
||||
mock_boto3.Session.return_value.client.return_value = client
|
||||
return AwsS3Storage()
|
||||
|
||||
|
||||
class TestAwsS3StoragePublicUrl:
|
||||
def test_returns_none_when_public_base_url_unset(self):
|
||||
storage = _build_storage(public_base_url=None)
|
||||
assert storage.get_public_url("upload_files/tenant/abc.png") is None
|
||||
|
||||
def test_returns_none_when_public_base_url_empty_string(self):
|
||||
storage = _build_storage(public_base_url="")
|
||||
assert storage.get_public_url("upload_files/tenant/abc.png") is None
|
||||
|
||||
def test_composes_url_when_configured(self):
|
||||
storage = _build_storage(public_base_url="https://cdn.example.com")
|
||||
assert (
|
||||
storage.get_public_url("upload_files/tenant/abc.png")
|
||||
== "https://cdn.example.com/upload_files/tenant/abc.png"
|
||||
)
|
||||
|
||||
def test_strips_trailing_slash(self):
|
||||
storage = _build_storage(public_base_url="https://cdn.example.com/")
|
||||
assert (
|
||||
storage.get_public_url("upload_files/tenant/abc.png")
|
||||
== "https://cdn.example.com/upload_files/tenant/abc.png"
|
||||
)
|
||||
|
||||
def test_preserves_path_separators_in_key(self):
|
||||
# Object key path separators must not be percent-encoded.
|
||||
storage = _build_storage(public_base_url="https://cdn.example.com")
|
||||
url = storage.get_public_url("a/b/c.txt")
|
||||
assert url == "https://cdn.example.com/a/b/c.txt"
|
||||
|
||||
def test_quotes_unsafe_characters_in_key(self):
|
||||
storage = _build_storage(public_base_url="https://cdn.example.com")
|
||||
url = storage.get_public_url("upload_files/has space.png")
|
||||
assert url == "https://cdn.example.com/upload_files/has%20space.png"
|
||||
|
||||
|
||||
class TestAwsS3StorageBucketCheck:
|
||||
def test_init_handles_403_on_head_bucket(self):
|
||||
# Regression: R2 / hardened buckets often return 403 on head_bucket; the
|
||||
# constructor must swallow the error instead of crashing.
|
||||
with patch("extensions.storage.aws_s3_storage.dify_config", autospec=True) as mock_config:
|
||||
mock_config.S3_BUCKET_NAME = "test-bucket"
|
||||
mock_config.S3_PUBLIC_BASE_URL = None
|
||||
mock_config.S3_USE_AWS_MANAGED_IAM = False
|
||||
mock_config.S3_ACCESS_KEY = "ak"
|
||||
mock_config.S3_SECRET_KEY = "sk"
|
||||
mock_config.S3_ENDPOINT = "https://example.com"
|
||||
mock_config.S3_REGION = "auto"
|
||||
mock_config.S3_ADDRESS_STYLE = "auto"
|
||||
|
||||
with patch("extensions.storage.aws_s3_storage.boto3") as mock_boto3:
|
||||
client = Mock()
|
||||
client.head_bucket.side_effect = ClientError(
|
||||
{"Error": {"Code": "403", "Message": "Forbidden"}}, "HeadBucket"
|
||||
)
|
||||
mock_boto3.client.return_value = client
|
||||
storage = AwsS3Storage()
|
||||
assert storage.bucket_name == "test-bucket"
|
||||
client.create_bucket.assert_not_called()
|
||||
@ -253,39 +253,15 @@ class TestFileService:
|
||||
patch("services.file_service.storage") as mock_storage,
|
||||
):
|
||||
mock_verify.return_value = True
|
||||
mock_storage.get_public_url.return_value = None
|
||||
mock_storage.load.return_value = iter([b"chunk1"])
|
||||
|
||||
# Execute
|
||||
public_url, gen, mime = file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
gen, mime = file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
|
||||
# Assert
|
||||
assert public_url is None
|
||||
assert list(gen) == [b"chunk1"]
|
||||
assert mime == "image/jpeg"
|
||||
|
||||
def test_get_image_preview_redirects_when_storage_has_public_url(self, file_service, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "jpg"
|
||||
upload_file.mime_type = "image/jpeg"
|
||||
upload_file.key = "upload_files/tenant/abc.jpg"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with (
|
||||
patch("services.file_service.file_helpers.verify_image_signature") as mock_verify,
|
||||
patch("services.file_service.storage") as mock_storage,
|
||||
):
|
||||
mock_verify.return_value = True
|
||||
mock_storage.get_public_url.return_value = "https://cdn.example.com/upload_files/tenant/abc.jpg"
|
||||
|
||||
public_url, gen, mime = file_service.get_image_preview("file_id", "ts", "nonce", "sign")
|
||||
|
||||
assert public_url == "https://cdn.example.com/upload_files/tenant/abc.jpg"
|
||||
assert gen is None
|
||||
assert mime == "image/jpeg"
|
||||
mock_storage.load.assert_not_called()
|
||||
|
||||
def test_get_image_preview_invalid_sig(self, file_service):
|
||||
with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify:
|
||||
mock_verify.return_value = False
|
||||
@ -320,33 +296,12 @@ class TestFileService:
|
||||
patch("services.file_service.storage") as mock_storage,
|
||||
):
|
||||
mock_verify.return_value = True
|
||||
mock_storage.get_public_url.return_value = None
|
||||
mock_storage.load.return_value = iter([b"chunk"])
|
||||
|
||||
public_url, gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
assert public_url is None
|
||||
gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
assert list(gen) == [b"chunk"]
|
||||
assert file == upload_file
|
||||
|
||||
def test_get_file_generator_by_file_id_redirects_when_storage_has_public_url(self, file_service, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.key = "upload_files/tenant/abc.bin"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with (
|
||||
patch("services.file_service.file_helpers.verify_file_signature") as mock_verify,
|
||||
patch("services.file_service.storage") as mock_storage,
|
||||
):
|
||||
mock_verify.return_value = True
|
||||
mock_storage.get_public_url.return_value = "https://cdn.example.com/upload_files/tenant/abc.bin"
|
||||
|
||||
public_url, gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign")
|
||||
assert public_url == "https://cdn.example.com/upload_files/tenant/abc.bin"
|
||||
assert gen is None
|
||||
assert file == upload_file
|
||||
mock_storage.load.assert_not_called()
|
||||
|
||||
def test_get_file_generator_by_file_id_invalid_sig(self, file_service):
|
||||
with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify:
|
||||
mock_verify.return_value = False
|
||||
@ -369,29 +324,11 @@ class TestFileService:
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.get_public_url.return_value = None
|
||||
mock_storage.load.return_value = b"image content"
|
||||
public_url, gen, mime = file_service.get_public_image_preview("file_id")
|
||||
assert public_url is None
|
||||
gen, mime = file_service.get_public_image_preview("file_id")
|
||||
assert gen == b"image content"
|
||||
assert mime == "image/png"
|
||||
|
||||
def test_get_public_image_preview_redirects_when_storage_has_public_url(self, file_service, mock_db_session):
|
||||
upload_file = MagicMock(spec=UploadFile)
|
||||
upload_file.id = "file_id"
|
||||
upload_file.extension = "png"
|
||||
upload_file.mime_type = "image/png"
|
||||
upload_file.key = "upload_files/tenant/logo.png"
|
||||
mock_db_session.scalar.return_value = upload_file
|
||||
|
||||
with patch("services.file_service.storage") as mock_storage:
|
||||
mock_storage.get_public_url.return_value = "https://cdn.example.com/upload_files/tenant/logo.png"
|
||||
public_url, gen, mime = file_service.get_public_image_preview("file_id")
|
||||
assert public_url == "https://cdn.example.com/upload_files/tenant/logo.png"
|
||||
assert gen is None
|
||||
assert mime == "image/png"
|
||||
mock_storage.load.assert_not_called()
|
||||
|
||||
def test_get_public_image_preview_not_found(self, file_service, mock_db_session):
|
||||
mock_db_session.scalar.return_value = None
|
||||
with pytest.raises(NotFound, match="File not found or signature is invalid"):
|
||||
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@ -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" },
|
||||
|
||||
@ -483,14 +483,6 @@ S3_ADDRESS_STYLE=auto
|
||||
# Whether to use AWS managed IAM roles for authenticating with the S3 service.
|
||||
# If set to false, the access key and secret key must be provided.
|
||||
S3_USE_AWS_MANAGED_IAM=false
|
||||
# Optional public base URL for objects in the bucket. When set, signed file
|
||||
# previews are served by 302-redirecting to "<base>/<object-key>" so that bytes
|
||||
# are delivered directly by the object store / CDN. Examples:
|
||||
# Cloudflare R2 custom domain: https://cdn.example.com
|
||||
# MinIO public endpoint: https://minio.example.com/your-bucket
|
||||
# Aliyun OSS public domain: https://your-bucket.oss-cn-hangzhou.aliyuncs.com
|
||||
# Leave empty to keep the default API-streamed behavior.
|
||||
S3_PUBLIC_BASE_URL=
|
||||
|
||||
# Workflow run and Conversation archive storage (S3-compatible)
|
||||
ARCHIVE_STORAGE_ENABLED=false
|
||||
|
||||
@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.3
|
||||
image: langgenius/dify-api:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -69,7 +69,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.3
|
||||
image: langgenius/dify-api:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -115,7 +115,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.3
|
||||
image: langgenius/dify-api:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -152,7 +152,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.3
|
||||
image: langgenius/dify-web:1.14.0
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
@ -268,7 +268,7 @@ services:
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: langgenius/dify-sandbox:0.2.14
|
||||
image: langgenius/dify-sandbox:0.2.15
|
||||
restart: always
|
||||
environment:
|
||||
# The DifySandbox configurations
|
||||
@ -292,7 +292,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.6.0-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@ -103,7 +103,7 @@ services:
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: langgenius/dify-sandbox:0.2.14
|
||||
image: langgenius/dify-sandbox:0.2.15
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
@ -129,7 +129,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.6.0-local
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
|
||||
@ -136,7 +136,6 @@ x-shared-env: &shared-api-worker-env
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
|
||||
S3_ADDRESS_STYLE: ${S3_ADDRESS_STYLE:-auto}
|
||||
S3_USE_AWS_MANAGED_IAM: ${S3_USE_AWS_MANAGED_IAM:-false}
|
||||
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
|
||||
ARCHIVE_STORAGE_ENABLED: ${ARCHIVE_STORAGE_ENABLED:-false}
|
||||
ARCHIVE_STORAGE_ENDPOINT: ${ARCHIVE_STORAGE_ENDPOINT:-}
|
||||
ARCHIVE_STORAGE_ARCHIVE_BUCKET: ${ARCHIVE_STORAGE_ARCHIVE_BUCKET:-}
|
||||
@ -746,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.
|
||||
@ -794,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.
|
||||
@ -840,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.
|
||||
@ -877,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:-}
|
||||
@ -993,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
|
||||
@ -1017,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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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({})
|
||||
|
||||
@ -9,6 +9,8 @@ import WorkflowToolConfigureButton from '../configure-button'
|
||||
import WorkflowToolAsModal from '../index'
|
||||
import MethodSelector from '../method-selector'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
// Mock Next.js navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@ -83,12 +85,11 @@ vi.mock('@/app/components/base/drawer-plus', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock EmojiPicker - simplified for testing
|
||||
vi.mock('@/app/components/base/emoji-picker', () => ({
|
||||
default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => (
|
||||
// Mock EmojiPickerInner - simplified for testing
|
||||
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
|
||||
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
|
||||
<div data-testid="emoji-picker">
|
||||
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#f0f0f0')}>Select Emoji</button>
|
||||
<button data-testid="close-emoji-picker" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -978,6 +979,7 @@ describe('WorkflowToolAsModal', () => {
|
||||
|
||||
// Select emoji
|
||||
await user.click(screen.getByTestId('select-emoji'))
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
|
||||
// Assert
|
||||
const updatedIcon = screen.getByTestId('app-icon')
|
||||
@ -1002,7 +1004,7 @@ describe('WorkflowToolAsModal', () => {
|
||||
|
||||
expect(screen.getByTestId('emoji-picker'))!.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('close-emoji-picker'))
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
@ -1501,7 +1503,7 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display parameter method text when value is llm', () => {
|
||||
@ -1562,11 +1564,11 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Act
|
||||
render(<MethodSelector {...props} />)
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange with llm when parameter option clicked', async () => {
|
||||
@ -1580,7 +1582,7 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Act
|
||||
render(<MethodSelector {...props} />)
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
const paramOption = screen.getAllByText('tools.createTool.toolInput.methodParameter')[0]
|
||||
await user.click(paramOption!)
|
||||
@ -1600,7 +1602,7 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Act
|
||||
render(<MethodSelector {...props} />)
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
const settingOption = screen.getByText('tools.createTool.toolInput.methodSetting')
|
||||
await user.click(settingOption)
|
||||
@ -1621,12 +1623,12 @@ describe('MethodSelector', () => {
|
||||
render(<MethodSelector {...props} />)
|
||||
|
||||
// First click - open
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
expect(screen.getByTestId('portal-content'))!.toBeInTheDocument()
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
|
||||
|
||||
// Second click - close
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1642,10 +1644,10 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Act
|
||||
render(<MethodSelector {...props} />)
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
// Assert - the first option (llm) should have a check icon container
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const content = screen.getByTestId('popover-content')
|
||||
expect(content)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1659,10 +1661,10 @@ describe('MethodSelector', () => {
|
||||
|
||||
// Act
|
||||
render(<MethodSelector {...props} />)
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
// Assert
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const content = screen.getByTestId('popover-content')
|
||||
expect(content)!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -18,11 +18,10 @@ vi.mock('@/app/components/base/drawer-plus', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/emoji-picker', () => ({
|
||||
default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => (
|
||||
vi.mock('@/app/components/base/emoji-picker/Inner', () => ({
|
||||
default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
|
||||
<div data-testid="emoji-picker">
|
||||
<button data-testid="select-emoji" onClick={() => onSelect('🚀', '#000000')}>Emoji</button>
|
||||
<button data-testid="close-emoji-picker" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -129,6 +128,7 @@ describe('WorkflowToolAsModal', () => {
|
||||
await user.click(screen.getByTestId('append-label'))
|
||||
await user.click(screen.getByTestId('app-icon'))
|
||||
await user.click(screen.getByTestId('select-emoji'))
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@ -195,6 +195,6 @@ describe('WorkflowToolAsModal', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('tools.createTool.toolOutput.reservedParameterDuplicateTip').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByTestId('reserved-output-warning').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MethodSelector from '../method-selector'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
// Test utilities
|
||||
const defaultProps: ComponentProps<typeof MethodSelector> = {
|
||||
value: 'llm',
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -93,13 +93,12 @@ describe('ConfirmModal', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert - Check for the dialog panel with modal content
|
||||
// The real modal structure has nested divs, we need to find the one with our classes
|
||||
const dialogContent = document.querySelector('.relative.rounded-2xl')
|
||||
// Assert
|
||||
const dialogContent = screen.getByRole('dialog')
|
||||
expect(dialogContent).toBeInTheDocument()
|
||||
expect(dialogContent).toHaveClass('w-[600px]')
|
||||
expect(dialogContent).toHaveClass('max-w-[600px]')
|
||||
expect(dialogContent).toHaveClass('p-8')
|
||||
expect(dialogContent).toHaveClass('w-[600px]!')
|
||||
expect(dialogContent).toHaveClass('max-w-[600px]!')
|
||||
expect(dialogContent).toHaveClass('p-8!')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -2,11 +2,9 @@
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
|
||||
type ConfirmModalProps = {
|
||||
show: boolean
|
||||
@ -18,28 +16,29 @@ const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={cn('w-[600px] max-w-[600px] p-8')}
|
||||
isShow={show}
|
||||
onClose={noop}
|
||||
>
|
||||
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-section p-3 shadow-xl">
|
||||
<AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" />
|
||||
</div>
|
||||
<div className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t('createTool.confirmTitle', { ns: 'tools' })}</div>
|
||||
<div className="my-1 text-sm leading-5 text-text-tertiary">
|
||||
{t('createTool.confirmTip', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="flex items-center justify-end pt-6">
|
||||
<div className="flex items-center">
|
||||
<Button className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button variant="primary" tone="destructive" onClick={onConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
|
||||
<Dialog open={show} disablePointerDismissal>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className={cn('w-[600px]! max-w-[600px]! p-8!')}
|
||||
>
|
||||
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-section p-3 shadow-xl">
|
||||
<AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" />
|
||||
</div>
|
||||
<DialogTitle className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t('createTool.confirmTitle', { ns: 'tools' })}</DialogTitle>
|
||||
<div className="my-1 text-sm leading-5 text-text-tertiary">
|
||||
{t('createTool.confirmTip', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="flex items-center justify-end pt-6">
|
||||
<div className="flex items-center">
|
||||
<Button className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button variant="primary" tone="destructive" onClick={onConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -437,7 +437,6 @@ describe('useConfigureButton', () => {
|
||||
expect(onRefreshData).toHaveBeenCalled()
|
||||
expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled()
|
||||
expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123')
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) })
|
||||
expect(result.current.showModal).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@ -206,7 +206,6 @@ export function useConfigureButton(options: UseConfigureButtonOptions) {
|
||||
onRefreshData?.()
|
||||
invalidateAllWorkflowTools()
|
||||
invalidateDetail(workflowAppId)
|
||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||
setShowModal(false)
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
@ -3,18 +3,18 @@ import type { FC } from 'react'
|
||||
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiErrorWarningLine } from '@remixicon/react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Drawer from '@/app/components/base/drawer-plus'
|
||||
import EmojiPicker from '@/app/components/base/emoji-picker'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import EmojiPickerInner from '@/app/components/base/emoji-picker/Inner'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import LabelSelector from '@/app/components/tools/labels/selector'
|
||||
import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal'
|
||||
import MethodSelector from '@/app/components/tools/workflow-tool/method-selector'
|
||||
@ -53,6 +53,111 @@ type Props = {
|
||||
workflow_tool_id: string
|
||||
}>) => void
|
||||
}
|
||||
|
||||
type WorkflowToolDrawerProps = {
|
||||
title: string
|
||||
onHide: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const InfoTooltip = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="i-ri-question-line h-3.5 w-3.5 shrink-0 cursor-help text-text-quaternary hover:text-text-tertiary" />
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className="w-[180px]">
|
||||
{children}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const WorkflowToolDrawer = ({ title, onHide, children }: WorkflowToolDrawerProps) => {
|
||||
return (
|
||||
<Dialog open disablePointerDismissal>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'top-2 right-2 bottom-2 left-auto h-[calc(100dvh-16px)] max-h-[calc(100dvh-16px)] w-[640px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! overflow-hidden rounded-xl border-none bg-transparent p-0 shadow-none',
|
||||
'data-ending-style:translate-x-4 data-ending-style:scale-100 data-starting-style:translate-x-4 data-starting-style:scale-100',
|
||||
)}
|
||||
backdropClassName="bg-background-overlay"
|
||||
>
|
||||
<div data-testid="drawer" className="flex h-full w-full flex-col rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg shadow-xl">
|
||||
<div className="shrink-0 border-b border-divider-subtle py-4">
|
||||
<div className="flex h-6 items-center justify-between pr-5 pl-6">
|
||||
<DialogTitle data-testid="drawer-title" className="system-xl-semibold text-text-primary">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="drawer-close"
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover"
|
||||
aria-label="Close"
|
||||
onClick={onHide}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type WorkflowToolEmojiPickerProps = {
|
||||
onSelect: (icon: string, background: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const WorkflowToolEmojiPicker = ({ onSelect, onClose }: WorkflowToolEmojiPickerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [selectedEmoji, setSelectedEmoji] = useState('')
|
||||
const [selectedBackground, setSelectedBackground] = useState<string>()
|
||||
|
||||
return (
|
||||
<Dialog open disablePointerDismissal>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="flex max-h-[552px] w-[480px]! flex-col overflow-hidden rounded-xl border-[0.5px] border-divider-subtle p-0! shadow-xl"
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{t('iconPicker.emoji', { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
<EmojiPickerInner
|
||||
className="pt-3"
|
||||
onSelect={(emoji, background) => {
|
||||
setSelectedEmoji(emoji)
|
||||
setSelectedBackground(background)
|
||||
}}
|
||||
/>
|
||||
<Divider className="mt-3 mb-0" />
|
||||
<div className="flex w-full items-center justify-center gap-2 p-3">
|
||||
<Button className="w-full" onClick={onClose}>
|
||||
{t('iconPicker.cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selectedEmoji === '' || !selectedBackground}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => onSelect(selectedEmoji, selectedBackground!)}
|
||||
>
|
||||
{t('iconPicker.ok', { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Add and Edit
|
||||
const WorkflowToolAsModal: FC<Props> = ({
|
||||
isAdd,
|
||||
@ -138,210 +243,201 @@ const WorkflowToolAsModal: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
isShow
|
||||
<WorkflowToolDrawer
|
||||
onHide={onHide}
|
||||
title={t('common.workflowAsTool', { ns: 'workflow' })!}
|
||||
panelClassName="mt-2 w-[640px]!"
|
||||
maxWidthClassName="max-w-[640px]!"
|
||||
height="calc(100vh - 16px)"
|
||||
headerClassName="!border-b-divider"
|
||||
body={(
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
|
||||
{/* name & icon */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.name', { ns: 'tools' })}
|
||||
{' '}
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} />
|
||||
<Input
|
||||
className="h-10 grow"
|
||||
placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="h-0 grow space-y-4 overflow-y-auto px-6 py-3">
|
||||
{/* name & icon */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.name', { ns: 'tools' })}
|
||||
{' '}
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</div>
|
||||
{/* name for tool call */}
|
||||
<div>
|
||||
<div className="flex items-center py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.nameForToolCall', { ns: 'tools' })}
|
||||
{' '}
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[180px]">
|
||||
{t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<AppIcon size="large" onClick={() => { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} />
|
||||
<Input
|
||||
className="h-10"
|
||||
placeholder={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })!}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
{!isWorkflowToolNameValid(name) && (
|
||||
<div className="text-xs leading-[18px] text-red-500">{t('createTool.nameForToolCallTip', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* description */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.description', { ns: 'tools' })}</div>
|
||||
<Textarea
|
||||
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Tool Input */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.title', { ns: 'tools' })}</div>
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
|
||||
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
|
||||
<thead className="text-text-tertiary uppercase">
|
||||
<tr className="border-b border-divider-regular">
|
||||
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.toolInput.name', { ns: 'tools' })}</th>
|
||||
<th className="w-[102px] p-2 pl-3 font-medium">{t('createTool.toolInput.method', { ns: 'tools' })}</th>
|
||||
<th className="p-2 pl-3 font-medium">{t('createTool.toolInput.description', { ns: 'tools' })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parameters.map((item, index) => (
|
||||
<tr key={index} className="border-b border-divider-regular last:border-0">
|
||||
<td className="max-w-[156px] p-2 pl-3">
|
||||
<div className="text-[13px] leading-[18px]">
|
||||
<div title={item.name} className="flex">
|
||||
<span className="truncate font-medium text-text-primary">{item.name}</span>
|
||||
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.required ? t('createTool.toolInput.required', { ns: 'tools' }) : ''}</span>
|
||||
</div>
|
||||
<div className="text-text-tertiary">{item.type}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{item.name === '__image' && (
|
||||
<div className={cn(
|
||||
'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2',
|
||||
)}
|
||||
>
|
||||
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
|
||||
{t('createTool.toolInput.methodParameter', { ns: 'tools' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.name !== '__image' && (
|
||||
<MethodSelector value={item.form} onChange={value => handleParameterChange('form', value, index)} />
|
||||
)}
|
||||
</td>
|
||||
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full appearance-none bg-transparent text-[13px] leading-[18px] font-normal text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary"
|
||||
placeholder={t('createTool.toolInput.descriptionPlaceholder', { ns: 'tools' })!}
|
||||
value={item.description}
|
||||
onChange={e => handleParameterChange('description', e.target.value, index)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tool Output */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolOutput.title', { ns: 'tools' })}</div>
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
|
||||
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
|
||||
<thead className="text-text-tertiary uppercase">
|
||||
<tr className="border-b border-divider-regular">
|
||||
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.name', { ns: 'tools' })}</th>
|
||||
<th className="p-2 pl-3 font-medium">{t('createTool.toolOutput.description', { ns: 'tools' })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...reservedOutputParameters, ...outputParameters].map((item, index) => (
|
||||
<tr key={index} className="border-b border-divider-regular last:border-0">
|
||||
<td className="max-w-[156px] p-2 pl-3">
|
||||
<div className="text-[13px] leading-[18px]">
|
||||
<div title={item.name} className="flex items-center">
|
||||
<span className="truncate font-medium text-text-primary">{item.name}</span>
|
||||
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.reserved ? t('createTool.toolOutput.reserved', { ns: 'tools' }) : ''}</span>
|
||||
{
|
||||
!item.reserved && hasReservedWorkflowOutputConflict(reservedOutputParameters, item.name)
|
||||
? (
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[180px]">
|
||||
{t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<RiErrorWarningLine className="h-3 w-3 text-text-warning-secondary" />
|
||||
</Tooltip>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div className="text-text-tertiary">{item.type}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
|
||||
<span className="text-[13px] leading-[18px] font-normal text-text-secondary">{item.description}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
|
||||
<LabelSelector value={labels} onChange={handleLabelSelect} />
|
||||
</div>
|
||||
{/* Privacy Policy */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
|
||||
<Input
|
||||
className="h-10"
|
||||
value={privacyPolicy}
|
||||
onChange={e => setPrivacyPolicy(e.target.value)}
|
||||
placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''}
|
||||
className="h-10 grow"
|
||||
placeholder={t('createTool.toolNamePlaceHolder', { ns: 'tools' })!}
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn((!isAdd && onRemove) ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}>
|
||||
{!isAdd && onRemove && (
|
||||
<Button variant="primary" tone="destructive" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button>
|
||||
)}
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (isAdd)
|
||||
onConfirm()
|
||||
else
|
||||
setShowModal(true)
|
||||
}}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
{/* name for tool call */}
|
||||
<div>
|
||||
<div className="flex items-center py-2 system-sm-medium text-text-primary">
|
||||
{t('createTool.nameForToolCall', { ns: 'tools' })}
|
||||
{' '}
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
<InfoTooltip>
|
||||
{t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })}
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<Input
|
||||
className="h-10"
|
||||
placeholder={t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })!}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
{!isWorkflowToolNameValid(name) && (
|
||||
<div className="text-xs leading-[18px] text-red-500">{t('createTool.nameForToolCallTip', { ns: 'tools' })}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* description */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.description', { ns: 'tools' })}</div>
|
||||
<Textarea
|
||||
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Tool Input */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.title', { ns: 'tools' })}</div>
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
|
||||
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
|
||||
<thead className="text-text-tertiary uppercase">
|
||||
<tr className="border-b border-divider-regular">
|
||||
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.toolInput.name', { ns: 'tools' })}</th>
|
||||
<th className="w-[102px] p-2 pl-3 font-medium">{t('createTool.toolInput.method', { ns: 'tools' })}</th>
|
||||
<th className="p-2 pl-3 font-medium">{t('createTool.toolInput.description', { ns: 'tools' })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parameters.map((item, index) => (
|
||||
<tr key={index} className="border-b border-divider-regular last:border-0">
|
||||
<td className="max-w-[156px] p-2 pl-3">
|
||||
<div className="text-[13px] leading-[18px]">
|
||||
<div title={item.name} className="flex">
|
||||
<span className="truncate font-medium text-text-primary">{item.name}</span>
|
||||
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.required ? t('createTool.toolInput.required', { ns: 'tools' }) : ''}</span>
|
||||
</div>
|
||||
<div className="text-text-tertiary">{item.type}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{item.name === '__image' && (
|
||||
<div className={cn(
|
||||
'flex h-9 min-h-[56px] cursor-default items-center gap-1 bg-transparent px-3 py-2',
|
||||
)}
|
||||
>
|
||||
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
|
||||
{t('createTool.toolInput.methodParameter', { ns: 'tools' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.name !== '__image' && (
|
||||
<MethodSelector value={item.form} onChange={value => handleParameterChange('form', value, index)} />
|
||||
)}
|
||||
</td>
|
||||
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full appearance-none bg-transparent text-[13px] leading-[18px] font-normal text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary"
|
||||
placeholder={t('createTool.toolInput.descriptionPlaceholder', { ns: 'tools' })!}
|
||||
value={item.description}
|
||||
onChange={e => handleParameterChange('description', e.target.value, index)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tool Output */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolOutput.title', { ns: 'tools' })}</div>
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-divider-regular">
|
||||
<table className="w-full text-xs leading-[18px] font-normal text-text-secondary">
|
||||
<thead className="text-text-tertiary uppercase">
|
||||
<tr className="border-b border-divider-regular">
|
||||
<th className="w-[156px] p-2 pl-3 font-medium">{t('createTool.name', { ns: 'tools' })}</th>
|
||||
<th className="p-2 pl-3 font-medium">{t('createTool.toolOutput.description', { ns: 'tools' })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...reservedOutputParameters, ...outputParameters].map((item, index) => (
|
||||
<tr key={index} className="border-b border-divider-regular last:border-0">
|
||||
<td className="max-w-[156px] p-2 pl-3">
|
||||
<div className="text-[13px] leading-[18px]">
|
||||
<div title={item.name} className="flex items-center">
|
||||
<span className="truncate font-medium text-text-primary">{item.name}</span>
|
||||
<span className="shrink-0 pl-1 text-xs leading-[18px] text-[#ec4a0a]">{item.reserved ? t('createTool.toolOutput.reserved', { ns: 'tools' }) : ''}</span>
|
||||
{
|
||||
!item.reserved && hasReservedWorkflowOutputConflict(reservedOutputParameters, item.name)
|
||||
? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span data-testid="reserved-output-warning" className="i-ri-error-warning-line h-3 w-3 text-text-warning-secondary" />
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className="w-[180px]">
|
||||
{t('createTool.toolOutput.reservedParameterDuplicateTip', { ns: 'tools' })}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div className="text-text-tertiary">{item.type}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-[236px] p-2 pl-3 text-text-tertiary">
|
||||
<span className="text-[13px] leading-[18px] font-normal text-text-secondary">{item.description}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.toolInput.label', { ns: 'tools' })}</div>
|
||||
<LabelSelector value={labels} onChange={handleLabelSelect} />
|
||||
</div>
|
||||
{/* Privacy Policy */}
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.privacyPolicy', { ns: 'tools' })}</div>
|
||||
<Input
|
||||
className="h-10"
|
||||
value={privacyPolicy}
|
||||
onChange={e => setPrivacyPolicy(e.target.value)}
|
||||
placeholder={t('createTool.privacyPolicyPlaceholder', { ns: 'tools' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
isShowMask={true}
|
||||
clickOutsideNotOpen={true}
|
||||
/>
|
||||
<div className={cn((!isAdd && onRemove) ? 'justify-between' : 'justify-end', 'mt-2 flex shrink-0 rounded-b-[10px] border-t border-divider-regular bg-background-section-burn px-6 py-4')}>
|
||||
{!isAdd && onRemove && (
|
||||
<Button variant="primary" tone="destructive" onClick={onRemove}>{t('operation.delete', { ns: 'common' })}</Button>
|
||||
)}
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (isAdd)
|
||||
onConfirm()
|
||||
else
|
||||
setShowModal(true)
|
||||
}}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WorkflowToolDrawer>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker
|
||||
<WorkflowToolEmojiPicker
|
||||
onSelect={(icon, icon_background) => {
|
||||
setEmoji({ content: icon, background: icon_background })
|
||||
setShowEmojiPicker(false)
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Check } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type MethodSelectorProps = {
|
||||
value?: string
|
||||
@ -20,37 +20,43 @@ const MethodSelector: FC<MethodSelectorProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const handleSelect = (value: string) => {
|
||||
onChange(value)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<div className="relative">
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="block"
|
||||
>
|
||||
<div className={cn(
|
||||
'flex h-9 min-h-[56px] cursor-pointer items-center gap-1 bg-transparent px-3 py-2 hover:bg-background-section-burn',
|
||||
open && 'bg-background-section-burn! hover:bg-background-section-burn',
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn(
|
||||
'flex h-9 min-h-[56px] cursor-pointer items-center gap-1 bg-transparent px-3 py-2 hover:bg-background-section-burn',
|
||||
open && 'bg-background-section-burn! hover:bg-background-section-burn',
|
||||
)}
|
||||
>
|
||||
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
|
||||
{value === 'llm' ? t('createTool.toolInput.methodParameter', { ns: 'tools' }) : t('createTool.toolInput.methodSetting', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className={cn('grow truncate text-[13px] leading-[18px] text-text-secondary')}>
|
||||
{value === 'llm' ? t('createTool.toolInput.methodParameter', { ns: 'tools' }) : t('createTool.toolInput.methodSetting', { ns: 'tools' })}
|
||||
</div>
|
||||
<div className="ml-1 shrink-0 text-text-secondary opacity-60">
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1040">
|
||||
<div className="relative w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
positionerProps={{ style: { zIndex: 1040 } }}
|
||||
>
|
||||
<div className="relative w-[320px]">
|
||||
<div className="p-1">
|
||||
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => onChange('llm')}>
|
||||
<div className="item-center flex gap-1">
|
||||
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => handleSelect('llm')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-4 w-4 shrink-0">
|
||||
{value === 'llm' && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
|
||||
</div>
|
||||
@ -58,8 +64,8 @@ const MethodSelector: FC<MethodSelectorProps> = ({
|
||||
</div>
|
||||
<div className="pl-5 text-[13px] leading-[18px] text-text-tertiary">{t('createTool.toolInput.methodParameterTip', { ns: 'tools' })}</div>
|
||||
</div>
|
||||
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => onChange('form')}>
|
||||
<div className="item-center flex gap-1">
|
||||
<div className="cursor-pointer rounded-lg py-2.5 pr-2 pl-3 hover:bg-components-panel-on-panel-item-bg-hover" onClick={() => handleSelect('form')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-4 w-4 shrink-0">
|
||||
{value === 'form' && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
|
||||
</div>
|
||||
@ -69,9 +75,9 @@ const MethodSelector: FC<MethodSelectorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
"analysis.ms": "мс",
|
||||
"analysis.title": "Анализ",
|
||||
"analysis.tokenPS": "Токен/с",
|
||||
"analysis.tokenUsage.consumed": "Потрачено",
|
||||
"analysis.tokenUsage.consumed": "Потреблено",
|
||||
"analysis.tokenUsage.explanation": "Отражает ежедневное использование токенов языковой модели для приложения, полезно для целей контроля затрат.",
|
||||
"analysis.tokenUsage.title": "Использование токенов",
|
||||
"analysis.totalConversations.explanation": "Ежедневное количество чатов с LLM; проектирование/отладка не учитываются.",
|
||||
@ -62,7 +62,7 @@
|
||||
"overview.appInfo.enableTooltip.description": "Чтобы включить эту функцию, добавьте на холст узел ввода пользователя. (Может уже существовать в черновике, вступает в силу после публикации)",
|
||||
"overview.appInfo.enableTooltip.learnMore": "Узнать больше",
|
||||
"overview.appInfo.explanation": "Готовое к использованию веб-приложение ИИ",
|
||||
"overview.appInfo.launch": "Баркас",
|
||||
"overview.appInfo.launch": "Запустить",
|
||||
"overview.appInfo.preUseReminder": "Пожалуйста, включите веб-приложение перед продолжением.",
|
||||
"overview.appInfo.preview": "Предварительный просмотр",
|
||||
"overview.appInfo.qrcode.download": "Скачать QR-код",
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
{
|
||||
"embedding.automatic": "Автоматически",
|
||||
"embedding.childMaxTokens": "Ребёнок",
|
||||
"embedding.childMaxTokens": "Наследник",
|
||||
"embedding.completed": "Встраивание завершено",
|
||||
"embedding.custom": "Пользовательский",
|
||||
"embedding.docName": "Предварительная обработка документа",
|
||||
"embedding.docName": "Имя документа",
|
||||
"embedding.economy": "Экономичный режим",
|
||||
"embedding.error": "Ошибка расчета эмбеддингов",
|
||||
"embedding.estimate": "Оценочное потребление",
|
||||
"embedding.hierarchical": "Родитель-дочерний",
|
||||
"embedding.estimate": "Оценка",
|
||||
"embedding.hierarchical": "Иерархический",
|
||||
"embedding.highQuality": "Режим высокого качества",
|
||||
"embedding.mode": "Правило сегментации",
|
||||
"embedding.parentMaxTokens": "Родитель",
|
||||
@ -16,7 +16,7 @@
|
||||
"embedding.previewTip": "Предварительный просмотр абзацев будет доступен после завершения расчета эмбеддингов",
|
||||
"embedding.processing": "Расчет эмбеддингов...",
|
||||
"embedding.resume": "Возобновить обработку",
|
||||
"embedding.segmentLength": "Длина фрагментов",
|
||||
"embedding.segmentLength": "Длина сегментов",
|
||||
"embedding.segments": "Абзацы",
|
||||
"embedding.stop": "Остановить обработку",
|
||||
"embedding.textCleaning": "Предварительная очистка текста",
|
||||
@ -279,25 +279,25 @@
|
||||
"metadata.type.webPage": "Веб-страница",
|
||||
"metadata.type.wikipediaEntry": "Статья в Википедии",
|
||||
"segment.addAnother": "Добавить еще один",
|
||||
"segment.addChildChunk": "Добавить дочерний чанк",
|
||||
"segment.addChunk": "Добавить чанк",
|
||||
"segment.addChildChunk": "Добавить дочерний фрагмент",
|
||||
"segment.addChunk": "Добавить фрагмент",
|
||||
"segment.addKeyWord": "Добавить ключевое слово",
|
||||
"segment.allFilesUploaded": "Все файлы должны быть загружены перед сохранением",
|
||||
"segment.answerEmpty": "Ответ не может быть пустым",
|
||||
"segment.answerPlaceholder": "добавьте ответ здесь",
|
||||
"segment.characters_one": "характер",
|
||||
"segment.characters_other": "письмена",
|
||||
"segment.childChunk": "Чайлд-Чанк",
|
||||
"segment.childChunkAdded": "Добавлен 1 дочерний чанк",
|
||||
"segment.childChunks_one": "ДОЧЕРНИЙ ЧАНК",
|
||||
"segment.childChunks_other": "ДЕТСКИЕ КУСОЧКИ",
|
||||
"segment.chunk": "Ломоть",
|
||||
"segment.chunkAdded": "Добавлен 1 блок",
|
||||
"segment.chunkDetail": "Деталь Чанка",
|
||||
"segment.chunks_one": "ЛОМОТЬ",
|
||||
"segment.chunks_other": "КУСКИ",
|
||||
"segment.characters_one": "символ",
|
||||
"segment.characters_other": "символы",
|
||||
"segment.childChunk": "Дочерний фрагмент",
|
||||
"segment.childChunkAdded": "Добавлен 1 дочерний фрагмент",
|
||||
"segment.childChunks_one": "ДОЧЕРНИЙ ФРАГМЕНТ",
|
||||
"segment.childChunks_other": "ДОЧЕРНИЕ ФРАГМЕНТЫ",
|
||||
"segment.chunk": "Фрагмент",
|
||||
"segment.chunkAdded": "Добавлен 1 фрагмент",
|
||||
"segment.chunkDetail": "Детали фрагмента",
|
||||
"segment.chunks_one": "ФРАГМЕНТ",
|
||||
"segment.chunks_other": "ФРАГМЕНТЫ",
|
||||
"segment.clearFilter": "Очистить фильтр",
|
||||
"segment.collapseChunks": "Сворачивание кусков",
|
||||
"segment.collapseChunks": "Свернуть фрагменты",
|
||||
"segment.contentEmpty": "Содержимое не может быть пустым",
|
||||
"segment.contentPlaceholder": "добавьте содержимое здесь",
|
||||
"segment.dateTimeFormat": "MM/DD/YYYY HH:mm",
|
||||
@ -307,15 +307,15 @@
|
||||
"segment.editParentChunk": "Редактирование родительского блока",
|
||||
"segment.edited": "ОТРЕДАКТИРОВАНЫ",
|
||||
"segment.editedAt": "Отредактировано в",
|
||||
"segment.empty": "Чанк не найден",
|
||||
"segment.expandChunks": "Развернуть чанки",
|
||||
"segment.empty": "Фрагмент не найден",
|
||||
"segment.expandChunks": "Развернуть фрагменты",
|
||||
"segment.hitCount": "Количество обращений",
|
||||
"segment.keywordDuplicate": "Ключевое слово уже существует",
|
||||
"segment.keywordEmpty": "Ключевое слово не может быть пустым",
|
||||
"segment.keywordError": "Максимальная длина ключевого слова - 20",
|
||||
"segment.keywords": "Ключевые слова",
|
||||
"segment.newChildChunk": "Новый дочерний чанк",
|
||||
"segment.newChunk": "Новый чанк",
|
||||
"segment.newChildChunk": "Новый дочерний фрагмент",
|
||||
"segment.newChunk": "Новый фрагмент",
|
||||
"segment.newQaSegment": "Новый сегмент вопрос-ответ",
|
||||
"segment.newTextSegment": "Новый текстовый сегмент",
|
||||
"segment.paragraphs": "Абзацы",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"blocks.agent": "Агент",
|
||||
"blocks.answer": "Ответ",
|
||||
"blocks.assigner": "Назначение переменной",
|
||||
"blocks.assigner": "Назначение переменных",
|
||||
"blocks.code": "Код",
|
||||
"blocks.datasource": "Источник данных",
|
||||
"blocks.datasource-empty": "Пустой источник данных",
|
||||
@ -17,10 +17,10 @@
|
||||
"blocks.list-operator": "Оператор списка",
|
||||
"blocks.llm": "LLM",
|
||||
"blocks.loop": "Цикл",
|
||||
"blocks.loop-end": "Выйти из цикла",
|
||||
"blocks.loop-end": "Конец цикла",
|
||||
"blocks.loop-start": "Начало цикла",
|
||||
"blocks.originalStartNode": "исходный начальный узел",
|
||||
"blocks.parameter-extractor": "Извлечение параметров",
|
||||
"blocks.parameter-extractor": "Экстрактор параметров",
|
||||
"blocks.question-classifier": "Классификатор вопросов",
|
||||
"blocks.start": "Начало",
|
||||
"blocks.template-transform": "Шаблон",
|
||||
@ -29,7 +29,7 @@
|
||||
"blocks.trigger-schedule": "Триггер расписания",
|
||||
"blocks.trigger-webhook": "Вебхук-триггер",
|
||||
"blocks.variable-aggregator": "Агрегатор переменных",
|
||||
"blocks.variable-assigner": "Агрегатор переменных",
|
||||
"blocks.variable-assigner": "Назначение переменных",
|
||||
"blocksAbout.agent": "Вызов больших языковых моделей для ответа на вопросы или обработки естественного языка",
|
||||
"blocksAbout.answer": "Определите содержимое ответа в чате",
|
||||
"blocksAbout.assigner": "Узел назначения переменной используется для назначения значений записываемым переменным (например, переменным разговора).",
|
||||
@ -485,7 +485,7 @@
|
||||
"nodes.common.pluginNotInstalled": "Плагин не установлен",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} плагинов не установлено",
|
||||
"nodes.common.retry.maxRetries": "максимальное количество повторных попыток",
|
||||
"nodes.common.retry.ms": "госпожа",
|
||||
"nodes.common.retry.ms": "мс",
|
||||
"nodes.common.retry.retries": "{{num}} Повторных попыток",
|
||||
"nodes.common.retry.retry": "Снова пробовать",
|
||||
"nodes.common.retry.retryFailed": "Повторная попытка не удалась",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.13.3",
|
||||
"version": "1.14.0",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#i18n": {
|
||||
|
||||
Reference in New Issue
Block a user