mirror of
https://github.com/langgenius/dify.git
synced 2026-02-10 23:50:13 -05:00
Compare commits
21 Commits
feat/notif
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9db50f781 | ||
|
|
0310f631ee | ||
|
|
abc5a61e98 | ||
|
|
5f1698add6 | ||
|
|
36e50f277f | ||
|
|
704ee40caa | ||
|
|
3119c99979 | ||
|
|
16b8733886 | ||
|
|
83f64104fd | ||
|
|
5077879886 | ||
|
|
697b57631a | ||
|
|
6015f23e79 | ||
|
|
f355c8d595 | ||
|
|
0142001fc2 | ||
|
|
4058e9ae23 | ||
|
|
95310561ec | ||
|
|
de33561a52 | ||
|
|
6d9665578b | ||
|
|
18f14c04dc | ||
|
|
14251b249d | ||
|
|
1819bd72ef |
2
api/.vscode/launch.json.example
vendored
2
api/.vscode/launch.json.example
vendored
@@ -54,7 +54,7 @@
|
||||
"--loglevel",
|
||||
"DEBUG",
|
||||
"-Q",
|
||||
"dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
"dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,workflow_based_app_execution,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -259,11 +259,20 @@ class CeleryConfig(DatabaseConfig):
|
||||
description="Password of the Redis Sentinel master.",
|
||||
default=None,
|
||||
)
|
||||
|
||||
CELERY_SENTINEL_SOCKET_TIMEOUT: PositiveFloat | None = Field(
|
||||
description="Timeout for Redis Sentinel socket operations in seconds.",
|
||||
default=0.1,
|
||||
)
|
||||
|
||||
CELERY_TASK_ANNOTATIONS: dict[str, Any] | None = Field(
|
||||
description=(
|
||||
"Annotations for Celery tasks as a JSON mapping of task name -> options "
|
||||
"(for example, rate limits or other task-specific settings)."
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
def CELERY_RESULT_BACKEND(self) -> str | None:
|
||||
if self.CELERY_BACKEND in ("database", "rabbitmq"):
|
||||
|
||||
@@ -21,6 +21,7 @@ language_timezone_mapping = {
|
||||
"th-TH": "Asia/Bangkok",
|
||||
"id-ID": "Asia/Jakarta",
|
||||
"ar-TN": "Africa/Tunis",
|
||||
"nl-NL": "Europe/Amsterdam",
|
||||
}
|
||||
|
||||
languages = list(language_timezone_mapping.keys())
|
||||
|
||||
@@ -39,7 +39,6 @@ from . import (
|
||||
feature,
|
||||
human_input_form,
|
||||
init_validate,
|
||||
notification,
|
||||
ping,
|
||||
setup,
|
||||
spec,
|
||||
@@ -185,7 +184,6 @@ __all__ = [
|
||||
"model_config",
|
||||
"model_providers",
|
||||
"models",
|
||||
"notification",
|
||||
"oauth",
|
||||
"oauth_server",
|
||||
"ops_trace",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import csv
|
||||
import io
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import ParamSpec, TypeVar
|
||||
@@ -8,7 +6,7 @@ from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
|
||||
from werkzeug.exceptions import NotFound, Unauthorized
|
||||
|
||||
from configs import dify_config
|
||||
from constants.languages import supported_language
|
||||
@@ -18,7 +16,6 @@ from core.db.session_factory import session_factory
|
||||
from extensions.ext_database import db
|
||||
from libs.token import extract_access_token
|
||||
from models.model import App, ExporleBanner, InstalledApp, RecommendedApp, TrialApp
|
||||
from services.billing_service import BillingService
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
@@ -280,115 +277,3 @@ class DeleteExploreBannerApi(Resource):
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}, 204
|
||||
|
||||
|
||||
class SaveNotificationContentPayload(BaseModel):
|
||||
content: str = Field(...)
|
||||
|
||||
|
||||
class SaveNotificationUserPayload(BaseModel):
|
||||
user_email: list[str] = Field(...)
|
||||
|
||||
|
||||
console_ns.schema_model(
|
||||
SaveNotificationContentPayload.__name__,
|
||||
SaveNotificationContentPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||
)
|
||||
|
||||
console_ns.schema_model(
|
||||
SaveNotificationUserPayload.__name__,
|
||||
SaveNotificationUserPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/admin/save_notification_content")
|
||||
class SaveNotificationContentApi(Resource):
|
||||
@console_ns.doc("save_notification_content")
|
||||
@console_ns.doc(description="Save a notification content")
|
||||
@console_ns.expect(console_ns.models[SaveNotificationContentPayload.__name__])
|
||||
@console_ns.response(200, "Notification content saved successfully")
|
||||
@only_edition_cloud
|
||||
@admin_required
|
||||
def post(self):
|
||||
payload = SaveNotificationContentPayload.model_validate(console_ns.payload)
|
||||
BillingService.save_notification_content(payload.content)
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
@console_ns.route("/admin/save_notification_user")
|
||||
class SaveNotificationUserApi(Resource):
|
||||
@console_ns.doc("save_notification_user")
|
||||
@console_ns.doc(
|
||||
description="Save notification users via JSON body or file upload. "
|
||||
'JSON: {"user_email": ["a@example.com", ...]}. '
|
||||
"File: multipart/form-data with a 'file' field (CSV or TXT, one email per line)."
|
||||
)
|
||||
@console_ns.response(200, "Notification users saved successfully")
|
||||
@only_edition_cloud
|
||||
@admin_required
|
||||
def post(self):
|
||||
# Determine input mode: file upload or JSON body
|
||||
if "file" in request.files:
|
||||
emails = self._parse_emails_from_file()
|
||||
else:
|
||||
payload = SaveNotificationUserPayload.model_validate(console_ns.payload)
|
||||
emails = payload.user_email
|
||||
|
||||
if not emails:
|
||||
raise BadRequest("No valid email addresses provided.")
|
||||
|
||||
# Use batch API for bulk insert (chunks of 1000 per request to billing service)
|
||||
result = BillingService.save_notification_users_batch(emails)
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
"total": len(emails),
|
||||
"succeeded": result["succeeded"],
|
||||
"failed_chunks": result["failed_chunks"],
|
||||
}, 200
|
||||
|
||||
@staticmethod
|
||||
def _parse_emails_from_file() -> list[str]:
|
||||
"""Parse email addresses from an uploaded CSV or TXT file."""
|
||||
file = request.files["file"]
|
||||
|
||||
if not file.filename:
|
||||
raise BadRequest("Uploaded file has no filename.")
|
||||
|
||||
filename_lower = file.filename.lower()
|
||||
if not filename_lower.endswith((".csv", ".txt")):
|
||||
raise BadRequest("Invalid file type. Only CSV (.csv) and TXT (.txt) files are allowed.")
|
||||
|
||||
# Read file content
|
||||
try:
|
||||
content = file.read().decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
file.seek(0)
|
||||
content = file.read().decode("gbk")
|
||||
except UnicodeDecodeError:
|
||||
raise BadRequest("Unable to decode the file. Please use UTF-8 or GBK encoding.")
|
||||
|
||||
emails: list[str] = []
|
||||
if filename_lower.endswith(".csv"):
|
||||
reader = csv.reader(io.StringIO(content))
|
||||
for row in reader:
|
||||
for cell in row:
|
||||
cell = cell.strip()
|
||||
emails.append(cell)
|
||||
else:
|
||||
# TXT file: one email per line
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
emails.append(line)
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen: set[str] = set()
|
||||
unique_emails: list[str] = []
|
||||
for email in emails:
|
||||
email_lower = email.lower()
|
||||
if email_lower not in seen:
|
||||
seen.add(email_lower)
|
||||
unique_emails.append(email)
|
||||
|
||||
return unique_emails
|
||||
|
||||
@@ -599,7 +599,12 @@ def _get_conversation(app_model, conversation_id):
|
||||
db.session.execute(
|
||||
sa.update(Conversation)
|
||||
.where(Conversation.id == conversation_id, Conversation.read_at.is_(None))
|
||||
.values(read_at=naive_utc_now(), read_account_id=current_user.id)
|
||||
# Keep updated_at unchanged when only marking a conversation as read.
|
||||
.values(
|
||||
read_at=naive_utc_now(),
|
||||
read_account_id=current_user.id,
|
||||
updated_at=Conversation.updated_at,
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
db.session.refresh(conversation)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import account_initialization_required, only_edition_cloud, setup_required
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from services.billing_service import BillingService
|
||||
|
||||
|
||||
@console_ns.route("/notification")
|
||||
class NotificationApi(Resource):
|
||||
@console_ns.doc("get_notification")
|
||||
@console_ns.doc(description="Get notification for the current user")
|
||||
@console_ns.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
401: "Unauthorized",
|
||||
}
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
def get(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
notification = BillingService.read_notification(current_user.email)
|
||||
return notification
|
||||
@@ -42,7 +42,15 @@ class SetupResponse(BaseModel):
|
||||
tags=["console"],
|
||||
)
|
||||
def get_setup_status_api() -> SetupStatusResponse:
|
||||
"""Get system setup status."""
|
||||
"""Get system setup status.
|
||||
|
||||
NOTE: This endpoint is unauthenticated by design.
|
||||
|
||||
During first-time bootstrap there is no admin account yet, so frontend initialization must be
|
||||
able to query setup progress before any login flow exists.
|
||||
|
||||
Only bootstrap-safe status information should be returned by this endpoint.
|
||||
"""
|
||||
if dify_config.EDITION == "SELF_HOSTED":
|
||||
setup_status = get_setup_status()
|
||||
if setup_status and not isinstance(setup_status, bool):
|
||||
@@ -61,7 +69,12 @@ def get_setup_status_api() -> SetupStatusResponse:
|
||||
)
|
||||
@only_edition_self_hosted
|
||||
def setup_system(payload: SetupRequestPayload) -> SetupResponse:
|
||||
"""Initialize system setup with admin account."""
|
||||
"""Initialize system setup with admin account.
|
||||
|
||||
NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
|
||||
Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
|
||||
and init-password validation rather than user session authentication.
|
||||
"""
|
||||
if get_setup_status():
|
||||
raise AlreadySetupError()
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ def stream_topic_events(
|
||||
on_subscribe()
|
||||
while True:
|
||||
try:
|
||||
msg = sub.receive(timeout=0.1)
|
||||
msg = sub.receive(timeout=1)
|
||||
except SubscriptionClosedError:
|
||||
return
|
||||
if msg is None:
|
||||
|
||||
@@ -45,6 +45,8 @@ from core.app.entities.task_entities import (
|
||||
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
|
||||
from core.file import helpers as file_helpers
|
||||
from core.file.enums import FileTransferMethod
|
||||
from core.model_manager import ModelInstance
|
||||
from core.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
|
||||
from core.model_runtime.entities.message_entities import (
|
||||
@@ -56,10 +58,11 @@ from core.ops.entities.trace_entity import TraceTaskName
|
||||
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
|
||||
from core.prompt.utils.prompt_message_util import PromptMessageUtil
|
||||
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
|
||||
from core.tools.signature import sign_tool_file
|
||||
from events.message_event import message_was_created
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.model import AppMode, Conversation, Message, MessageAgentThought
|
||||
from models.model import AppMode, Conversation, Message, MessageAgentThought, MessageFile, UploadFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -463,6 +466,85 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline):
|
||||
metadata=metadata_dict,
|
||||
)
|
||||
|
||||
def _record_files(self):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
message_files = session.scalars(select(MessageFile).where(MessageFile.message_id == self._message_id)).all()
|
||||
if not message_files:
|
||||
return None
|
||||
|
||||
files_list = []
|
||||
upload_file_ids = [
|
||||
mf.upload_file_id
|
||||
for mf in message_files
|
||||
if mf.transfer_method == FileTransferMethod.LOCAL_FILE and mf.upload_file_id
|
||||
]
|
||||
upload_files_map = {}
|
||||
if upload_file_ids:
|
||||
upload_files = session.scalars(select(UploadFile).where(UploadFile.id.in_(upload_file_ids))).all()
|
||||
upload_files_map = {uf.id: uf for uf in upload_files}
|
||||
|
||||
for message_file in message_files:
|
||||
upload_file = None
|
||||
if message_file.transfer_method == FileTransferMethod.LOCAL_FILE and message_file.upload_file_id:
|
||||
upload_file = upload_files_map.get(message_file.upload_file_id)
|
||||
|
||||
url = None
|
||||
filename = "file"
|
||||
mime_type = "application/octet-stream"
|
||||
size = 0
|
||||
extension = ""
|
||||
|
||||
if message_file.transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
url = message_file.url
|
||||
if message_file.url:
|
||||
filename = message_file.url.split("/")[-1].split("?")[0] # Remove query params
|
||||
elif message_file.transfer_method == FileTransferMethod.LOCAL_FILE:
|
||||
if upload_file:
|
||||
url = file_helpers.get_signed_file_url(upload_file_id=str(upload_file.id))
|
||||
filename = upload_file.name
|
||||
mime_type = upload_file.mime_type or "application/octet-stream"
|
||||
size = upload_file.size or 0
|
||||
extension = f".{upload_file.extension}" if upload_file.extension else ""
|
||||
elif message_file.upload_file_id:
|
||||
# Fallback: generate URL even if upload_file not found
|
||||
url = file_helpers.get_signed_file_url(upload_file_id=str(message_file.upload_file_id))
|
||||
elif message_file.transfer_method == FileTransferMethod.TOOL_FILE and message_file.url:
|
||||
# For tool files, use URL directly if it's HTTP, otherwise sign it
|
||||
if message_file.url.startswith("http"):
|
||||
url = message_file.url
|
||||
filename = message_file.url.split("/")[-1].split("?")[0]
|
||||
else:
|
||||
# Extract tool file id and extension from URL
|
||||
url_parts = message_file.url.split("/")
|
||||
if url_parts:
|
||||
file_part = url_parts[-1].split("?")[0] # Remove query params first
|
||||
# Use rsplit to correctly handle filenames with multiple dots
|
||||
if "." in file_part:
|
||||
tool_file_id, ext = file_part.rsplit(".", 1)
|
||||
extension = f".{ext}"
|
||||
else:
|
||||
tool_file_id = file_part
|
||||
extension = ".bin"
|
||||
url = sign_tool_file(tool_file_id=tool_file_id, extension=extension)
|
||||
filename = file_part
|
||||
|
||||
transfer_method_value = message_file.transfer_method
|
||||
remote_url = message_file.url if message_file.transfer_method == FileTransferMethod.REMOTE_URL else ""
|
||||
file_dict = {
|
||||
"related_id": message_file.id,
|
||||
"extension": extension,
|
||||
"filename": filename,
|
||||
"size": size,
|
||||
"mime_type": mime_type,
|
||||
"transfer_method": transfer_method_value,
|
||||
"type": message_file.type,
|
||||
"url": url or "",
|
||||
"upload_file_id": message_file.upload_file_id or message_file.id,
|
||||
"remote_url": remote_url,
|
||||
}
|
||||
files_list.append(file_dict)
|
||||
return files_list or None
|
||||
|
||||
def _agent_message_to_stream_response(self, answer: str, message_id: str) -> AgentMessageStreamResponse:
|
||||
"""
|
||||
Agent message to stream response.
|
||||
|
||||
@@ -64,7 +64,13 @@ class MessageCycleManager:
|
||||
|
||||
# Use SQLAlchemy 2.x style session.scalar(select(...))
|
||||
with session_factory.create_session() as session:
|
||||
message_file = session.scalar(select(MessageFile).where(MessageFile.message_id == message_id))
|
||||
message_file = session.scalar(
|
||||
select(MessageFile)
|
||||
.where(
|
||||
MessageFile.message_id == message_id,
|
||||
)
|
||||
.where(MessageFile.belongs_to == "assistant")
|
||||
)
|
||||
|
||||
if message_file:
|
||||
self._message_has_file.add(message_id)
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections.abc import Generator
|
||||
from copy import deepcopy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from models.model import File
|
||||
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
@@ -171,7 +171,7 @@ class Tool(ABC):
|
||||
def create_file_message(self, file: File) -> ToolInvokeMessage:
|
||||
return ToolInvokeMessage(
|
||||
type=ToolInvokeMessage.MessageType.FILE,
|
||||
message=ToolInvokeMessage.FileMessage(),
|
||||
message=ToolInvokeMessage.FileMessage(file_marker="file_marker"),
|
||||
meta={"file": file},
|
||||
)
|
||||
|
||||
|
||||
@@ -80,8 +80,14 @@ def init_app(app: DifyApp) -> Celery:
|
||||
worker_hijack_root_logger=False,
|
||||
timezone=pytz.timezone(dify_config.LOG_TZ or "UTC"),
|
||||
task_ignore_result=True,
|
||||
task_annotations=dify_config.CELERY_TASK_ANNOTATIONS,
|
||||
)
|
||||
|
||||
if dify_config.CELERY_BACKEND == "redis":
|
||||
celery_app.conf.update(
|
||||
result_backend_transport_options=broker_transport_options,
|
||||
)
|
||||
|
||||
# Apply SSL configuration if enabled
|
||||
ssl_options = _get_celery_ssl_options()
|
||||
if ssl_options:
|
||||
|
||||
@@ -119,7 +119,7 @@ class RedisClientWrapper:
|
||||
|
||||
|
||||
redis_client: RedisClientWrapper = RedisClientWrapper()
|
||||
pubsub_redis_client: RedisClientWrapper = RedisClientWrapper()
|
||||
_pubsub_redis_client: redis.Redis | RedisCluster | None = None
|
||||
|
||||
|
||||
def _get_ssl_configuration() -> tuple[type[Union[Connection, SSLConnection]], dict[str, Any]]:
|
||||
@@ -232,7 +232,7 @@ def _create_standalone_client(redis_params: dict[str, Any]) -> Union[redis.Redis
|
||||
return client
|
||||
|
||||
|
||||
def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> Union[redis.Redis, RedisCluster]:
|
||||
def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> redis.Redis | RedisCluster:
|
||||
if use_clusters:
|
||||
return RedisCluster.from_url(pubsub_url)
|
||||
return redis.Redis.from_url(pubsub_url)
|
||||
@@ -256,23 +256,19 @@ def init_app(app: DifyApp):
|
||||
redis_client.initialize(client)
|
||||
app.extensions["redis"] = redis_client
|
||||
|
||||
pubsub_client = client
|
||||
global _pubsub_redis_client
|
||||
_pubsub_redis_client = client
|
||||
if dify_config.normalized_pubsub_redis_url:
|
||||
pubsub_client = _create_pubsub_client(
|
||||
_pubsub_redis_client = _create_pubsub_client(
|
||||
dify_config.normalized_pubsub_redis_url, dify_config.PUBSUB_REDIS_USE_CLUSTERS
|
||||
)
|
||||
pubsub_redis_client.initialize(pubsub_client)
|
||||
|
||||
|
||||
def get_pubsub_redis_client() -> RedisClientWrapper:
|
||||
return pubsub_redis_client
|
||||
|
||||
|
||||
def get_pubsub_broadcast_channel() -> BroadcastChannelProtocol:
|
||||
redis_conn = get_pubsub_redis_client()
|
||||
assert _pubsub_redis_client is not None, "PubSub redis Client should be initialized here."
|
||||
if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "sharded":
|
||||
return ShardedRedisBroadcastChannel(redis_conn) # pyright: ignore[reportArgumentType]
|
||||
return RedisBroadcastChannel(redis_conn) # pyright: ignore[reportArgumentType]
|
||||
return ShardedRedisBroadcastChannel(_pubsub_redis_client)
|
||||
return RedisBroadcastChannel(_pubsub_redis_client)
|
||||
|
||||
|
||||
P = ParamSpec("P")
|
||||
|
||||
@@ -152,7 +152,7 @@ class RedisSubscriptionBase(Subscription):
|
||||
"""Iterator for consuming messages from the subscription."""
|
||||
while not self._closed.is_set():
|
||||
try:
|
||||
item = self._queue.get(timeout=0.1)
|
||||
item = self._queue.get(timeout=1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from libs.broadcast_channel.channel import Producer, Subscriber, Subscription
|
||||
from redis import Redis
|
||||
from redis import Redis, RedisCluster
|
||||
|
||||
from ._subscription import RedisSubscriptionBase
|
||||
|
||||
@@ -18,7 +18,7 @@ class BroadcastChannel:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: Redis,
|
||||
redis_client: Redis | RedisCluster,
|
||||
):
|
||||
self._client = redis_client
|
||||
|
||||
@@ -27,7 +27,7 @@ class BroadcastChannel:
|
||||
|
||||
|
||||
class Topic:
|
||||
def __init__(self, redis_client: Redis, topic: str):
|
||||
def __init__(self, redis_client: Redis | RedisCluster, topic: str):
|
||||
self._client = redis_client
|
||||
self._topic = topic
|
||||
|
||||
|
||||
@@ -70,8 +70,9 @@ class _RedisShardedSubscription(RedisSubscriptionBase):
|
||||
# Since we have already filtered at the caller's site, we can safely set
|
||||
# `ignore_subscribe_messages=False`.
|
||||
if isinstance(self._client, RedisCluster):
|
||||
# NOTE(QuantumGhost): due to an issue in upstream code, calling `get_sharded_message`
|
||||
# would use busy-looping to wait for incoming message, consuming excessive CPU quota.
|
||||
# NOTE(QuantumGhost): due to an issue in upstream code, calling `get_sharded_message` without
|
||||
# specifying the `target_node` argument would use busy-looping to wait
|
||||
# for incoming message, consuming excessive CPU quota.
|
||||
#
|
||||
# Here we specify the `target_node` to mitigate this problem.
|
||||
node = self._client.get_node_from_key(self._topic)
|
||||
@@ -80,8 +81,10 @@ class _RedisShardedSubscription(RedisSubscriptionBase):
|
||||
timeout=1,
|
||||
target_node=node,
|
||||
)
|
||||
else:
|
||||
elif isinstance(self._client, Redis):
|
||||
return self._pubsub.get_sharded_message(ignore_subscribe_messages=False, timeout=1) # type: ignore[attr-defined]
|
||||
else:
|
||||
raise AssertionError("client should be either Redis or RedisCluster.")
|
||||
|
||||
def _get_message_type(self) -> str:
|
||||
return "smessage"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""add unique constraint to tenant_default_models
|
||||
|
||||
Revision ID: fix_tenant_default_model_unique
|
||||
Revises: 9d77545f524e
|
||||
Create Date: 2026-01-19 15:07:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _is_pg(conn):
|
||||
return conn.dialect.name == "postgresql"
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f55813ffe2c8'
|
||||
down_revision = 'c3df22613c99'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# First, remove duplicate records keeping only the most recent one per (tenant_id, model_type)
|
||||
# This is necessary before adding the unique constraint
|
||||
conn = op.get_bind()
|
||||
|
||||
# Delete duplicates: keep the record with the latest updated_at for each (tenant_id, model_type)
|
||||
# If updated_at is the same, keep the one with the largest id as tiebreaker
|
||||
if _is_pg(conn):
|
||||
# PostgreSQL: Use DISTINCT ON for efficient deduplication
|
||||
conn.execute(sa.text("""
|
||||
DELETE FROM tenant_default_models
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT ON (tenant_id, model_type) id
|
||||
FROM tenant_default_models
|
||||
ORDER BY tenant_id, model_type, updated_at DESC, id DESC
|
||||
)
|
||||
"""))
|
||||
else:
|
||||
# MySQL: Use self-join to find and delete duplicates
|
||||
# Keep the record with latest updated_at (or largest id if updated_at is equal)
|
||||
conn.execute(sa.text("""
|
||||
DELETE t1 FROM tenant_default_models t1
|
||||
INNER JOIN tenant_default_models t2
|
||||
ON t1.tenant_id = t2.tenant_id
|
||||
AND t1.model_type = t2.model_type
|
||||
AND (t1.updated_at < t2.updated_at
|
||||
OR (t1.updated_at = t2.updated_at AND t1.id < t2.id))
|
||||
"""))
|
||||
|
||||
# Now add the unique constraint
|
||||
with op.batch_alter_table('tenant_default_models', schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint('unique_tenant_default_model_type', ['tenant_id', 'model_type'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('tenant_default_models', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('unique_tenant_default_model_type', type_='unique')
|
||||
@@ -227,7 +227,7 @@ class App(Base):
|
||||
with Session(db.engine) as session:
|
||||
if api_provider_ids:
|
||||
existing_api_providers = [
|
||||
api_provider.id
|
||||
str(api_provider.id)
|
||||
for api_provider in session.execute(
|
||||
text("SELECT id FROM tool_api_providers WHERE id IN :provider_ids"),
|
||||
{"provider_ids": tuple(api_provider_ids)},
|
||||
|
||||
@@ -181,6 +181,7 @@ class TenantDefaultModel(TypeBase):
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="tenant_default_model_pkey"),
|
||||
sa.Index("tenant_default_model_tenant_id_provider_type_idx", "tenant_id", "provider_name", "model_type"),
|
||||
sa.UniqueConstraint("tenant_id", "model_type", name="unique_tenant_default_model_type"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
|
||||
@@ -393,35 +393,3 @@ class BillingService:
|
||||
for item in data:
|
||||
tenant_whitelist.append(item["tenant_id"])
|
||||
return tenant_whitelist
|
||||
|
||||
@classmethod
|
||||
def read_notification(cls, user_email: str):
|
||||
params = {"user_email": user_email}
|
||||
return cls._send_request("GET", "/notification/read", params=params)
|
||||
|
||||
@classmethod
|
||||
def save_notification_user(cls, user_email: str):
|
||||
json = {"user_email": user_email}
|
||||
return cls._send_request("POST", "/notification/new-notification-user", json=json)
|
||||
|
||||
@classmethod
|
||||
def save_notification_users_batch(cls, user_emails: list[str]) -> dict:
|
||||
"""Batch save notification users in chunks of 1000."""
|
||||
chunk_size = 1000
|
||||
total_succeeded = 0
|
||||
failed_chunks: list[dict] = []
|
||||
|
||||
for i in range(0, len(user_emails), chunk_size):
|
||||
chunk = user_emails[i : i + chunk_size]
|
||||
try:
|
||||
resp = cls._send_request("POST", "/notification/batch-notification-users", json={"user_emails": chunk})
|
||||
total_succeeded += resp.get("count", len(chunk))
|
||||
except Exception as e:
|
||||
failed_chunks.append({"offset": i, "count": len(chunk), "error": str(e)})
|
||||
|
||||
return {"succeeded": total_succeeded, "failed_chunks": failed_chunks}
|
||||
|
||||
@classmethod
|
||||
def save_notification_content(cls, content: str):
|
||||
json = {"content": content}
|
||||
return cls._send_request("POST", "/notification/new-notification", json=json)
|
||||
|
||||
@@ -22,7 +22,7 @@ from libs.exception import BaseHTTPException
|
||||
from models.human_input import RecipientType
|
||||
from models.model import App, AppMode
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE, resume_app_execution
|
||||
from tasks.app_generate.workflow_execute_task import resume_app_execution
|
||||
|
||||
|
||||
class Form:
|
||||
@@ -230,7 +230,6 @@ class HumanInputService:
|
||||
try:
|
||||
resume_app_execution.apply_async(
|
||||
kwargs={"payload": payload},
|
||||
queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE,
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Failed to enqueue resume task for workflow run %s", workflow_run_id)
|
||||
|
||||
@@ -129,15 +129,15 @@ def build_workflow_event_stream(
|
||||
return
|
||||
|
||||
try:
|
||||
event = buffer_state.queue.get(timeout=0.1)
|
||||
event = buffer_state.queue.get(timeout=1)
|
||||
except queue.Empty:
|
||||
current_time = time.time()
|
||||
if current_time - last_msg_time > idle_timeout:
|
||||
logger.debug(
|
||||
"No workflow events received for %s seconds, keeping stream open",
|
||||
"Idle timeout of %s seconds reached, closing workflow event stream.",
|
||||
idle_timeout,
|
||||
)
|
||||
last_msg_time = current_time
|
||||
return
|
||||
if current_time - last_ping_time >= ping_interval:
|
||||
yield StreamEvent.PING.value
|
||||
last_ping_time = current_time
|
||||
@@ -405,7 +405,7 @@ def _start_buffering(subscription) -> BufferState:
|
||||
dropped_count = 0
|
||||
try:
|
||||
while not buffer_state.stop_event.is_set():
|
||||
msg = subscription.receive(timeout=0.1)
|
||||
msg = subscription.receive(timeout=1)
|
||||
if msg is None:
|
||||
continue
|
||||
event = _parse_event_message(msg)
|
||||
|
||||
@@ -51,7 +51,7 @@ def _patch_redis_clients_on_loaded_modules():
|
||||
continue
|
||||
if hasattr(module, "redis_client"):
|
||||
module.redis_client = redis_mock
|
||||
if hasattr(module, "pubsub_redis_client"):
|
||||
if hasattr(module, "_pubsub_redis_client"):
|
||||
module.pubsub_redis_client = redis_mock
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ def _patch_redis_clients():
|
||||
|
||||
with (
|
||||
patch.object(ext_redis, "redis_client", redis_mock),
|
||||
patch.object(ext_redis, "pubsub_redis_client", redis_mock),
|
||||
patch.object(ext_redis, "_pubsub_redis_client", redis_mock),
|
||||
):
|
||||
_patch_redis_clients_on_loaded_modules()
|
||||
yield
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from controllers.console.app.conversation import _get_conversation
|
||||
|
||||
|
||||
def test_get_conversation_mark_read_keeps_updated_at_unchanged():
|
||||
app_model = SimpleNamespace(id="app-id")
|
||||
account = SimpleNamespace(id="account-id")
|
||||
conversation = MagicMock()
|
||||
conversation.id = "conversation-id"
|
||||
|
||||
with (
|
||||
patch("controllers.console.app.conversation.current_account_with_tenant", return_value=(account, None)),
|
||||
patch("controllers.console.app.conversation.naive_utc_now", return_value=datetime(2026, 2, 9, 0, 0, 0)),
|
||||
patch("controllers.console.app.conversation.db.session") as mock_session,
|
||||
):
|
||||
mock_session.query.return_value.where.return_value.first.return_value = conversation
|
||||
|
||||
_get_conversation(app_model, "conversation-id")
|
||||
|
||||
statement = mock_session.execute.call_args[0][0]
|
||||
compiled = statement.compile()
|
||||
sql_text = str(compiled).lower()
|
||||
compact_sql_text = sql_text.replace(" ", "")
|
||||
params = compiled.params
|
||||
|
||||
assert "updated_at=current_timestamp" not in compact_sql_text
|
||||
assert "updated_at=conversations.updated_at" in compact_sql_text
|
||||
assert "read_at=:read_at" in compact_sql_text
|
||||
assert "read_account_id=:read_account_id" in compact_sql_text
|
||||
assert params["read_at"] == datetime(2026, 2, 9, 0, 0, 0)
|
||||
assert params["read_account_id"] == "account-id"
|
||||
@@ -25,15 +25,19 @@ class TestMessageCycleManagerOptimization:
|
||||
task_state = Mock()
|
||||
return MessageCycleManager(application_generate_entity=mock_application_generate_entity, task_state=task_state)
|
||||
|
||||
def test_get_message_event_type_with_message_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE_FILE when message has files."""
|
||||
def test_get_message_event_type_with_assistant_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE_FILE when message has assistant-generated files.
|
||||
|
||||
This ensures that AI-generated images (belongs_to='assistant') trigger the MESSAGE_FILE event,
|
||||
allowing the frontend to properly display generated image files with url field.
|
||||
"""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_message_file = Mock()
|
||||
# Current implementation uses session.scalar(select(...))
|
||||
mock_message_file.belongs_to = "assistant"
|
||||
mock_session.scalar.return_value = mock_message_file
|
||||
|
||||
# Execute
|
||||
@@ -44,6 +48,31 @@ class TestMessageCycleManagerOptimization:
|
||||
assert result == StreamEvent.MESSAGE_FILE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_with_user_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE when message only has user-uploaded files.
|
||||
|
||||
This is a regression test for the issue where user-uploaded images (belongs_to='user')
|
||||
caused the LLM text response to be incorrectly tagged with MESSAGE_FILE event,
|
||||
resulting in broken images in the chat UI. The query filters for belongs_to='assistant',
|
||||
so when only user files exist, the database query returns None, resulting in MESSAGE event type.
|
||||
"""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
|
||||
# When querying for assistant files with only user files present, return None
|
||||
# (simulates database query with belongs_to='assistant' filter returning no results)
|
||||
mock_session.scalar.return_value = None
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_without_message_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE when message has no files."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
@@ -69,7 +98,7 @@ class TestMessageCycleManagerOptimization:
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_message_file = Mock()
|
||||
# Current implementation uses session.scalar(select(...))
|
||||
mock_message_file.belongs_to = "assistant"
|
||||
mock_session.scalar.return_value = mock_message_file
|
||||
|
||||
# Execute: compute event type once, then pass to message_to_stream_response
|
||||
|
||||
211
api/tests/unit_tests/core/tools/test_base_tool.py
Normal file
211
api/tests/unit_tests/core/tools/test_base_tool.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolProviderType
|
||||
|
||||
|
||||
class DummyCastType:
|
||||
def cast_value(self, value: Any) -> str:
|
||||
return f"cast:{value}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyParameter:
|
||||
name: str
|
||||
type: DummyCastType
|
||||
form: str = "llm"
|
||||
required: bool = False
|
||||
default: Any = None
|
||||
options: list[Any] | None = None
|
||||
llm_description: str | None = None
|
||||
|
||||
|
||||
class DummyTool(Tool):
|
||||
def __init__(self, entity: ToolEntity, runtime: ToolRuntime):
|
||||
super().__init__(entity=entity, runtime=runtime)
|
||||
self.result: ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None] = (
|
||||
self.create_text_message("default")
|
||||
)
|
||||
self.runtime_parameter_overrides: list[Any] | None = None
|
||||
self.last_invocation: dict[str, Any] | None = None
|
||||
|
||||
def tool_provider_type(self) -> ToolProviderType:
|
||||
return ToolProviderType.BUILT_IN
|
||||
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
tool_parameters: dict[str, Any],
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None]:
|
||||
self.last_invocation = {
|
||||
"user_id": user_id,
|
||||
"tool_parameters": tool_parameters,
|
||||
"conversation_id": conversation_id,
|
||||
"app_id": app_id,
|
||||
"message_id": message_id,
|
||||
}
|
||||
return self.result
|
||||
|
||||
def get_runtime_parameters(
|
||||
self,
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
):
|
||||
if self.runtime_parameter_overrides is not None:
|
||||
return self.runtime_parameter_overrides
|
||||
return super().get_runtime_parameters(
|
||||
conversation_id=conversation_id,
|
||||
app_id=app_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
|
||||
def _build_tool(runtime: ToolRuntime | None = None) -> DummyTool:
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="dummy", label=I18nObject(en_US="dummy"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = runtime or ToolRuntime(tenant_id="tenant-1", invoke_from=InvokeFrom.DEBUGGER, runtime_parameters={})
|
||||
return DummyTool(entity=entity, runtime=runtime)
|
||||
|
||||
|
||||
def test_invoke_supports_single_message_and_parameter_casting():
|
||||
runtime = ToolRuntime(
|
||||
tenant_id="tenant-1",
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
runtime_parameters={"from_runtime": "runtime-value"},
|
||||
)
|
||||
tool = _build_tool(runtime)
|
||||
tool.entity.parameters = cast(
|
||||
Any,
|
||||
[
|
||||
DummyParameter(name="unused", type=DummyCastType()),
|
||||
DummyParameter(name="age", type=DummyCastType()),
|
||||
],
|
||||
)
|
||||
tool.result = tool.create_text_message("ok")
|
||||
|
||||
messages = list(
|
||||
tool.invoke(
|
||||
user_id="user-1",
|
||||
tool_parameters={"age": "18", "raw": "keep"},
|
||||
conversation_id="conv-1",
|
||||
app_id="app-1",
|
||||
message_id="msg-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].message.text == "ok"
|
||||
assert tool.last_invocation == {
|
||||
"user_id": "user-1",
|
||||
"tool_parameters": {"age": "cast:18", "raw": "keep", "from_runtime": "runtime-value"},
|
||||
"conversation_id": "conv-1",
|
||||
"app_id": "app-1",
|
||||
"message_id": "msg-1",
|
||||
}
|
||||
|
||||
|
||||
def test_invoke_supports_list_and_generator_results():
|
||||
tool = _build_tool()
|
||||
tool.result = [tool.create_text_message("a"), tool.create_text_message("b")]
|
||||
list_messages = list(tool.invoke(user_id="user-1", tool_parameters={}))
|
||||
assert [msg.message.text for msg in list_messages] == ["a", "b"]
|
||||
|
||||
def _message_generator() -> Generator[ToolInvokeMessage, None, None]:
|
||||
yield tool.create_text_message("g1")
|
||||
yield tool.create_text_message("g2")
|
||||
|
||||
tool.result = _message_generator()
|
||||
generated_messages = list(tool.invoke(user_id="user-2", tool_parameters={}))
|
||||
assert [msg.message.text for msg in generated_messages] == ["g1", "g2"]
|
||||
|
||||
|
||||
def test_fork_tool_runtime_returns_new_tool_with_copied_entity():
|
||||
tool = _build_tool()
|
||||
new_runtime = ToolRuntime(tenant_id="tenant-2", invoke_from=InvokeFrom.EXPLORE, runtime_parameters={})
|
||||
|
||||
forked = tool.fork_tool_runtime(new_runtime)
|
||||
|
||||
assert isinstance(forked, DummyTool)
|
||||
assert forked is not tool
|
||||
assert forked.runtime == new_runtime
|
||||
assert forked.entity == tool.entity
|
||||
assert forked.entity is not tool.entity
|
||||
|
||||
|
||||
def test_get_runtime_parameters_and_merge_runtime_parameters():
|
||||
tool = _build_tool()
|
||||
original = DummyParameter(name="temperature", type=DummyCastType(), form="schema", required=True, default="0.7")
|
||||
tool.entity.parameters = cast(Any, [original])
|
||||
|
||||
default_runtime_parameters = tool.get_runtime_parameters()
|
||||
assert default_runtime_parameters == [original]
|
||||
|
||||
override = DummyParameter(name="temperature", type=DummyCastType(), form="llm", required=False, default="0.5")
|
||||
appended = DummyParameter(name="new_param", type=DummyCastType(), form="form", required=False, default="x")
|
||||
tool.runtime_parameter_overrides = [override, appended]
|
||||
|
||||
merged = tool.get_merged_runtime_parameters()
|
||||
assert len(merged) == 2
|
||||
assert merged[0].name == "temperature"
|
||||
assert merged[0].form == "llm"
|
||||
assert merged[0].required is False
|
||||
assert merged[0].default == "0.5"
|
||||
assert merged[1].name == "new_param"
|
||||
|
||||
|
||||
def test_message_factory_helpers():
|
||||
tool = _build_tool()
|
||||
|
||||
image_message = tool.create_image_message("https://example.com/image.png")
|
||||
assert image_message.type == ToolInvokeMessage.MessageType.IMAGE
|
||||
assert image_message.message.text == "https://example.com/image.png"
|
||||
|
||||
file_obj = object()
|
||||
file_message = tool.create_file_message(file_obj) # type: ignore[arg-type]
|
||||
assert file_message.type == ToolInvokeMessage.MessageType.FILE
|
||||
assert file_message.message.file_marker == "file_marker"
|
||||
assert file_message.meta == {"file": file_obj}
|
||||
|
||||
link_message = tool.create_link_message("https://example.com")
|
||||
assert link_message.type == ToolInvokeMessage.MessageType.LINK
|
||||
assert link_message.message.text == "https://example.com"
|
||||
|
||||
text_message = tool.create_text_message("hello")
|
||||
assert text_message.type == ToolInvokeMessage.MessageType.TEXT
|
||||
assert text_message.message.text == "hello"
|
||||
|
||||
blob_message = tool.create_blob_message(b"blob", meta={"source": "unit-test"})
|
||||
assert blob_message.type == ToolInvokeMessage.MessageType.BLOB
|
||||
assert blob_message.message.blob == b"blob"
|
||||
assert blob_message.meta == {"source": "unit-test"}
|
||||
|
||||
json_message = tool.create_json_message({"k": "v"}, suppress_output=True)
|
||||
assert json_message.type == ToolInvokeMessage.MessageType.JSON
|
||||
assert json_message.message.json_object == {"k": "v"}
|
||||
assert json_message.message.suppress_output is True
|
||||
|
||||
variable_message = tool.create_variable_message("answer", 42, stream=False)
|
||||
assert variable_message.type == ToolInvokeMessage.MessageType.VARIABLE
|
||||
assert variable_message.message.variable_name == "answer"
|
||||
assert variable_message.message.variable_value == 42
|
||||
assert variable_message.message.stream is False
|
||||
|
||||
|
||||
def test_base_abstract_invoke_placeholder_returns_none():
|
||||
tool = _build_tool()
|
||||
assert Tool._invoke(tool, user_id="u", tool_parameters={}) is None
|
||||
@@ -255,6 +255,32 @@ def test_create_variable_message():
|
||||
assert message.message.stream is False
|
||||
|
||||
|
||||
def test_create_file_message_should_include_file_marker():
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = ToolRuntime(tenant_id="test_tool", invoke_from=InvokeFrom.EXPLORE)
|
||||
tool = WorkflowTool(
|
||||
workflow_app_id="",
|
||||
workflow_as_tool_id="",
|
||||
version="1",
|
||||
workflow_entities={},
|
||||
workflow_call_depth=1,
|
||||
entity=entity,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
file_obj = object()
|
||||
message = tool.create_file_message(file_obj) # type: ignore[arg-type]
|
||||
|
||||
assert message.type == ToolInvokeMessage.MessageType.FILE
|
||||
assert message.message.file_marker == "file_marker"
|
||||
assert message.meta == {"file": file_obj}
|
||||
|
||||
|
||||
def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Ensure worker context can resolve EndUser when Account is missing."""
|
||||
|
||||
|
||||
@@ -198,6 +198,15 @@ class SubscriptionTestCase:
|
||||
description: str = ""
|
||||
|
||||
|
||||
class FakeRedisClient:
|
||||
"""Minimal fake Redis client for unit tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.publish = MagicMock()
|
||||
self.spublish = MagicMock()
|
||||
self.pubsub = MagicMock(return_value=MagicMock())
|
||||
|
||||
|
||||
class TestRedisSubscription:
|
||||
"""Test cases for the _RedisSubscription class."""
|
||||
|
||||
@@ -619,10 +628,13 @@ class TestRedisSubscription:
|
||||
class TestRedisShardedSubscription:
|
||||
"""Test cases for the _RedisShardedSubscription class."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_sharded_redis_type(self, monkeypatch):
|
||||
monkeypatch.setattr("libs.broadcast_channel.redis.sharded_channel.Redis", FakeRedisClient)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client(self) -> MagicMock:
|
||||
client = MagicMock()
|
||||
return client
|
||||
def mock_redis_client(self) -> FakeRedisClient:
|
||||
return FakeRedisClient()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pubsub(self) -> MagicMock:
|
||||
@@ -636,7 +648,7 @@ class TestRedisShardedSubscription:
|
||||
|
||||
@pytest.fixture
|
||||
def sharded_subscription(
|
||||
self, mock_pubsub: MagicMock, mock_redis_client: MagicMock
|
||||
self, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient
|
||||
) -> Generator[_RedisShardedSubscription, None, None]:
|
||||
"""Create a _RedisShardedSubscription instance for testing."""
|
||||
subscription = _RedisShardedSubscription(
|
||||
@@ -657,7 +669,7 @@ class TestRedisShardedSubscription:
|
||||
|
||||
# ==================== Lifecycle Tests ====================
|
||||
|
||||
def test_sharded_subscription_initialization(self, mock_pubsub: MagicMock, mock_redis_client: MagicMock):
|
||||
def test_sharded_subscription_initialization(self, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient):
|
||||
"""Test that sharded subscription is properly initialized."""
|
||||
subscription = _RedisShardedSubscription(
|
||||
client=mock_redis_client,
|
||||
@@ -970,7 +982,7 @@ class TestRedisShardedSubscription:
|
||||
],
|
||||
)
|
||||
def test_sharded_subscription_scenarios(
|
||||
self, test_case: SubscriptionTestCase, mock_pubsub: MagicMock, mock_redis_client: MagicMock
|
||||
self, test_case: SubscriptionTestCase, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient
|
||||
):
|
||||
"""Test various sharded subscription scenarios using table-driven approach."""
|
||||
subscription = _RedisShardedSubscription(
|
||||
@@ -1058,7 +1070,7 @@ class TestRedisShardedSubscription:
|
||||
# Close should still work
|
||||
sharded_subscription.close() # Should not raise
|
||||
|
||||
def test_channel_name_variations(self, mock_pubsub: MagicMock, mock_redis_client: MagicMock):
|
||||
def test_channel_name_variations(self, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient):
|
||||
"""Test various sharded channel name formats."""
|
||||
channel_names = [
|
||||
"simple",
|
||||
@@ -1120,10 +1132,13 @@ class TestRedisSubscriptionCommon:
|
||||
"""Parameterized fixture providing subscription type and class."""
|
||||
return request.param
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_sharded_redis_type(self, monkeypatch):
|
||||
monkeypatch.setattr("libs.broadcast_channel.redis.sharded_channel.Redis", FakeRedisClient)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client(self) -> MagicMock:
|
||||
client = MagicMock()
|
||||
return client
|
||||
def mock_redis_client(self) -> FakeRedisClient:
|
||||
return FakeRedisClient()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pubsub(self) -> MagicMock:
|
||||
@@ -1140,7 +1155,7 @@ class TestRedisSubscriptionCommon:
|
||||
return pubsub
|
||||
|
||||
@pytest.fixture
|
||||
def subscription(self, subscription_params, mock_pubsub: MagicMock, mock_redis_client: MagicMock):
|
||||
def subscription(self, subscription_params, mock_pubsub: MagicMock, mock_redis_client: FakeRedisClient):
|
||||
"""Create a subscription instance based on parameterized type."""
|
||||
subscription_type, subscription_class = subscription_params
|
||||
topic_name = f"test-{subscription_type}-topic"
|
||||
|
||||
@@ -17,7 +17,6 @@ from core.workflow.nodes.human_input.entities import (
|
||||
from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormKind, HumanInputFormStatus
|
||||
from models.human_input import RecipientType
|
||||
from services.human_input_service import Form, FormExpiredError, HumanInputService, InvalidFormDataError
|
||||
from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -88,7 +87,6 @@ def test_enqueue_resume_dispatches_task_for_workflow(mocker, mock_session_factor
|
||||
|
||||
resume_task.apply_async.assert_called_once()
|
||||
call_kwargs = resume_task.apply_async.call_args.kwargs
|
||||
assert call_kwargs["queue"] == WORKFLOW_BASED_APP_EXECUTION_QUEUE
|
||||
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
|
||||
|
||||
|
||||
@@ -130,7 +128,6 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(mocker, mock_session_f
|
||||
|
||||
resume_task.apply_async.assert_called_once()
|
||||
call_kwargs = resume_task.apply_async.call_args.kwargs
|
||||
assert call_kwargs["queue"] == WORKFLOW_BASED_APP_EXECUTION_QUEUE
|
||||
assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
|
||||
|
||||
|
||||
|
||||
@@ -106,10 +106,10 @@ if [[ -z "${QUEUES}" ]]; then
|
||||
# Configure queues based on edition
|
||||
if [[ "${EDITION}" == "CLOUD" ]]; then
|
||||
# Cloud edition: separate queues for dataset and trigger tasks
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention,workflow_based_app_execution"
|
||||
else
|
||||
# Community edition (SELF_HOSTED): dataset and workflow have separate queues
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention,workflow_based_app_execution"
|
||||
fi
|
||||
|
||||
echo "No queues specified, using edition-based defaults: ${QUEUES}"
|
||||
|
||||
@@ -62,6 +62,9 @@ LANG=C.UTF-8
|
||||
LC_ALL=C.UTF-8
|
||||
PYTHONIOENCODING=utf-8
|
||||
|
||||
# Set UV cache directory to avoid permission issues with non-existent home directory
|
||||
UV_CACHE_DIR=/tmp/.uv-cache
|
||||
|
||||
# ------------------------------
|
||||
# Server Configuration
|
||||
# ------------------------------
|
||||
@@ -384,6 +387,8 @@ CELERY_USE_SENTINEL=false
|
||||
CELERY_SENTINEL_MASTER_NAME=
|
||||
CELERY_SENTINEL_PASSWORD=
|
||||
CELERY_SENTINEL_SOCKET_TIMEOUT=0.1
|
||||
# e.g. {"tasks.add": {"rate_limit": "10/s"}}
|
||||
CELERY_TASK_ANNOTATIONS=null
|
||||
|
||||
# ------------------------------
|
||||
# CORS Configuration
|
||||
|
||||
@@ -16,6 +16,7 @@ x-shared-env: &shared-api-worker-env
|
||||
LANG: ${LANG:-C.UTF-8}
|
||||
LC_ALL: ${LC_ALL:-C.UTF-8}
|
||||
PYTHONIOENCODING: ${PYTHONIOENCODING:-utf-8}
|
||||
UV_CACHE_DIR: ${UV_CACHE_DIR:-/tmp/.uv-cache}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text}
|
||||
LOG_FILE: ${LOG_FILE:-/app/logs/server.log}
|
||||
@@ -105,6 +106,7 @@ x-shared-env: &shared-api-worker-env
|
||||
CELERY_SENTINEL_MASTER_NAME: ${CELERY_SENTINEL_MASTER_NAME:-}
|
||||
CELERY_SENTINEL_PASSWORD: ${CELERY_SENTINEL_PASSWORD:-}
|
||||
CELERY_SENTINEL_SOCKET_TIMEOUT: ${CELERY_SENTINEL_SOCKET_TIMEOUT:-0.1}
|
||||
CELERY_TASK_ANNOTATIONS: ${CELERY_TASK_ANNOTATIONS:-null}
|
||||
WEB_API_CORS_ALLOW_ORIGINS: ${WEB_API_CORS_ALLOW_ORIGINS:-*}
|
||||
CONSOLE_CORS_ALLOW_ORIGINS: ${CONSOLE_CORS_ALLOW_ORIGINS:-*}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
|
||||
8
sdks/nodejs-client/pnpm-lock.yaml
generated
8
sdks/nodejs-client/pnpm-lock.yaml
generated
@@ -10,7 +10,7 @@ importers:
|
||||
dependencies:
|
||||
axios:
|
||||
specifier: ^1.13.2
|
||||
version: 1.13.2
|
||||
version: 1.13.5
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.2
|
||||
@@ -544,8 +544,8 @@ packages:
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
axios@1.13.2:
|
||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||
axios@1.13.5:
|
||||
resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
@@ -1677,7 +1677,7 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.13.2:
|
||||
axios@1.13.5:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* MAX_PARALLEL_LIMIT Configuration Bug Test
|
||||
*
|
||||
* This test reproduces and verifies the fix for issue #23083:
|
||||
* MAX_PARALLEL_LIMIT environment variable does not take effect in iteration panel
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
|
||||
// Mock environment variables before importing constants
|
||||
const originalEnv = process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
// Test with different environment values
|
||||
function setupEnvironment(value?: string) {
|
||||
if (value)
|
||||
process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = value
|
||||
else
|
||||
delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
// Clear module cache to force re-evaluation
|
||||
vi.resetModules()
|
||||
}
|
||||
|
||||
function restoreEnvironment() {
|
||||
if (originalEnv)
|
||||
process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = originalEnv
|
||||
else
|
||||
delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
vi.resetModules()
|
||||
}
|
||||
|
||||
// Mock i18next with proper implementation
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
if (key.includes('MaxParallelismTitle'))
|
||||
return 'Max Parallelism'
|
||||
if (key.includes('MaxParallelismDesc'))
|
||||
return 'Maximum number of parallel executions'
|
||||
if (key.includes('parallelMode'))
|
||||
return 'Parallel Mode'
|
||||
if (key.includes('parallelPanelDesc'))
|
||||
return 'Enable parallel execution'
|
||||
if (key.includes('errorResponseMethod'))
|
||||
return 'Error Response Method'
|
||||
return key
|
||||
},
|
||||
}),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock i18next module completely to prevent initialization issues
|
||||
vi.mock('i18next', () => ({
|
||||
use: vi.fn().mockReturnThis(),
|
||||
init: vi.fn().mockReturnThis(),
|
||||
t: vi.fn(key => key),
|
||||
isInitialized: true,
|
||||
}))
|
||||
|
||||
// Mock the useConfig hook
|
||||
vi.mock('@/app/components/workflow/nodes/iteration/use-config', () => ({
|
||||
default: () => ({
|
||||
inputs: {
|
||||
is_parallel: true,
|
||||
parallel_nums: 5,
|
||||
error_handle_mode: 'terminated',
|
||||
},
|
||||
changeParallel: vi.fn(),
|
||||
changeParallelNums: vi.fn(),
|
||||
changeErrorHandleMode: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock other components
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
default: function MockVarReferencePicker() {
|
||||
return <div data-testid="var-reference-picker">VarReferencePicker</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
|
||||
default: function MockSplit() {
|
||||
return <div data-testid="split">Split</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
|
||||
default: function MockField({ title, children }: { title: string, children: React.ReactNode }) {
|
||||
return (
|
||||
<div data-testid="field">
|
||||
<label>{title}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const getParallelControls = () => ({
|
||||
numberInput: screen.getByRole('spinbutton'),
|
||||
slider: screen.getByRole('slider'),
|
||||
})
|
||||
|
||||
describe('MAX_PARALLEL_LIMIT Configuration Bug', () => {
|
||||
const mockNodeData = {
|
||||
id: 'test-iteration-node',
|
||||
type: 'iteration' as const,
|
||||
data: {
|
||||
title: 'Test Iteration',
|
||||
desc: 'Test iteration node',
|
||||
iterator_selector: ['test'],
|
||||
output_selector: ['output'],
|
||||
is_parallel: true,
|
||||
parallel_nums: 5,
|
||||
error_handle_mode: 'terminated' as const,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnvironment()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
restoreEnvironment()
|
||||
})
|
||||
|
||||
describe('Environment Variable Parsing', () => {
|
||||
it('should parse MAX_PARALLEL_LIMIT from NEXT_PUBLIC_MAX_PARALLEL_LIMIT environment variable', async () => {
|
||||
setupEnvironment('25')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(25)
|
||||
})
|
||||
|
||||
it('should fallback to default when environment variable is not set', async () => {
|
||||
setupEnvironment() // No environment variable
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
it('should handle invalid environment variable values', async () => {
|
||||
setupEnvironment('invalid')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
// Should fall back to default when parsing fails
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
it('should handle empty environment variable', async () => {
|
||||
setupEnvironment('')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
// Should fall back to default when empty
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
// Edge cases for boundary values
|
||||
it('should clamp MAX_PARALLEL_LIMIT to MIN when env is 0 or negative', async () => {
|
||||
setupEnvironment('0')
|
||||
let { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
|
||||
|
||||
setupEnvironment('-5')
|
||||
;({ MAX_PARALLEL_LIMIT } = await import('@/config'))
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
|
||||
})
|
||||
|
||||
it('should handle float numbers by parseInt behavior', async () => {
|
||||
setupEnvironment('12.7')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
// parseInt truncates to integer
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI Component Integration (Main Fix Verification)', () => {
|
||||
it('should render iteration panel with environment-configured max value', async () => {
|
||||
// Set environment variable to a different value
|
||||
setupEnvironment('30')
|
||||
|
||||
// Import Panel after setting environment
|
||||
const Panel = await import('@/app/components/workflow/nodes/iteration/panel').then(mod => mod.default)
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="test-node"
|
||||
// @ts-expect-error key type mismatch
|
||||
data={mockNodeData.data}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Behavior-focused assertion: UI max should equal MAX_PARALLEL_LIMIT
|
||||
const { numberInput, slider } = getParallelControls()
|
||||
expect(numberInput).toHaveAttribute('max', String(MAX_PARALLEL_LIMIT))
|
||||
expect(slider).toHaveAttribute('aria-valuemax', String(MAX_PARALLEL_LIMIT))
|
||||
|
||||
// Verify the actual values
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(30)
|
||||
expect(numberInput.getAttribute('max')).toBe('30')
|
||||
expect(slider.getAttribute('aria-valuemax')).toBe('30')
|
||||
})
|
||||
|
||||
it('should maintain UI consistency with different environment values', async () => {
|
||||
setupEnvironment('15')
|
||||
const Panel = await import('@/app/components/workflow/nodes/iteration/panel').then(mod => mod.default)
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="test-node"
|
||||
// @ts-expect-error key type mismatch
|
||||
data={mockNodeData.data}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Both input and slider should use the same max value from MAX_PARALLEL_LIMIT
|
||||
const { numberInput, slider } = getParallelControls()
|
||||
|
||||
expect(numberInput.getAttribute('max')).toBe(slider.getAttribute('aria-valuemax'))
|
||||
expect(numberInput.getAttribute('max')).toBe(String(MAX_PARALLEL_LIMIT))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Legacy Constant Verification (For Transition Period)', () => {
|
||||
// Marked as transition/deprecation tests
|
||||
it('should maintain MAX_ITERATION_PARALLEL_NUM for backward compatibility', async () => {
|
||||
const { MAX_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
|
||||
expect(typeof MAX_ITERATION_PARALLEL_NUM).toBe('number')
|
||||
expect(MAX_ITERATION_PARALLEL_NUM).toBe(10) // Hardcoded legacy value
|
||||
})
|
||||
|
||||
it('should demonstrate MAX_PARALLEL_LIMIT vs legacy constant difference', async () => {
|
||||
setupEnvironment('50')
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
const { MAX_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
|
||||
|
||||
// MAX_PARALLEL_LIMIT is configurable, MAX_ITERATION_PARALLEL_NUM is not
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(50)
|
||||
expect(MAX_ITERATION_PARALLEL_NUM).toBe(10)
|
||||
expect(MAX_PARALLEL_LIMIT).not.toBe(MAX_ITERATION_PARALLEL_NUM)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Constants Validation', () => {
|
||||
it('should validate that required constants exist and have correct types', async () => {
|
||||
const { MAX_PARALLEL_LIMIT } = await import('@/config')
|
||||
const { MIN_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants')
|
||||
expect(typeof MAX_PARALLEL_LIMIT).toBe('number')
|
||||
expect(typeof MIN_ITERATION_PARALLEL_NUM).toBe('number')
|
||||
expect(MAX_PARALLEL_LIMIT).toBeGreaterThanOrEqual(MIN_ITERATION_PARALLEL_NUM)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
enum BadgeState {
|
||||
Warning = 'warning',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { UserActionButtonType } from '@/app/components/workflow/nodes/human-inpu
|
||||
import 'dayjs/locale/en'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/ja'
|
||||
import 'dayjs/locale/nl'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(relativeTime)
|
||||
@@ -45,6 +46,7 @@ const localeMap: Record<string, string> = {
|
||||
'en-US': 'en',
|
||||
'zh-Hans': 'zh-cn',
|
||||
'ja-JP': 'ja',
|
||||
'nl-NL': 'nl',
|
||||
}
|
||||
|
||||
export const getRelativeTime = (
|
||||
|
||||
@@ -98,7 +98,9 @@ const VoiceParamConfig = ({
|
||||
className="h-full w-full cursor-pointer rounded-lg border-0 bg-components-input-bg-normal py-1.5 pl-3 pr-10 focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6"
|
||||
>
|
||||
<span className={cn('block truncate text-left text-text-secondary', !languageItem?.name && 'text-text-tertiary')}>
|
||||
{languageItem?.name ? t(`voice.language.${replace(languageItem?.value, '-', '')}`, { ns: 'common' }) : localLanguagePlaceholder}
|
||||
{languageItem?.name
|
||||
? t(`voice.language.${replace(languageItem?.value ?? '', '-', '')}`, languageItem?.name, { ns: 'common' as const })
|
||||
: localLanguagePlaceholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronDownIcon
|
||||
@@ -129,7 +131,7 @@ const VoiceParamConfig = ({
|
||||
<span
|
||||
className={cn('block', selected && 'font-normal')}
|
||||
>
|
||||
{t(`voice.language.${replace((item.value), '-', '')}`, { ns: 'common' })}
|
||||
{t(`voice.language.${replace((item.value), '-', '')}`, item.name, { ns: 'common' as const })}
|
||||
</span>
|
||||
{(selected || item.value === text2speech?.language) && (
|
||||
<span
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
export const InputTypeEnum = z.enum([
|
||||
'text-input',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ZodNumber, ZodSchema, ZodString } from 'zod'
|
||||
import type { BaseConfiguration } from './types'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import { BaseFieldType } from './types'
|
||||
|
||||
export const generateZodSchema = (fields: BaseConfiguration[]) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
const ContactMethod = z.union([
|
||||
z.literal('email'),
|
||||
@@ -22,10 +22,10 @@ export const UserSchema = z.object({
|
||||
.min(3, 'Surname must be at least 3 characters long')
|
||||
.regex(/^[A-Z]/, 'Surname must start with a capital letter'),
|
||||
isAcceptingTerms: z.boolean().refine(val => val, {
|
||||
message: 'You must accept the terms and conditions',
|
||||
error: 'You must accept the terms and conditions',
|
||||
}),
|
||||
contact: z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
email: z.email('Invalid email address'),
|
||||
phone: z.string().optional(),
|
||||
preferredContactMethod: ContactMethod,
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ZodSchema, ZodString } from 'zod'
|
||||
import type { InputFieldConfiguration } from './types'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import { SupportedFileTypes, TransferMethod } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/schema'
|
||||
import { InputFieldType } from './types'
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { env } from '@/env'
|
||||
import ParamItem from '.'
|
||||
|
||||
type Props = {
|
||||
@@ -11,12 +12,7 @@ type Props = {
|
||||
enable: boolean
|
||||
}
|
||||
|
||||
const maxTopK = (() => {
|
||||
const configValue = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-top-k-max-value') || '', 10)
|
||||
if (configValue && !isNaN(configValue))
|
||||
return configValue
|
||||
return 10
|
||||
})()
|
||||
const maxTopK = env.NEXT_PUBLIC_TOP_K_MAX_VALUE
|
||||
const VALUE_LIMIT = {
|
||||
default: 2,
|
||||
step: 1,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { Highlight } from '@/app/components/base/icons/src/public/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
const PremiumBadgeVariants = cva(
|
||||
'premium-badge',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import withValidation from '.'
|
||||
|
||||
describe('withValidation HOC', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import withValidation from '.'
|
||||
|
||||
// Sample components to wrap with validation
|
||||
@@ -65,7 +65,7 @@ const ProductCard = ({ name, price, category, inStock }: ProductCardProps) => {
|
||||
// Create validated versions
|
||||
const userSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email'),
|
||||
email: z.email('Invalid email'),
|
||||
age: z.number().min(0).max(150),
|
||||
})
|
||||
|
||||
@@ -371,7 +371,7 @@ export const ConfigurationValidation: Story = {
|
||||
)
|
||||
|
||||
const configSchema = z.object({
|
||||
apiUrl: z.string().url('Must be valid URL'),
|
||||
apiUrl: z.url('Must be valid URL'),
|
||||
timeout: z.number().min(0).max(30000),
|
||||
retries: z.number().min(0).max(5),
|
||||
debug: z.boolean(),
|
||||
@@ -430,7 +430,7 @@ export const UsageDocumentation: Story = {
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-900">Usage Example</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
|
||||
{`import { z } from 'zod'
|
||||
{`import * as z from 'zod'
|
||||
import withValidation from './withValidation'
|
||||
|
||||
// Define your component
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { env } from '@/env'
|
||||
|
||||
const TextLabel: FC<PropsWithChildren> = (props) => {
|
||||
return <label className="text-xs font-semibold leading-none text-text-secondary">{props.children}</label>
|
||||
@@ -46,7 +47,7 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = (props) =>
|
||||
}
|
||||
|
||||
export const MaxLengthInput: FC<InputNumberProps> = (props) => {
|
||||
const maxValue = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000', 10)
|
||||
const maxValue = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
|
||||
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ParentMode, PreProcessingRule, ProcessRule, Rules, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { env } from '@/env'
|
||||
import { ChunkingMode, ProcessMode } from '@/models/datasets'
|
||||
import escape from './escape'
|
||||
import unescape from './unescape'
|
||||
@@ -8,10 +9,7 @@ import unescape from './unescape'
|
||||
export const DEFAULT_SEGMENT_IDENTIFIER = '\\n\\n'
|
||||
export const DEFAULT_MAXIMUM_CHUNK_LENGTH = 1024
|
||||
export const DEFAULT_OVERLAP = 50
|
||||
export const MAXIMUM_CHUNK_TOKEN_LENGTH = Number.parseInt(
|
||||
globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000',
|
||||
10,
|
||||
)
|
||||
export const MAXIMUM_CHUNK_TOKEN_LENGTH = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
|
||||
|
||||
export type ParentChildConfig = {
|
||||
chunkForContext: ParentMode
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Actions from './actions'
|
||||
@@ -53,7 +53,7 @@ const createFailingSchema = () => {
|
||||
issues: [{ path: ['field1'], message: 'is required' }],
|
||||
},
|
||||
}),
|
||||
} as unknown as z.ZodSchema
|
||||
} as unknown as z.ZodType
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DocType } from '@/models/datasets'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useMetadataMap } from '@/hooks/use-metadata'
|
||||
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from '../style.module.css'
|
||||
|
||||
const TypeIcon: FC<{ iconName: string, className?: string }> = ({ iconName, className = '' }) => {
|
||||
return <div className={cn(s.commonIcon, s[`${iconName}Icon`], className)} />
|
||||
}
|
||||
|
||||
const IconButton: FC<{ type: DocType, isChecked: boolean }> = ({ type, isChecked = false }) => {
|
||||
const metadataMap = useMetadataMap()
|
||||
return (
|
||||
<Tooltip popupContent={metadataMap[type].text}>
|
||||
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<TypeIcon
|
||||
iconName={metadataMap[type].iconName || ''}
|
||||
className={`group-hover:bg-primary-600 ${isChecked ? '!bg-primary-600' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type DocTypeSelectorProps = {
|
||||
docType: DocType | ''
|
||||
documentType?: DocType | ''
|
||||
tempDocType: DocType | ''
|
||||
onTempDocTypeChange: (type: DocType | '') => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const DocTypeSelector: FC<DocTypeSelectorProps> = ({
|
||||
docType,
|
||||
documentType,
|
||||
tempDocType,
|
||||
onTempDocTypeChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isFirstTime = !docType && !documentType
|
||||
const currValue = tempDocType ?? documentType
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFirstTime && (
|
||||
<div className={s.desc}>{t('metadata.desc', { ns: 'datasetDocuments' })}</div>
|
||||
)}
|
||||
<div className={s.operationWrapper}>
|
||||
{isFirstTime && (
|
||||
<span className={s.title}>{t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })}</span>
|
||||
)}
|
||||
{documentType && (
|
||||
<>
|
||||
<span className={s.title}>{t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}</span>
|
||||
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
<Radio.Group value={currValue ?? ''} onChange={onTempDocTypeChange} className={s.radioGroup}>
|
||||
{CUSTOMIZABLE_DOC_TYPES.map(type => (
|
||||
<Radio key={type} value={type} className={`${s.radio} ${currValue === type ? 'shadow-none' : ''}`}>
|
||||
<IconButton type={type} isChecked={currValue === type} />
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
{isFirstTime && (
|
||||
<Button variant="primary" onClick={onConfirm} disabled={!tempDocType}>
|
||||
{t('metadata.firstMetaAction', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
)}
|
||||
{documentType && (
|
||||
<div className={s.opBtnWrapper}>
|
||||
<Button onClick={onConfirm} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary">
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button onClick={onCancel} className={`${s.opBtn} ${s.opCancelBtn}`}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type DocumentTypeDisplayProps = {
|
||||
displayType: DocType | ''
|
||||
showChangeLink?: boolean
|
||||
onChangeClick?: () => void
|
||||
}
|
||||
|
||||
export const DocumentTypeDisplay: FC<DocumentTypeDisplayProps> = ({
|
||||
displayType,
|
||||
showChangeLink = false,
|
||||
onChangeClick,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const metadataMap = useMetadataMap()
|
||||
const effectiveType = displayType || 'book'
|
||||
|
||||
return (
|
||||
<div className={s.documentTypeShow}>
|
||||
{(displayType || !showChangeLink) && (
|
||||
<>
|
||||
<TypeIcon iconName={metadataMap[effectiveType]?.iconName || ''} className={s.iconShow} />
|
||||
{metadataMap[effectiveType].text}
|
||||
{showChangeLink && (
|
||||
<div className="ml-1 inline-flex items-center gap-1">
|
||||
·
|
||||
<div onClick={onChangeClick} className="cursor-pointer hover:text-text-accent">
|
||||
{t('operation.change', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocTypeSelector
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { inputType } from '@/hooks/use-metadata'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import { getTextWidthWithCanvas } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from '../style.module.css'
|
||||
|
||||
type FieldInfoProps = {
|
||||
label: string
|
||||
value?: string
|
||||
valueIcon?: ReactNode
|
||||
displayedValue?: string
|
||||
defaultValue?: string
|
||||
showEdit?: boolean
|
||||
inputType?: inputType
|
||||
selectOptions?: Array<{ value: string, name: string }>
|
||||
onUpdate?: (v: string) => void
|
||||
}
|
||||
|
||||
const FieldInfo: FC<FieldInfoProps> = ({
|
||||
label,
|
||||
value = '',
|
||||
valueIcon,
|
||||
displayedValue = '',
|
||||
defaultValue,
|
||||
showEdit = false,
|
||||
inputType = 'input',
|
||||
selectOptions = [],
|
||||
onUpdate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190
|
||||
const editAlignTop = showEdit && inputType === 'textarea'
|
||||
const readAlignTop = !showEdit && textNeedWrap
|
||||
|
||||
const renderContent = () => {
|
||||
if (!showEdit)
|
||||
return displayedValue
|
||||
|
||||
if (inputType === 'select') {
|
||||
return (
|
||||
<SimpleSelect
|
||||
onSelect={({ value }) => onUpdate?.(value as string)}
|
||||
items={selectOptions}
|
||||
defaultValue={value}
|
||||
className={s.select}
|
||||
wrapperClassName={s.selectWrapper}
|
||||
placeholder={`${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (inputType === 'textarea') {
|
||||
return (
|
||||
<AutoHeightTextarea
|
||||
onChange={e => onUpdate?.(e.target.value)}
|
||||
value={value}
|
||||
className={s.textArea}
|
||||
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
onChange={e => onUpdate?.(e.target.value)}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-5 items-center gap-1 py-0.5 text-xs', editAlignTop && '!items-start', readAlignTop && '!items-start pt-1')}>
|
||||
<div className={cn('w-[200px] shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-text-tertiary', editAlignTop && 'pt-1')}>{label}</div>
|
||||
<div className="flex grow items-center gap-1 text-text-secondary">
|
||||
{valueIcon}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FieldInfo
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { metadataType } from '@/hooks/use-metadata'
|
||||
import type { FullDocumentDetail } from '@/models/datasets'
|
||||
import { get } from 'es-toolkit/compat'
|
||||
import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata'
|
||||
import FieldInfo from './field-info'
|
||||
|
||||
const map2Options = (map: Record<string, string>) => {
|
||||
return Object.keys(map).map(key => ({ value: key, name: map[key] }))
|
||||
}
|
||||
|
||||
function useCategoryMapResolver(mainField: metadataType | '') {
|
||||
const languageMap = useLanguages()
|
||||
const bookCategoryMap = useBookCategories()
|
||||
const personalDocCategoryMap = usePersonalDocCategories()
|
||||
const businessDocCategoryMap = useBusinessDocCategories()
|
||||
|
||||
return (field: string): Record<string, string> => {
|
||||
if (field === 'language')
|
||||
return languageMap
|
||||
if (field === 'category' && mainField === 'book')
|
||||
return bookCategoryMap
|
||||
if (field === 'document_type') {
|
||||
if (mainField === 'personal_document')
|
||||
return personalDocCategoryMap
|
||||
if (mainField === 'business_document')
|
||||
return businessDocCategoryMap
|
||||
}
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
type MetadataFieldListProps = {
|
||||
mainField: metadataType | ''
|
||||
canEdit?: boolean
|
||||
metadata?: Record<string, string>
|
||||
docDetail?: FullDocumentDetail
|
||||
onFieldUpdate?: (field: string, value: string) => void
|
||||
}
|
||||
|
||||
const MetadataFieldList: FC<MetadataFieldListProps> = ({
|
||||
mainField,
|
||||
canEdit = false,
|
||||
metadata,
|
||||
docDetail,
|
||||
onFieldUpdate,
|
||||
}) => {
|
||||
const metadataMap = useMetadataMap()
|
||||
const getCategoryMap = useCategoryMapResolver(mainField)
|
||||
|
||||
if (!mainField)
|
||||
return null
|
||||
|
||||
const fieldMap = metadataMap[mainField]?.subFieldsMap
|
||||
const isFixedField = ['originInfo', 'technicalParameters'].includes(mainField)
|
||||
const sourceData = isFixedField ? docDetail : metadata
|
||||
|
||||
const getDisplayValue = (field: string) => {
|
||||
const val = get(sourceData, field, '')
|
||||
if (!val && val !== 0)
|
||||
return '-'
|
||||
if (fieldMap[field]?.inputType === 'select')
|
||||
return getCategoryMap(field)[val]
|
||||
if (fieldMap[field]?.render)
|
||||
return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined)
|
||||
return val
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.keys(fieldMap).map(field => (
|
||||
<FieldInfo
|
||||
key={fieldMap[field]?.label}
|
||||
label={fieldMap[field]?.label}
|
||||
displayedValue={getDisplayValue(field)}
|
||||
value={get(sourceData, field, '')}
|
||||
inputType={fieldMap[field]?.inputType || 'input'}
|
||||
showEdit={canEdit}
|
||||
onUpdate={val => onFieldUpdate?.(field, val)}
|
||||
selectOptions={map2Options(getCategoryMap(field))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MetadataFieldList
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { DocType, FullDocumentDetail } from '@/models/datasets'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { modifyDocMetadata } from '@/service/datasets'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { useDocumentContext } from '../../context'
|
||||
|
||||
type MetadataState = {
|
||||
documentType?: DocType | ''
|
||||
metadata: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw doc_type: treat 'others' as empty string.
|
||||
*/
|
||||
const normalizeDocType = (rawDocType: string): DocType | '' => {
|
||||
return rawDocType === 'others' ? '' : rawDocType as DocType | ''
|
||||
}
|
||||
|
||||
type UseMetadataStateOptions = {
|
||||
docDetail?: FullDocumentDetail
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export function useMetadataState({ docDetail, onUpdate }: UseMetadataStateOptions) {
|
||||
const { doc_metadata = {} } = docDetail || {}
|
||||
const rawDocType = docDetail?.doc_type ?? ''
|
||||
const docType = normalizeDocType(rawDocType)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const datasetId = useDocumentContext(s => s.datasetId)
|
||||
const documentId = useDocumentContext(s => s.documentId)
|
||||
|
||||
// If no documentType yet, start in editing + showDocTypes mode
|
||||
const [editStatus, setEditStatus] = useState(!docType)
|
||||
const [metadataParams, setMetadataParams] = useState<MetadataState>(
|
||||
docType
|
||||
? { documentType: docType, metadata: (doc_metadata || {}) as Record<string, string> }
|
||||
: { metadata: {} },
|
||||
)
|
||||
const [showDocTypes, setShowDocTypes] = useState(!docType)
|
||||
const [tempDocType, setTempDocType] = useState<DocType | ''>('')
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
|
||||
// Sync local state when the upstream docDetail changes (e.g. after save or navigation).
|
||||
// These setters are intentionally called together to batch-reset multiple pieces
|
||||
// of derived editing state that cannot be expressed as pure derived values.
|
||||
useEffect(() => {
|
||||
if (docDetail?.doc_type) {
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setEditStatus(false)
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setShowDocTypes(false)
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setTempDocType(docType)
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setMetadataParams({
|
||||
documentType: docType,
|
||||
metadata: (docDetail?.doc_metadata || {}) as Record<string, string>,
|
||||
})
|
||||
}
|
||||
}, [docDetail?.doc_type, docDetail?.doc_metadata, docType])
|
||||
|
||||
const confirmDocType = () => {
|
||||
if (!tempDocType)
|
||||
return
|
||||
setMetadataParams({
|
||||
documentType: tempDocType,
|
||||
// Clear metadata when switching to a different doc type
|
||||
metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {},
|
||||
})
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
|
||||
const cancelDocType = () => {
|
||||
setTempDocType(metadataParams.documentType ?? '')
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
|
||||
const enableEdit = () => {
|
||||
setEditStatus(true)
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setMetadataParams({ documentType: docType || '', metadata: { ...(docDetail?.doc_metadata || {}) } })
|
||||
setEditStatus(!docType)
|
||||
if (!docType)
|
||||
setShowDocTypes(true)
|
||||
}
|
||||
|
||||
const saveMetadata = async () => {
|
||||
setSaveLoading(true)
|
||||
const [e] = await asyncRunSafe<CommonResponse>(modifyDocMetadata({
|
||||
datasetId,
|
||||
documentId,
|
||||
body: {
|
||||
doc_type: metadataParams.documentType || docType || '',
|
||||
doc_metadata: metadataParams.metadata,
|
||||
},
|
||||
}) as Promise<CommonResponse>)
|
||||
if (!e)
|
||||
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
else
|
||||
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
onUpdate?.()
|
||||
setEditStatus(false)
|
||||
setSaveLoading(false)
|
||||
}
|
||||
|
||||
const updateMetadataField = (field: string, value: string) => {
|
||||
setMetadataParams(prev => ({ ...prev, metadata: { ...prev.metadata, [field]: value } }))
|
||||
}
|
||||
|
||||
return {
|
||||
docType,
|
||||
editStatus,
|
||||
showDocTypes,
|
||||
tempDocType,
|
||||
saveLoading,
|
||||
metadataParams,
|
||||
setTempDocType,
|
||||
setShowDocTypes,
|
||||
confirmDocType,
|
||||
cancelDocType,
|
||||
enableEdit,
|
||||
cancelEdit,
|
||||
saveMetadata,
|
||||
updateMetadataField,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { FullDocumentDetail } from '@/models/datasets'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Metadata, { FieldInfo } from './index'
|
||||
|
||||
// Mock document context
|
||||
@@ -121,7 +120,6 @@ vi.mock('@/hooks/use-metadata', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock getTextWidthWithCanvas
|
||||
vi.mock('@/utils', () => ({
|
||||
asyncRunSafe: async (promise: Promise<unknown>) => {
|
||||
try {
|
||||
@@ -135,33 +133,32 @@ vi.mock('@/utils', () => ({
|
||||
getTextWidthWithCanvas: () => 100,
|
||||
}))
|
||||
|
||||
const createMockDocDetail = (overrides = {}): FullDocumentDetail => ({
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
doc_type: 'book',
|
||||
doc_metadata: {
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
language: 'en',
|
||||
},
|
||||
data_source_type: 'upload_file',
|
||||
segment_count: 10,
|
||||
hit_count: 5,
|
||||
...overrides,
|
||||
} as FullDocumentDetail)
|
||||
|
||||
describe('Metadata', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const createMockDocDetail = (overrides = {}): FullDocumentDetail => ({
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
doc_type: 'book',
|
||||
doc_metadata: {
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
language: 'en',
|
||||
},
|
||||
data_source_type: 'upload_file',
|
||||
segment_count: 10,
|
||||
hit_count: 5,
|
||||
...overrides,
|
||||
} as FullDocumentDetail)
|
||||
|
||||
const defaultProps = {
|
||||
docDetail: createMockDocDetail(),
|
||||
loading: false,
|
||||
onUpdate: vi.fn(),
|
||||
}
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
@@ -191,7 +188,7 @@ describe('Metadata', () => {
|
||||
// Arrange & Act
|
||||
render(<Metadata {...defaultProps} loading={true} />)
|
||||
|
||||
// Assert - Loading component should be rendered
|
||||
// Assert - Loading component should be rendered, title should not
|
||||
expect(screen.queryByText(/metadata\.title/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -204,7 +201,7 @@ describe('Metadata', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Edit mode tests
|
||||
// Edit mode (tests useMetadataState hook integration)
|
||||
describe('Edit Mode', () => {
|
||||
it('should enter edit mode when edit button is clicked', () => {
|
||||
// Arrange
|
||||
@@ -303,7 +300,7 @@ describe('Metadata', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Document type selection
|
||||
// Document type selection (tests DocTypeSelector sub-component integration)
|
||||
describe('Document Type Selection', () => {
|
||||
it('should show doc type selection when no doc_type exists', () => {
|
||||
// Arrange
|
||||
@@ -353,13 +350,13 @@ describe('Metadata', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Origin info and technical parameters
|
||||
// Fixed fields (tests MetadataFieldList sub-component integration)
|
||||
describe('Fixed Fields', () => {
|
||||
it('should render origin info fields', () => {
|
||||
// Arrange & Act
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Assert - Origin info fields should be displayed
|
||||
// Assert
|
||||
expect(screen.getByText('Data Source Type')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -382,7 +379,7 @@ describe('Metadata', () => {
|
||||
// Act
|
||||
const { container } = render(<Metadata {...defaultProps} docDetail={docDetail} />)
|
||||
|
||||
// Assert - should render without crashing
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -390,7 +387,7 @@ describe('Metadata', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Metadata {...defaultProps} docDetail={undefined} loading={false} />)
|
||||
|
||||
// Assert - should render without crashing
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -425,7 +422,6 @@ describe('Metadata', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// FieldInfo component tests
|
||||
describe('FieldInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -543,3 +539,149 @@ describe('FieldInfo', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- useMetadataState hook coverage tests (via component interactions) ---
|
||||
describe('useMetadataState coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
docDetail: createMockDocDetail(),
|
||||
loading: false,
|
||||
onUpdate: vi.fn(),
|
||||
}
|
||||
|
||||
describe('cancelDocType', () => {
|
||||
it('should cancel doc type change and return to edit mode', () => {
|
||||
// Arrange
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode → click change to open doc type selector
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
fireEvent.click(screen.getByText(/operation\.change/i))
|
||||
|
||||
// Now in doc type selector mode — should show cancel button
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
|
||||
// Act — cancel the doc type change
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/i))
|
||||
|
||||
// Assert — should be back to edit mode (cancel + save buttons visible)
|
||||
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmDocType', () => {
|
||||
it('should confirm same doc type and return to edit mode keeping metadata', () => {
|
||||
// Arrange — useEffect syncs tempDocType='book' from docDetail
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode → click change to open doc type selector
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
fireEvent.click(screen.getByText(/operation\.change/i))
|
||||
|
||||
// DocTypeSelector shows save/cancel buttons
|
||||
expect(screen.getByText(/metadata\.docTypeChangeTitle/i)).toBeInTheDocument()
|
||||
|
||||
// Act — click save to confirm same doc type (tempDocType='book')
|
||||
fireEvent.click(screen.getByText(/operation\.save/i))
|
||||
|
||||
// Assert — should return to edit mode with metadata fields visible
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelEdit when no docType', () => {
|
||||
it('should show doc type selection when cancel is clicked with doc_type others', () => {
|
||||
// Arrange — doc with 'others' type normalizes to '' internally.
|
||||
// The useEffect sees doc_type='others' (truthy) and syncs state,
|
||||
// so the component initially shows view mode. Enter edit → cancel to trigger cancelEdit.
|
||||
const docDetail = createMockDocDetail({ doc_type: 'others' })
|
||||
render(<Metadata {...defaultProps} docDetail={docDetail} />)
|
||||
|
||||
// 'others' is normalized to '' → useEffect fires (doc_type truthy) → view mode
|
||||
// The rendered type uses default 'book' fallback for display
|
||||
expect(screen.getByText(/operation\.edit/i)).toBeInTheDocument()
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
|
||||
// Act — cancel edit; internally docType is '' so cancelEdit goes to showDocTypes
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/i))
|
||||
|
||||
// Assert — should show doc type selection since normalized docType was ''
|
||||
expect(screen.getByText(/metadata\.docTypeSelectTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMetadataField', () => {
|
||||
it('should update metadata field value via input', () => {
|
||||
// Arrange
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
// Act — find an input and change its value (Title field)
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
fireEvent.change(inputs[0], { target: { value: 'Updated Title' } })
|
||||
|
||||
// Assert — the input should have the new value
|
||||
expect(inputs[0]).toHaveValue('Updated Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveMetadata calls modifyDocMetadata with correct body', () => {
|
||||
it('should pass doc_type and doc_metadata in save request', async () => {
|
||||
// Arrange
|
||||
mockModifyDocMetadata.mockResolvedValueOnce({})
|
||||
render(<Metadata {...defaultProps} />)
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText(/operation\.edit/i))
|
||||
|
||||
// Act — save
|
||||
fireEvent.click(screen.getByText(/operation\.save/i))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockModifyDocMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
datasetId: 'test-dataset-id',
|
||||
documentId: 'test-document-id',
|
||||
body: expect.objectContaining({
|
||||
doc_type: 'book',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEffect sync', () => {
|
||||
it('should handle doc_metadata being null in effect sync', () => {
|
||||
// Arrange — first render with null metadata
|
||||
const { rerender } = render(
|
||||
<Metadata
|
||||
{...defaultProps}
|
||||
docDetail={createMockDocDetail({ doc_metadata: null })}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act — rerender with a different doc_type to trigger useEffect sync
|
||||
rerender(
|
||||
<Metadata
|
||||
{...defaultProps}
|
||||
docDetail={createMockDocDetail({ doc_type: 'paper', doc_metadata: null })}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert — should render without crashing, showing Paper type
|
||||
expect(screen.getByText('Paper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,422 +1,124 @@
|
||||
'use client'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { inputType, metadataType } from '@/hooks/use-metadata'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { DocType, FullDocumentDetail } from '@/models/datasets'
|
||||
import type { FC } from 'react'
|
||||
import type { FullDocumentDetail } from '@/models/datasets'
|
||||
import { PencilIcon } from '@heroicons/react/24/outline'
|
||||
import { get } from 'es-toolkit/compat'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata'
|
||||
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
|
||||
import { modifyDocMetadata } from '@/service/datasets'
|
||||
import { asyncRunSafe, getTextWidthWithCanvas } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useDocumentContext } from '../context'
|
||||
import { useMetadataMap } from '@/hooks/use-metadata'
|
||||
import DocTypeSelector, { DocumentTypeDisplay } from './components/doc-type-selector'
|
||||
import MetadataFieldList from './components/metadata-field-list'
|
||||
import { useMetadataState } from './hooks/use-metadata-state'
|
||||
import s from './style.module.css'
|
||||
|
||||
const map2Options = (map: { [key: string]: string }) => {
|
||||
return Object.keys(map).map(key => ({ value: key, name: map[key] }))
|
||||
}
|
||||
export { default as FieldInfo } from './components/field-info'
|
||||
|
||||
type IFieldInfoProps = {
|
||||
label: string
|
||||
value?: string
|
||||
valueIcon?: ReactNode
|
||||
displayedValue?: string
|
||||
defaultValue?: string
|
||||
showEdit?: boolean
|
||||
inputType?: inputType
|
||||
selectOptions?: Array<{ value: string, name: string }>
|
||||
onUpdate?: (v: any) => void
|
||||
}
|
||||
|
||||
export const FieldInfo: FC<IFieldInfoProps> = ({
|
||||
label,
|
||||
value = '',
|
||||
valueIcon,
|
||||
displayedValue = '',
|
||||
defaultValue,
|
||||
showEdit = false,
|
||||
inputType = 'input',
|
||||
selectOptions = [],
|
||||
onUpdate,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190
|
||||
const editAlignTop = showEdit && inputType === 'textarea'
|
||||
const readAlignTop = !showEdit && textNeedWrap
|
||||
|
||||
const renderContent = () => {
|
||||
if (!showEdit)
|
||||
return displayedValue
|
||||
|
||||
if (inputType === 'select') {
|
||||
return (
|
||||
<SimpleSelect
|
||||
onSelect={({ value }) => onUpdate?.(value as string)}
|
||||
items={selectOptions}
|
||||
defaultValue={value}
|
||||
className={s.select}
|
||||
wrapperClassName={s.selectWrapper}
|
||||
placeholder={`${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (inputType === 'textarea') {
|
||||
return (
|
||||
<AutoHeightTextarea
|
||||
onChange={e => onUpdate?.(e.target.value)}
|
||||
value={value}
|
||||
className={s.textArea}
|
||||
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
onChange={e => onUpdate?.(e.target.value)}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-5 items-center gap-1 py-0.5 text-xs', editAlignTop && '!items-start', readAlignTop && '!items-start pt-1')}>
|
||||
<div className={cn('w-[200px] shrink-0 overflow-hidden text-ellipsis whitespace-nowrap text-text-tertiary', editAlignTop && 'pt-1')}>{label}</div>
|
||||
<div className="flex grow items-center gap-1 text-text-secondary">
|
||||
{valueIcon}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TypeIcon: FC<{ iconName: string, className?: string }> = ({ iconName, className = '' }) => {
|
||||
return (
|
||||
<div className={cn(s.commonIcon, s[`${iconName}Icon`], className)} />
|
||||
)
|
||||
}
|
||||
|
||||
const IconButton: FC<{
|
||||
type: DocType
|
||||
isChecked: boolean
|
||||
}> = ({ type, isChecked = false }) => {
|
||||
const metadataMap = useMetadataMap()
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={metadataMap[type].text}
|
||||
>
|
||||
<button type="button" className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<TypeIcon
|
||||
iconName={metadataMap[type].iconName || ''}
|
||||
className={`group-hover:bg-primary-600 ${isChecked ? '!bg-primary-600' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type IMetadataProps = {
|
||||
type MetadataProps = {
|
||||
docDetail?: FullDocumentDetail
|
||||
loading: boolean
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
type MetadataState = {
|
||||
documentType?: DocType | ''
|
||||
metadata: Record<string, string>
|
||||
}
|
||||
|
||||
const Metadata: FC<IMetadataProps> = ({ docDetail, loading, onUpdate }) => {
|
||||
const { doc_metadata = {} } = docDetail || {}
|
||||
const rawDocType = docDetail?.doc_type ?? ''
|
||||
const doc_type = rawDocType === 'others' ? '' : rawDocType
|
||||
|
||||
const Metadata: FC<MetadataProps> = ({ docDetail, loading, onUpdate }) => {
|
||||
const { t } = useTranslation()
|
||||
const metadataMap = useMetadataMap()
|
||||
const languageMap = useLanguages()
|
||||
const bookCategoryMap = useBookCategories()
|
||||
const personalDocCategoryMap = usePersonalDocCategories()
|
||||
const businessDocCategoryMap = useBusinessDocCategories()
|
||||
const [editStatus, setEditStatus] = useState(!doc_type) // if no documentType, in editing status by default
|
||||
// the initial values are according to the documentType
|
||||
const [metadataParams, setMetadataParams] = useState<MetadataState>(
|
||||
doc_type
|
||||
? {
|
||||
documentType: doc_type as DocType,
|
||||
metadata: (doc_metadata || {}) as Record<string, string>,
|
||||
}
|
||||
: { metadata: {} },
|
||||
)
|
||||
const [showDocTypes, setShowDocTypes] = useState(!doc_type) // whether show doc types
|
||||
const [tempDocType, setTempDocType] = useState<DocType | ''>('') // for remember icon click
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
|
||||
const { notify } = useContext(ToastContext)
|
||||
const datasetId = useDocumentContext(s => s.datasetId)
|
||||
const documentId = useDocumentContext(s => s.documentId)
|
||||
|
||||
useEffect(() => {
|
||||
if (docDetail?.doc_type) {
|
||||
setEditStatus(false)
|
||||
setShowDocTypes(false)
|
||||
setTempDocType(doc_type as DocType | '')
|
||||
setMetadataParams({
|
||||
documentType: doc_type as DocType | '',
|
||||
metadata: (docDetail?.doc_metadata || {}) as Record<string, string>,
|
||||
})
|
||||
}
|
||||
}, [docDetail?.doc_type, docDetail?.doc_metadata, doc_type])
|
||||
|
||||
// confirm doc type
|
||||
const confirmDocType = () => {
|
||||
if (!tempDocType)
|
||||
return
|
||||
setMetadataParams({
|
||||
documentType: tempDocType,
|
||||
metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {} as Record<string, string>, // change doc type, clear metadata
|
||||
})
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
|
||||
// cancel doc type
|
||||
const cancelDocType = () => {
|
||||
setTempDocType(metadataParams.documentType ?? '')
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
|
||||
// show doc type select
|
||||
const renderSelectDocType = () => {
|
||||
const { documentType } = metadataParams
|
||||
const {
|
||||
docType,
|
||||
editStatus,
|
||||
showDocTypes,
|
||||
tempDocType,
|
||||
saveLoading,
|
||||
metadataParams,
|
||||
setTempDocType,
|
||||
setShowDocTypes,
|
||||
confirmDocType,
|
||||
cancelDocType,
|
||||
enableEdit,
|
||||
cancelEdit,
|
||||
saveMetadata,
|
||||
updateMetadataField,
|
||||
} = useMetadataState({ docDetail, onUpdate })
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
{!doc_type && !documentType && (
|
||||
<>
|
||||
<div className={s.desc}>{t('metadata.desc', { ns: 'datasetDocuments' })}</div>
|
||||
</>
|
||||
)}
|
||||
<div className={s.operationWrapper}>
|
||||
{!doc_type && !documentType && (
|
||||
<>
|
||||
<span className={s.title}>{t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
{documentType && (
|
||||
<>
|
||||
<span className={s.title}>{t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}</span>
|
||||
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
<Radio.Group value={tempDocType ?? documentType ?? ''} onChange={setTempDocType} className={s.radioGroup}>
|
||||
{CUSTOMIZABLE_DOC_TYPES.map((type, index) => {
|
||||
const currValue = tempDocType ?? documentType
|
||||
return (
|
||||
<Radio key={index} value={type} className={`${s.radio} ${currValue === type ? 'shadow-none' : ''}`}>
|
||||
<IconButton
|
||||
type={type}
|
||||
isChecked={currValue === type}
|
||||
/>
|
||||
</Radio>
|
||||
)
|
||||
})}
|
||||
</Radio.Group>
|
||||
{!doc_type && !documentType && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={confirmDocType}
|
||||
disabled={!tempDocType}
|
||||
>
|
||||
{t('metadata.firstMetaAction', { ns: 'datasetDocuments' })}
|
||||
</Button>
|
||||
)}
|
||||
{documentType && (
|
||||
<div className={s.opBtnWrapper}>
|
||||
<Button onClick={confirmDocType} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary">{t('operation.save', { ns: 'common' })}</Button>
|
||||
<Button onClick={cancelDocType} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// show metadata info and edit
|
||||
const renderFieldInfos = ({ mainField = 'book', canEdit }: { mainField?: metadataType | '', canEdit?: boolean }) => {
|
||||
if (!mainField)
|
||||
return null
|
||||
const fieldMap = metadataMap[mainField]?.subFieldsMap
|
||||
const sourceData = ['originInfo', 'technicalParameters'].includes(mainField) ? docDetail : metadataParams.metadata
|
||||
|
||||
const getTargetMap = (field: string) => {
|
||||
if (field === 'language')
|
||||
return languageMap
|
||||
if (field === 'category' && mainField === 'book')
|
||||
return bookCategoryMap
|
||||
|
||||
if (field === 'document_type') {
|
||||
if (mainField === 'personal_document')
|
||||
return personalDocCategoryMap
|
||||
if (mainField === 'business_document')
|
||||
return businessDocCategoryMap
|
||||
}
|
||||
return {} as any
|
||||
}
|
||||
|
||||
const getTargetValue = (field: string) => {
|
||||
const val = get(sourceData, field, '')
|
||||
if (!val && val !== 0)
|
||||
return '-'
|
||||
if (fieldMap[field]?.inputType === 'select')
|
||||
return getTargetMap(field)[val]
|
||||
if (fieldMap[field]?.render)
|
||||
return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined)
|
||||
return val
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.keys(fieldMap).map((field) => {
|
||||
return (
|
||||
<FieldInfo
|
||||
key={fieldMap[field]?.label}
|
||||
label={fieldMap[field]?.label}
|
||||
displayedValue={getTargetValue(field)}
|
||||
value={get(sourceData, field, '')}
|
||||
inputType={fieldMap[field]?.inputType || 'input'}
|
||||
showEdit={canEdit}
|
||||
onUpdate={(val) => {
|
||||
setMetadataParams(pre => ({ ...pre, metadata: { ...pre.metadata, [field]: val } }))
|
||||
}}
|
||||
selectOptions={map2Options(getTargetMap(field))}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div className={`${s.main} bg-gray-25`}>
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const enabledEdit = () => {
|
||||
setEditStatus(true)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setMetadataParams({ documentType: doc_type || '', metadata: { ...docDetail?.doc_metadata } })
|
||||
setEditStatus(!doc_type)
|
||||
if (!doc_type)
|
||||
setShowDocTypes(true)
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
setSaveLoading(true)
|
||||
const [e] = await asyncRunSafe<CommonResponse>(modifyDocMetadata({
|
||||
datasetId,
|
||||
documentId,
|
||||
body: {
|
||||
doc_type: metadataParams.documentType || doc_type || '',
|
||||
doc_metadata: metadataParams.metadata,
|
||||
},
|
||||
}) as Promise<CommonResponse>)
|
||||
if (!e)
|
||||
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
else
|
||||
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
onUpdate?.()
|
||||
setEditStatus(false)
|
||||
setSaveLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${s.main} ${editStatus ? 'bg-white' : 'bg-gray-25'}`}>
|
||||
{loading
|
||||
? (<Loading type="app" />)
|
||||
: (
|
||||
<>
|
||||
<div className={s.titleWrapper}>
|
||||
<span className={s.title}>{t('metadata.title', { ns: 'datasetDocuments' })}</span>
|
||||
{!editStatus
|
||||
? (
|
||||
<Button onClick={enabledEdit} className={`${s.opBtn} ${s.opEditBtn}`}>
|
||||
<PencilIcon className={s.opIcon} />
|
||||
{t('operation.edit', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
: showDocTypes
|
||||
? null
|
||||
: (
|
||||
<div className={s.opBtnWrapper}>
|
||||
<Button onClick={onCancel} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
className={`${s.opBtn} ${s.opSaveBtn}`}
|
||||
variant="primary"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Header: title + action buttons */}
|
||||
<div className={s.titleWrapper}>
|
||||
<span className={s.title}>{t('metadata.title', { ns: 'datasetDocuments' })}</span>
|
||||
{!editStatus
|
||||
? (
|
||||
<Button onClick={enableEdit} className={`${s.opBtn} ${s.opEditBtn}`}>
|
||||
<PencilIcon className={s.opIcon} />
|
||||
{t('operation.edit', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
: !showDocTypes && (
|
||||
<div className={s.opBtnWrapper}>
|
||||
<Button onClick={cancelEdit} className={`${s.opBtn} ${s.opCancelBtn}`}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button onClick={saveMetadata} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary" loading={saveLoading}>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
{/* show selected doc type and changing entry */}
|
||||
{!editStatus
|
||||
? (
|
||||
<div className={s.documentTypeShow}>
|
||||
<TypeIcon iconName={metadataMap[doc_type || 'book']?.iconName || ''} className={s.iconShow} />
|
||||
{metadataMap[doc_type || 'book'].text}
|
||||
</div>
|
||||
)
|
||||
: showDocTypes
|
||||
? null
|
||||
: (
|
||||
<div className={s.documentTypeShow}>
|
||||
{metadataParams.documentType && (
|
||||
<>
|
||||
<TypeIcon iconName={metadataMap[metadataParams.documentType || 'book'].iconName || ''} className={s.iconShow} />
|
||||
{metadataMap[metadataParams.documentType || 'book'].text}
|
||||
{editStatus && (
|
||||
<div className="ml-1 inline-flex items-center gap-1">
|
||||
·
|
||||
<div
|
||||
onClick={() => { setShowDocTypes(true) }}
|
||||
className="cursor-pointer hover:text-text-accent"
|
||||
>
|
||||
{t('operation.change', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(!doc_type && showDocTypes) ? null : <Divider />}
|
||||
{showDocTypes ? renderSelectDocType() : renderFieldInfos({ mainField: metadataParams.documentType, canEdit: editStatus })}
|
||||
{/* show fixed fields */}
|
||||
<Divider />
|
||||
{renderFieldInfos({ mainField: 'originInfo', canEdit: false })}
|
||||
<div className={`${s.title} mt-8`}>{metadataMap.technicalParameters.text}</div>
|
||||
<Divider />
|
||||
{renderFieldInfos({ mainField: 'technicalParameters', canEdit: false })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document type display / selector */}
|
||||
{!editStatus
|
||||
? <DocumentTypeDisplay displayType={docType} />
|
||||
: showDocTypes
|
||||
? null
|
||||
: (
|
||||
<DocumentTypeDisplay
|
||||
displayType={metadataParams.documentType || ''}
|
||||
showChangeLink={editStatus}
|
||||
onChangeClick={() => setShowDocTypes(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Divider between type display and fields (skip when in first-time selection) */}
|
||||
{(!docType && showDocTypes) ? null : <Divider />}
|
||||
|
||||
{/* Doc type selector or editable metadata fields */}
|
||||
{showDocTypes
|
||||
? (
|
||||
<DocTypeSelector
|
||||
docType={docType}
|
||||
documentType={metadataParams.documentType}
|
||||
tempDocType={tempDocType}
|
||||
onTempDocTypeChange={setTempDocType}
|
||||
onConfirm={confirmDocType}
|
||||
onCancel={cancelDocType}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<MetadataFieldList
|
||||
mainField={metadataParams.documentType || ''}
|
||||
canEdit={editStatus}
|
||||
metadata={metadataParams.metadata}
|
||||
docDetail={docDetail}
|
||||
onFieldUpdate={updateMetadataField}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fixed fields: origin info */}
|
||||
<Divider />
|
||||
<MetadataFieldList mainField="originInfo" docDetail={docDetail} />
|
||||
|
||||
{/* Fixed fields: technical parameters */}
|
||||
<div className={`${s.title} mt-8`}>{metadataMap.technicalParameters.text}</div>
|
||||
<Divider />
|
||||
<MetadataFieldList mainField="technicalParameters" docDetail={docDetail} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { env } from '@/env'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import AccountAbout from '../account-about'
|
||||
@@ -178,7 +179,7 @@ export default function AppSelector() {
|
||||
</Link>
|
||||
</MenuItem>
|
||||
{
|
||||
document?.body?.getAttribute('data-public-site-about') !== 'hide' && (
|
||||
env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { SerwistProvider } from '@serwist/turbopack/react'
|
||||
import { useEffect } from 'react'
|
||||
import { IS_DEV } from '@/config'
|
||||
import { env } from '@/env'
|
||||
import { isClient } from '@/utils/client'
|
||||
|
||||
export function PWAProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -10,7 +11,7 @@ export function PWAProvider({ children }: { children: React.ReactNode }) {
|
||||
return <DisabledPWAProvider>{children}</DisabledPWAProvider>
|
||||
}
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
|
||||
const basePath = env.NEXT_PUBLIC_BASE_PATH
|
||||
const swUrl = `${basePath}/serwist/sw.js`
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { SchemaOptions } from './types'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import { InputTypeEnum } from '@/app/components/base/form/components/field/input-type-select/types'
|
||||
import { MAX_VAR_KEY_LENGTH } from '@/config'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DSLImportStatus } from '@/models/app'
|
||||
import UpdateDSLModal from './update-dsl-modal'
|
||||
|
||||
@@ -145,11 +145,6 @@ vi.mock('@/app/components/workflow/constants', () => ({
|
||||
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('UpdateDSLModal', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnBackup = vi.fn()
|
||||
|
||||
@@ -1,40 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import type { MouseEventHandler } from 'react'
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiCloseLine,
|
||||
RiFileDownloadLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { WORKFLOW_DATA_UPDATE } from '@/app/components/workflow/constants'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
} from '@/app/components/workflow/utils'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import {
|
||||
DSLImportMode,
|
||||
DSLImportStatus,
|
||||
} from '@/models/app'
|
||||
import {
|
||||
useImportPipelineDSL,
|
||||
useImportPipelineDSLConfirm,
|
||||
} from '@/service/use-pipeline'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { useUpdateDSLModal } from '../hooks/use-update-dsl-modal'
|
||||
import VersionMismatchModal from './version-mismatch-modal'
|
||||
|
||||
type UpdateDSLModalProps = {
|
||||
onCancel: () => void
|
||||
@@ -48,146 +25,17 @@ const UpdateDSLModal = ({
|
||||
onImport,
|
||||
}: UpdateDSLModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [currentFile, setDSLFile] = useState<File>()
|
||||
const [fileContent, setFileContent] = useState<string>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const [show, setShow] = useState(true)
|
||||
const [showErrorModal, setShowErrorModal] = useState(false)
|
||||
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
|
||||
const [importId, setImportId] = useState<string>()
|
||||
const { handleCheckPluginDependencies } = usePluginDependencies()
|
||||
const { mutateAsync: importDSL } = useImportPipelineDSL()
|
||||
const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const readFile = (file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = function (event) {
|
||||
const content = event.target?.result
|
||||
setFileContent(content as string)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleFile = (file?: File) => {
|
||||
setDSLFile(file)
|
||||
if (file)
|
||||
readFile(file)
|
||||
if (!file)
|
||||
setFileContent('')
|
||||
}
|
||||
|
||||
const handleWorkflowUpdate = useCallback(async (pipelineId: string) => {
|
||||
const {
|
||||
graph,
|
||||
hash,
|
||||
rag_pipeline_variables,
|
||||
} = await fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`)
|
||||
|
||||
const { nodes, edges, viewport } = graph
|
||||
|
||||
eventEmitter?.emit({
|
||||
type: WORKFLOW_DATA_UPDATE,
|
||||
payload: {
|
||||
nodes: initialNodes(nodes, edges),
|
||||
edges: initialEdges(edges, nodes),
|
||||
viewport,
|
||||
hash,
|
||||
rag_pipeline_variables: rag_pipeline_variables || [],
|
||||
},
|
||||
} as any)
|
||||
}, [eventEmitter])
|
||||
|
||||
const isCreatingRef = useRef(false)
|
||||
const handleImport: MouseEventHandler = useCallback(async () => {
|
||||
const { pipelineId } = workflowStore.getState()
|
||||
if (isCreatingRef.current)
|
||||
return
|
||||
isCreatingRef.current = true
|
||||
if (!currentFile)
|
||||
return
|
||||
try {
|
||||
if (pipelineId && fileContent) {
|
||||
setLoading(true)
|
||||
const response = await importDSL({ mode: DSLImportMode.YAML_CONTENT, yaml_content: fileContent, pipeline_id: pipelineId })
|
||||
const { id, status, pipeline_id, imported_dsl_version, current_dsl_version } = response
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
if (!pipeline_id) {
|
||||
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
|
||||
return
|
||||
}
|
||||
handleWorkflowUpdate(pipeline_id)
|
||||
if (onImport)
|
||||
onImport()
|
||||
notify({
|
||||
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
|
||||
message: t(status === DSLImportStatus.COMPLETED ? 'common.importSuccess' : 'common.importWarning', { ns: 'workflow' }),
|
||||
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('common.importWarningDetails', { ns: 'workflow' }),
|
||||
})
|
||||
await handleCheckPluginDependencies(pipeline_id, true)
|
||||
setLoading(false)
|
||||
onCancel()
|
||||
}
|
||||
else if (status === DSLImportStatus.PENDING) {
|
||||
setShow(false)
|
||||
setTimeout(() => {
|
||||
setShowErrorModal(true)
|
||||
}, 300)
|
||||
setVersions({
|
||||
importedVersion: imported_dsl_version ?? '',
|
||||
systemVersion: current_dsl_version ?? '',
|
||||
})
|
||||
setImportId(id)
|
||||
}
|
||||
else {
|
||||
setLoading(false)
|
||||
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
setLoading(false)
|
||||
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
|
||||
}
|
||||
isCreatingRef.current = false
|
||||
}, [currentFile, fileContent, onCancel, notify, t, onImport, handleWorkflowUpdate, handleCheckPluginDependencies, workflowStore, importDSL])
|
||||
|
||||
const onUpdateDSLConfirm: MouseEventHandler = async () => {
|
||||
try {
|
||||
if (!importId)
|
||||
return
|
||||
const response = await importDSLConfirm(importId)
|
||||
|
||||
const { status, pipeline_id } = response
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
if (!pipeline_id) {
|
||||
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
|
||||
return
|
||||
}
|
||||
handleWorkflowUpdate(pipeline_id)
|
||||
await handleCheckPluginDependencies(pipeline_id, true)
|
||||
if (onImport)
|
||||
onImport()
|
||||
notify({ type: 'success', message: t('common.importSuccess', { ns: 'workflow' }) })
|
||||
setLoading(false)
|
||||
onCancel()
|
||||
}
|
||||
else if (status === DSLImportStatus.FAILED) {
|
||||
setLoading(false)
|
||||
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
setLoading(false)
|
||||
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
|
||||
}
|
||||
}
|
||||
const {
|
||||
currentFile,
|
||||
handleFile,
|
||||
show,
|
||||
showErrorModal,
|
||||
setShowErrorModal,
|
||||
loading,
|
||||
versions,
|
||||
handleImport,
|
||||
onUpdateDSLConfirm,
|
||||
} = useUpdateDSLModal({ onCancel, onImport })
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -250,32 +98,12 @@ const UpdateDSLModal = ({
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
<VersionMismatchModal
|
||||
isShow={showErrorModal}
|
||||
versions={versions}
|
||||
onClose={() => setShowErrorModal(false)}
|
||||
className="w-[480px]"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
|
||||
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
|
||||
<div className="system-md-regular flex grow flex-col text-text-secondary">
|
||||
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
||||
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
|
||||
<br />
|
||||
<div>
|
||||
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
|
||||
<span className="system-md-medium">{versions?.importedVersion}</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
|
||||
<span className="system-md-medium">{versions?.systemVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
|
||||
<Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t('newApp.Cancel', { ns: 'app' })}</Button>
|
||||
<Button variant="primary" destructive onClick={onUpdateDSLConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
onConfirm={onUpdateDSLConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import VersionMismatchModal from './version-mismatch-modal'
|
||||
|
||||
describe('VersionMismatchModal', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnConfirm = vi.fn()
|
||||
|
||||
const defaultVersions = {
|
||||
importedVersion: '0.8.0',
|
||||
systemVersion: '1.0.0',
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
isShow: true,
|
||||
versions: defaultVersions,
|
||||
onClose: mockOnClose,
|
||||
onConfirm: mockOnConfirm,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render dialog when isShow is true', () => {
|
||||
render(<VersionMismatchModal {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render dialog when isShow is false', () => {
|
||||
render(<VersionMismatchModal {...defaultProps} isShow={false} />)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error title', () => {
|
||||
render(<VersionMismatchModal {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all error description parts', () => {
|
||||
render(<VersionMismatchModal {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorPart1')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorPart2')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorPart3')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorPart4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display imported and system version numbers', () => {
|
||||
render(<VersionMismatchModal {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('0.8.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel and confirm buttons', () => {
|
||||
render(<VersionMismatchModal {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /app\.newApp\.Cancel/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /app\.newApp\.Confirm/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('user interactions', () => {
|
||||
it('should call onClose when cancel button is clicked', () => {
|
||||
render(<VersionMismatchModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Cancel/ }))
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onConfirm when confirm button is clicked', () => {
|
||||
render(<VersionMismatchModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Confirm/ }))
|
||||
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('button variants', () => {
|
||||
it('should render cancel button with secondary variant', () => {
|
||||
render(<VersionMismatchModal {...defaultProps} />)
|
||||
|
||||
const cancelBtn = screen.getByRole('button', { name: /app\.newApp\.Cancel/ })
|
||||
expect(cancelBtn).toHaveClass('btn-secondary')
|
||||
})
|
||||
|
||||
it('should render confirm button with primary destructive variant', () => {
|
||||
render(<VersionMismatchModal {...defaultProps} />)
|
||||
|
||||
const confirmBtn = screen.getByRole('button', { name: /app\.newApp\.Confirm/ })
|
||||
expect(confirmBtn).toHaveClass('btn-primary')
|
||||
expect(confirmBtn).toHaveClass('btn-destructive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle undefined versions gracefully', () => {
|
||||
render(<VersionMismatchModal {...defaultProps} versions={undefined} />)
|
||||
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty version strings', () => {
|
||||
const emptyVersions = { importedVersion: '', systemVersion: '' }
|
||||
render(<VersionMismatchModal {...defaultProps} versions={emptyVersions} />)
|
||||
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { MouseEventHandler } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
|
||||
type VersionMismatchModalProps = {
|
||||
isShow: boolean
|
||||
versions?: {
|
||||
importedVersion: string
|
||||
systemVersion: string
|
||||
}
|
||||
onClose: () => void
|
||||
onConfirm: MouseEventHandler
|
||||
}
|
||||
|
||||
const VersionMismatchModal = ({
|
||||
isShow,
|
||||
versions,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: VersionMismatchModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={isShow}
|
||||
onClose={onClose}
|
||||
className="w-[480px]"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
|
||||
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
|
||||
<div className="system-md-regular flex grow flex-col text-text-secondary">
|
||||
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
||||
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
|
||||
<br />
|
||||
<div>
|
||||
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
|
||||
<span className="system-md-medium">{versions?.importedVersion}</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
|
||||
<span className="system-md-medium">{versions?.systemVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
|
||||
<Button variant="secondary" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button>
|
||||
<Button variant="primary" destructive onClick={onConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default VersionMismatchModal
|
||||
@@ -68,23 +68,20 @@ vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
}))
|
||||
|
||||
// Mock postWithKeepalive from service/fetch
|
||||
const mockPostWithKeepalive = vi.fn()
|
||||
vi.mock('@/service/fetch', () => ({
|
||||
postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('useNodesSyncDraft', () => {
|
||||
const mockSendBeacon = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup navigator.sendBeacon mock
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
value: mockSendBeacon,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// Default store state
|
||||
mockStoreGetState.mockReturnValue({
|
||||
getNodes: mockGetNodes,
|
||||
@@ -134,7 +131,7 @@ describe('useNodesSyncDraft', () => {
|
||||
})
|
||||
|
||||
describe('syncWorkflowDraftWhenPageClose', () => {
|
||||
it('should not call sendBeacon when nodes are read only', () => {
|
||||
it('should not call postWithKeepalive when nodes are read only', () => {
|
||||
mockGetNodesReadOnly.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
@@ -143,10 +140,10 @@ describe('useNodesSyncDraft', () => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
expect(mockSendBeacon).not.toHaveBeenCalled()
|
||||
expect(mockPostWithKeepalive).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call sendBeacon with correct URL and params', () => {
|
||||
it('should call postWithKeepalive with correct URL and params', () => {
|
||||
mockGetNodesReadOnly.mockReturnValue(false)
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
|
||||
@@ -158,13 +155,16 @@ describe('useNodesSyncDraft', () => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
expect(mockSendBeacon).toHaveBeenCalledWith(
|
||||
expect(mockPostWithKeepalive).toHaveBeenCalledWith(
|
||||
'/api/rag/pipelines/test-pipeline-id/workflows/draft',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
graph: expect.any(Object),
|
||||
hash: 'test-hash',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not call sendBeacon when pipelineId is missing', () => {
|
||||
it('should not call postWithKeepalive when pipelineId is missing', () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
pipelineId: undefined,
|
||||
environmentVariables: [],
|
||||
@@ -178,10 +178,10 @@ describe('useNodesSyncDraft', () => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
expect(mockSendBeacon).not.toHaveBeenCalled()
|
||||
expect(mockPostWithKeepalive).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call sendBeacon when nodes array is empty', () => {
|
||||
it('should not call postWithKeepalive when nodes array is empty', () => {
|
||||
mockGetNodes.mockReturnValue([])
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
@@ -190,7 +190,7 @@ describe('useNodesSyncDraft', () => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
expect(mockSendBeacon).not.toHaveBeenCalled()
|
||||
expect(mockPostWithKeepalive).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should filter out temp nodes', () => {
|
||||
@@ -204,8 +204,8 @@ describe('useNodesSyncDraft', () => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
// Should not call sendBeacon because after filtering temp nodes, array is empty
|
||||
expect(mockSendBeacon).not.toHaveBeenCalled()
|
||||
// Should not call postWithKeepalive because after filtering temp nodes, array is empty
|
||||
expect(mockPostWithKeepalive).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove underscore-prefixed data keys from nodes', () => {
|
||||
@@ -219,9 +219,9 @@ describe('useNodesSyncDraft', () => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
expect(mockSendBeacon).toHaveBeenCalled()
|
||||
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
|
||||
expect(sentData.graph.nodes[0].data._privateData).toBeUndefined()
|
||||
expect(mockPostWithKeepalive).toHaveBeenCalled()
|
||||
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
|
||||
expect(sentParams.graph.nodes[0].data._privateData).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -395,8 +395,8 @@ describe('useNodesSyncDraft', () => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
|
||||
expect(sentData.graph.viewport).toEqual({ x: 100, y: 200, zoom: 1.5 })
|
||||
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
|
||||
expect(sentParams.graph.viewport).toEqual({ x: 100, y: 200, zoom: 1.5 })
|
||||
})
|
||||
|
||||
it('should include environment variables in params', () => {
|
||||
@@ -418,8 +418,8 @@ describe('useNodesSyncDraft', () => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
|
||||
expect(sentData.environment_variables).toEqual([{ key: 'API_KEY', value: 'secret' }])
|
||||
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
|
||||
expect(sentParams.environment_variables).toEqual([{ key: 'API_KEY', value: 'secret' }])
|
||||
})
|
||||
|
||||
it('should include rag pipeline variables in params', () => {
|
||||
@@ -441,8 +441,8 @@ describe('useNodesSyncDraft', () => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
|
||||
expect(sentData.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }])
|
||||
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
|
||||
expect(sentParams.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }])
|
||||
})
|
||||
|
||||
it('should remove underscore-prefixed keys from edges', () => {
|
||||
@@ -461,9 +461,9 @@ describe('useNodesSyncDraft', () => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1])
|
||||
expect(sentData.graph.edges[0].data._hidden).toBeUndefined()
|
||||
expect(sentData.graph.edges[0].data.visible).toBe(false)
|
||||
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
|
||||
expect(sentParams.graph.edges[0].data._hidden).toBeUndefined()
|
||||
expect(sentParams.graph.edges[0].data.visible).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useWorkflowStore,
|
||||
} from '@/app/components/workflow/store'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { postWithKeepalive } from '@/service/fetch'
|
||||
import { syncWorkflowDraft } from '@/service/workflow'
|
||||
import { usePipelineRefreshDraft } from '.'
|
||||
|
||||
@@ -76,12 +77,8 @@ export const useNodesSyncDraft = () => {
|
||||
return
|
||||
const postParams = getPostParams()
|
||||
|
||||
if (postParams) {
|
||||
navigator.sendBeacon(
|
||||
`${API_PREFIX}${postParams.url}`,
|
||||
JSON.stringify(postParams.params),
|
||||
)
|
||||
}
|
||||
if (postParams)
|
||||
postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params)
|
||||
}, [getPostParams, getNodesReadOnly])
|
||||
|
||||
const performSync = useCallback(async (
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DSLImportMode, DSLImportStatus } from '@/models/app'
|
||||
import { useUpdateDSLModal } from './use-update-dsl-modal'
|
||||
|
||||
// --- FileReader stub ---
|
||||
class MockFileReader {
|
||||
onload: ((this: FileReader, event: ProgressEvent<FileReader>) => void) | null = null
|
||||
|
||||
readAsText(_file: Blob) {
|
||||
const event = { target: { result: 'test content' } } as unknown as ProgressEvent<FileReader>
|
||||
this.onload?.call(this as unknown as FileReader, event)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader)
|
||||
|
||||
// --- Module-level mock functions ---
|
||||
const mockNotify = vi.fn()
|
||||
const mockEmit = vi.fn()
|
||||
const mockImportDSL = vi.fn()
|
||||
const mockImportDSLConfirm = vi.fn()
|
||||
const mockHandleCheckPluginDependencies = vi.fn()
|
||||
|
||||
// --- Mocks ---
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
ToastContext: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: { emit: mockEmit },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({ pipelineId: 'test-pipeline-id' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
initialNodes: (nodes: unknown[]) => nodes,
|
||||
initialEdges: (edges: unknown[]) => edges,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
usePluginDependencies: () => ({
|
||||
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useImportPipelineDSL: () => ({ mutateAsync: mockImportDSL }),
|
||||
useImportPipelineDSLConfirm: () => ({ mutateAsync: mockImportDSLConfirm }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: vi.fn().mockResolvedValue({
|
||||
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
hash: 'test-hash',
|
||||
rag_pipeline_variables: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
// --- Helpers ---
|
||||
const createFile = () => new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
|
||||
|
||||
// Cast MouseEventHandler to a plain callable for tests (event param is unused)
|
||||
type AsyncFn = () => Promise<void>
|
||||
|
||||
describe('useUpdateDSLModal', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnImport = vi.fn()
|
||||
|
||||
const renderUpdateDSLModal = (overrides?: { onImport?: () => void }) =>
|
||||
renderHook(() =>
|
||||
useUpdateDSLModal({
|
||||
onCancel: mockOnCancel,
|
||||
onImport: overrides?.onImport ?? mockOnImport,
|
||||
}),
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
})
|
||||
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
// Initial state values
|
||||
describe('initial state', () => {
|
||||
it('should return correct defaults', () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
|
||||
expect(result.current.currentFile).toBeUndefined()
|
||||
expect(result.current.show).toBe(true)
|
||||
expect(result.current.showErrorModal).toBe(false)
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.versions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// File handling
|
||||
describe('handleFile', () => {
|
||||
it('should set currentFile when file is provided', () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
const file = createFile()
|
||||
|
||||
act(() => {
|
||||
result.current.handleFile(file)
|
||||
})
|
||||
|
||||
expect(result.current.currentFile).toBe(file)
|
||||
})
|
||||
|
||||
it('should clear currentFile when called with undefined', () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleFile(undefined)
|
||||
})
|
||||
|
||||
expect(result.current.currentFile).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// Modal state management
|
||||
describe('modal state', () => {
|
||||
it('should allow toggling showErrorModal', () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
|
||||
expect(result.current.showErrorModal).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.setShowErrorModal(true)
|
||||
})
|
||||
expect(result.current.showErrorModal).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.setShowErrorModal(false)
|
||||
})
|
||||
expect(result.current.showErrorModal).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Import flow
|
||||
describe('handleImport', () => {
|
||||
it('should call importDSL with correct parameters', async () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockImportDSL).toHaveBeenCalledWith({
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: 'test content',
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call importDSL when no file is selected', async () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockImportDSL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// COMPLETED status
|
||||
it('should notify success on COMPLETED status', async () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
|
||||
})
|
||||
|
||||
it('should call onImport on successful import', async () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockOnImport).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel on successful import', async () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should emit workflow update event on success', async () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockEmit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleCheckPluginDependencies on success', async () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('test-pipeline-id', true)
|
||||
})
|
||||
|
||||
// COMPLETED_WITH_WARNINGS status
|
||||
it('should notify warning on COMPLETED_WITH_WARNINGS status', async () => {
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.COMPLETED_WITH_WARNINGS,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'warning' }))
|
||||
})
|
||||
|
||||
// PENDING status (version mismatch)
|
||||
it('should switch to version mismatch modal on PENDING status', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.PENDING,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
imported_dsl_version: '0.8.0',
|
||||
current_dsl_version: '1.0.0',
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
|
||||
expect(result.current.show).toBe(false)
|
||||
expect(result.current.showErrorModal).toBe(true)
|
||||
expect(result.current.versions).toEqual({
|
||||
importedVersion: '0.8.0',
|
||||
systemVersion: '1.0.0',
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should default version strings to empty when undefined', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.PENDING,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
imported_dsl_version: undefined,
|
||||
current_dsl_version: undefined,
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
|
||||
expect(result.current.versions).toEqual({
|
||||
importedVersion: '',
|
||||
systemVersion: '',
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// FAILED / unknown status
|
||||
it('should notify error on FAILED status', async () => {
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.FAILED,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
// Exception
|
||||
it('should notify error when importDSL throws', async () => {
|
||||
mockImportDSL.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
// Missing pipeline_id
|
||||
it('should notify error when pipeline_id is missing on success', async () => {
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
pipeline_id: undefined,
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
})
|
||||
|
||||
// Confirm flow (after PENDING → version mismatch)
|
||||
describe('onUpdateDSLConfirm', () => {
|
||||
// Helper: drive the hook into PENDING state so importId is set
|
||||
const setupPendingState = async (result: { current: ReturnType<typeof useUpdateDSLModal> }) => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
|
||||
mockImportDSL.mockResolvedValue({
|
||||
id: 'import-id',
|
||||
status: DSLImportStatus.PENDING,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
imported_dsl_version: '0.8.0',
|
||||
current_dsl_version: '1.0.0',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
it('should call importDSLConfirm with the stored importId', async () => {
|
||||
mockImportDSLConfirm.mockResolvedValue({
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
await setupPendingState(result)
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-id')
|
||||
})
|
||||
|
||||
it('should notify success and call onCancel after successful confirm', async () => {
|
||||
mockImportDSLConfirm.mockResolvedValue({
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
await setupPendingState(result)
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onImport after successful confirm', async () => {
|
||||
mockImportDSLConfirm.mockResolvedValue({
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
await setupPendingState(result)
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockOnImport).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should notify error on FAILED confirm status', async () => {
|
||||
mockImportDSLConfirm.mockResolvedValue({
|
||||
status: DSLImportStatus.FAILED,
|
||||
pipeline_id: 'test-pipeline-id',
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
await setupPendingState(result)
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should notify error when confirm throws exception', async () => {
|
||||
mockImportDSLConfirm.mockRejectedValue(new Error('Confirm failed'))
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
await setupPendingState(result)
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should notify error when confirm succeeds but pipeline_id is missing', async () => {
|
||||
mockImportDSLConfirm.mockResolvedValue({
|
||||
status: DSLImportStatus.COMPLETED,
|
||||
pipeline_id: undefined,
|
||||
})
|
||||
|
||||
const { result } = renderUpdateDSLModal()
|
||||
await setupPendingState(result)
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should not call importDSLConfirm when importId is not set', async () => {
|
||||
const { result } = renderUpdateDSLModal()
|
||||
|
||||
// No pending state → importId is undefined
|
||||
await act(async () => {
|
||||
await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
expect(mockImportDSLConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Optional onImport callback
|
||||
describe('optional onImport', () => {
|
||||
it('should work without onImport callback', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUpdateDSLModal({ onCancel: mockOnCancel }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleFile(createFile())
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await (result.current.handleImport as unknown as AsyncFn)()
|
||||
})
|
||||
|
||||
// Should succeed without throwing
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
205
web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts
Normal file
205
web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { MouseEventHandler } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { WORKFLOW_DATA_UPDATE } from '@/app/components/workflow/constants'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
} from '@/app/components/workflow/utils'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import {
|
||||
DSLImportMode,
|
||||
DSLImportStatus,
|
||||
} from '@/models/app'
|
||||
import {
|
||||
useImportPipelineDSL,
|
||||
useImportPipelineDSLConfirm,
|
||||
} from '@/service/use-pipeline'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
|
||||
type VersionInfo = {
|
||||
importedVersion: string
|
||||
systemVersion: string
|
||||
}
|
||||
|
||||
type UseUpdateDSLModalParams = {
|
||||
onCancel: () => void
|
||||
onImport?: () => void
|
||||
}
|
||||
|
||||
const isCompletedStatus = (status: DSLImportStatus): boolean =>
|
||||
status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS
|
||||
|
||||
export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParams) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleCheckPluginDependencies } = usePluginDependencies()
|
||||
const { mutateAsync: importDSL } = useImportPipelineDSL()
|
||||
const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm()
|
||||
|
||||
// File state
|
||||
const [currentFile, setDSLFile] = useState<File>()
|
||||
const [fileContent, setFileContent] = useState<string>()
|
||||
|
||||
// Modal state
|
||||
const [show, setShow] = useState(true)
|
||||
const [showErrorModal, setShowErrorModal] = useState(false)
|
||||
|
||||
// Import state
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [versions, setVersions] = useState<VersionInfo>()
|
||||
const [importId, setImportId] = useState<string>()
|
||||
const isCreatingRef = useRef(false)
|
||||
|
||||
const readFile = (file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
setFileContent(event.target?.result as string)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleFile = (file?: File) => {
|
||||
setDSLFile(file)
|
||||
if (file)
|
||||
readFile(file)
|
||||
if (!file)
|
||||
setFileContent('')
|
||||
}
|
||||
|
||||
const notifyError = useCallback(() => {
|
||||
setLoading(false)
|
||||
notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) })
|
||||
}, [notify, t])
|
||||
|
||||
const updateWorkflow = useCallback(async (pipelineId: string) => {
|
||||
const { graph, hash, rag_pipeline_variables } = await fetchWorkflowDraft(
|
||||
`/rag/pipelines/${pipelineId}/workflows/draft`,
|
||||
)
|
||||
const { nodes, edges, viewport } = graph
|
||||
|
||||
eventEmitter?.emit({
|
||||
type: WORKFLOW_DATA_UPDATE,
|
||||
payload: {
|
||||
nodes: initialNodes(nodes, edges),
|
||||
edges: initialEdges(edges, nodes),
|
||||
viewport,
|
||||
hash,
|
||||
rag_pipeline_variables: rag_pipeline_variables || [],
|
||||
},
|
||||
})
|
||||
}, [eventEmitter])
|
||||
|
||||
const completeImport = useCallback(async (
|
||||
pipelineId: string | undefined,
|
||||
status: DSLImportStatus = DSLImportStatus.COMPLETED,
|
||||
) => {
|
||||
if (!pipelineId) {
|
||||
notifyError()
|
||||
return
|
||||
}
|
||||
|
||||
updateWorkflow(pipelineId)
|
||||
onImport?.()
|
||||
|
||||
const isWarning = status === DSLImportStatus.COMPLETED_WITH_WARNINGS
|
||||
notify({
|
||||
type: isWarning ? 'warning' : 'success',
|
||||
message: t(isWarning ? 'common.importWarning' : 'common.importSuccess', { ns: 'workflow' }),
|
||||
children: isWarning && t('common.importWarningDetails', { ns: 'workflow' }),
|
||||
})
|
||||
|
||||
await handleCheckPluginDependencies(pipelineId, true)
|
||||
setLoading(false)
|
||||
onCancel()
|
||||
}, [updateWorkflow, onImport, notify, t, handleCheckPluginDependencies, onCancel, notifyError])
|
||||
|
||||
const showVersionMismatch = useCallback((
|
||||
id: string,
|
||||
importedVersion?: string,
|
||||
systemVersion?: string,
|
||||
) => {
|
||||
setShow(false)
|
||||
setTimeout(() => setShowErrorModal(true), 300)
|
||||
setVersions({
|
||||
importedVersion: importedVersion ?? '',
|
||||
systemVersion: systemVersion ?? '',
|
||||
})
|
||||
setImportId(id)
|
||||
}, [])
|
||||
|
||||
const handleImport: MouseEventHandler = useCallback(async () => {
|
||||
const { pipelineId } = workflowStore.getState()
|
||||
if (isCreatingRef.current)
|
||||
return
|
||||
isCreatingRef.current = true
|
||||
if (!currentFile)
|
||||
return
|
||||
|
||||
try {
|
||||
if (!pipelineId || !fileContent)
|
||||
return
|
||||
|
||||
setLoading(true)
|
||||
const response = await importDSL({
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: fileContent,
|
||||
pipeline_id: pipelineId,
|
||||
})
|
||||
const { id, status, pipeline_id, imported_dsl_version, current_dsl_version } = response
|
||||
|
||||
if (isCompletedStatus(status))
|
||||
await completeImport(pipeline_id, status)
|
||||
else if (status === DSLImportStatus.PENDING)
|
||||
showVersionMismatch(id, imported_dsl_version, current_dsl_version)
|
||||
else
|
||||
notifyError()
|
||||
}
|
||||
catch {
|
||||
notifyError()
|
||||
}
|
||||
isCreatingRef.current = false
|
||||
}, [currentFile, fileContent, workflowStore, importDSL, completeImport, showVersionMismatch, notifyError])
|
||||
|
||||
const onUpdateDSLConfirm: MouseEventHandler = useCallback(async () => {
|
||||
if (!importId)
|
||||
return
|
||||
|
||||
try {
|
||||
const { status, pipeline_id } = await importDSLConfirm(importId)
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
await completeImport(pipeline_id)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === DSLImportStatus.FAILED)
|
||||
notifyError()
|
||||
}
|
||||
catch {
|
||||
notifyError()
|
||||
}
|
||||
}, [importId, importDSLConfirm, completeImport, notifyError])
|
||||
|
||||
return {
|
||||
currentFile,
|
||||
handleFile,
|
||||
show,
|
||||
showErrorModal,
|
||||
setShowErrorModal,
|
||||
loading,
|
||||
versions,
|
||||
handleImport,
|
||||
onUpdateDSLConfirm,
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import * as Sentry from '@sentry/react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { IS_DEV } from '@/config'
|
||||
import { env } from '@/env'
|
||||
|
||||
const SentryInitializer = ({
|
||||
children,
|
||||
}: { children: React.ReactElement }) => {
|
||||
useEffect(() => {
|
||||
const SENTRY_DSN = document?.body?.getAttribute('data-public-sentry-dsn')
|
||||
const SENTRY_DSN = env.NEXT_PUBLIC_SENTRY_DSN
|
||||
if (!IS_DEV && SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
|
||||
@@ -13,6 +13,54 @@ describe('buildWorkflowOutputParameters', () => {
|
||||
expect(result).toBe(params)
|
||||
})
|
||||
|
||||
it('fills missing output description and type from schema when array input exists', () => {
|
||||
const params: WorkflowToolProviderOutputParameter[] = [
|
||||
{ name: 'answer', description: '', type: undefined },
|
||||
{ name: 'files', description: 'keep this description', type: VarType.arrayFile },
|
||||
]
|
||||
const schema: WorkflowToolProviderOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: {
|
||||
type: VarType.string,
|
||||
description: 'Generated answer',
|
||||
},
|
||||
files: {
|
||||
type: VarType.arrayFile,
|
||||
description: 'Schema files description',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = buildWorkflowOutputParameters(params, schema)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'answer', description: 'Generated answer', type: VarType.string },
|
||||
{ name: 'files', description: 'keep this description', type: VarType.arrayFile },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to empty description when both payload and schema descriptions are missing', () => {
|
||||
const params: WorkflowToolProviderOutputParameter[] = [
|
||||
{ name: 'missing_desc', description: '', type: undefined },
|
||||
]
|
||||
const schema: WorkflowToolProviderOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
other_field: {
|
||||
type: VarType.string,
|
||||
description: 'Other',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = buildWorkflowOutputParameters(params, schema)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'missing_desc', description: '', type: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it('derives parameters from schema when explicit array missing', () => {
|
||||
const schema: WorkflowToolProviderOutputSchema = {
|
||||
type: 'object',
|
||||
@@ -44,4 +92,56 @@ describe('buildWorkflowOutputParameters', () => {
|
||||
it('returns empty array when no source information is provided', () => {
|
||||
expect(buildWorkflowOutputParameters(null, null)).toEqual([])
|
||||
})
|
||||
|
||||
it('derives parameters from schema when explicit array is empty', () => {
|
||||
const schema: WorkflowToolProviderOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
output_text: {
|
||||
type: VarType.string,
|
||||
description: 'Output text',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = buildWorkflowOutputParameters([], schema)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'output_text', description: 'Output text', type: VarType.string },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns undefined type when schema output type is missing', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: {
|
||||
description: 'Answer without type',
|
||||
},
|
||||
},
|
||||
} as unknown as WorkflowToolProviderOutputSchema
|
||||
|
||||
const result = buildWorkflowOutputParameters(undefined, schema)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'answer', description: 'Answer without type', type: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to empty description when schema-derived description is missing', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
answer: {
|
||||
type: VarType.string,
|
||||
},
|
||||
},
|
||||
} as unknown as WorkflowToolProviderOutputSchema
|
||||
|
||||
const result = buildWorkflowOutputParameters(undefined, schema)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'answer', description: '', type: VarType.string },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,15 +14,28 @@ export const buildWorkflowOutputParameters = (
|
||||
outputParameters: WorkflowToolProviderOutputParameter[] | null | undefined,
|
||||
outputSchema?: WorkflowToolProviderOutputSchema | null,
|
||||
): WorkflowToolProviderOutputParameter[] => {
|
||||
if (Array.isArray(outputParameters))
|
||||
return outputParameters
|
||||
const schemaProperties = outputSchema?.properties
|
||||
|
||||
if (!outputSchema?.properties)
|
||||
if (Array.isArray(outputParameters) && outputParameters.length > 0) {
|
||||
if (!schemaProperties)
|
||||
return outputParameters
|
||||
|
||||
return outputParameters.map((item) => {
|
||||
const schema = schemaProperties[item.name]
|
||||
return {
|
||||
...item,
|
||||
description: item.description || schema?.description || '',
|
||||
type: normalizeVarType(item.type || schema?.type),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!schemaProperties)
|
||||
return []
|
||||
|
||||
return Object.entries(outputSchema.properties).map(([name, schema]) => ({
|
||||
return Object.entries(schemaProperties).map(([name, schema]) => ({
|
||||
name,
|
||||
description: schema.description,
|
||||
description: schema.description || '',
|
||||
type: normalizeVarType(schema.type),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-seri
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { postWithKeepalive } from '@/service/fetch'
|
||||
import { syncWorkflowDraft } from '@/service/workflow'
|
||||
import { useWorkflowRefreshDraft } from '.'
|
||||
|
||||
@@ -85,7 +86,7 @@ export const useNodesSyncDraft = () => {
|
||||
const postParams = getPostParams()
|
||||
|
||||
if (postParams)
|
||||
navigator.sendBeacon(`${API_PREFIX}${postParams.url}`, JSON.stringify(postParams.params))
|
||||
postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params)
|
||||
}, [getPostParams, getNodesReadOnly])
|
||||
|
||||
const performSync = useCallback(async (
|
||||
|
||||
@@ -159,6 +159,9 @@ const useLastRun = <T>({
|
||||
if (!warningForNode)
|
||||
return false
|
||||
|
||||
if (warningForNode.unConnected && !warningForNode.errorMessage)
|
||||
return false
|
||||
|
||||
const message = warningForNode.errorMessage || 'This node has unresolved checklist issues'
|
||||
Toast.notify({ type: 'error', message })
|
||||
return true
|
||||
|
||||
@@ -4,13 +4,6 @@ import type {
|
||||
} from 'react'
|
||||
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
RiLoader2Line,
|
||||
RiPauseCircleFill,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
cloneElement,
|
||||
memo,
|
||||
@@ -109,7 +102,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
} = useMemo(() => {
|
||||
return {
|
||||
showRunningBorder: (data._runningStatus === NodeRunningStatus.Running || data._runningStatus === NodeRunningStatus.Paused) && !showSelectedBorder,
|
||||
showSuccessBorder: (data._runningStatus === NodeRunningStatus.Succeeded || hasVarValue) && !showSelectedBorder,
|
||||
showSuccessBorder: (data._runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !data._runningStatus)) && !showSelectedBorder,
|
||||
showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
|
||||
showExceptionBorder: data._runningStatus === NodeRunningStatus.Exception && !showSelectedBorder,
|
||||
}
|
||||
@@ -127,7 +120,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-medium mr-2 text-text-tertiary',
|
||||
'mr-2 text-text-tertiary system-xs-medium',
|
||||
data._runningStatus === NodeRunningStatus.Running && 'text-text-accent',
|
||||
)}
|
||||
>
|
||||
@@ -167,7 +160,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
{
|
||||
data.type === BlockEnum.DataSource && (
|
||||
<div className="absolute inset-[-2px] top-[-22px] z-[-1] rounded-[18px] bg-node-data-source-bg p-0.5 backdrop-blur-[6px]">
|
||||
<div className="system-2xs-semibold-uppercase flex h-5 items-center px-2.5 text-text-tertiary">
|
||||
<div className="flex h-5 items-center px-2.5 text-text-tertiary system-2xs-semibold-uppercase">
|
||||
{t('blocks.datasource', { ns: 'workflow' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,7 +245,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
/>
|
||||
<div
|
||||
title={data.title}
|
||||
className="system-sm-semibold-uppercase mr-1 flex grow items-center truncate text-text-primary"
|
||||
className="mr-1 flex grow items-center truncate text-text-primary system-sm-semibold-uppercase"
|
||||
>
|
||||
<div>
|
||||
{data.title}
|
||||
@@ -268,7 +261,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase ml-1 flex items-center justify-center rounded-[5px] border-[1px] border-text-warning px-[5px] py-[3px] text-text-warning ">
|
||||
<div className="ml-1 flex items-center justify-center rounded-[5px] border-[1px] border-text-warning px-[5px] py-[3px] text-text-warning system-2xs-medium-uppercase">
|
||||
{t('nodes.iteration.parallelModeUpper', { ns: 'workflow' })}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -288,26 +281,26 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
!!(data.type === BlockEnum.Loop && data._loopIndex) && LoopIndex
|
||||
}
|
||||
{
|
||||
isLoading && <RiLoader2Line className="h-3.5 w-3.5 animate-spin text-text-accent" />
|
||||
isLoading && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin text-text-accent" />
|
||||
}
|
||||
{
|
||||
!isLoading && data._runningStatus === NodeRunningStatus.Failed && (
|
||||
<RiErrorWarningFill className="h-3.5 w-3.5 text-text-destructive" />
|
||||
<span className="i-ri-error-warning-fill h-3.5 w-3.5 text-text-destructive" />
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && data._runningStatus === NodeRunningStatus.Exception && (
|
||||
<RiAlertFill className="h-3.5 w-3.5 text-text-warning-secondary" />
|
||||
<span className="i-ri-alert-fill h-3.5 w-3.5 text-text-warning-secondary" />
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && (data._runningStatus === NodeRunningStatus.Succeeded || hasVarValue) && (
|
||||
<RiCheckboxCircleFill className="h-3.5 w-3.5 text-text-success" />
|
||||
!isLoading && (data._runningStatus === NodeRunningStatus.Succeeded || (hasVarValue && !data._runningStatus)) && (
|
||||
<span className="i-ri-checkbox-circle-fill h-3.5 w-3.5 text-text-success" />
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && data._runningStatus === NodeRunningStatus.Paused && (
|
||||
<RiPauseCircleFill className="h-3.5 w-3.5 text-text-warning-secondary" />
|
||||
<span className="i-ri-pause-circle-fill h-3.5 w-3.5 text-text-warning-secondary" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -341,7 +334,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
}
|
||||
{
|
||||
!!(data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && (
|
||||
<div className="system-xs-regular whitespace-pre-line break-words px-3 pb-2 pt-1 text-text-tertiary">
|
||||
<div className="whitespace-pre-line break-words px-3 pb-2 pt-1 text-text-tertiary system-xs-regular">
|
||||
{data.desc}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type {
|
||||
Edge,
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
|
||||
import humanInputDefault from '@/app/components/workflow/nodes/human-input/default'
|
||||
import HumanInputNode from '@/app/components/workflow/nodes/human-input/node'
|
||||
import {
|
||||
DeliveryMethodType,
|
||||
UserActionButtonType,
|
||||
} from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { initialNodes, preprocessNodesAndEdges } from '@/app/components/workflow/utils/workflow-init'
|
||||
|
||||
// Mock reactflow which is needed by initialNodes and NodeSourceHandle
|
||||
vi.mock('reactflow', async () => {
|
||||
const reactflow = await vi.importActual('reactflow')
|
||||
return {
|
||||
...reactflow,
|
||||
Handle: ({ children }: { children?: ReactNode }) => <div data-testid="handle">{children}</div>,
|
||||
}
|
||||
})
|
||||
|
||||
// Minimal store state mirroring the fields that NodeSourceHandle selects
|
||||
const mockStoreState = {
|
||||
shouldAutoOpenStartNodeSelector: false,
|
||||
setShouldAutoOpenStartNodeSelector: vi.fn(),
|
||||
setHasSelectedStartNode: vi.fn(),
|
||||
}
|
||||
|
||||
// Mock workflow store used by NodeSourceHandle
|
||||
// useStore accepts a selector and applies it to the state, so tests break
|
||||
// if the component starts selecting fields that aren't provided here.
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: vi.fn((selector?: (s: typeof mockStoreState) => unknown) =>
|
||||
selector ? selector(mockStoreState) : mockStoreState,
|
||||
),
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
getState: () => ({
|
||||
getNodes: () => [],
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock workflow hooks barrel (used by NodeSourceHandle via ../../../hooks)
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeAdd: vi.fn(),
|
||||
}),
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: () => false,
|
||||
nodesReadOnly: false,
|
||||
}),
|
||||
useAvailableBlocks: () => ({
|
||||
availableNextBlocks: [],
|
||||
availablePrevBlocks: [],
|
||||
}),
|
||||
useIsChatMode: () => false,
|
||||
}))
|
||||
|
||||
// ── Factory: Build a realistic human-input node as it would appear after DSL import ──
|
||||
const createHumanInputNode = (overrides?: Partial<HumanInputNodeType>): Node => ({
|
||||
id: 'human-input-1',
|
||||
type: 'custom',
|
||||
position: { x: 400, y: 200 },
|
||||
data: {
|
||||
type: BlockEnum.HumanInput,
|
||||
title: 'Human Input',
|
||||
desc: 'Wait for human input',
|
||||
delivery_methods: [
|
||||
{
|
||||
id: 'dm-1',
|
||||
type: DeliveryMethodType.WebApp,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'dm-2',
|
||||
type: DeliveryMethodType.Email,
|
||||
enabled: true,
|
||||
config: {
|
||||
recipients: { whole_workspace: false, items: [] },
|
||||
subject: 'Please review',
|
||||
body: 'Please review the form',
|
||||
debug_mode: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
form_content: '# Review Form\nPlease fill in the details below.',
|
||||
inputs: [
|
||||
{
|
||||
type: 'text-input',
|
||||
output_variable_name: 'review_result',
|
||||
default: { selector: [], type: 'constant' as const, value: '' },
|
||||
},
|
||||
],
|
||||
user_actions: [
|
||||
{
|
||||
id: 'approve',
|
||||
title: 'Approve',
|
||||
button_style: UserActionButtonType.Primary,
|
||||
},
|
||||
{
|
||||
id: 'reject',
|
||||
title: 'Reject',
|
||||
button_style: UserActionButtonType.Default,
|
||||
},
|
||||
],
|
||||
timeout: 3,
|
||||
timeout_unit: 'day' as const,
|
||||
...overrides,
|
||||
} as HumanInputNodeType,
|
||||
})
|
||||
|
||||
const createStartNode = (): Node => ({
|
||||
id: 'start-1',
|
||||
type: 'custom',
|
||||
position: { x: 100, y: 200 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
} as Node['data'],
|
||||
})
|
||||
|
||||
const createEdge = (source: string, target: string, sourceHandle = 'source', targetHandle = 'target'): Edge => ({
|
||||
id: `${source}-${sourceHandle}-${target}-${targetHandle}`,
|
||||
type: 'custom',
|
||||
source,
|
||||
sourceHandle,
|
||||
target,
|
||||
targetHandle,
|
||||
data: {},
|
||||
} as Edge)
|
||||
|
||||
describe('DSL Import with Human Input Node', () => {
|
||||
// ── preprocessNodesAndEdges: human-input nodes pass through without error ──
|
||||
describe('preprocessNodesAndEdges', () => {
|
||||
it('should pass through a workflow containing a human-input node unchanged', () => {
|
||||
const humanInputNode = createHumanInputNode()
|
||||
const startNode = createStartNode()
|
||||
const nodes = [startNode, humanInputNode]
|
||||
const edges = [createEdge('start-1', 'human-input-1')]
|
||||
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], edges as Edge[])
|
||||
|
||||
expect(result.nodes).toHaveLength(2)
|
||||
expect(result.edges).toHaveLength(1)
|
||||
expect(result.nodes).toEqual(nodes)
|
||||
expect(result.edges).toEqual(edges)
|
||||
})
|
||||
|
||||
it('should not treat human-input node as an iteration or loop node', () => {
|
||||
const humanInputNode = createHumanInputNode()
|
||||
const nodes = [humanInputNode]
|
||||
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
|
||||
// No extra iteration/loop start nodes should be injected
|
||||
expect(result.nodes).toHaveLength(1)
|
||||
expect(result.nodes[0].data.type).toBe(BlockEnum.HumanInput)
|
||||
})
|
||||
})
|
||||
|
||||
// ── initialNodes: human-input nodes are properly initialized ──
|
||||
describe('initialNodes', () => {
|
||||
it('should initialize a human-input node with connected handle IDs', () => {
|
||||
const humanInputNode = createHumanInputNode()
|
||||
const startNode = createStartNode()
|
||||
const nodes = [startNode, humanInputNode]
|
||||
const edges = [createEdge('start-1', 'human-input-1')]
|
||||
|
||||
const result = initialNodes(nodes as Node[], edges as Edge[])
|
||||
|
||||
const processedHumanInput = result.find(n => n.id === 'human-input-1')
|
||||
expect(processedHumanInput).toBeDefined()
|
||||
expect(processedHumanInput!.data.type).toBe(BlockEnum.HumanInput)
|
||||
// initialNodes sets _connectedSourceHandleIds and _connectedTargetHandleIds
|
||||
expect(processedHumanInput!.data._connectedSourceHandleIds).toBeDefined()
|
||||
expect(processedHumanInput!.data._connectedTargetHandleIds).toBeDefined()
|
||||
})
|
||||
|
||||
it('should preserve human-input node data after initialization', () => {
|
||||
const humanInputNode = createHumanInputNode()
|
||||
const nodes = [humanInputNode]
|
||||
|
||||
const result = initialNodes(nodes as Node[], [])
|
||||
|
||||
const processed = result[0]
|
||||
const nodeData = processed.data as HumanInputNodeType
|
||||
expect(nodeData.delivery_methods).toHaveLength(2)
|
||||
expect(nodeData.user_actions).toHaveLength(2)
|
||||
expect(nodeData.form_content).toBe('# Review Form\nPlease fill in the details below.')
|
||||
expect(nodeData.timeout).toBe(3)
|
||||
expect(nodeData.timeout_unit).toBe('day')
|
||||
})
|
||||
|
||||
it('should set node type to custom if not set', () => {
|
||||
const humanInputNode = createHumanInputNode()
|
||||
delete (humanInputNode as Record<string, unknown>).type
|
||||
|
||||
const result = initialNodes([humanInputNode] as Node[], [])
|
||||
|
||||
expect(result[0].type).toBe('custom')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Node component: renders without crashing for all data variations ──
|
||||
describe('HumanInputNode Component', () => {
|
||||
it('should render without crashing with full DSL data', () => {
|
||||
const node = createHumanInputNode()
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<HumanInputNode
|
||||
id={node.id}
|
||||
data={node.data as HumanInputNodeType}
|
||||
/>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should display delivery method labels when methods are present', () => {
|
||||
const node = createHumanInputNode()
|
||||
|
||||
render(
|
||||
<HumanInputNode
|
||||
id={node.id}
|
||||
data={node.data as HumanInputNodeType}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Delivery method type labels are rendered in lowercase
|
||||
expect(screen.getByText('webapp')).toBeInTheDocument()
|
||||
expect(screen.getByText('email')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display user action IDs', () => {
|
||||
const node = createHumanInputNode()
|
||||
|
||||
render(
|
||||
<HumanInputNode
|
||||
id={node.id}
|
||||
data={node.data as HumanInputNodeType}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('approve')).toBeInTheDocument()
|
||||
expect(screen.getByText('reject')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should always display Timeout handle', () => {
|
||||
const node = createHumanInputNode()
|
||||
|
||||
render(
|
||||
<HumanInputNode
|
||||
id={node.id}
|
||||
data={node.data as HumanInputNodeType}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Timeout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without crashing when delivery_methods is empty', () => {
|
||||
const node = createHumanInputNode({ delivery_methods: [] })
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<HumanInputNode
|
||||
id={node.id}
|
||||
data={node.data as HumanInputNodeType}
|
||||
/>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
|
||||
// Delivery method section should not be rendered
|
||||
expect(screen.queryByText('webapp')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('email')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without crashing when user_actions is empty', () => {
|
||||
const node = createHumanInputNode({ user_actions: [] })
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<HumanInputNode
|
||||
id={node.id}
|
||||
data={node.data as HumanInputNodeType}
|
||||
/>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
|
||||
// Timeout handle should still exist
|
||||
expect(screen.getByText('Timeout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without crashing when both delivery_methods and user_actions are empty', () => {
|
||||
const node = createHumanInputNode({
|
||||
delivery_methods: [],
|
||||
user_actions: [],
|
||||
form_content: '',
|
||||
inputs: [],
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<HumanInputNode
|
||||
id={node.id}
|
||||
data={node.data as HumanInputNodeType}
|
||||
/>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render with only webapp delivery method', () => {
|
||||
const node = createHumanInputNode({
|
||||
delivery_methods: [
|
||||
{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true },
|
||||
],
|
||||
})
|
||||
|
||||
render(
|
||||
<HumanInputNode
|
||||
id={node.id}
|
||||
data={node.data as HumanInputNodeType}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('webapp')).toBeInTheDocument()
|
||||
expect(screen.queryByText('email')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with multiple user actions', () => {
|
||||
const node = createHumanInputNode({
|
||||
user_actions: [
|
||||
{ id: 'action_1', title: 'Approve', button_style: UserActionButtonType.Primary },
|
||||
{ id: 'action_2', title: 'Reject', button_style: UserActionButtonType.Default },
|
||||
{ id: 'action_3', title: 'Escalate', button_style: UserActionButtonType.Accent },
|
||||
],
|
||||
})
|
||||
|
||||
render(
|
||||
<HumanInputNode
|
||||
id={node.id}
|
||||
data={node.data as HumanInputNodeType}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('action_1')).toBeInTheDocument()
|
||||
expect(screen.getByText('action_2')).toBeInTheDocument()
|
||||
expect(screen.getByText('action_3')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Node registration: human-input is included in the workflow node registry ──
|
||||
// Verify via WORKFLOW_COMMON_NODES (lightweight metadata-only imports) instead
|
||||
// of NodeComponentMap/PanelComponentMap which pull in every node's heavy UI deps.
|
||||
describe('Node Registration', () => {
|
||||
it('should have HumanInput included in WORKFLOW_COMMON_NODES', () => {
|
||||
const entry = WORKFLOW_COMMON_NODES.find(
|
||||
n => n.metaData.type === BlockEnum.HumanInput,
|
||||
)
|
||||
expect(entry).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Default config & validation ──
|
||||
describe('HumanInput Default Configuration', () => {
|
||||
it('should provide default values for a new human-input node', () => {
|
||||
const defaultValue = humanInputDefault.defaultValue
|
||||
|
||||
expect(defaultValue.delivery_methods).toEqual([])
|
||||
expect(defaultValue.user_actions).toEqual([])
|
||||
expect(defaultValue.form_content).toBe('')
|
||||
expect(defaultValue.inputs).toEqual([])
|
||||
expect(defaultValue.timeout).toBe(3)
|
||||
expect(defaultValue.timeout_unit).toBe('day')
|
||||
})
|
||||
|
||||
it('should validate that delivery methods are required', () => {
|
||||
const t = (key: string) => key
|
||||
const payload = {
|
||||
...humanInputDefault.defaultValue,
|
||||
delivery_methods: [],
|
||||
} as HumanInputNodeType
|
||||
|
||||
const result = humanInputDefault.checkValid(payload, t)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.errorMessage).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should validate that at least one delivery method is enabled', () => {
|
||||
const t = (key: string) => key
|
||||
const payload = {
|
||||
...humanInputDefault.defaultValue,
|
||||
delivery_methods: [
|
||||
{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: false },
|
||||
],
|
||||
user_actions: [
|
||||
{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
|
||||
],
|
||||
} as HumanInputNodeType
|
||||
|
||||
const result = humanInputDefault.checkValid(payload, t)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate that user actions are required', () => {
|
||||
const t = (key: string) => key
|
||||
const payload = {
|
||||
...humanInputDefault.defaultValue,
|
||||
delivery_methods: [
|
||||
{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true },
|
||||
],
|
||||
user_actions: [],
|
||||
} as HumanInputNodeType
|
||||
|
||||
const result = humanInputDefault.checkValid(payload, t)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate that user action IDs are not duplicated', () => {
|
||||
const t = (key: string) => key
|
||||
const payload = {
|
||||
...humanInputDefault.defaultValue,
|
||||
delivery_methods: [
|
||||
{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true },
|
||||
],
|
||||
user_actions: [
|
||||
{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
|
||||
{ id: 'approve', title: 'Also Approve', button_style: UserActionButtonType.Default },
|
||||
],
|
||||
} as HumanInputNodeType
|
||||
|
||||
const result = humanInputDefault.checkValid(payload, t)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass validation with correct configuration', () => {
|
||||
const t = (key: string) => key
|
||||
const payload = {
|
||||
...humanInputDefault.defaultValue,
|
||||
delivery_methods: [
|
||||
{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true },
|
||||
],
|
||||
user_actions: [
|
||||
{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
|
||||
{ id: 'reject', title: 'Reject', button_style: UserActionButtonType.Default },
|
||||
],
|
||||
} as HumanInputNodeType
|
||||
|
||||
const result = humanInputDefault.checkValid(payload, t)
|
||||
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.errorMessage).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Output variables generation ──
|
||||
describe('HumanInput Output Variables', () => {
|
||||
it('should generate output variables from form inputs', () => {
|
||||
const payload = {
|
||||
...humanInputDefault.defaultValue,
|
||||
inputs: [
|
||||
{ type: 'text-input', output_variable_name: 'review_result', default: { selector: [], type: 'constant' as const, value: '' } },
|
||||
{ type: 'text-input', output_variable_name: 'comment', default: { selector: [], type: 'constant' as const, value: '' } },
|
||||
],
|
||||
} as HumanInputNodeType
|
||||
|
||||
const outputVars = humanInputDefault.getOutputVars!(payload, {}, [])
|
||||
|
||||
expect(outputVars).toEqual([
|
||||
{ variable: 'review_result', type: 'string' },
|
||||
{ variable: 'comment', type: 'string' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should return empty output variables when no form inputs exist', () => {
|
||||
const payload = {
|
||||
...humanInputDefault.defaultValue,
|
||||
inputs: [],
|
||||
} as HumanInputNodeType
|
||||
|
||||
const outputVars = humanInputDefault.getOutputVars!(payload, {}, [])
|
||||
|
||||
expect(outputVars).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ── Full DSL import simulation: start → human-input → end ──
|
||||
describe('Full Workflow with Human Input Node', () => {
|
||||
it('should process a start → human-input → end workflow without errors', () => {
|
||||
const startNode = createStartNode()
|
||||
const humanInputNode = createHumanInputNode()
|
||||
const endNode: Node = {
|
||||
id: 'end-1',
|
||||
type: 'custom',
|
||||
position: { x: 700, y: 200 },
|
||||
data: {
|
||||
type: BlockEnum.End,
|
||||
title: 'End',
|
||||
desc: '',
|
||||
outputs: [],
|
||||
} as Node['data'],
|
||||
}
|
||||
|
||||
const nodes = [startNode, humanInputNode, endNode]
|
||||
const edges = [
|
||||
createEdge('start-1', 'human-input-1'),
|
||||
createEdge('human-input-1', 'end-1', 'approve', 'target'),
|
||||
]
|
||||
|
||||
const processed = preprocessNodesAndEdges(nodes as Node[], edges as Edge[])
|
||||
expect(processed.nodes).toHaveLength(3)
|
||||
expect(processed.edges).toHaveLength(2)
|
||||
|
||||
const initialized = initialNodes(nodes as Node[], edges as Edge[])
|
||||
expect(initialized).toHaveLength(3)
|
||||
|
||||
// All node types should be preserved
|
||||
const types = initialized.map(n => n.data.type)
|
||||
expect(types).toContain(BlockEnum.Start)
|
||||
expect(types).toContain(BlockEnum.HumanInput)
|
||||
expect(types).toContain(BlockEnum.End)
|
||||
})
|
||||
|
||||
it('should handle multiple branches from human-input user actions', () => {
|
||||
const startNode = createStartNode()
|
||||
const humanInputNode = createHumanInputNode()
|
||||
const approveEndNode: Node = {
|
||||
id: 'approve-end',
|
||||
type: 'custom',
|
||||
position: { x: 700, y: 100 },
|
||||
data: { type: BlockEnum.End, title: 'Approve End', desc: '', outputs: [] } as Node['data'],
|
||||
}
|
||||
const rejectEndNode: Node = {
|
||||
id: 'reject-end',
|
||||
type: 'custom',
|
||||
position: { x: 700, y: 300 },
|
||||
data: { type: BlockEnum.End, title: 'Reject End', desc: '', outputs: [] } as Node['data'],
|
||||
}
|
||||
|
||||
const nodes = [startNode, humanInputNode, approveEndNode, rejectEndNode]
|
||||
const edges = [
|
||||
createEdge('start-1', 'human-input-1'),
|
||||
createEdge('human-input-1', 'approve-end', 'approve', 'target'),
|
||||
createEdge('human-input-1', 'reject-end', 'reject', 'target'),
|
||||
]
|
||||
|
||||
const initialized = initialNodes(nodes as Node[], edges as Edge[])
|
||||
expect(initialized).toHaveLength(4)
|
||||
|
||||
// Human input node should still have correct data
|
||||
const hiNode = initialized.find(n => n.id === 'human-input-1')!
|
||||
expect((hiNode.data as HumanInputNodeType).user_actions).toHaveLength(2)
|
||||
expect((hiNode.data as HumanInputNodeType).delivery_methods).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { env } from '@/env'
|
||||
|
||||
export type TopKAndScoreThresholdProps = {
|
||||
topK: number
|
||||
@@ -15,12 +16,7 @@ export type TopKAndScoreThresholdProps = {
|
||||
hiddenScoreThreshold?: boolean
|
||||
}
|
||||
|
||||
const maxTopK = (() => {
|
||||
const configValue = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-top-k-max-value') || '', 10)
|
||||
if (configValue && !isNaN(configValue))
|
||||
return configValue
|
||||
return 10
|
||||
})()
|
||||
const maxTopK = env.NEXT_PUBLIC_TOP_K_MAX_VALUE
|
||||
const TOP_K_VALUE_LIMIT = {
|
||||
amount: 1,
|
||||
min: 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ValidationError } from 'jsonschema'
|
||||
import type { ArrayItems, Field, LLMNodeType } from './types'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import { draft07Validator, forbidBooleanProperties } from '@/utils/validators'
|
||||
import { ArrayType, Type } from './types'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
const arrayStringSchemaParttern = z.array(z.string())
|
||||
const arrayNumberSchemaParttern = z.array(z.number())
|
||||
@@ -7,7 +7,7 @@ const arrayNumberSchemaParttern = z.array(z.number())
|
||||
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()])
|
||||
type Literal = z.infer<typeof literalSchema>
|
||||
type Json = Literal | { [key: string]: Json } | Json[]
|
||||
const jsonSchema: z.ZodType<Json> = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]))
|
||||
const jsonSchema: z.ZodType<Json> = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(z.string(), jsonSchema)]))
|
||||
const arrayJsonSchema: z.ZodType<Json[]> = z.lazy(() => z.array(jsonSchema))
|
||||
|
||||
export const validateJSONSchema = (schema: any, type: string) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { formContext, useAppForm } from '@/app/components/base/form'
|
||||
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
|
||||
@@ -22,10 +22,10 @@ import Input from '../components/base/input'
|
||||
import Loading from '../components/base/loading'
|
||||
|
||||
const accountFormSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: 'error.emailInValid' })
|
||||
.email('error.emailInValid'),
|
||||
email: z.email('error.emailInValid')
|
||||
.min(1, {
|
||||
error: 'error.emailInValid',
|
||||
}),
|
||||
})
|
||||
|
||||
const ForgotPasswordForm = () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { formContext, useAppForm } from '@/app/components/base/form'
|
||||
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
|
||||
@@ -22,13 +22,15 @@ import { encryptPassword as encodePassword } from '@/utils/encryption'
|
||||
import Loading from '../components/base/loading'
|
||||
|
||||
const accountFormSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: 'error.emailInValid' })
|
||||
.email('error.emailInValid'),
|
||||
name: z.string().min(1, { message: 'error.nameEmpty' }),
|
||||
email: z.email('error.emailInValid')
|
||||
.min(1, {
|
||||
error: 'error.emailInValid',
|
||||
}),
|
||||
name: z.string().min(1, {
|
||||
error: 'error.nameEmpty',
|
||||
}),
|
||||
password: z.string().min(8, {
|
||||
message: 'error.passwordLengthInValid',
|
||||
error: 'error.passwordLengthInValid',
|
||||
}).regex(validPassword, 'error.passwordInvalid'),
|
||||
})
|
||||
|
||||
@@ -197,7 +199,7 @@ const InstallForm = () => {
|
||||
</div>
|
||||
|
||||
<div className={cn('mt-1 text-xs text-text-secondary', {
|
||||
'text-red-400 !text-sm': passwordErrors && passwordErrors.length > 0,
|
||||
'!text-sm text-red-400': passwordErrors && passwordErrors.length > 0,
|
||||
})}
|
||||
>
|
||||
{t('error.passwordInvalid', { ns: 'login' })}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Instrument_Serif } from 'next/font/google'
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import GlobalPublicStoreProvider from '@/context/global-public-context'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import { getDatasetMap } from '@/env'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { DatasetAttr } from '@/types/feature'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { ToastProvider } from './components/base/toast'
|
||||
import BrowserInitializer from './components/browser-initializer'
|
||||
@@ -39,40 +39,7 @@ const LocaleLayout = async ({
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const locale = await getLocaleOnServer()
|
||||
|
||||
const datasetMap: Record<DatasetAttr, string | undefined> = {
|
||||
[DatasetAttr.DATA_API_PREFIX]: process.env.NEXT_PUBLIC_API_PREFIX,
|
||||
[DatasetAttr.DATA_PUBLIC_API_PREFIX]: process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX,
|
||||
[DatasetAttr.DATA_MARKETPLACE_API_PREFIX]: process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX,
|
||||
[DatasetAttr.DATA_MARKETPLACE_URL_PREFIX]: process.env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX,
|
||||
[DatasetAttr.DATA_PUBLIC_EDITION]: process.env.NEXT_PUBLIC_EDITION,
|
||||
[DatasetAttr.DATA_PUBLIC_AMPLITUDE_API_KEY]: process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY,
|
||||
[DatasetAttr.DATA_PUBLIC_COOKIE_DOMAIN]: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
|
||||
[DatasetAttr.DATA_PUBLIC_SUPPORT_MAIL_LOGIN]: process.env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN,
|
||||
[DatasetAttr.DATA_PUBLIC_SENTRY_DSN]: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
[DatasetAttr.DATA_PUBLIC_MAINTENANCE_NOTICE]: process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE,
|
||||
[DatasetAttr.DATA_PUBLIC_SITE_ABOUT]: process.env.NEXT_PUBLIC_SITE_ABOUT,
|
||||
[DatasetAttr.DATA_PUBLIC_TEXT_GENERATION_TIMEOUT_MS]: process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS,
|
||||
[DatasetAttr.DATA_PUBLIC_MAX_TOOLS_NUM]: process.env.NEXT_PUBLIC_MAX_TOOLS_NUM,
|
||||
[DatasetAttr.DATA_PUBLIC_MAX_PARALLEL_LIMIT]: process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT,
|
||||
[DatasetAttr.DATA_PUBLIC_TOP_K_MAX_VALUE]: process.env.NEXT_PUBLIC_TOP_K_MAX_VALUE,
|
||||
[DatasetAttr.DATA_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH]: process.env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH,
|
||||
[DatasetAttr.DATA_PUBLIC_LOOP_NODE_MAX_COUNT]: process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT,
|
||||
[DatasetAttr.DATA_PUBLIC_MAX_ITERATIONS_NUM]: process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM,
|
||||
[DatasetAttr.DATA_PUBLIC_MAX_TREE_DEPTH]: process.env.NEXT_PUBLIC_MAX_TREE_DEPTH,
|
||||
[DatasetAttr.DATA_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME]: process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME,
|
||||
[DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_JINAREADER]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER,
|
||||
[DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_FIRECRAWL]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL,
|
||||
[DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL,
|
||||
[DatasetAttr.DATA_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX]: process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX,
|
||||
[DatasetAttr.NEXT_PUBLIC_ZENDESK_WIDGET_KEY]: process.env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY,
|
||||
[DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT,
|
||||
[DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION,
|
||||
[DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL,
|
||||
[DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID,
|
||||
[DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN,
|
||||
[DatasetAttr.DATA_PUBLIC_BATCH_CONCURRENCY]: process.env.NEXT_PUBLIC_BATCH_CONCURRENCY,
|
||||
}
|
||||
const datasetMap = getDatasetMap()
|
||||
|
||||
return (
|
||||
<html lang={locale ?? 'en'} className={cn('h-full', instrumentSerif.variable)} suppressHydrationWarning>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSerwistRoute } from '@serwist/turbopack'
|
||||
import { env } from '@/env'
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
|
||||
const basePath = env.NEXT_PUBLIC_BASE_PATH
|
||||
|
||||
export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute({
|
||||
swSrc: 'app/sw.ts',
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
@import "preflight.css";
|
||||
|
||||
|
||||
@import '../../themes/light.css';
|
||||
@import '../../themes/dark.css';
|
||||
@import "../../themes/manual-light.css";
|
||||
@import "../../themes/manual-dark.css";
|
||||
@import "./monaco-sticky-fix.css";
|
||||
|
||||
@import "../components/base/button/index.css";
|
||||
@import "../components/base/action-button/index.css";
|
||||
@import "../components/base/badge/index.css";
|
||||
@import "../components/base/button/index.css";
|
||||
@import "../components/base/modal/index.css";
|
||||
@import "../components/base/premium-badge/index.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
@@ -1,101 +1,51 @@
|
||||
import type { ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { env } from '@/env'
|
||||
import { PromptRole } from '@/models/debug'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { AgentStrategy } from '@/types/app'
|
||||
import { DatasetAttr } from '@/types/feature'
|
||||
import pkg from '../package.json'
|
||||
|
||||
const getBooleanConfig = (
|
||||
envVar: string | undefined,
|
||||
dataAttrKey: DatasetAttr,
|
||||
defaultValue: boolean = true,
|
||||
) => {
|
||||
if (envVar !== undefined && envVar !== '')
|
||||
return envVar === 'true'
|
||||
const attrValue = globalThis.document?.body?.getAttribute(dataAttrKey)
|
||||
if (attrValue !== undefined && attrValue !== '')
|
||||
return attrValue === 'true'
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const getNumberConfig = (
|
||||
envVar: string | undefined,
|
||||
dataAttrKey: DatasetAttr,
|
||||
defaultValue: number,
|
||||
) => {
|
||||
if (envVar) {
|
||||
const parsed = Number.parseInt(envVar)
|
||||
if (!Number.isNaN(parsed) && parsed > 0)
|
||||
return parsed
|
||||
}
|
||||
|
||||
const attrValue = globalThis.document?.body?.getAttribute(dataAttrKey)
|
||||
if (attrValue) {
|
||||
const parsed = Number.parseInt(attrValue)
|
||||
if (!Number.isNaN(parsed) && parsed > 0)
|
||||
return parsed
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const getStringConfig = (
|
||||
envVar: string | undefined,
|
||||
dataAttrKey: DatasetAttr,
|
||||
defaultValue: string,
|
||||
) => {
|
||||
if (envVar)
|
||||
return envVar
|
||||
|
||||
const attrValue = globalThis.document?.body?.getAttribute(dataAttrKey)
|
||||
if (attrValue)
|
||||
return attrValue
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
export const API_PREFIX = getStringConfig(
|
||||
process.env.NEXT_PUBLIC_API_PREFIX,
|
||||
DatasetAttr.DATA_API_PREFIX,
|
||||
env.NEXT_PUBLIC_API_PREFIX,
|
||||
'http://localhost:5001/console/api',
|
||||
)
|
||||
export const PUBLIC_API_PREFIX = getStringConfig(
|
||||
process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX,
|
||||
DatasetAttr.DATA_PUBLIC_API_PREFIX,
|
||||
env.NEXT_PUBLIC_PUBLIC_API_PREFIX,
|
||||
'http://localhost:5001/api',
|
||||
)
|
||||
export const MARKETPLACE_API_PREFIX = getStringConfig(
|
||||
process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX,
|
||||
DatasetAttr.DATA_MARKETPLACE_API_PREFIX,
|
||||
env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX,
|
||||
'http://localhost:5002/api',
|
||||
)
|
||||
export const MARKETPLACE_URL_PREFIX = getStringConfig(
|
||||
process.env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX,
|
||||
DatasetAttr.DATA_MARKETPLACE_URL_PREFIX,
|
||||
env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX,
|
||||
'',
|
||||
)
|
||||
|
||||
const EDITION = getStringConfig(
|
||||
process.env.NEXT_PUBLIC_EDITION,
|
||||
DatasetAttr.DATA_PUBLIC_EDITION,
|
||||
'SELF_HOSTED',
|
||||
)
|
||||
const EDITION = env.NEXT_PUBLIC_EDITION
|
||||
|
||||
export const IS_CE_EDITION = EDITION === 'SELF_HOSTED'
|
||||
export const IS_CLOUD_EDITION = EDITION === 'CLOUD'
|
||||
|
||||
export const AMPLITUDE_API_KEY = getStringConfig(
|
||||
process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY,
|
||||
DatasetAttr.DATA_PUBLIC_AMPLITUDE_API_KEY,
|
||||
env.NEXT_PUBLIC_AMPLITUDE_API_KEY,
|
||||
'',
|
||||
)
|
||||
|
||||
export const IS_DEV = process.env.NODE_ENV === 'development'
|
||||
export const IS_PROD = process.env.NODE_ENV === 'production'
|
||||
export const IS_DEV = env.NODE_ENV === 'development'
|
||||
export const IS_PROD = env.NODE_ENV === 'production'
|
||||
|
||||
export const SUPPORT_MAIL_LOGIN = !!(
|
||||
process.env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN
|
||||
|| globalThis.document?.body?.getAttribute('data-public-support-mail-login')
|
||||
)
|
||||
export const SUPPORT_MAIL_LOGIN = env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN
|
||||
|
||||
export const TONE_LIST = [
|
||||
{
|
||||
@@ -161,16 +111,11 @@ export const getMaxToken = (modelId: string) => {
|
||||
export const LOCALE_COOKIE_NAME = 'locale'
|
||||
|
||||
const COOKIE_DOMAIN = getStringConfig(
|
||||
process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
|
||||
DatasetAttr.DATA_PUBLIC_COOKIE_DOMAIN,
|
||||
env.NEXT_PUBLIC_COOKIE_DOMAIN,
|
||||
'',
|
||||
).trim()
|
||||
|
||||
export const BATCH_CONCURRENCY = getNumberConfig(
|
||||
process.env.NEXT_PUBLIC_BATCH_CONCURRENCY,
|
||||
DatasetAttr.DATA_PUBLIC_BATCH_CONCURRENCY,
|
||||
5, // default
|
||||
)
|
||||
export const BATCH_CONCURRENCY = env.NEXT_PUBLIC_BATCH_CONCURRENCY
|
||||
|
||||
export const CSRF_COOKIE_NAME = () => {
|
||||
if (COOKIE_DOMAIN)
|
||||
@@ -344,112 +289,62 @@ export const resetReg = () => (VAR_REGEX.lastIndex = 0)
|
||||
export const HITL_INPUT_REG = /\{\{(#\$output\.(?:[a-z_]\w{0,29}){1,10}#)\}\}/gi
|
||||
export const resetHITLInputReg = () => HITL_INPUT_REG.lastIndex = 0
|
||||
|
||||
export const DISABLE_UPLOAD_IMAGE_AS_ICON = process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON === 'true'
|
||||
export const DISABLE_UPLOAD_IMAGE_AS_ICON = env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON
|
||||
|
||||
export const GITHUB_ACCESS_TOKEN
|
||||
= process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN || ''
|
||||
= env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN
|
||||
|
||||
export const SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS = '.difypkg,.difybndl'
|
||||
export const FULL_DOC_PREVIEW_LENGTH = 50
|
||||
|
||||
export const JSON_SCHEMA_MAX_DEPTH = 10
|
||||
|
||||
export const MAX_TOOLS_NUM = getNumberConfig(
|
||||
process.env.NEXT_PUBLIC_MAX_TOOLS_NUM,
|
||||
DatasetAttr.DATA_PUBLIC_MAX_TOOLS_NUM,
|
||||
10,
|
||||
)
|
||||
export const MAX_PARALLEL_LIMIT = getNumberConfig(
|
||||
process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT,
|
||||
DatasetAttr.DATA_PUBLIC_MAX_PARALLEL_LIMIT,
|
||||
10,
|
||||
)
|
||||
export const TEXT_GENERATION_TIMEOUT_MS = getNumberConfig(
|
||||
process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS,
|
||||
DatasetAttr.DATA_PUBLIC_TEXT_GENERATION_TIMEOUT_MS,
|
||||
60000,
|
||||
)
|
||||
export const LOOP_NODE_MAX_COUNT = getNumberConfig(
|
||||
process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT,
|
||||
DatasetAttr.DATA_PUBLIC_LOOP_NODE_MAX_COUNT,
|
||||
100,
|
||||
)
|
||||
export const MAX_ITERATIONS_NUM = getNumberConfig(
|
||||
process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM,
|
||||
DatasetAttr.DATA_PUBLIC_MAX_ITERATIONS_NUM,
|
||||
99,
|
||||
)
|
||||
export const MAX_TREE_DEPTH = getNumberConfig(
|
||||
process.env.NEXT_PUBLIC_MAX_TREE_DEPTH,
|
||||
DatasetAttr.DATA_PUBLIC_MAX_TREE_DEPTH,
|
||||
50,
|
||||
)
|
||||
export const MAX_TOOLS_NUM = env.NEXT_PUBLIC_MAX_TOOLS_NUM
|
||||
export const MAX_PARALLEL_LIMIT = env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
export const TEXT_GENERATION_TIMEOUT_MS = env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS
|
||||
export const LOOP_NODE_MAX_COUNT = env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT
|
||||
export const MAX_ITERATIONS_NUM = env.NEXT_PUBLIC_MAX_ITERATIONS_NUM
|
||||
export const MAX_TREE_DEPTH = env.NEXT_PUBLIC_MAX_TREE_DEPTH
|
||||
|
||||
export const ALLOW_UNSAFE_DATA_SCHEME = getBooleanConfig(
|
||||
process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME,
|
||||
DatasetAttr.DATA_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME,
|
||||
false,
|
||||
)
|
||||
export const ENABLE_WEBSITE_JINAREADER = getBooleanConfig(
|
||||
process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER,
|
||||
DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_JINAREADER,
|
||||
true,
|
||||
)
|
||||
export const ENABLE_WEBSITE_FIRECRAWL = getBooleanConfig(
|
||||
process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL,
|
||||
DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_FIRECRAWL,
|
||||
true,
|
||||
)
|
||||
export const ENABLE_WEBSITE_WATERCRAWL = getBooleanConfig(
|
||||
process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL,
|
||||
DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL,
|
||||
false,
|
||||
)
|
||||
export const ENABLE_SINGLE_DOLLAR_LATEX = getBooleanConfig(
|
||||
process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX,
|
||||
DatasetAttr.DATA_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX,
|
||||
false,
|
||||
)
|
||||
export const ALLOW_UNSAFE_DATA_SCHEME = env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME
|
||||
export const ENABLE_WEBSITE_JINAREADER = env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER
|
||||
export const ENABLE_WEBSITE_FIRECRAWL = env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL
|
||||
export const ENABLE_WEBSITE_WATERCRAWL = env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL
|
||||
export const ENABLE_SINGLE_DOLLAR_LATEX = env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX
|
||||
|
||||
export const VALUE_SELECTOR_DELIMITER = '@@@'
|
||||
|
||||
export const validPassword = /^(?=.*[a-z])(?=.*\d)\S{8,}$/i
|
||||
|
||||
export const ZENDESK_WIDGET_KEY = getStringConfig(
|
||||
process.env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY,
|
||||
DatasetAttr.NEXT_PUBLIC_ZENDESK_WIDGET_KEY,
|
||||
env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY,
|
||||
'',
|
||||
)
|
||||
export const ZENDESK_FIELD_IDS = {
|
||||
ENVIRONMENT: getStringConfig(
|
||||
process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT,
|
||||
DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT,
|
||||
env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT,
|
||||
'',
|
||||
),
|
||||
VERSION: getStringConfig(
|
||||
process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION,
|
||||
DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION,
|
||||
env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION,
|
||||
'',
|
||||
),
|
||||
EMAIL: getStringConfig(
|
||||
process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL,
|
||||
DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL,
|
||||
env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL,
|
||||
'',
|
||||
),
|
||||
WORKSPACE_ID: getStringConfig(
|
||||
process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID,
|
||||
DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID,
|
||||
env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID,
|
||||
'',
|
||||
),
|
||||
PLAN: getStringConfig(
|
||||
process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN,
|
||||
DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN,
|
||||
env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN,
|
||||
'',
|
||||
),
|
||||
}
|
||||
export const APP_VERSION = pkg.version
|
||||
|
||||
export const IS_MARKETPLACE = globalThis.document?.body?.getAttribute('data-is-marketplace') === 'true'
|
||||
export const IS_MARKETPLACE = env.NEXT_PUBLIC_IS_MARKETPLACE
|
||||
|
||||
export const RAG_PIPELINE_PREVIEW_CHUNK_NUM = 20
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
|
||||
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
|
||||
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
|
||||
import { ZENDESK_FIELD_IDS } from '@/config'
|
||||
import { env } from '@/env'
|
||||
import {
|
||||
useCurrentWorkspace,
|
||||
useLangGeniusVersion,
|
||||
@@ -204,7 +205,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
{globalThis.document?.body?.getAttribute('data-public-maintenance-notice') && <MaintenanceNotice />}
|
||||
{env.NEXT_PUBLIC_MAINTENANCE_NOTICE && <MaintenanceNotice />}
|
||||
<div className="relative flex grow flex-col overflow-y-auto overflow-x-hidden bg-background-body">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,19 @@ import type { EventEmitter } from 'ahooks/lib/useEventEmitter'
|
||||
import { useEventEmitter } from 'ahooks'
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
|
||||
const EventEmitterContext = createContext<{ eventEmitter: EventEmitter<string> | null }>({
|
||||
/**
|
||||
* Typed event object emitted via the shared EventEmitter.
|
||||
* Covers workflow updates, prompt-editor commands, DSL export checks, etc.
|
||||
*/
|
||||
export type EventEmitterMessage = {
|
||||
type: string
|
||||
payload?: unknown
|
||||
instanceId?: string
|
||||
}
|
||||
|
||||
export type EventEmitterValue = string | EventEmitterMessage
|
||||
|
||||
const EventEmitterContext = createContext<{ eventEmitter: EventEmitter<EventEmitterValue> | null }>({
|
||||
eventEmitter: null,
|
||||
})
|
||||
|
||||
@@ -16,7 +28,7 @@ type EventEmitterContextProviderProps = {
|
||||
export const EventEmitterContextProvider = ({
|
||||
children,
|
||||
}: EventEmitterContextProviderProps) => {
|
||||
const eventEmitter = useEventEmitter<string>()
|
||||
const eventEmitter = useEventEmitter<EventEmitterValue>()
|
||||
|
||||
return (
|
||||
<EventEmitterContext.Provider value={{ eventEmitter }}>
|
||||
|
||||
235
web/env.ts
Normal file
235
web/env.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { CamelCase, Replace } from 'string-ts'
|
||||
import { createEnv } from '@t3-oss/env-nextjs'
|
||||
import { concat, kebabCase, length, slice } from 'string-ts'
|
||||
import * as z from 'zod'
|
||||
import { isClient, isServer } from './utils/client'
|
||||
import { ObjectFromEntries, ObjectKeys } from './utils/object'
|
||||
|
||||
const CLIENT_ENV_PREFIX = 'NEXT_PUBLIC_'
|
||||
type ClientSchema = Record<`${typeof CLIENT_ENV_PREFIX}${string}`, z.ZodType>
|
||||
|
||||
const coercedBoolean = z.string()
|
||||
.refine(s => s === 'true' || s === 'false' || s === '0' || s === '1')
|
||||
.transform(s => s === 'true' || s === '1')
|
||||
const coercedNumber = z.coerce.number().int().positive()
|
||||
|
||||
/// keep-sorted
|
||||
const clientSchema = {
|
||||
/**
|
||||
* Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking
|
||||
*/
|
||||
NEXT_PUBLIC_ALLOW_EMBED: coercedBoolean.default(false),
|
||||
/**
|
||||
* Allow rendering unsafe URLs which have "data:" scheme.
|
||||
*/
|
||||
NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME: coercedBoolean.default(false),
|
||||
/**
|
||||
* The API key of amplitude
|
||||
*/
|
||||
NEXT_PUBLIC_AMPLITUDE_API_KEY: z.string().optional(),
|
||||
/**
|
||||
* The base URL of console application, refers to the Console base URL of WEB service if console domain is
|
||||
* different from api or web app domain.
|
||||
* example: http://cloud.dify.ai/console/api
|
||||
*/
|
||||
NEXT_PUBLIC_API_PREFIX: z.string().optional(),
|
||||
/**
|
||||
* The base path for the application
|
||||
*/
|
||||
NEXT_PUBLIC_BASE_PATH: z.string().regex(/^\/.*[^/]$/).or(z.literal('')).default(''),
|
||||
/**
|
||||
* number of concurrency
|
||||
*/
|
||||
NEXT_PUBLIC_BATCH_CONCURRENCY: coercedNumber.default(5),
|
||||
/**
|
||||
* When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.
|
||||
*/
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN: z.string().optional(),
|
||||
/**
|
||||
* CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
|
||||
*/
|
||||
NEXT_PUBLIC_CSP_WHITELIST: z.string().optional(),
|
||||
/**
|
||||
* For production release, change this to PRODUCTION
|
||||
*/
|
||||
NEXT_PUBLIC_DEPLOY_ENV: z.enum(['DEVELOPMENT', 'PRODUCTION', 'TESTING']).optional(),
|
||||
NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON: coercedBoolean.default(false),
|
||||
/**
|
||||
* The deployment edition, SELF_HOSTED
|
||||
*/
|
||||
NEXT_PUBLIC_EDITION: z.enum(['SELF_HOSTED', 'CLOUD']).default('SELF_HOSTED'),
|
||||
/**
|
||||
* Enable inline LaTeX rendering with single dollar signs ($...$)
|
||||
* Default is false for security reasons to prevent conflicts with regular text
|
||||
*/
|
||||
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: coercedBoolean.default(false),
|
||||
NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL: coercedBoolean.default(true),
|
||||
NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER: coercedBoolean.default(true),
|
||||
NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL: coercedBoolean.default(false),
|
||||
/**
|
||||
* Github Access Token, used for invoking Github API
|
||||
*/
|
||||
NEXT_PUBLIC_GITHUB_ACCESS_TOKEN: z.string().optional(),
|
||||
/**
|
||||
* The maximum number of tokens for segmentation
|
||||
*/
|
||||
NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: coercedNumber.default(4000),
|
||||
NEXT_PUBLIC_IS_MARKETPLACE: coercedBoolean.default(false),
|
||||
/**
|
||||
* Maximum loop count in the workflow
|
||||
*/
|
||||
NEXT_PUBLIC_LOOP_NODE_MAX_COUNT: coercedNumber.default(100),
|
||||
NEXT_PUBLIC_MAINTENANCE_NOTICE: z.string().optional(),
|
||||
/**
|
||||
* The API PREFIX for MARKETPLACE
|
||||
*/
|
||||
NEXT_PUBLIC_MARKETPLACE_API_PREFIX: z.url().optional(),
|
||||
/**
|
||||
* The URL for MARKETPLACE
|
||||
*/
|
||||
NEXT_PUBLIC_MARKETPLACE_URL_PREFIX: z.url().optional(),
|
||||
/**
|
||||
* The maximum number of iterations for agent setting
|
||||
*/
|
||||
NEXT_PUBLIC_MAX_ITERATIONS_NUM: coercedNumber.default(99),
|
||||
/**
|
||||
* Maximum number of Parallelism branches in the workflow
|
||||
*/
|
||||
NEXT_PUBLIC_MAX_PARALLEL_LIMIT: coercedNumber.default(10),
|
||||
/**
|
||||
* Maximum number of tools in the agent/workflow
|
||||
*/
|
||||
NEXT_PUBLIC_MAX_TOOLS_NUM: coercedNumber.default(10),
|
||||
/**
|
||||
* The maximum number of tree node depth for workflow
|
||||
*/
|
||||
NEXT_PUBLIC_MAX_TREE_DEPTH: coercedNumber.default(50),
|
||||
/**
|
||||
* The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from
|
||||
* console or api domain.
|
||||
* example: http://udify.app/api
|
||||
*/
|
||||
NEXT_PUBLIC_PUBLIC_API_PREFIX: z.string().optional(),
|
||||
/**
|
||||
* SENTRY
|
||||
*/
|
||||
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
|
||||
NEXT_PUBLIC_SITE_ABOUT: z.string().optional(),
|
||||
NEXT_PUBLIC_SUPPORT_MAIL_LOGIN: coercedBoolean.default(false),
|
||||
/**
|
||||
* The timeout for the text generation in millisecond
|
||||
*/
|
||||
NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS: coercedNumber.default(60000),
|
||||
/**
|
||||
* The maximum number of top-k value for RAG.
|
||||
*/
|
||||
NEXT_PUBLIC_TOP_K_MAX_VALUE: coercedNumber.default(10),
|
||||
/**
|
||||
* Disable Upload Image as WebApp icon default is false
|
||||
*/
|
||||
NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON: coercedBoolean.default(false),
|
||||
NEXT_PUBLIC_WEB_PREFIX: z.url().optional(),
|
||||
NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL: z.string().optional(),
|
||||
NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT: z.string().optional(),
|
||||
NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN: z.string().optional(),
|
||||
NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION: z.string().optional(),
|
||||
NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_ZENDESK_WIDGET_KEY: z.string().optional(),
|
||||
} satisfies ClientSchema
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
/**
|
||||
* Maximum length of segmentation tokens for indexing
|
||||
*/
|
||||
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: coercedNumber.default(4000),
|
||||
/**
|
||||
* Disable Next.js Telemetry (https://nextjs.org/telemetry)
|
||||
*/
|
||||
NEXT_TELEMETRY_DISABLED: coercedBoolean.optional(),
|
||||
PORT: coercedNumber.default(3000),
|
||||
/**
|
||||
* The timeout for the text generation in millisecond
|
||||
*/
|
||||
TEXT_GENERATION_TIMEOUT_MS: coercedNumber.default(60000),
|
||||
},
|
||||
shared: {
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
},
|
||||
client: clientSchema,
|
||||
experimental__runtimeEnv: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXT_PUBLIC_ALLOW_EMBED: isServer ? process.env.NEXT_PUBLIC_ALLOW_EMBED : getRuntimeEnvFromBody('allowEmbed'),
|
||||
NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME: isServer ? process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME : getRuntimeEnvFromBody('allowUnsafeDataScheme'),
|
||||
NEXT_PUBLIC_AMPLITUDE_API_KEY: isServer ? process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY : getRuntimeEnvFromBody('amplitudeApiKey'),
|
||||
NEXT_PUBLIC_API_PREFIX: isServer ? process.env.NEXT_PUBLIC_API_PREFIX : getRuntimeEnvFromBody('apiPrefix'),
|
||||
NEXT_PUBLIC_BASE_PATH: isServer ? process.env.NEXT_PUBLIC_BASE_PATH : getRuntimeEnvFromBody('basePath'),
|
||||
NEXT_PUBLIC_BATCH_CONCURRENCY: isServer ? process.env.NEXT_PUBLIC_BATCH_CONCURRENCY : getRuntimeEnvFromBody('batchConcurrency'),
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN: isServer ? process.env.NEXT_PUBLIC_COOKIE_DOMAIN : getRuntimeEnvFromBody('cookieDomain'),
|
||||
NEXT_PUBLIC_CSP_WHITELIST: isServer ? process.env.NEXT_PUBLIC_CSP_WHITELIST : getRuntimeEnvFromBody('cspWhitelist'),
|
||||
NEXT_PUBLIC_DEPLOY_ENV: isServer ? process.env.NEXT_PUBLIC_DEPLOY_ENV : getRuntimeEnvFromBody('deployEnv'),
|
||||
NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON: isServer ? process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON : getRuntimeEnvFromBody('disableUploadImageAsIcon'),
|
||||
NEXT_PUBLIC_EDITION: isServer ? process.env.NEXT_PUBLIC_EDITION : getRuntimeEnvFromBody('edition'),
|
||||
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: isServer ? process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX : getRuntimeEnvFromBody('enableSingleDollarLatex'),
|
||||
NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL : getRuntimeEnvFromBody('enableWebsiteFirecrawl'),
|
||||
NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER : getRuntimeEnvFromBody('enableWebsiteJinareader'),
|
||||
NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL : getRuntimeEnvFromBody('enableWebsiteWatercrawl'),
|
||||
NEXT_PUBLIC_GITHUB_ACCESS_TOKEN: isServer ? process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN : getRuntimeEnvFromBody('githubAccessToken'),
|
||||
NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: isServer ? process.env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH : getRuntimeEnvFromBody('indexingMaxSegmentationTokensLength'),
|
||||
NEXT_PUBLIC_IS_MARKETPLACE: isServer ? process.env.NEXT_PUBLIC_IS_MARKETPLACE : getRuntimeEnvFromBody('isMarketplace'),
|
||||
NEXT_PUBLIC_LOOP_NODE_MAX_COUNT: isServer ? process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT : getRuntimeEnvFromBody('loopNodeMaxCount'),
|
||||
NEXT_PUBLIC_MAINTENANCE_NOTICE: isServer ? process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE : getRuntimeEnvFromBody('maintenanceNotice'),
|
||||
NEXT_PUBLIC_MARKETPLACE_API_PREFIX: isServer ? process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX : getRuntimeEnvFromBody('marketplaceApiPrefix'),
|
||||
NEXT_PUBLIC_MARKETPLACE_URL_PREFIX: isServer ? process.env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX : getRuntimeEnvFromBody('marketplaceUrlPrefix'),
|
||||
NEXT_PUBLIC_MAX_ITERATIONS_NUM: isServer ? process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM : getRuntimeEnvFromBody('maxIterationsNum'),
|
||||
NEXT_PUBLIC_MAX_PARALLEL_LIMIT: isServer ? process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT : getRuntimeEnvFromBody('maxParallelLimit'),
|
||||
NEXT_PUBLIC_MAX_TOOLS_NUM: isServer ? process.env.NEXT_PUBLIC_MAX_TOOLS_NUM : getRuntimeEnvFromBody('maxToolsNum'),
|
||||
NEXT_PUBLIC_MAX_TREE_DEPTH: isServer ? process.env.NEXT_PUBLIC_MAX_TREE_DEPTH : getRuntimeEnvFromBody('maxTreeDepth'),
|
||||
NEXT_PUBLIC_PUBLIC_API_PREFIX: isServer ? process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX : getRuntimeEnvFromBody('publicApiPrefix'),
|
||||
NEXT_PUBLIC_SENTRY_DSN: isServer ? process.env.NEXT_PUBLIC_SENTRY_DSN : getRuntimeEnvFromBody('sentryDsn'),
|
||||
NEXT_PUBLIC_SITE_ABOUT: isServer ? process.env.NEXT_PUBLIC_SITE_ABOUT : getRuntimeEnvFromBody('siteAbout'),
|
||||
NEXT_PUBLIC_SUPPORT_MAIL_LOGIN: isServer ? process.env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN : getRuntimeEnvFromBody('supportMailLogin'),
|
||||
NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS: isServer ? process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS : getRuntimeEnvFromBody('textGenerationTimeoutMs'),
|
||||
NEXT_PUBLIC_TOP_K_MAX_VALUE: isServer ? process.env.NEXT_PUBLIC_TOP_K_MAX_VALUE : getRuntimeEnvFromBody('topKMaxValue'),
|
||||
NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON: isServer ? process.env.NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON : getRuntimeEnvFromBody('uploadImageAsIcon'),
|
||||
NEXT_PUBLIC_WEB_PREFIX: isServer ? process.env.NEXT_PUBLIC_WEB_PREFIX : getRuntimeEnvFromBody('webPrefix'),
|
||||
NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL : getRuntimeEnvFromBody('zendeskFieldIdEmail'),
|
||||
NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT : getRuntimeEnvFromBody('zendeskFieldIdEnvironment'),
|
||||
NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN : getRuntimeEnvFromBody('zendeskFieldIdPlan'),
|
||||
NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION : getRuntimeEnvFromBody('zendeskFieldIdVersion'),
|
||||
NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID : getRuntimeEnvFromBody('zendeskFieldIdWorkspaceId'),
|
||||
NEXT_PUBLIC_ZENDESK_WIDGET_KEY: isServer ? process.env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY : getRuntimeEnvFromBody('zendeskWidgetKey'),
|
||||
},
|
||||
emptyStringAsUndefined: true,
|
||||
})
|
||||
|
||||
type ClientEnvKey = keyof typeof clientSchema
|
||||
type DatasetKey = CamelCase<Replace<ClientEnvKey, typeof CLIENT_ENV_PREFIX>>
|
||||
|
||||
/**
|
||||
* Browser-only function to get runtime env value from HTML body dataset.
|
||||
*/
|
||||
function getRuntimeEnvFromBody(key: DatasetKey) {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new TypeError('getRuntimeEnvFromBody can only be called in the browser')
|
||||
}
|
||||
|
||||
const value = document.body.dataset[key]
|
||||
return value || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-only function to get dataset map for embedding into the HTML body.
|
||||
*/
|
||||
export function getDatasetMap() {
|
||||
if (isClient) {
|
||||
throw new TypeError('getDatasetMap can only be called on the server')
|
||||
}
|
||||
return ObjectFromEntries(
|
||||
ObjectKeys(clientSchema)
|
||||
.map(envKey => [
|
||||
concat('data-', kebabCase(slice(envKey, length(CLIENT_ENV_PREFIX)))),
|
||||
env[envKey],
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -2512,11 +2512,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/param-item/top-k-item.tsx": {
|
||||
"unicorn/prefer-number-properties": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/portal-to-follow-elem/index.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 2
|
||||
@@ -3822,14 +3817,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/datasets/documents/detail/metadata/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 4
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/datasets/documents/detail/new-segment.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
@@ -5664,10 +5651,12 @@
|
||||
},
|
||||
"app/components/rag-pipeline/components/update-dsl-modal.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 5
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/rag-pipeline/components/version-mismatch-modal.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/rag-pipeline/hooks/use-DSL.ts": {
|
||||
@@ -6811,12 +6800,6 @@
|
||||
}
|
||||
},
|
||||
"app/components/workflow/nodes/_base/node.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 5
|
||||
},
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
@@ -7272,9 +7255,6 @@
|
||||
"app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
},
|
||||
"unicorn/prefer-number-properties": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts": {
|
||||
@@ -8590,11 +8570,6 @@
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"app/install/installForm.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/reset-password/check-code/page.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 4
|
||||
|
||||
@@ -7,6 +7,10 @@ import sonar from 'eslint-plugin-sonarjs'
|
||||
import storybook from 'eslint-plugin-storybook'
|
||||
import dify from './eslint-rules/index.js'
|
||||
|
||||
// Enable Tailwind CSS IntelliSense mode for ESLint runs
|
||||
// See: tailwind-css-plugin.ts
|
||||
process.env.TAILWIND_MODE ??= 'ESLINT'
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
react: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'dayjs/locale/fr'
|
||||
import 'dayjs/locale/hi'
|
||||
import 'dayjs/locale/id'
|
||||
import 'dayjs/locale/it'
|
||||
import 'dayjs/locale/nl'
|
||||
import 'dayjs/locale/ja'
|
||||
import 'dayjs/locale/ko'
|
||||
import 'dayjs/locale/pl'
|
||||
|
||||
@@ -46,6 +46,7 @@ export const localeMap: Record<Locale, string> = {
|
||||
'it-IT': 'it',
|
||||
'th-TH': 'th',
|
||||
'id-ID': 'id',
|
||||
'nl-NL': 'nl',
|
||||
'uk-UA': 'uk',
|
||||
'vi-VN': 'vi',
|
||||
'ro-RO': 'ro',
|
||||
|
||||
@@ -147,6 +147,13 @@ const data = {
|
||||
example: 'Halo, Dify!',
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
value: 'nl-NL',
|
||||
name: 'Nederlands (Nederland)',
|
||||
prompt_name: 'Dutch',
|
||||
example: 'Hallo, Dify!',
|
||||
supported: true,
|
||||
},
|
||||
{
|
||||
value: 'ar-TN',
|
||||
name: 'العربية (تونس)',
|
||||
|
||||
70
web/i18n/nl-NL/app-annotation.json
Normal file
70
web/i18n/nl-NL/app-annotation.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"addModal.answerName": "Answer",
|
||||
"addModal.answerPlaceholder": "Type answer here",
|
||||
"addModal.createNext": "Add another annotated response",
|
||||
"addModal.queryName": "Question",
|
||||
"addModal.queryPlaceholder": "Type query here",
|
||||
"addModal.title": "Add Annotation Reply",
|
||||
"batchAction.cancel": "Cancel",
|
||||
"batchAction.delete": "Delete",
|
||||
"batchAction.selected": "Selected",
|
||||
"batchModal.answer": "answer",
|
||||
"batchModal.browse": "browse",
|
||||
"batchModal.cancel": "Cancel",
|
||||
"batchModal.completed": "Import completed",
|
||||
"batchModal.content": "content",
|
||||
"batchModal.contentTitle": "chunk content",
|
||||
"batchModal.csvUploadTitle": "Drag and drop your CSV file here, or ",
|
||||
"batchModal.error": "Import Error",
|
||||
"batchModal.ok": "OK",
|
||||
"batchModal.processing": "In batch processing",
|
||||
"batchModal.question": "question",
|
||||
"batchModal.run": "Run Batch",
|
||||
"batchModal.runError": "Run batch failed",
|
||||
"batchModal.template": "Download the template here",
|
||||
"batchModal.tip": "The CSV file must conform to the following structure:",
|
||||
"batchModal.title": "Bulk Import",
|
||||
"editBy": "Answer edited by {{author}}",
|
||||
"editModal.answerName": "Storyteller Bot",
|
||||
"editModal.answerPlaceholder": "Type your answer here",
|
||||
"editModal.createdAt": "Created At",
|
||||
"editModal.queryName": "User Query",
|
||||
"editModal.queryPlaceholder": "Type your query here",
|
||||
"editModal.removeThisCache": "Remove this Annotation",
|
||||
"editModal.title": "Edit Annotation Reply",
|
||||
"editModal.yourAnswer": "Your Answer",
|
||||
"editModal.yourQuery": "Your Query",
|
||||
"embeddingModelSwitchTip": "Annotation text vectorization model, switching models will be re-embedded, resulting in additional costs.",
|
||||
"errorMessage.answerRequired": "Answer is required",
|
||||
"errorMessage.queryRequired": "Question is required",
|
||||
"hitHistoryTable.match": "Match",
|
||||
"hitHistoryTable.query": "Query",
|
||||
"hitHistoryTable.response": "Response",
|
||||
"hitHistoryTable.score": "Score",
|
||||
"hitHistoryTable.source": "Source",
|
||||
"hitHistoryTable.time": "Time",
|
||||
"initSetup.configConfirmBtn": "Save",
|
||||
"initSetup.configTitle": "Annotation Reply Setup",
|
||||
"initSetup.confirmBtn": "Save & Enable",
|
||||
"initSetup.title": "Annotation Reply Initial Setup",
|
||||
"list.delete.title": "Are you sure Delete?",
|
||||
"name": "Annotation Reply",
|
||||
"noData.description": "You can edit annotations during app debugging or import annotations in bulk here for a high-quality response.",
|
||||
"noData.title": "No annotations",
|
||||
"table.header.actions": "actions",
|
||||
"table.header.addAnnotation": "Add Annotation",
|
||||
"table.header.answer": "answer",
|
||||
"table.header.bulkExport": "Bulk Export",
|
||||
"table.header.bulkImport": "Bulk Import",
|
||||
"table.header.clearAll": "Delete All",
|
||||
"table.header.clearAllConfirm": "Delete all annotations?",
|
||||
"table.header.createdAt": "created at",
|
||||
"table.header.hits": "hits",
|
||||
"table.header.question": "question",
|
||||
"title": "Annotations",
|
||||
"viewModal.annotatedResponse": "Annotation Reply",
|
||||
"viewModal.hit": "Hit",
|
||||
"viewModal.hitHistory": "Hit History",
|
||||
"viewModal.hits": "Hits",
|
||||
"viewModal.noHitHistory": "No hit history"
|
||||
}
|
||||
72
web/i18n/nl-NL/app-api.json
Normal file
72
web/i18n/nl-NL/app-api.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"actionMsg.deleteConfirmTips": "This action cannot be undone.",
|
||||
"actionMsg.deleteConfirmTitle": "Delete this secret key?",
|
||||
"actionMsg.ok": "OK",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyModal.apiSecretKey": "API Secret key",
|
||||
"apiKeyModal.apiSecretKeyTips": "To prevent API abuse, protect your API Key. Avoid using it as plain text in front-end code. :)",
|
||||
"apiKeyModal.createNewSecretKey": "Create new Secret key",
|
||||
"apiKeyModal.created": "CREATED",
|
||||
"apiKeyModal.generateTips": "Keep this key in a secure and accessible place.",
|
||||
"apiKeyModal.lastUsed": "LAST USED",
|
||||
"apiKeyModal.secretKey": "Secret Key",
|
||||
"apiServer": "API Server",
|
||||
"chatMode.blocking": "Blocking type, waiting for execution to complete and returning results. (Requests may be interrupted if the process is long)",
|
||||
"chatMode.chatMsgHistoryApi": "Get the chat history message",
|
||||
"chatMode.chatMsgHistoryApiTip": "The first page returns the latest `limit` bar, which is in reverse order.",
|
||||
"chatMode.chatMsgHistoryConversationIdTip": "Conversation ID",
|
||||
"chatMode.chatMsgHistoryFirstId": "ID of the first chat record on the current page. The default is none.",
|
||||
"chatMode.chatMsgHistoryLimit": "How many chats are returned in one request",
|
||||
"chatMode.conversationIdTip": "(Optional) Conversation ID: leave empty for first-time conversation; pass conversation_id from context to continue dialogue.",
|
||||
"chatMode.conversationRenamingApi": "Conversation renaming",
|
||||
"chatMode.conversationRenamingApiTip": "Rename conversations; the name is displayed in multi-session client interfaces.",
|
||||
"chatMode.conversationRenamingNameTip": "New name",
|
||||
"chatMode.conversationsListApi": "Get conversation list",
|
||||
"chatMode.conversationsListApiTip": "Gets the session list of the current user. By default, the last 20 sessions are returned.",
|
||||
"chatMode.conversationsListFirstIdTip": "The ID of the last record on the current page, default none.",
|
||||
"chatMode.conversationsListLimitTip": "How many chats are returned in one request",
|
||||
"chatMode.createChatApi": "Create chat message",
|
||||
"chatMode.createChatApiTip": "Create a new conversation message or continue an existing dialogue.",
|
||||
"chatMode.info": "For versatile conversational apps using a Q&A format, call the chat-messages API to initiate dialogue. Maintain ongoing conversations by passing the returned conversation_id. Response parameters and templates depend on Dify Prompt Eng. settings.",
|
||||
"chatMode.inputsTips": "(Optional) Provide user input fields as key-value pairs, corresponding to variables in Prompt Eng. Key is the variable name, Value is the parameter value. If the field type is Select, the submitted Value must be one of the preset choices.",
|
||||
"chatMode.messageFeedbackApi": "Message terminal user feedback, like",
|
||||
"chatMode.messageFeedbackApiTip": "Rate received messages on behalf of end-users with likes or dislikes. This data is visible in the Logs & Annotations page and used for future model fine-tuning.",
|
||||
"chatMode.messageIDTip": "Message ID",
|
||||
"chatMode.parametersApi": "Obtain application parameter information",
|
||||
"chatMode.parametersApiTip": "Retrieve configured Input parameters, including variable names, field names, types, and default values. Typically used for displaying these fields in a form or filling in default values after the client loads.",
|
||||
"chatMode.queryTips": "User input/question content",
|
||||
"chatMode.ratingTip": "like or dislike, null is undo",
|
||||
"chatMode.streaming": "streaming returns. Implementation of streaming return based on SSE (Server-Sent Events).",
|
||||
"chatMode.title": "Chat App API",
|
||||
"completionMode.blocking": "Blocking type, waiting for execution to complete and returning results. (Requests may be interrupted if the process is long)",
|
||||
"completionMode.createCompletionApi": "Create Completion Message",
|
||||
"completionMode.createCompletionApiTip": "Create a Completion Message to support the question-and-answer mode.",
|
||||
"completionMode.info": "For high-quality text generation, such as articles, summaries, and translations, use the completion-messages API with user input. Text generation relies on the model parameters and prompt templates set in Dify Prompt Engineering.",
|
||||
"completionMode.inputsTips": "(Optional) Provide user input fields as key-value pairs, corresponding to variables in Prompt Eng. Key is the variable name, Value is the parameter value. If the field type is Select, the submitted Value must be one of the preset choices.",
|
||||
"completionMode.messageFeedbackApi": "Message feedback (like)",
|
||||
"completionMode.messageFeedbackApiTip": "Rate received messages on behalf of end-users with likes or dislikes. This data is visible in the Logs & Annotations page and used for future model fine-tuning.",
|
||||
"completionMode.messageIDTip": "Message ID",
|
||||
"completionMode.parametersApi": "Obtain application parameter information",
|
||||
"completionMode.parametersApiTip": "Retrieve configured Input parameters, including variable names, field names, types, and default values. Typically used for displaying these fields in a form or filling in default values after the client loads.",
|
||||
"completionMode.queryTips": "User input text content.",
|
||||
"completionMode.ratingTip": "like or dislike, null is undo",
|
||||
"completionMode.streaming": "streaming returns. Implementation of streaming return based on SSE (Server-Sent Events).",
|
||||
"completionMode.title": "Completion App API",
|
||||
"copied": "Copied",
|
||||
"copy": "Copy",
|
||||
"develop.noContent": "No content",
|
||||
"develop.pathParams": "Path Params",
|
||||
"develop.query": "Query",
|
||||
"develop.requestBody": "Request Body",
|
||||
"develop.toc": "Contents",
|
||||
"disabled": "Disabled",
|
||||
"loading": "Loading",
|
||||
"merMaid.rerender": "Redo Rerender",
|
||||
"never": "Never",
|
||||
"ok": "In Service",
|
||||
"pause": "Pause",
|
||||
"play": "Play",
|
||||
"playing": "Playing",
|
||||
"regenerate": "Regenerate",
|
||||
"status": "Status"
|
||||
}
|
||||
393
web/i18n/nl-NL/app-debug.json
Normal file
393
web/i18n/nl-NL/app-debug.json
Normal file
@@ -0,0 +1,393 @@
|
||||
{
|
||||
"agent.agentMode": "Agent Mode",
|
||||
"agent.agentModeDes": "Set the type of inference mode for the agent",
|
||||
"agent.agentModeType.ReACT": "ReAct",
|
||||
"agent.agentModeType.functionCall": "Function Calling",
|
||||
"agent.buildInPrompt": "Build-In Prompt",
|
||||
"agent.firstPrompt": "First Prompt",
|
||||
"agent.nextIteration": "Next Iteration",
|
||||
"agent.promptPlaceholder": "Write your prompt here",
|
||||
"agent.setting.description": "Agent Assistant settings allow setting agent mode and advanced features like built-in prompts, only available in Agent type.",
|
||||
"agent.setting.maximumIterations.description": "Limit the number of iterations an agent assistant can execute",
|
||||
"agent.setting.maximumIterations.name": "Maximum Iterations",
|
||||
"agent.setting.name": "Agent Settings",
|
||||
"agent.tools.description": "Using tools can extend the capabilities of LLM, such as searching the internet or performing scientific calculations",
|
||||
"agent.tools.enabled": "Enabled",
|
||||
"agent.tools.name": "Tools",
|
||||
"assistantType.agentAssistant.description": "Build an intelligent Agent which can autonomously choose tools to complete the tasks",
|
||||
"assistantType.agentAssistant.name": "Agent Assistant",
|
||||
"assistantType.chatAssistant.description": "Build a chat-based assistant using a Large Language Model",
|
||||
"assistantType.chatAssistant.name": "Basic Assistant",
|
||||
"assistantType.name": "Assistant Type",
|
||||
"autoAddVar": "Undefined variables referenced in pre-prompt, are you want to add them in user input form?",
|
||||
"chatSubTitle": "Instructions",
|
||||
"code.instruction": "Instruction",
|
||||
"codegen.apply": "Apply",
|
||||
"codegen.applyChanges": "Apply Changes",
|
||||
"codegen.description": "The Code Generator uses configured models to generate high-quality code based on your instructions. Please provide clear and detailed instructions.",
|
||||
"codegen.generate": "Generate",
|
||||
"codegen.generatedCodeTitle": "Generated Code",
|
||||
"codegen.instruction": "Instructions",
|
||||
"codegen.instructionPlaceholder": "Enter detailed description of the code you want to generate.",
|
||||
"codegen.loading": "Generating code...",
|
||||
"codegen.noDataLine1": "Describe your use case on the left,",
|
||||
"codegen.noDataLine2": "the code preview will show here.",
|
||||
"codegen.overwriteConfirmMessage": "This action will overwrite the existing code. Do you want to continue?",
|
||||
"codegen.overwriteConfirmTitle": "Overwrite existing code?",
|
||||
"codegen.resTitle": "Generated Code",
|
||||
"codegen.title": "Code Generator",
|
||||
"completionSubTitle": "Prefix Prompt",
|
||||
"datasetConfig.embeddingModelRequired": "A configured Embedding Model is required",
|
||||
"datasetConfig.knowledgeTip": "Click the “+” button to add knowledge",
|
||||
"datasetConfig.params": "Params",
|
||||
"datasetConfig.rerankModelRequired": "A configured Rerank Model is required",
|
||||
"datasetConfig.retrieveChangeTip": "Modifying the index mode and retrieval mode may affect applications associated with this Knowledge.",
|
||||
"datasetConfig.retrieveMultiWay.description": "Based on user intent, queries across all Knowledge, retrieves relevant text from multi-sources, and selects the best results matching the user query after reranking.",
|
||||
"datasetConfig.retrieveMultiWay.title": "Multi-path retrieval",
|
||||
"datasetConfig.retrieveOneWay.description": "Based on user intent and Knowledge descriptions, the Agent autonomously selects the best Knowledge for querying. Best for applications with distinct, limited Knowledge.",
|
||||
"datasetConfig.retrieveOneWay.title": "N-to-1 retrieval",
|
||||
"datasetConfig.score_threshold": "Score Threshold",
|
||||
"datasetConfig.score_thresholdTip": "Used to set the similarity threshold for chunks filtering.",
|
||||
"datasetConfig.settingTitle": "Retrieval settings",
|
||||
"datasetConfig.top_k": "Top K",
|
||||
"datasetConfig.top_kTip": "Used to filter chunks that are most similar to user questions. The system will also dynamically adjust the value of Top K, according to max_tokens of the selected model.",
|
||||
"debugAsMultipleModel": "Debug as Multiple Models",
|
||||
"debugAsSingleModel": "Debug as Single Model",
|
||||
"duplicateModel": "Duplicate",
|
||||
"errorMessage.nameOfKeyRequired": "name of the key: {{key}} required",
|
||||
"errorMessage.notSelectModel": "Please choose a model",
|
||||
"errorMessage.queryRequired": "Request text is required.",
|
||||
"errorMessage.valueOfVarRequired": "{{key}} value can not be empty",
|
||||
"errorMessage.waitForBatchResponse": "Please wait for the response to the batch task to complete.",
|
||||
"errorMessage.waitForFileUpload": "Please wait for the file/files to upload",
|
||||
"errorMessage.waitForImgUpload": "Please wait for the image to upload",
|
||||
"errorMessage.waitForResponse": "Please wait for the response to the previous message to complete.",
|
||||
"feature.annotation.add": "Add annotation",
|
||||
"feature.annotation.cacheManagement": "Annotations",
|
||||
"feature.annotation.cached": "Annotated",
|
||||
"feature.annotation.description": "You can manually add high-quality response to the cache for prioritized matching with similar user questions.",
|
||||
"feature.annotation.edit": "Edit annotation",
|
||||
"feature.annotation.matchVariable.choosePlaceholder": "Choose match variable",
|
||||
"feature.annotation.matchVariable.title": "Match Variable",
|
||||
"feature.annotation.remove": "Remove",
|
||||
"feature.annotation.removeConfirm": "Delete this annotation ?",
|
||||
"feature.annotation.resDes": "Annotation Response is enabled",
|
||||
"feature.annotation.scoreThreshold.accurateMatch": "Accurate Match",
|
||||
"feature.annotation.scoreThreshold.description": "Used to set the similarity threshold for annotation reply.",
|
||||
"feature.annotation.scoreThreshold.easyMatch": "Easy Match",
|
||||
"feature.annotation.scoreThreshold.title": "Score Threshold",
|
||||
"feature.annotation.title": "Annotation Reply",
|
||||
"feature.audioUpload.description": "Enable Audio will allow the model to process audio files for transcription and analysis.",
|
||||
"feature.audioUpload.title": "Audio",
|
||||
"feature.bar.empty": "Enable feature to enhance web app user experience",
|
||||
"feature.bar.enableText": "Features Enabled",
|
||||
"feature.bar.manage": "Manage",
|
||||
"feature.citation.description": "Show source document and attributed section of the generated content.",
|
||||
"feature.citation.resDes": "Citations and Attributions is enabled",
|
||||
"feature.citation.title": "Citations and Attributions",
|
||||
"feature.conversationHistory.description": "Set prefix names for conversation roles",
|
||||
"feature.conversationHistory.editModal.assistantPrefix": "Assistant prefix",
|
||||
"feature.conversationHistory.editModal.title": "Edit Conversation Role Names",
|
||||
"feature.conversationHistory.editModal.userPrefix": "User prefix",
|
||||
"feature.conversationHistory.learnMore": "Learn more",
|
||||
"feature.conversationHistory.tip": "The Conversation History is not enabled, please add <histories> in the prompt above.",
|
||||
"feature.conversationHistory.title": "Conversation History",
|
||||
"feature.conversationOpener.description": "In a chat app, the first sentence that the AI actively speaks to the user is usually used as a welcome.",
|
||||
"feature.conversationOpener.title": "Conversation Opener",
|
||||
"feature.dataSet.noData": "You can import Knowledge as context",
|
||||
"feature.dataSet.noDataSet": "No Knowledge found",
|
||||
"feature.dataSet.notSupportSelectMulti": "Currently only support one Knowledge",
|
||||
"feature.dataSet.queryVariable.choosePlaceholder": "Choose query variable",
|
||||
"feature.dataSet.queryVariable.contextVarNotEmpty": "context query variable can not be empty",
|
||||
"feature.dataSet.queryVariable.deleteContextVarTip": "This variable has been set as a context query variable, and removing it will impact the normal use of the Knowledge. If you still need to delete it, please reselect it in the context section.",
|
||||
"feature.dataSet.queryVariable.deleteContextVarTitle": "Delete variable “{{varName}}”?",
|
||||
"feature.dataSet.queryVariable.noVar": "No variables",
|
||||
"feature.dataSet.queryVariable.noVarTip": "please create a variable under the Variables section",
|
||||
"feature.dataSet.queryVariable.ok": "OK",
|
||||
"feature.dataSet.queryVariable.tip": "This variable will be used as the query input for context retrieval, obtaining context information related to the input of this variable.",
|
||||
"feature.dataSet.queryVariable.title": "Query variable",
|
||||
"feature.dataSet.queryVariable.unableToQueryDataSet": "Unable to query the Knowledge",
|
||||
"feature.dataSet.queryVariable.unableToQueryDataSetTip": "Unable to query the Knowledge successfully, please choose a context query variable in the context section.",
|
||||
"feature.dataSet.selectTitle": "Select reference Knowledge",
|
||||
"feature.dataSet.selected": "Knowledge selected",
|
||||
"feature.dataSet.title": "Knowledge",
|
||||
"feature.dataSet.toCreate": "Go to create",
|
||||
"feature.documentUpload.description": "Enable Document will allows the model to take in documents and answer questions about them.",
|
||||
"feature.documentUpload.title": "Document",
|
||||
"feature.fileUpload.description": "The chat input box allows uploading of images, documents, and other files.",
|
||||
"feature.fileUpload.modalTitle": "File Upload Setting",
|
||||
"feature.fileUpload.numberLimit": "Max uploads",
|
||||
"feature.fileUpload.supportedTypes": "Support File Types",
|
||||
"feature.fileUpload.title": "File Upload",
|
||||
"feature.groupChat.description": "Add pre-conversation settings for apps can enhance user experience.",
|
||||
"feature.groupChat.title": "Chat enhance",
|
||||
"feature.groupExperience.title": "Experience enhance",
|
||||
"feature.imageUpload.description": "Allow uploading images.",
|
||||
"feature.imageUpload.modalTitle": "Image Upload Setting",
|
||||
"feature.imageUpload.numberLimit": "Max uploads",
|
||||
"feature.imageUpload.supportedTypes": "Support File Types",
|
||||
"feature.imageUpload.title": "Image Upload",
|
||||
"feature.moderation.allEnabled": "INPUT & OUTPUT",
|
||||
"feature.moderation.contentEnableLabel": "Content moderation enabled",
|
||||
"feature.moderation.description": "Secure model output by using moderation API or maintaining a sensitive word list.",
|
||||
"feature.moderation.inputEnabled": "INPUT",
|
||||
"feature.moderation.modal.content.condition": "Moderate INPUT and OUTPUT Content enabled at least one",
|
||||
"feature.moderation.modal.content.errorMessage": "Preset replies cannot be empty",
|
||||
"feature.moderation.modal.content.fromApi": "Preset replies are returned by API",
|
||||
"feature.moderation.modal.content.input": "Moderate INPUT Content",
|
||||
"feature.moderation.modal.content.output": "Moderate OUTPUT Content",
|
||||
"feature.moderation.modal.content.placeholder": "Preset replies content here",
|
||||
"feature.moderation.modal.content.preset": "Preset replies",
|
||||
"feature.moderation.modal.content.supportMarkdown": "Markdown supported",
|
||||
"feature.moderation.modal.keywords.line": "Line",
|
||||
"feature.moderation.modal.keywords.placeholder": "One per line, separated by line breaks",
|
||||
"feature.moderation.modal.keywords.tip": "One per line, separated by line breaks. Up to 100 characters per line.",
|
||||
"feature.moderation.modal.openaiNotConfig.after": "",
|
||||
"feature.moderation.modal.openaiNotConfig.before": "OpenAI Moderation requires an OpenAI API key configured in the",
|
||||
"feature.moderation.modal.provider.keywords": "Keywords",
|
||||
"feature.moderation.modal.provider.openai": "OpenAI Moderation",
|
||||
"feature.moderation.modal.provider.openaiTip.prefix": "OpenAI Moderation requires an OpenAI API key configured in the ",
|
||||
"feature.moderation.modal.provider.openaiTip.suffix": ".",
|
||||
"feature.moderation.modal.provider.title": "Provider",
|
||||
"feature.moderation.modal.title": "Content moderation settings",
|
||||
"feature.moderation.outputEnabled": "OUTPUT",
|
||||
"feature.moderation.title": "Content moderation",
|
||||
"feature.moreLikeThis.description": "Generate multiple texts at once, and then edit and continue to generate",
|
||||
"feature.moreLikeThis.generateNumTip": "Number of each generated times",
|
||||
"feature.moreLikeThis.tip": "Using this feature will incur additional tokens overhead",
|
||||
"feature.moreLikeThis.title": "More like this",
|
||||
"feature.speechToText.description": "Voice input can be used in chat.",
|
||||
"feature.speechToText.resDes": "Voice input is enabled",
|
||||
"feature.speechToText.title": "Speech to Text",
|
||||
"feature.suggestedQuestionsAfterAnswer.description": "Setting up next questions suggestion can give users a better chat.",
|
||||
"feature.suggestedQuestionsAfterAnswer.resDes": "3 suggestions for user next question.",
|
||||
"feature.suggestedQuestionsAfterAnswer.title": "Follow-up",
|
||||
"feature.suggestedQuestionsAfterAnswer.tryToAsk": "Try to ask",
|
||||
"feature.textToSpeech.description": "Conversation messages can be converted to speech.",
|
||||
"feature.textToSpeech.resDes": "Text to Audio is enabled",
|
||||
"feature.textToSpeech.title": "Text to Speech",
|
||||
"feature.toolbox.title": "TOOLBOX",
|
||||
"feature.tools.modal.name.placeholder": "Please enter the name",
|
||||
"feature.tools.modal.name.title": "Name",
|
||||
"feature.tools.modal.title": "Tool",
|
||||
"feature.tools.modal.toolType.placeholder": "Please select the tool type",
|
||||
"feature.tools.modal.toolType.title": "Tool Type",
|
||||
"feature.tools.modal.variableName.placeholder": "Please enter the variable name",
|
||||
"feature.tools.modal.variableName.title": "Variable Name",
|
||||
"feature.tools.tips": "Tools provide a standard API call method, taking user input or variables as request parameters for querying external data as context.",
|
||||
"feature.tools.title": "Tools",
|
||||
"feature.tools.toolsInUse": "{{count}} tools in use",
|
||||
"formattingChangedText": "Modifying the formatting will reset the debug area, are you sure?",
|
||||
"formattingChangedTitle": "Formatting changed",
|
||||
"generate.apply": "Apply",
|
||||
"generate.codeGenInstructionPlaceHolderLine": "The more detailed the feedback, such as the data types of input and output as well as how variables are processed, the more accurate the code generation will be.",
|
||||
"generate.description": "The Prompt Generator uses the configured model to optimize prompts for higher quality and better structure. Please write clear and detailed instructions.",
|
||||
"generate.dismiss": "Dismiss",
|
||||
"generate.generate": "Generate",
|
||||
"generate.idealOutput": "Ideal Output",
|
||||
"generate.idealOutputPlaceholder": "Describe your ideal response format, length, tone, and content requirements...",
|
||||
"generate.insertContext": "insert context",
|
||||
"generate.instruction": "Instructions",
|
||||
"generate.instructionPlaceHolderLine1": "Make the output more concise, retaining the core points.",
|
||||
"generate.instructionPlaceHolderLine2": "The output format is incorrect, please strictly follow the JSON format.",
|
||||
"generate.instructionPlaceHolderLine3": "The tone is too harsh, please make it more friendly.",
|
||||
"generate.instructionPlaceHolderTitle": "Describe how you would like to improve this Prompt. For example:",
|
||||
"generate.latest": "Latest",
|
||||
"generate.loading": "Orchestrating the application for you...",
|
||||
"generate.newNoDataLine1": "Write a instruction in the left column, and click Generate to see response. ",
|
||||
"generate.optimizationNote": "Optimization Note",
|
||||
"generate.optimizePromptTooltip": "Optimize in Prompt Generator",
|
||||
"generate.optional": "Optional",
|
||||
"generate.overwriteMessage": "Applying this prompt will override existing configuration.",
|
||||
"generate.overwriteTitle": "Override existing configuration?",
|
||||
"generate.press": "Press",
|
||||
"generate.resTitle": "Generated Prompt",
|
||||
"generate.template.GitGud.instruction": "Generate appropriate Git commands based on user described version control actions",
|
||||
"generate.template.GitGud.name": "Git gud",
|
||||
"generate.template.SQLSorcerer.instruction": "Transform everyday language into SQL queries",
|
||||
"generate.template.SQLSorcerer.name": "SQL sorcerer",
|
||||
"generate.template.excelFormulaExpert.instruction": "A chatbot that can help novice users understand, use and create Excel formulas based on user instructions",
|
||||
"generate.template.excelFormulaExpert.name": "Excel formula expert",
|
||||
"generate.template.meetingTakeaways.instruction": "Distill meetings into concise summaries including discussion topics, key takeaways, and action items",
|
||||
"generate.template.meetingTakeaways.name": "Meeting takeaways",
|
||||
"generate.template.professionalAnalyst.instruction": "Extract insights, identify risk and distill key information from long reports into single memo",
|
||||
"generate.template.professionalAnalyst.name": "Professional analyst",
|
||||
"generate.template.pythonDebugger.instruction": "A bot that can generate and debug your code based on your instruction",
|
||||
"generate.template.pythonDebugger.name": "Python debugger",
|
||||
"generate.template.translation.instruction": "A translator that can translate multiple languages",
|
||||
"generate.template.translation.name": "Translation",
|
||||
"generate.template.travelPlanning.instruction": "The Travel Planning Assistant is an intelligent tool designed to help users effortlessly plan their trips",
|
||||
"generate.template.travelPlanning.name": "Travel planning",
|
||||
"generate.template.writingsPolisher.instruction": "Use advanced copyediting techniques to improve your writings",
|
||||
"generate.template.writingsPolisher.name": "Writing polisher",
|
||||
"generate.title": "Prompt Generator",
|
||||
"generate.to": "to ",
|
||||
"generate.tryIt": "Try it",
|
||||
"generate.version": "Version",
|
||||
"generate.versions": "Versions",
|
||||
"inputs.chatVarTip": "Fill in the value of the variable, which will be automatically replaced in the prompt word every time a new session is started",
|
||||
"inputs.completionVarTip": "Fill in the value of the variable, which will be automatically replaced in the prompt words every time a question is submitted.",
|
||||
"inputs.noPrompt": "Try write some prompt in pre-prompt input",
|
||||
"inputs.noVar": "Fill in the value of the variable, which will be automatically replaced in the prompt word every time a new session is started.",
|
||||
"inputs.previewTitle": "Prompt preview",
|
||||
"inputs.queryPlaceholder": "Please enter the request text.",
|
||||
"inputs.queryTitle": "Query content",
|
||||
"inputs.run": "RUN",
|
||||
"inputs.title": "Debug & Preview",
|
||||
"inputs.userInputField": "User Input Field",
|
||||
"modelConfig.modeType.chat": "Chat",
|
||||
"modelConfig.modeType.completion": "Complete",
|
||||
"modelConfig.model": "Model",
|
||||
"modelConfig.setTone": "Set tone of responses",
|
||||
"modelConfig.title": "Model and Parameters",
|
||||
"noResult": "Output will be displayed here.",
|
||||
"notSetAPIKey.description": "The LLM provider key has not been set, and it needs to be set before debugging.",
|
||||
"notSetAPIKey.settingBtn": "Go to settings",
|
||||
"notSetAPIKey.title": "LLM provider key has not been set",
|
||||
"notSetAPIKey.trailFinished": "Trail finished",
|
||||
"notSetVar": "Variables allow users to introduce prompt words or opening remarks when filling out forms. You can try entering \"{{input}}\" in the prompt words.",
|
||||
"openingStatement.add": "Add",
|
||||
"openingStatement.noDataPlaceHolder": "Starting the conversation with the user can help AI establish a closer connection with them in conversational applications.",
|
||||
"openingStatement.notIncludeKey": "The initial prompt does not include the variable: {{key}}. Please add it to the initial prompt.",
|
||||
"openingStatement.openingQuestion": "Opening Questions",
|
||||
"openingStatement.openingQuestionPlaceholder": "You can use variables, try typing {{variable}}.",
|
||||
"openingStatement.placeholder": "Write your opener message here, you can use variables, try type {{variable}}.",
|
||||
"openingStatement.title": "Conversation Opener",
|
||||
"openingStatement.tooShort": "At least 20 words of initial prompt are required to generate an opening remarks for the conversation.",
|
||||
"openingStatement.varTip": "You can use variables, try type {{variable}}",
|
||||
"openingStatement.writeOpener": "Edit opener",
|
||||
"operation.addFeature": "Add Feature",
|
||||
"operation.agree": "like",
|
||||
"operation.applyConfig": "Publish",
|
||||
"operation.automatic": "Generate",
|
||||
"operation.cancelAgree": "Cancel like",
|
||||
"operation.cancelDisagree": "Cancel dislike",
|
||||
"operation.debugConfig": "Debug",
|
||||
"operation.disagree": "dislike",
|
||||
"operation.resetConfig": "Reset",
|
||||
"operation.stopResponding": "Stop responding",
|
||||
"operation.userAction": "User ",
|
||||
"orchestrate": "Orchestrate",
|
||||
"otherError.historyNoBeEmpty": "Conversation history must be set in the prompt",
|
||||
"otherError.promptNoBeEmpty": "Prompt can not be empty",
|
||||
"otherError.queryNoBeEmpty": "Query must be set in the prompt",
|
||||
"pageTitle.line1": "PROMPT",
|
||||
"pageTitle.line2": "Engineering",
|
||||
"promptMode.advanced": "Expert Mode",
|
||||
"promptMode.advancedWarning.description": "In Expert Mode, you can edit whole PROMPT.",
|
||||
"promptMode.advancedWarning.learnMore": "Learn more",
|
||||
"promptMode.advancedWarning.ok": "OK",
|
||||
"promptMode.advancedWarning.title": "You have switched to Expert Mode, and once you modify the PROMPT, you CANNOT return to the basic mode.",
|
||||
"promptMode.contextMissing": "Context component missed, the effectiveness of the prompt may not be good.",
|
||||
"promptMode.operation.addMessage": "Add Message",
|
||||
"promptMode.simple": "Switch to Expert Mode to edit the whole PROMPT",
|
||||
"promptMode.switchBack": "Switch back",
|
||||
"promptTip": "Prompts guide AI responses with instructions and constraints. Insert variables like {{input}}. This prompt won't be visible to users.",
|
||||
"publishAs": "Publish as",
|
||||
"resetConfig.message": "Reset discards changes, restoring the last published configuration.",
|
||||
"resetConfig.title": "Confirm reset?",
|
||||
"result": "Output Text",
|
||||
"trailUseGPT4Info.description": "Use gpt-4, please set API Key.",
|
||||
"trailUseGPT4Info.title": "Does not support gpt-4 now",
|
||||
"varKeyError.canNoBeEmpty": "{{key}} is required",
|
||||
"varKeyError.keyAlreadyExists": "{{key}} already exists",
|
||||
"varKeyError.notStartWithNumber": "{{key}} can not start with a number",
|
||||
"varKeyError.notValid": "{{key}} is invalid. Can only contain letters, numbers, and underscores",
|
||||
"varKeyError.tooLong": "{{key}} is too length. Can not be longer then 30 characters",
|
||||
"variableConfig.addModalTitle": "Add Input Field",
|
||||
"variableConfig.addOption": "Add option",
|
||||
"variableConfig.apiBasedVar": "API-based Variable",
|
||||
"variableConfig.both": "Both",
|
||||
"variableConfig.checkbox": "Checkbox",
|
||||
"variableConfig.content": "Content",
|
||||
"variableConfig.defaultValue": "Default Value",
|
||||
"variableConfig.defaultValuePlaceholder": "Enter default value to pre-populate the field",
|
||||
"variableConfig.description": "Setting for variable {{varName}}",
|
||||
"variableConfig.displayName": "Display Name",
|
||||
"variableConfig.editModalTitle": "Edit Input Field",
|
||||
"variableConfig.errorMsg.atLeastOneOption": "At least one option is required",
|
||||
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema is not valid JSON",
|
||||
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema must have type \"object\"",
|
||||
"variableConfig.errorMsg.labelNameRequired": "Label name is required",
|
||||
"variableConfig.errorMsg.optionRepeat": "Has repeat options",
|
||||
"variableConfig.errorMsg.varNameCanBeRepeat": "Variable name can not be repeated",
|
||||
"variableConfig.fieldType": "Field Type",
|
||||
"variableConfig.file.audio.name": "Audio",
|
||||
"variableConfig.file.custom.createPlaceholder": "+ File extension, e.g .doc",
|
||||
"variableConfig.file.custom.description": "Specify other file types.",
|
||||
"variableConfig.file.custom.name": "Other file types",
|
||||
"variableConfig.file.document.name": "Document",
|
||||
"variableConfig.file.image.name": "Image",
|
||||
"variableConfig.file.supportFileTypes": "Support File Types",
|
||||
"variableConfig.file.video.name": "Video",
|
||||
"variableConfig.hide": "Hide",
|
||||
"variableConfig.inputPlaceholder": "Please input",
|
||||
"variableConfig.json": "JSON Code",
|
||||
"variableConfig.jsonSchema": "JSON Schema",
|
||||
"variableConfig.labelName": "Label Name",
|
||||
"variableConfig.localUpload": "Local Upload",
|
||||
"variableConfig.maxLength": "Max Length",
|
||||
"variableConfig.maxNumberOfUploads": "Max number of uploads",
|
||||
"variableConfig.maxNumberTip": "Document < {{docLimit}}, image < {{imgLimit}}, audio < {{audioLimit}}, video < {{videoLimit}}",
|
||||
"variableConfig.multi-files": "File List",
|
||||
"variableConfig.noDefaultSelected": "Don't select",
|
||||
"variableConfig.noDefaultValue": "No default value",
|
||||
"variableConfig.notSet": "Not set, try typing {{input}} in the prefix prompt",
|
||||
"variableConfig.number": "Number",
|
||||
"variableConfig.optional": "optional",
|
||||
"variableConfig.options": "Options",
|
||||
"variableConfig.paragraph": "Paragraph",
|
||||
"variableConfig.placeholder": "Placeholder",
|
||||
"variableConfig.placeholderPlaceholder": "Enter text to display when the field is empty",
|
||||
"variableConfig.required": "Required",
|
||||
"variableConfig.select": "Select",
|
||||
"variableConfig.selectDefaultValue": "Select default value",
|
||||
"variableConfig.showAllSettings": "Show All Settings",
|
||||
"variableConfig.single-file": "Single File",
|
||||
"variableConfig.startChecked": "Start checked",
|
||||
"variableConfig.startSelectedOption": "Start selected option",
|
||||
"variableConfig.string": "Short Text",
|
||||
"variableConfig.stringTitle": "Form text box options",
|
||||
"variableConfig.text-input": "Short Text",
|
||||
"variableConfig.tooltips": "Tooltips",
|
||||
"variableConfig.tooltipsPlaceholder": "Enter helpful text shown when hovering over the label",
|
||||
"variableConfig.unit": "Unit",
|
||||
"variableConfig.unitPlaceholder": "Display units after numbers, e.g. tokens",
|
||||
"variableConfig.uploadFileTypes": "Upload File Types",
|
||||
"variableConfig.uploadMethod": "Upload Method",
|
||||
"variableConfig.varName": "Variable Name",
|
||||
"variableTable.action": "Actions",
|
||||
"variableTable.key": "Variable Key",
|
||||
"variableTable.name": "User Input Field Name",
|
||||
"variableTable.type": "Input Type",
|
||||
"variableTable.typeSelect": "Select",
|
||||
"variableTable.typeString": "String",
|
||||
"variableTip": "Users fill variables in a form, automatically replacing variables in the prompt.",
|
||||
"variableTitle": "Variables",
|
||||
"vision.description": "Enable Vision will allows the model to take in images and answer questions about them.",
|
||||
"vision.name": "Vision",
|
||||
"vision.onlySupportVisionModelTip": "Only supports vision models",
|
||||
"vision.settings": "Settings",
|
||||
"vision.visionSettings.both": "Both",
|
||||
"vision.visionSettings.high": "High",
|
||||
"vision.visionSettings.localUpload": "Local Upload",
|
||||
"vision.visionSettings.low": "Low",
|
||||
"vision.visionSettings.resolution": "Resolution",
|
||||
"vision.visionSettings.resolutionTooltip": "low res will allow model receive a low-res 512 x 512 version of the image, and represent the image with a budget of 65 tokens. This allows the API to return faster responses and consume fewer input tokens for use cases that do not require high detail.\nhigh res will first allows the model to see the low res image and then creates detailed crops of input images as 512px squares based on the input image size. Each of the detailed crops uses twice the token budget for a total of 129 tokens.",
|
||||
"vision.visionSettings.title": "Vision Settings",
|
||||
"vision.visionSettings.uploadLimit": "Upload Limit",
|
||||
"vision.visionSettings.uploadMethod": "Upload Method",
|
||||
"vision.visionSettings.url": "URL",
|
||||
"voice.defaultDisplay": "Default Voice",
|
||||
"voice.description": "Text to speech voice Settings",
|
||||
"voice.name": "Voice",
|
||||
"voice.settings": "Settings",
|
||||
"voice.voiceSettings.autoPlay": "Auto Play",
|
||||
"voice.voiceSettings.autoPlayDisabled": "Off",
|
||||
"voice.voiceSettings.autoPlayEnabled": "On",
|
||||
"voice.voiceSettings.language": "Language",
|
||||
"voice.voiceSettings.resolutionTooltip": "Text-to-speech voice support language。",
|
||||
"voice.voiceSettings.title": "Voice Settings",
|
||||
"voice.voiceSettings.voice": "Voice",
|
||||
"warningMessage.timeoutExceeded": "Results are not displayed due to timeout. Please refer to the logs to gather complete results."
|
||||
}
|
||||
84
web/i18n/nl-NL/app-log.json
Normal file
84
web/i18n/nl-NL/app-log.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"agentLog": "Agent Log",
|
||||
"agentLogDetail.agentMode": "Agent Mode",
|
||||
"agentLogDetail.finalProcessing": "Final Processing",
|
||||
"agentLogDetail.iteration": "Iteration",
|
||||
"agentLogDetail.iterations": "Iterations",
|
||||
"agentLogDetail.toolUsed": "Tool Used",
|
||||
"dateFormat": "MM/DD/YYYY",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "The logs record the running status of the application, including user inputs and AI replies.",
|
||||
"detail.annotationTip": "Improvements Marked by {{user}}",
|
||||
"detail.conversationId": "Conversation ID",
|
||||
"detail.loading": "loading",
|
||||
"detail.modelParams": "Model parameters",
|
||||
"detail.operation.addAnnotation": "Add Improvement",
|
||||
"detail.operation.annotationPlaceholder": "Enter the expected answer that you want AI to reply, which can be used for model fine-tuning and continuous improvement of text generation quality in the future.",
|
||||
"detail.operation.dislike": "dislike",
|
||||
"detail.operation.editAnnotation": "Edit Improvement",
|
||||
"detail.operation.like": "like",
|
||||
"detail.promptTemplate": "Prompt Template",
|
||||
"detail.promptTemplateBeforeChat": "Prompt Template Before Chat · As System Message",
|
||||
"detail.second": "s",
|
||||
"detail.time": "Time",
|
||||
"detail.timeConsuming": "",
|
||||
"detail.tokenCost": "Token spent",
|
||||
"detail.uploadImages": "Uploaded Images",
|
||||
"detail.variables": "Variables",
|
||||
"filter.annotation.all": "All",
|
||||
"filter.annotation.annotated": "Annotated Improvements ({{count}} items)",
|
||||
"filter.annotation.not_annotated": "Not Annotated",
|
||||
"filter.ascending": "ascending",
|
||||
"filter.descending": "descending",
|
||||
"filter.period.allTime": "All time",
|
||||
"filter.period.custom": "Custom",
|
||||
"filter.period.last12months": "Last 12 months",
|
||||
"filter.period.last30days": "Last 30 Days",
|
||||
"filter.period.last3months": "Last 3 months",
|
||||
"filter.period.last4weeks": "Last 4 weeks",
|
||||
"filter.period.last7days": "Last 7 Days",
|
||||
"filter.period.monthToDate": "Month to date",
|
||||
"filter.period.quarterToDate": "Quarter to date",
|
||||
"filter.period.today": "Today",
|
||||
"filter.period.yearToDate": "Year to date",
|
||||
"filter.sortBy": "Sort by:",
|
||||
"promptLog": "Prompt Log",
|
||||
"runDetail.fileListDetail": "Detail",
|
||||
"runDetail.fileListLabel": "File Details",
|
||||
"runDetail.testWithParams": "Test With Params",
|
||||
"runDetail.title": "Conversation Log",
|
||||
"runDetail.workflowTitle": "Log Detail",
|
||||
"table.empty.element.content": "Observe and annotate interactions between end-users and AI applications here to continuously improve AI accuracy. You can try <shareLink>sharing</shareLink> or <testLink>testing</testLink> the Web App yourself, then return to this page.",
|
||||
"table.empty.element.title": "Is anyone there?",
|
||||
"table.empty.noChat": "No conversation yet",
|
||||
"table.empty.noOutput": "No output",
|
||||
"table.header.adminRate": "Op. Rate",
|
||||
"table.header.endUser": "End User or Account",
|
||||
"table.header.input": "Input",
|
||||
"table.header.messageCount": "Message Count",
|
||||
"table.header.output": "Output",
|
||||
"table.header.runtime": "RUN TIME",
|
||||
"table.header.startTime": "START TIME",
|
||||
"table.header.status": "STATUS",
|
||||
"table.header.summary": "Title",
|
||||
"table.header.time": "Created time",
|
||||
"table.header.tokens": "TOKENS",
|
||||
"table.header.triggered_from": "TRIGGER BY",
|
||||
"table.header.updatedTime": "Updated time",
|
||||
"table.header.user": "END USER OR ACCOUNT",
|
||||
"table.header.userRate": "User Rate",
|
||||
"table.header.version": "VERSION",
|
||||
"table.pagination.next": "Next",
|
||||
"table.pagination.previous": "Prev",
|
||||
"title": "Logs",
|
||||
"triggerBy.appRun": "WebApp",
|
||||
"triggerBy.debugging": "Debugging",
|
||||
"triggerBy.plugin": "Plugin",
|
||||
"triggerBy.ragPipelineDebugging": "RAG Debugging",
|
||||
"triggerBy.ragPipelineRun": "RAG Pipeline",
|
||||
"triggerBy.schedule": "Schedule",
|
||||
"triggerBy.webhook": "Webhook",
|
||||
"viewLog": "View Log",
|
||||
"workflowSubtitle": "The log recorded the operation of Automate.",
|
||||
"workflowTitle": "Workflow Logs"
|
||||
}
|
||||
121
web/i18n/nl-NL/app-overview.json
Normal file
121
web/i18n/nl-NL/app-overview.json
Normal file
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"analysis.activeUsers.explanation": "Unique users engaging in Q&A with AI; prompt engineering/debugging excluded.",
|
||||
"analysis.activeUsers.title": "Active Users",
|
||||
"analysis.avgResponseTime.explanation": "Time (ms) for AI to process/respond; for text-based apps.",
|
||||
"analysis.avgResponseTime.title": "Avg. Response Time",
|
||||
"analysis.avgSessionInteractions.explanation": "Continuous user-AI communication count; for conversation-based apps.",
|
||||
"analysis.avgSessionInteractions.title": "Avg. Session Interactions",
|
||||
"analysis.avgUserInteractions.explanation": "Reflects the daily usage frequency of users. This metric reflects user stickiness.",
|
||||
"analysis.avgUserInteractions.title": "Avg. User Interactions",
|
||||
"analysis.ms": "ms",
|
||||
"analysis.title": "Analysis",
|
||||
"analysis.tokenPS": "Token/s",
|
||||
"analysis.tokenUsage.consumed": "Consumed",
|
||||
"analysis.tokenUsage.explanation": "Reflects the daily token usage of the language model for the application, useful for cost control purposes.",
|
||||
"analysis.tokenUsage.title": "Token Usage",
|
||||
"analysis.totalConversations.explanation": "Daily AI conversations count; prompt engineering/debugging excluded.",
|
||||
"analysis.totalConversations.title": "Total Conversations",
|
||||
"analysis.totalMessages.explanation": "Daily AI interactions count.",
|
||||
"analysis.totalMessages.title": "Total Messages",
|
||||
"analysis.tps.explanation": "Measure the performance of the LLM. Count the Tokens output speed of LLM from the beginning of the request to the completion of the output.",
|
||||
"analysis.tps.title": "Token Output Speed",
|
||||
"analysis.userSatisfactionRate.explanation": "The number of likes per 1,000 messages. This indicates the proportion of answers that users are highly satisfied with.",
|
||||
"analysis.userSatisfactionRate.title": "User Satisfaction Rate",
|
||||
"apiKeyInfo.callTimes": "Call times",
|
||||
"apiKeyInfo.cloud.exhausted.description": "You have exhausted your trial quota. Please set up your own model provider or purchase additional quota.",
|
||||
"apiKeyInfo.cloud.exhausted.title": "Your trial quota have been used up, please set up your APIKey.",
|
||||
"apiKeyInfo.cloud.trial.description": "The trial quota is provided for your testing purposes. Before the trial quota is exhausted, please set up your own model provider or purchase additional quota.",
|
||||
"apiKeyInfo.cloud.trial.title": "You are using the {{providerName}} trial quota.",
|
||||
"apiKeyInfo.selfHost.title.row1": "To get started,",
|
||||
"apiKeyInfo.selfHost.title.row2": "setup your model provider first.",
|
||||
"apiKeyInfo.setAPIBtn": "Go to setup model provider",
|
||||
"apiKeyInfo.tryCloud": "Or try the cloud version of Dify with free quote",
|
||||
"apiKeyInfo.usedToken": "Used token",
|
||||
"overview.apiInfo.accessibleAddress": "Service API Endpoint",
|
||||
"overview.apiInfo.doc": "API Reference",
|
||||
"overview.apiInfo.explanation": "Easily integrated into your application",
|
||||
"overview.apiInfo.title": "Backend Service API",
|
||||
"overview.appInfo.accessibleAddress": "Public URL",
|
||||
"overview.appInfo.customize.entry": "Customize",
|
||||
"overview.appInfo.customize.explanation": "You can customize the frontend of the Web App to fit your scenario and style needs.",
|
||||
"overview.appInfo.customize.title": "Customize AI web app",
|
||||
"overview.appInfo.customize.way": "way",
|
||||
"overview.appInfo.customize.way1.name": "Fork the client code, modify it and deploy to Vercel (recommended)",
|
||||
"overview.appInfo.customize.way1.step1": "Fork the client code and modify it",
|
||||
"overview.appInfo.customize.way1.step1Operation": "Dify-WebClient",
|
||||
"overview.appInfo.customize.way1.step1Tip": "Click here to fork the source code into your GitHub account and modify the code",
|
||||
"overview.appInfo.customize.way1.step2": "Deploy to Vercel",
|
||||
"overview.appInfo.customize.way1.step2Operation": "Import repository",
|
||||
"overview.appInfo.customize.way1.step2Tip": "Click here to import the repository into Vercel and deploy",
|
||||
"overview.appInfo.customize.way1.step3": "Configure environment variables",
|
||||
"overview.appInfo.customize.way1.step3Tip": "Add the following environment variables in Vercel",
|
||||
"overview.appInfo.customize.way2.name": "Write client-side code to call the API and deploy it to a server",
|
||||
"overview.appInfo.customize.way2.operation": "Documentation",
|
||||
"overview.appInfo.embedded.chromePlugin": "Install Dify Chatbot Chrome Extension",
|
||||
"overview.appInfo.embedded.copied": "Copied",
|
||||
"overview.appInfo.embedded.copy": "Copy",
|
||||
"overview.appInfo.embedded.entry": "Embedded",
|
||||
"overview.appInfo.embedded.explanation": "Choose the way to embed chat app to your website",
|
||||
"overview.appInfo.embedded.iframe": "To add the chat app any where on your website, add this iframe to your html code.",
|
||||
"overview.appInfo.embedded.scripts": "To add a chat app to the bottom right of your website add this code to your html.",
|
||||
"overview.appInfo.embedded.title": "Embed on website",
|
||||
"overview.appInfo.enableTooltip.description": "To enable this feature, please add a User Input node to the canvas. (May already exist in draft, takes effect after publishing)",
|
||||
"overview.appInfo.enableTooltip.learnMore": "Learn more",
|
||||
"overview.appInfo.explanation": "Ready-to-use AI web app",
|
||||
"overview.appInfo.launch": "Launch",
|
||||
"overview.appInfo.preUseReminder": "Please enable web app before continuing.",
|
||||
"overview.appInfo.preview": "Preview",
|
||||
"overview.appInfo.qrcode.download": "Download QR Code",
|
||||
"overview.appInfo.qrcode.scan": "Scan To Share",
|
||||
"overview.appInfo.qrcode.title": "Link QR Code",
|
||||
"overview.appInfo.regenerate": "Regenerate",
|
||||
"overview.appInfo.regenerateNotice": "Do you want to regenerate the public URL?",
|
||||
"overview.appInfo.settings.chatColorTheme": "Chat color theme",
|
||||
"overview.appInfo.settings.chatColorThemeDesc": "Set the color theme of the chatbot",
|
||||
"overview.appInfo.settings.chatColorThemeInverted": "Inverted",
|
||||
"overview.appInfo.settings.entry": "Settings",
|
||||
"overview.appInfo.settings.invalidHexMessage": "Invalid hex value",
|
||||
"overview.appInfo.settings.invalidPrivacyPolicy": "Invalid privacy policy link. Please use a valid link that starts with http or https",
|
||||
"overview.appInfo.settings.language": "Language",
|
||||
"overview.appInfo.settings.modalTip": "Client-side web app settings. ",
|
||||
"overview.appInfo.settings.more.copyRightPlaceholder": "Enter the name of the author or organization",
|
||||
"overview.appInfo.settings.more.copyright": "Copyright",
|
||||
"overview.appInfo.settings.more.copyrightTip": "Display copyright information in the web app",
|
||||
"overview.appInfo.settings.more.copyrightTooltip": "Please upgrade to Professional plan or above",
|
||||
"overview.appInfo.settings.more.customDisclaimer": "Custom Disclaimer",
|
||||
"overview.appInfo.settings.more.customDisclaimerPlaceholder": "Enter the custom disclaimer text",
|
||||
"overview.appInfo.settings.more.customDisclaimerTip": "Custom disclaimer text will be displayed on the client side, providing additional information about the application",
|
||||
"overview.appInfo.settings.more.entry": "Show more settings",
|
||||
"overview.appInfo.settings.more.privacyPolicy": "Privacy Policy",
|
||||
"overview.appInfo.settings.more.privacyPolicyPlaceholder": "Enter the privacy policy link",
|
||||
"overview.appInfo.settings.more.privacyPolicyTip": "Helps visitors understand the data the application collects, see Dify's <privacyPolicyLink>Privacy Policy</privacyPolicyLink>.",
|
||||
"overview.appInfo.settings.sso.description": "All users are required to login with SSO before using web app",
|
||||
"overview.appInfo.settings.sso.label": "SSO Enforcement",
|
||||
"overview.appInfo.settings.sso.title": "web app SSO",
|
||||
"overview.appInfo.settings.sso.tooltip": "Contact the administrator to enable web app SSO",
|
||||
"overview.appInfo.settings.title": "Web App Settings",
|
||||
"overview.appInfo.settings.webDesc": "web app Description",
|
||||
"overview.appInfo.settings.webDescPlaceholder": "Enter the description of the web app",
|
||||
"overview.appInfo.settings.webDescTip": "This text will be displayed on the client side, providing basic guidance on how to use the application",
|
||||
"overview.appInfo.settings.webName": "web app Name",
|
||||
"overview.appInfo.settings.workflow.hide": "Hide",
|
||||
"overview.appInfo.settings.workflow.show": "Show",
|
||||
"overview.appInfo.settings.workflow.showDesc": "Show or hide workflow details in web app",
|
||||
"overview.appInfo.settings.workflow.subTitle": "Workflow Details",
|
||||
"overview.appInfo.settings.workflow.title": "Workflow",
|
||||
"overview.appInfo.title": "Web App",
|
||||
"overview.disableTooltip.triggerMode": "The {{feature}} feature is not supported in Trigger Node mode.",
|
||||
"overview.status.disable": "Disabled",
|
||||
"overview.status.running": "In Service",
|
||||
"overview.title": "Overview",
|
||||
"overview.triggerInfo.explanation": "Workflow trigger management",
|
||||
"overview.triggerInfo.learnAboutTriggers": "Learn about Triggers",
|
||||
"overview.triggerInfo.noTriggerAdded": "No trigger added",
|
||||
"overview.triggerInfo.title": "Triggers",
|
||||
"overview.triggerInfo.triggerStatusDescription": "Trigger node status appears here. (May already exist in draft, takes effect after publishing)",
|
||||
"overview.triggerInfo.triggersAdded": "{{count}} Triggers added",
|
||||
"welcome.enterKeyTip": "enter your OpenAI API Key below",
|
||||
"welcome.firstStepTip": "To get started,",
|
||||
"welcome.getKeyTip": "Get your API Key from OpenAI dashboard",
|
||||
"welcome.placeholder": "Your OpenAI API Key (eg.sk-xxxx)"
|
||||
}
|
||||
283
web/i18n/nl-NL/app.json
Normal file
283
web/i18n/nl-NL/app.json
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"accessControl": "Web App Access Control",
|
||||
"accessControlDialog.accessItems.anyone": "Anyone with the link",
|
||||
"accessControlDialog.accessItems.external": "Authenticated external users",
|
||||
"accessControlDialog.accessItems.organization": "All members within the platform",
|
||||
"accessControlDialog.accessItems.specific": "Specific members within the platform",
|
||||
"accessControlDialog.accessLabel": "Who has access",
|
||||
"accessControlDialog.description": "Set web app access permissions",
|
||||
"accessControlDialog.groups_one": "{{count}} GROUP",
|
||||
"accessControlDialog.groups_other": "{{count}} GROUPS",
|
||||
"accessControlDialog.members_one": "{{count}} MEMBER",
|
||||
"accessControlDialog.members_other": "{{count}} MEMBERS",
|
||||
"accessControlDialog.noGroupsOrMembers": "No groups or members selected",
|
||||
"accessControlDialog.operateGroupAndMember.allMembers": "All members",
|
||||
"accessControlDialog.operateGroupAndMember.expand": "Expand",
|
||||
"accessControlDialog.operateGroupAndMember.noResult": "No result",
|
||||
"accessControlDialog.operateGroupAndMember.searchPlaceholder": "Search groups and members",
|
||||
"accessControlDialog.title": "Web App Access Control",
|
||||
"accessControlDialog.updateSuccess": "Update successfully",
|
||||
"accessControlDialog.webAppSSONotEnabledTip": "Please contact your organization administrator to configure external authentication for the web app.",
|
||||
"accessItemsDescription.anyone": "Anyone can access the web app (no login required)",
|
||||
"accessItemsDescription.external": "Only authenticated external users can access the web app",
|
||||
"accessItemsDescription.organization": "All members within the platform can access the web app",
|
||||
"accessItemsDescription.specific": "Only specific members within the platform can access the web app",
|
||||
"answerIcon.description": "Whether to use the web app icon to replace 🤖 in the shared application",
|
||||
"answerIcon.descriptionInExplore": "Whether to use the web app icon to replace 🤖 in Explore",
|
||||
"answerIcon.title": "Use web app icon to replace 🤖",
|
||||
"appDeleteFailed": "Failed to delete app",
|
||||
"appDeleted": "App deleted",
|
||||
"appNamePlaceholder": "Give your app a name",
|
||||
"appSelector.label": "APP",
|
||||
"appSelector.noParams": "No parameters needed",
|
||||
"appSelector.params": "APP PARAMETERS",
|
||||
"appSelector.placeholder": "Select an app...",
|
||||
"communityIntro": "Discuss with team members, contributors and developers on different channels.",
|
||||
"createApp": "CREATE APP",
|
||||
"createFromConfigFile": "Create from DSL file",
|
||||
"deleteAppConfirmContent": "Deleting the app is irreversible. Users will no longer be able to access your app, and all prompt configurations and logs will be permanently deleted.",
|
||||
"deleteAppConfirmTitle": "Delete this app?",
|
||||
"dslUploader.browse": "Browse",
|
||||
"dslUploader.button": "Drag and drop file, or",
|
||||
"duplicate": "Duplicate",
|
||||
"duplicateTitle": "Duplicate App",
|
||||
"editApp": "Edit Info",
|
||||
"editAppTitle": "Edit App Info",
|
||||
"editDone": "App info updated",
|
||||
"editFailed": "Failed to update app info",
|
||||
"export": "Export DSL",
|
||||
"exportFailed": "Export DSL failed.",
|
||||
"gotoAnything.actions.accountDesc": "Navigate to account page",
|
||||
"gotoAnything.actions.communityDesc": "Open Discord community",
|
||||
"gotoAnything.actions.docDesc": "Open help documentation",
|
||||
"gotoAnything.actions.feedbackDesc": "Open community feedback discussions",
|
||||
"gotoAnything.actions.languageCategoryDesc": "Switch interface language",
|
||||
"gotoAnything.actions.languageCategoryTitle": "Language",
|
||||
"gotoAnything.actions.languageChangeDesc": "Change UI language",
|
||||
"gotoAnything.actions.runDesc": "Run quick commands (theme, language, ...)",
|
||||
"gotoAnything.actions.runTitle": "Commands",
|
||||
"gotoAnything.actions.searchApplications": "Search Applications",
|
||||
"gotoAnything.actions.searchApplicationsDesc": "Search and navigate to your applications",
|
||||
"gotoAnything.actions.searchKnowledgeBases": "Search Knowledge Bases",
|
||||
"gotoAnything.actions.searchKnowledgeBasesDesc": "Search and navigate to your knowledge bases",
|
||||
"gotoAnything.actions.searchPlugins": "Search Plugins",
|
||||
"gotoAnything.actions.searchPluginsDesc": "Search and navigate to your plugins",
|
||||
"gotoAnything.actions.searchWorkflowNodes": "Search Workflow Nodes",
|
||||
"gotoAnything.actions.searchWorkflowNodesDesc": "Find and jump to nodes in the current workflow by name or type",
|
||||
"gotoAnything.actions.searchWorkflowNodesHelp": "This feature only works when viewing a workflow. Navigate to a workflow first.",
|
||||
"gotoAnything.actions.slashDesc": "Execute commands (type / to see all available commands)",
|
||||
"gotoAnything.actions.slashTitle": "Commands",
|
||||
"gotoAnything.actions.themeCategoryDesc": "Switch application theme",
|
||||
"gotoAnything.actions.themeCategoryTitle": "Theme",
|
||||
"gotoAnything.actions.themeDark": "Dark Theme",
|
||||
"gotoAnything.actions.themeDarkDesc": "Use dark appearance",
|
||||
"gotoAnything.actions.themeLight": "Light Theme",
|
||||
"gotoAnything.actions.themeLightDesc": "Use light appearance",
|
||||
"gotoAnything.actions.themeSystem": "System Theme",
|
||||
"gotoAnything.actions.themeSystemDesc": "Follow your OS appearance",
|
||||
"gotoAnything.actions.zenDesc": "Toggle canvas focus mode",
|
||||
"gotoAnything.actions.zenTitle": "Zen Mode",
|
||||
"gotoAnything.clearToSearchAll": "Clear @ to search all",
|
||||
"gotoAnything.commandHint": "Type @ to browse by category",
|
||||
"gotoAnything.emptyState.noAppsFound": "No apps found",
|
||||
"gotoAnything.emptyState.noKnowledgeBasesFound": "No knowledge bases found",
|
||||
"gotoAnything.emptyState.noPluginsFound": "No plugins found",
|
||||
"gotoAnything.emptyState.noWorkflowNodesFound": "No workflow nodes found",
|
||||
"gotoAnything.emptyState.tryDifferentTerm": "Try a different search term",
|
||||
"gotoAnything.emptyState.trySpecificSearch": "Try {{shortcuts}} for specific searches",
|
||||
"gotoAnything.groups.apps": "Apps",
|
||||
"gotoAnything.groups.commands": "Commands",
|
||||
"gotoAnything.groups.knowledgeBases": "Knowledge Bases",
|
||||
"gotoAnything.groups.plugins": "Plugins",
|
||||
"gotoAnything.groups.workflowNodes": "Workflow Nodes",
|
||||
"gotoAnything.inScope": "in {{scope}}s",
|
||||
"gotoAnything.noMatchingCommands": "No matching commands found",
|
||||
"gotoAnything.noResults": "No results found",
|
||||
"gotoAnything.pressEscToClose": "Press ESC to close",
|
||||
"gotoAnything.resultCount": "{{count}} result",
|
||||
"gotoAnything.resultCount_other": "{{count}} results",
|
||||
"gotoAnything.searchFailed": "Search failed",
|
||||
"gotoAnything.searchHint": "Start typing to search everything instantly",
|
||||
"gotoAnything.searchPlaceholder": "Search or type @ or / for commands...",
|
||||
"gotoAnything.searchTemporarilyUnavailable": "Search temporarily unavailable",
|
||||
"gotoAnything.searchTitle": "Search for anything",
|
||||
"gotoAnything.searching": "Searching...",
|
||||
"gotoAnything.selectSearchType": "Choose what to search for",
|
||||
"gotoAnything.selectToNavigate": "Select to navigate",
|
||||
"gotoAnything.servicesUnavailableMessage": "Some search services may be experiencing issues. Try again in a moment.",
|
||||
"gotoAnything.slashHint": "Type / to see all available commands",
|
||||
"gotoAnything.someServicesUnavailable": "Some search services unavailable",
|
||||
"gotoAnything.startTyping": "Start typing to search",
|
||||
"gotoAnything.tips": "Press ↑↓ to navigate",
|
||||
"gotoAnything.tryDifferentSearch": "Try a different search term",
|
||||
"gotoAnything.useAtForSpecific": "Use @ for specific types",
|
||||
"iconPicker.cancel": "Cancel",
|
||||
"iconPicker.emoji": "Emoji",
|
||||
"iconPicker.image": "Image",
|
||||
"iconPicker.ok": "OK",
|
||||
"importDSL": "Import DSL file",
|
||||
"importFromDSL": "Import from DSL",
|
||||
"importFromDSLFile": "From DSL file",
|
||||
"importFromDSLUrl": "From URL",
|
||||
"importFromDSLUrlPlaceholder": "Paste DSL link here",
|
||||
"join": "Join the community",
|
||||
"maxActiveRequests": "Max concurrent requests",
|
||||
"maxActiveRequestsPlaceholder": "Enter 0 for unlimited",
|
||||
"maxActiveRequestsTip": "Maximum number of concurrent active requests per app (0 for unlimited)",
|
||||
"mermaid.classic": "Classic",
|
||||
"mermaid.handDrawn": "Hand Drawn",
|
||||
"newApp.Cancel": "Cancel",
|
||||
"newApp.Confirm": "Confirm",
|
||||
"newApp.Create": "Create",
|
||||
"newApp.advancedShortDescription": "Workflow enhanced for multi-turn chats",
|
||||
"newApp.advancedUserDescription": "Workflow with additional memory features and a chatbot interface.",
|
||||
"newApp.agentAssistant": "New Agent Assistant",
|
||||
"newApp.agentShortDescription": "Intelligent agent with reasoning and autonomous tool use",
|
||||
"newApp.agentUserDescription": "An intelligent agent capable of iterative reasoning and autonomous tool use to achieve task goals.",
|
||||
"newApp.appCreateDSLErrorPart1": "A significant difference in DSL versions has been detected. Forcing the import may cause the application to malfunction.",
|
||||
"newApp.appCreateDSLErrorPart2": "Do you want to continue?",
|
||||
"newApp.appCreateDSLErrorPart3": "Current application DSL version: ",
|
||||
"newApp.appCreateDSLErrorPart4": "System-supported DSL version: ",
|
||||
"newApp.appCreateDSLErrorTitle": "Version Incompatibility",
|
||||
"newApp.appCreateDSLWarning": "Caution: DSL version difference may affect certain features",
|
||||
"newApp.appCreateFailed": "Failed to create app",
|
||||
"newApp.appCreated": "App created",
|
||||
"newApp.appDescriptionPlaceholder": "Enter the description of the app",
|
||||
"newApp.appNamePlaceholder": "Give your app a name",
|
||||
"newApp.appTemplateNotSelected": "Please select a template",
|
||||
"newApp.appTypeRequired": "Please select an app type",
|
||||
"newApp.captionDescription": "Description",
|
||||
"newApp.captionName": "App Name & Icon",
|
||||
"newApp.caution": "Caution",
|
||||
"newApp.chatApp": "Assistant",
|
||||
"newApp.chatAppIntro": "I want to build a chat-based application. This app uses a question-and-answer format, allowing for multiple rounds of continuous conversation.",
|
||||
"newApp.chatbotShortDescription": "LLM-based chatbot with simple setup",
|
||||
"newApp.chatbotUserDescription": "Quickly build an LLM-based chatbot with simple configuration. You can switch to Chatflow later.",
|
||||
"newApp.chooseAppType": "Choose an App Type",
|
||||
"newApp.completeApp": "Text Generator",
|
||||
"newApp.completeAppIntro": "I want to create an application that generates high-quality text based on prompts, such as generating articles, summaries, translations, and more.",
|
||||
"newApp.completionShortDescription": "AI assistant for text generation tasks",
|
||||
"newApp.completionUserDescription": "Quickly build an AI assistant for text generation tasks with simple configuration.",
|
||||
"newApp.dropDSLToCreateApp": "Drop DSL file here to create app",
|
||||
"newApp.forAdvanced": "FOR ADVANCED USERS",
|
||||
"newApp.forBeginners": "More basic app types",
|
||||
"newApp.foundResult": "{{count}} Result",
|
||||
"newApp.foundResults": "{{count}} Results",
|
||||
"newApp.hideTemplates": "Go back to mode selection",
|
||||
"newApp.import": "Import",
|
||||
"newApp.learnMore": "Learn more",
|
||||
"newApp.nameNotEmpty": "Name cannot be empty",
|
||||
"newApp.noAppsFound": "No apps found",
|
||||
"newApp.noIdeaTip": "No ideas? Check out our templates",
|
||||
"newApp.noTemplateFound": "No templates found",
|
||||
"newApp.noTemplateFoundTip": "Try searching using different keywords.",
|
||||
"newApp.optional": "Optional",
|
||||
"newApp.previewDemo": "Preview demo",
|
||||
"newApp.showTemplates": "I want to choose from a template",
|
||||
"newApp.startFromBlank": "Create from Blank",
|
||||
"newApp.startFromTemplate": "Create from Template",
|
||||
"newApp.useTemplate": "Use this template",
|
||||
"newApp.workflowShortDescription": "Agentic flow for intelligent automations",
|
||||
"newApp.workflowUserDescription": "Visually build autonomous AI workflows with drag-and-drop simplicity.",
|
||||
"newApp.workflowWarning": "Currently in beta",
|
||||
"newAppFromTemplate.byCategories": "BY CATEGORIES",
|
||||
"newAppFromTemplate.searchAllTemplate": "Search all templates...",
|
||||
"newAppFromTemplate.sidebar.Agent": "Agent",
|
||||
"newAppFromTemplate.sidebar.Assistant": "Assistant",
|
||||
"newAppFromTemplate.sidebar.HR": "HR",
|
||||
"newAppFromTemplate.sidebar.Programming": "Programming",
|
||||
"newAppFromTemplate.sidebar.Recommended": "Recommended",
|
||||
"newAppFromTemplate.sidebar.Workflow": "Workflow",
|
||||
"newAppFromTemplate.sidebar.Writing": "Writing",
|
||||
"noAccessPermission": "No permission to access web app",
|
||||
"noUserInputNode": "Missing user input node",
|
||||
"notPublishedYet": "App is not published yet",
|
||||
"openInExplore": "Open in Explore",
|
||||
"publishApp.notSet": "Not set",
|
||||
"publishApp.notSetDesc": "Currently nobody can access the web app. Please set permissions.",
|
||||
"publishApp.title": "Who can access web app",
|
||||
"removeOriginal": "Delete the original app",
|
||||
"roadmap": "See our roadmap",
|
||||
"showMyCreatedAppsOnly": "Created by me",
|
||||
"structOutput.LLMResponse": "LLM Response",
|
||||
"structOutput.configure": "Configure",
|
||||
"structOutput.modelNotSupported": "Model not supported",
|
||||
"structOutput.modelNotSupportedTip": "The current model does not support this feature and is automatically downgraded to prompt injection.",
|
||||
"structOutput.moreFillTip": "Showing max 10 levels of nesting",
|
||||
"structOutput.notConfiguredTip": "Structured output has not been configured yet",
|
||||
"structOutput.required": "Required",
|
||||
"structOutput.structured": "Structured",
|
||||
"structOutput.structuredTip": "Structured Outputs is a feature that ensures the model will always generate responses that adhere to your supplied JSON Schema",
|
||||
"switch": "Switch to Workflow Orchestrate",
|
||||
"switchLabel": "The app copy to be created",
|
||||
"switchStart": "Start switch",
|
||||
"switchTip": "not allow",
|
||||
"switchTipEnd": " switching back to Basic Orchestrate.",
|
||||
"switchTipStart": "A new app copy will be created for you, and the new copy will switch to Workflow Orchestrate. The new copy will ",
|
||||
"theme.switchDark": "Switch to dark theme",
|
||||
"theme.switchLight": "Switch to light theme",
|
||||
"tracing.aliyun.description": "The fully-managed and maintenance-free observability platform provided by Alibaba Cloud, enables out-of-the-box monitoring, tracing, and evaluation of Dify applications.",
|
||||
"tracing.aliyun.title": "Cloud Monitor",
|
||||
"tracing.arize.description": "Enterprise-grade LLM observability, online & offline evaluation, monitoring, and experimentation—powered by OpenTelemetry. Purpose-built for LLM & agent-driven applications.",
|
||||
"tracing.arize.title": "Arize",
|
||||
"tracing.collapse": "Collapse",
|
||||
"tracing.config": "Config",
|
||||
"tracing.configProvider.clientId": "OAuth Client ID",
|
||||
"tracing.configProvider.clientSecret": "OAuth Client Secret",
|
||||
"tracing.configProvider.databricksHost": "Databricks Workspace URL",
|
||||
"tracing.configProvider.experimentId": "Experiment ID",
|
||||
"tracing.configProvider.password": "Password",
|
||||
"tracing.configProvider.personalAccessToken": "Personal Access Token (legacy)",
|
||||
"tracing.configProvider.placeholder": "Enter your {{key}}",
|
||||
"tracing.configProvider.project": "Project",
|
||||
"tracing.configProvider.publicKey": "Public Key",
|
||||
"tracing.configProvider.removeConfirmContent": "The current configuration is in use, removing it will turn off the Tracing feature.",
|
||||
"tracing.configProvider.removeConfirmTitle": "Remove {{key}} configuration?",
|
||||
"tracing.configProvider.secretKey": "Secret Key",
|
||||
"tracing.configProvider.title": "Config ",
|
||||
"tracing.configProvider.trackingUri": "Tracking URI",
|
||||
"tracing.configProvider.username": "Username",
|
||||
"tracing.configProvider.viewDocsLink": "View {{key}} docs",
|
||||
"tracing.configProviderTitle.configured": "Configured",
|
||||
"tracing.configProviderTitle.moreProvider": "More Provider",
|
||||
"tracing.configProviderTitle.notConfigured": "Config provider to enable tracing",
|
||||
"tracing.databricks.description": "Databricks offers fully-managed MLflow with strong governance and security for storing trace data.",
|
||||
"tracing.databricks.title": "Databricks",
|
||||
"tracing.description": "Configuring a Third-Party LLMOps provider and tracing app performance.",
|
||||
"tracing.disabled": "Disabled",
|
||||
"tracing.disabledTip": "Please config provider first",
|
||||
"tracing.enabled": "In Service",
|
||||
"tracing.expand": "Expand",
|
||||
"tracing.inUse": "In use",
|
||||
"tracing.langfuse.description": "Open-source LLM observability, evaluation, prompt management and metrics to debug and improve your LLM application.",
|
||||
"tracing.langfuse.title": "Langfuse",
|
||||
"tracing.langsmith.description": "An all-in-one developer platform for every step of the LLM-powered application lifecycle.",
|
||||
"tracing.langsmith.title": "LangSmith",
|
||||
"tracing.mlflow.description": "MLflow is an open-source platform for experiment management, evaluation, and monitoring of LLM applications.",
|
||||
"tracing.mlflow.title": "MLflow",
|
||||
"tracing.opik.description": "Opik is an open-source platform for evaluating, testing, and monitoring LLM applications.",
|
||||
"tracing.opik.title": "Opik",
|
||||
"tracing.phoenix.description": "Open-source & OpenTelemetry-based observability, evaluation, prompt engineering and experimentation platform for your LLM workflows and agents.",
|
||||
"tracing.phoenix.title": "Phoenix",
|
||||
"tracing.tencent.description": "Tencent Application Performance Monitoring provides comprehensive tracing and multi-dimensional analysis for LLM applications.",
|
||||
"tracing.tencent.title": "Tencent APM",
|
||||
"tracing.title": "Tracing app performance",
|
||||
"tracing.tracing": "Tracing",
|
||||
"tracing.tracingDescription": "Capture the full context of app execution, including LLM calls, context, prompts, HTTP requests, and more, to a third-party tracing platform.",
|
||||
"tracing.view": "View",
|
||||
"tracing.weave.description": "Weave is an open-source platform for evaluating, testing, and monitoring LLM applications.",
|
||||
"tracing.weave.title": "Weave",
|
||||
"typeSelector.advanced": "Chatflow",
|
||||
"typeSelector.agent": "Agent",
|
||||
"typeSelector.all": "All Types ",
|
||||
"typeSelector.chatbot": "Chatbot",
|
||||
"typeSelector.completion": "Completion",
|
||||
"typeSelector.workflow": "Workflow",
|
||||
"types.advanced": "Chatflow",
|
||||
"types.agent": "Agent",
|
||||
"types.all": "All",
|
||||
"types.basic": "Basic",
|
||||
"types.chatbot": "Chatbot",
|
||||
"types.completion": "Completion",
|
||||
"types.workflow": "Workflow"
|
||||
}
|
||||
186
web/i18n/nl-NL/billing.json
Normal file
186
web/i18n/nl-NL/billing.json
Normal file
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"annotatedResponse.fullTipLine1": "Upgrade your plan to",
|
||||
"annotatedResponse.fullTipLine2": "annotate more conversations.",
|
||||
"annotatedResponse.quotaTitle": "Annotation Reply Quota",
|
||||
"apps.contactUs": "Contact us",
|
||||
"apps.fullTip1": "Upgrade to create more apps",
|
||||
"apps.fullTip1des": "You've reached the limit of build apps on this plan",
|
||||
"apps.fullTip2": "Plan limit reached",
|
||||
"apps.fullTip2des": "It is recommended to clean up inactive applications to free up usage, or contact us.",
|
||||
"buyPermissionDeniedTip": "Please contact your enterprise administrator to subscribe",
|
||||
"currentPlan": "Current Plan",
|
||||
"plans.community.btnText": "Get Started",
|
||||
"plans.community.description": "For open-source enthusiasts, individual developers, and non-commercial projects",
|
||||
"plans.community.features": [
|
||||
"All Core Features Released Under the Public Repository",
|
||||
"Single Workspace",
|
||||
"Complies with Dify Open Source License"
|
||||
],
|
||||
"plans.community.for": "For Individual Users, Small Teams, or Non-commercial Projects",
|
||||
"plans.community.includesTitle": "Free Features:",
|
||||
"plans.community.name": "Community",
|
||||
"plans.community.price": "Free",
|
||||
"plans.community.priceTip": "",
|
||||
"plans.enterprise.btnText": "Contact Sales",
|
||||
"plans.enterprise.description": "For enterprise requiring organization-grade security, compliance, scalability, control and custom solutions",
|
||||
"plans.enterprise.features": [
|
||||
"Enterprise-grade Scalable Deployment Solutions",
|
||||
"Commercial License Authorization",
|
||||
"Exclusive Enterprise Features",
|
||||
"Multiple Workspaces & Enterprise Management",
|
||||
"SSO",
|
||||
"Negotiated SLAs by Dify Partners",
|
||||
"Advanced Security & Controls",
|
||||
"Updates and Maintenance by Dify Officially",
|
||||
"Professional Technical Support"
|
||||
],
|
||||
"plans.enterprise.for": "For large-sized Teams",
|
||||
"plans.enterprise.includesTitle": "Everything from <highlight>Premium</highlight>, plus:",
|
||||
"plans.enterprise.name": "Enterprise",
|
||||
"plans.enterprise.price": "Custom",
|
||||
"plans.enterprise.priceTip": "Annual Billing Only",
|
||||
"plans.premium.btnText": "Get Premium on",
|
||||
"plans.premium.comingSoon": "Microsoft Azure & Google Cloud Support Coming Soon",
|
||||
"plans.premium.description": "For Mid-sized organizations needing deployment flexibility and enhanced support",
|
||||
"plans.premium.features": [
|
||||
"Self-managed Reliability by Various Cloud Providers",
|
||||
"Single Workspace",
|
||||
"WebApp Logo & Branding Customization",
|
||||
"Priority Email & Chat Support"
|
||||
],
|
||||
"plans.premium.for": "For Mid-sized Organizations and Teams",
|
||||
"plans.premium.includesTitle": "Everything from Community, plus:",
|
||||
"plans.premium.name": "Premium",
|
||||
"plans.premium.price": "Scalable",
|
||||
"plans.premium.priceTip": "Based on Cloud Marketplace",
|
||||
"plans.professional.description": "For independent developers & small teams ready to build production AI applications.",
|
||||
"plans.professional.for": "For Independent Developers/Small Teams",
|
||||
"plans.professional.name": "Professional",
|
||||
"plans.sandbox.description": "Try core features for free.",
|
||||
"plans.sandbox.for": "Free Trial of Core Capabilities",
|
||||
"plans.sandbox.name": "Sandbox",
|
||||
"plans.team.description": "For medium-sized teams requiring collaboration and higher throughput.",
|
||||
"plans.team.for": "For Medium-sized Teams",
|
||||
"plans.team.name": "Team",
|
||||
"plansCommon.annotatedResponse.title": "{{count,number}} Annotation Quota Limits",
|
||||
"plansCommon.annotatedResponse.tooltip": "Manual editing and annotation of responses provides customizable high-quality question-answering abilities for apps. (Applicable only in Chat apps)",
|
||||
"plansCommon.annotationQuota": "Annotation Quota",
|
||||
"plansCommon.annualBilling": "Bill Annually Save {{percent}}%",
|
||||
"plansCommon.apiRateLimit": "API Rate Limit",
|
||||
"plansCommon.apiRateLimitTooltip": "API Rate Limit applies to all requests made through the Dify API, including text generation, chat conversations, workflow executions, and document processing.",
|
||||
"plansCommon.apiRateLimitUnit": "{{count,number}}",
|
||||
"plansCommon.buildApps": "{{count,number}} Apps",
|
||||
"plansCommon.cloud": "Cloud Service",
|
||||
"plansCommon.comingSoon": "Coming soon",
|
||||
"plansCommon.comparePlanAndFeatures": "Compare plans & features",
|
||||
"plansCommon.contactSales": "Contact Sales",
|
||||
"plansCommon.contractOwner": "Contact team manager",
|
||||
"plansCommon.contractSales": "Contact sales",
|
||||
"plansCommon.currentPlan": "Current Plan",
|
||||
"plansCommon.customTools": "Custom Tools",
|
||||
"plansCommon.days": "Days",
|
||||
"plansCommon.documentProcessingPriority": " Document Processing",
|
||||
"plansCommon.documentProcessingPriorityTip": "For higher document processing priority, please upgrade your plan.",
|
||||
"plansCommon.documentProcessingPriorityUpgrade": "Process more data with higher accuracy at faster speeds.",
|
||||
"plansCommon.documents": "{{count,number}} Knowledge Documents",
|
||||
"plansCommon.documentsRequestQuota": "{{count,number}} Knowledge Request/min",
|
||||
"plansCommon.documentsRequestQuotaTooltip": "Specifies the total number of actions a workspace can perform per minute within the knowledge base, including dataset creation, deletion, updates, document uploads, modifications, archiving, and knowledge base queries. This metric is used to evaluate the performance of knowledge base requests. For example, if a Sandbox user performs 10 consecutive hit tests within one minute, their workspace will be temporarily restricted from performing the following actions for the next minute: dataset creation, deletion, updates, and document uploads or modifications. ",
|
||||
"plansCommon.documentsTooltip": "Quota on the number of documents imported from the Knowledge Data Source.",
|
||||
"plansCommon.free": "Free",
|
||||
"plansCommon.freeTrialTip": "free trial of 200 OpenAI calls. ",
|
||||
"plansCommon.freeTrialTipPrefix": "Sign up and get a ",
|
||||
"plansCommon.freeTrialTipSuffix": "No credit card required",
|
||||
"plansCommon.getStarted": "Get Started",
|
||||
"plansCommon.logsHistory": "{{days}} Log history",
|
||||
"plansCommon.member": "Member",
|
||||
"plansCommon.memberAfter": "Member",
|
||||
"plansCommon.messageRequest.title": "{{count,number}} message credits",
|
||||
"plansCommon.messageRequest.titlePerMonth": "{{count,number}} message credits/month",
|
||||
"plansCommon.messageRequest.tooltip": "Message credits are provided to help you easily try out different models from OpenAI, Anthropic, Gemini, xAI, DeepSeek and Tongyi in Dify. Credits are consumed based on the model type. Once they're used up, you can switch to your own API key.",
|
||||
"plansCommon.modelProviders": "Support OpenAI/Anthropic/Llama2/Azure OpenAI/Hugging Face/Replicate",
|
||||
"plansCommon.month": "month",
|
||||
"plansCommon.mostPopular": "Popular",
|
||||
"plansCommon.planRange.monthly": "Monthly",
|
||||
"plansCommon.planRange.yearly": "Yearly",
|
||||
"plansCommon.priceTip": "per workspace/",
|
||||
"plansCommon.priority.priority": "Priority",
|
||||
"plansCommon.priority.standard": "Standard",
|
||||
"plansCommon.priority.top-priority": "Top Priority",
|
||||
"plansCommon.ragAPIRequestTooltip": "Refers to the number of API calls invoking only the knowledge base processing capabilities of Dify.",
|
||||
"plansCommon.receiptInfo": "Only team owner and team admin can subscribe and view billing information",
|
||||
"plansCommon.save": "Save ",
|
||||
"plansCommon.self": "Self-Hosted",
|
||||
"plansCommon.startBuilding": "Start Building",
|
||||
"plansCommon.startForFree": "Start for Free",
|
||||
"plansCommon.startNodes.limited": "Up to {{count}} Triggers/workflow",
|
||||
"plansCommon.startNodes.unlimited": "Unlimited Triggers/workflow",
|
||||
"plansCommon.support": "Support",
|
||||
"plansCommon.supportItems.SSOAuthentication": "SSO authentication",
|
||||
"plansCommon.supportItems.agentMode": "Agent Mode",
|
||||
"plansCommon.supportItems.bulkUpload": "Bulk upload documents",
|
||||
"plansCommon.supportItems.communityForums": "Community forums",
|
||||
"plansCommon.supportItems.customIntegration": "Custom integration and support",
|
||||
"plansCommon.supportItems.dedicatedAPISupport": "Dedicated API support",
|
||||
"plansCommon.supportItems.emailSupport": "Email support",
|
||||
"plansCommon.supportItems.llmLoadingBalancing": "LLM Load Balancing",
|
||||
"plansCommon.supportItems.llmLoadingBalancingTooltip": "Add multiple API keys to models, effectively bypassing the API rate limits. ",
|
||||
"plansCommon.supportItems.logoChange": "Logo change",
|
||||
"plansCommon.supportItems.personalizedSupport": "Personalized support",
|
||||
"plansCommon.supportItems.priorityEmail": "Priority email & chat support",
|
||||
"plansCommon.supportItems.ragAPIRequest": "RAG API Requests",
|
||||
"plansCommon.supportItems.workflow": "Workflow",
|
||||
"plansCommon.talkToSales": "Talk to Sales",
|
||||
"plansCommon.taxTip": "All subscription prices (monthly/annual) exclude applicable taxes (e.g., VAT, sales tax).",
|
||||
"plansCommon.taxTipSecond": "If your region has no applicable tax requirements, no tax will appear in your checkout, and you won’t be charged any additional fees for the entire subscription term.",
|
||||
"plansCommon.teamMember_one": "{{count,number}} Team Member",
|
||||
"plansCommon.teamMember_other": "{{count,number}} Team Members",
|
||||
"plansCommon.teamWorkspace": "{{count,number}} Team Workspace",
|
||||
"plansCommon.title.description": "Select the plan that best fits your team's needs.",
|
||||
"plansCommon.title.plans": "plans",
|
||||
"plansCommon.triggerEvents.professional": "{{count,number}} Trigger Events/month",
|
||||
"plansCommon.triggerEvents.sandbox": "{{count,number}} Trigger Events",
|
||||
"plansCommon.triggerEvents.tooltip": "The number of events that automatically start workflows through Plugin, Schedule, or Webhook triggers.",
|
||||
"plansCommon.triggerEvents.unlimited": "Unlimited Trigger Events",
|
||||
"plansCommon.unavailable": "Unavailable",
|
||||
"plansCommon.unlimited": "Unlimited",
|
||||
"plansCommon.unlimitedApiRate": "No Dify API Rate Limit",
|
||||
"plansCommon.vectorSpace": "{{size}} Knowledge Data Storage",
|
||||
"plansCommon.vectorSpaceTooltip": "Documents with the High Quality indexing mode will consume Knowledge Data Storage resources. When Knowledge Data Storage reaches the limit, new documents will not be uploaded.",
|
||||
"plansCommon.workflowExecution.faster": "Faster Workflow Execution",
|
||||
"plansCommon.workflowExecution.priority": "Priority Workflow Execution",
|
||||
"plansCommon.workflowExecution.standard": "Standard Workflow Execution",
|
||||
"plansCommon.workflowExecution.tooltip": "Workflow execution queue priority and speed.",
|
||||
"plansCommon.year": "year",
|
||||
"plansCommon.yearlyTip": "Pay for 10 months, enjoy 1 Year!",
|
||||
"teamMembers": "Team Members",
|
||||
"triggerLimitModal.description": "You've reached the limit of workflow event triggers for this plan.",
|
||||
"triggerLimitModal.dismiss": "Dismiss",
|
||||
"triggerLimitModal.title": "Upgrade to unlock more trigger events",
|
||||
"triggerLimitModal.upgrade": "Upgrade",
|
||||
"triggerLimitModal.usageTitle": "TRIGGER EVENTS",
|
||||
"upgrade.addChunks.description": "You’ve reached the limit of adding chunks for this plan.",
|
||||
"upgrade.addChunks.title": "Upgrade to continue adding chunks",
|
||||
"upgrade.uploadMultipleFiles.description": "Batch-upload more documents at once to save time and improve efficiency.",
|
||||
"upgrade.uploadMultipleFiles.title": "Upgrade to unlock batch document upload",
|
||||
"upgrade.uploadMultiplePages.description": "You’ve reached the upload limit — only one document can be selected and uploaded at a time on your current plan.",
|
||||
"upgrade.uploadMultiplePages.title": "Upgrade to upload multiple documents at once",
|
||||
"upgradeBtn.encourage": "Upgrade Now",
|
||||
"upgradeBtn.encourageShort": "Upgrade",
|
||||
"upgradeBtn.plain": "View Plan",
|
||||
"usagePage.annotationQuota": "Annotation Quota",
|
||||
"usagePage.buildApps": "Build Apps",
|
||||
"usagePage.documentsUploadQuota": "Documents Upload Quota",
|
||||
"usagePage.perMonth": "per month",
|
||||
"usagePage.resetsIn": "Resets in {{count,number}} days",
|
||||
"usagePage.storageThresholdTooltip": "Detailed usage is shown once storage exceeds 50 MB.",
|
||||
"usagePage.teamMembers": "Team Members",
|
||||
"usagePage.triggerEvents": "Trigger Events",
|
||||
"usagePage.vectorSpace": "Knowledge Data Storage",
|
||||
"usagePage.vectorSpaceTooltip": "Documents with the High Quality indexing mode will consume Knowledge Data Storage resources. When Knowledge Data Storage reaches the limit, new documents will not be uploaded.",
|
||||
"vectorSpace.fullSolution": "Upgrade your plan to get more space.",
|
||||
"vectorSpace.fullTip": "Vector Space is full.",
|
||||
"viewBilling": "Manage billing and subscriptions",
|
||||
"viewBillingAction": "Manage",
|
||||
"viewBillingDescription": "Manage payment methods, invoices, and subscription changes",
|
||||
"viewBillingTitle": "Billing and Subscriptions"
|
||||
}
|
||||
631
web/i18n/nl-NL/common.json
Normal file
631
web/i18n/nl-NL/common.json
Normal file
@@ -0,0 +1,631 @@
|
||||
{
|
||||
"about.changeLog": "Changelog",
|
||||
"about.latestAvailable": "Dify {{version}} is the latest version available.",
|
||||
"about.nowAvailable": "Dify {{version}} is now available.",
|
||||
"about.updateNow": "Update now",
|
||||
"account.account": "Account",
|
||||
"account.avatar": "Avatar",
|
||||
"account.changeEmail.authTip": "Once your email is changed, Google or GitHub accounts linked to your old email will no longer be able to log in to this account.",
|
||||
"account.changeEmail.changeTo": "Change to {{email}}",
|
||||
"account.changeEmail.codeLabel": "Verification code",
|
||||
"account.changeEmail.codePlaceholder": "Paste the 6-digit code",
|
||||
"account.changeEmail.content1": "If you continue, we'll send a verification code to <email>{{email}}</email> for re-authentication.",
|
||||
"account.changeEmail.content2": "Your current email is <email>{{email}}</email>. Verification code has been sent to this email address.",
|
||||
"account.changeEmail.content3": "Enter a new email and we will send you a verification code.",
|
||||
"account.changeEmail.content4": "We just sent you a temporary verification code to <email>{{email}}</email>.",
|
||||
"account.changeEmail.continue": "Continue",
|
||||
"account.changeEmail.emailLabel": "New email",
|
||||
"account.changeEmail.emailPlaceholder": "Enter a new email",
|
||||
"account.changeEmail.existingEmail": "A user with this email already exists.",
|
||||
"account.changeEmail.newEmail": "Set up a new email address",
|
||||
"account.changeEmail.resend": "Resend",
|
||||
"account.changeEmail.resendCount": "Resend in {{count}}s",
|
||||
"account.changeEmail.resendTip": "Didn't receive a code?",
|
||||
"account.changeEmail.sendVerifyCode": "Send verification code",
|
||||
"account.changeEmail.title": "Change Email",
|
||||
"account.changeEmail.unAvailableEmail": "This email is temporarily unavailable.",
|
||||
"account.changeEmail.verifyEmail": "Verify your current email",
|
||||
"account.changeEmail.verifyNew": "Verify your new email",
|
||||
"account.confirmPassword": "Confirm password",
|
||||
"account.currentPassword": "Current password",
|
||||
"account.delete": "Delete Account",
|
||||
"account.deleteLabel": "To confirm, please type in your email below",
|
||||
"account.deletePlaceholder": "Please enter your email",
|
||||
"account.deletePrivacyLink": "Privacy Policy.",
|
||||
"account.deletePrivacyLinkTip": "For more information about how we handle your data, please see our ",
|
||||
"account.deleteSuccessTip": "Your account needs time to finish deleting. We'll email you when it's all done.",
|
||||
"account.deleteTip": "Please note, once confirmed, as the Owner of any Workspaces, your workspaces will be scheduled in a queue for permanent deletion, and all your user data will be queued for permanent deletion.",
|
||||
"account.editName": "Edit Name",
|
||||
"account.editWorkspaceInfo": "Edit Workspace Info",
|
||||
"account.email": "Email",
|
||||
"account.feedbackLabel": "Tell us why you deleted your account?",
|
||||
"account.feedbackPlaceholder": "Optional",
|
||||
"account.feedbackTitle": "Feedback",
|
||||
"account.langGeniusAccount": "Account's data",
|
||||
"account.langGeniusAccountTip": "The user data of your account.",
|
||||
"account.myAccount": "My Account",
|
||||
"account.name": "Name",
|
||||
"account.newPassword": "New password",
|
||||
"account.notEqual": "Two passwords are different.",
|
||||
"account.password": "Password",
|
||||
"account.passwordTip": "You can set a permanent password if you don’t want to use temporary login codes",
|
||||
"account.permanentlyDeleteButton": "Permanently Delete Account",
|
||||
"account.resetPassword": "Reset password",
|
||||
"account.sendVerificationButton": "Send Verification Code",
|
||||
"account.setPassword": "Set a password",
|
||||
"account.showAppLength": "Show {{length}} apps",
|
||||
"account.studio": "Studio",
|
||||
"account.verificationLabel": "Verification Code",
|
||||
"account.verificationPlaceholder": "Paste the 6-digit code",
|
||||
"account.workspaceIcon": "Workspace Icon",
|
||||
"account.workspaceName": "Workspace Name",
|
||||
"account.workspaceNamePlaceholder": "Enter workspace name",
|
||||
"actionMsg.copySuccessfully": "Copied successfully",
|
||||
"actionMsg.downloadUnsuccessfully": "Download failed. Please try again later.",
|
||||
"actionMsg.generatedSuccessfully": "Generated successfully",
|
||||
"actionMsg.generatedUnsuccessfully": "Generated unsuccessfully",
|
||||
"actionMsg.modifiedSuccessfully": "Modified successfully",
|
||||
"actionMsg.modifiedUnsuccessfully": "Modified unsuccessfully",
|
||||
"actionMsg.noModification": "No modifications at the moment.",
|
||||
"actionMsg.payCancelled": "Payment cancelled",
|
||||
"actionMsg.paySucceeded": "Payment succeeded",
|
||||
"api.actionFailed": "Action failed",
|
||||
"api.actionSuccess": "Action succeeded",
|
||||
"api.create": "Created",
|
||||
"api.remove": "Removed",
|
||||
"api.saved": "Saved",
|
||||
"api.success": "Success",
|
||||
"apiBasedExtension.add": "Add API Extension",
|
||||
"apiBasedExtension.link": "Learn how to develop your own API Extension.",
|
||||
"apiBasedExtension.modal.apiEndpoint.placeholder": "Please enter the API endpoint",
|
||||
"apiBasedExtension.modal.apiEndpoint.title": "API Endpoint",
|
||||
"apiBasedExtension.modal.apiKey.lengthError": "API-key length cannot be less than 5 characters",
|
||||
"apiBasedExtension.modal.apiKey.placeholder": "Please enter the API-key",
|
||||
"apiBasedExtension.modal.apiKey.title": "API-key",
|
||||
"apiBasedExtension.modal.editTitle": "Edit API Extension",
|
||||
"apiBasedExtension.modal.name.placeholder": "Please enter the name",
|
||||
"apiBasedExtension.modal.name.title": "Name",
|
||||
"apiBasedExtension.modal.title": "Add API Extension",
|
||||
"apiBasedExtension.selector.manage": "Manage API Extension",
|
||||
"apiBasedExtension.selector.placeholder": "Please select API extension",
|
||||
"apiBasedExtension.selector.title": "API Extension",
|
||||
"apiBasedExtension.title": "API extensions provide centralized API management, simplifying configuration for easy use across Dify's applications.",
|
||||
"apiBasedExtension.type": "Type",
|
||||
"appMenus.apiAccess": "API Access",
|
||||
"appMenus.apiAccessTip": "This knowledge base is accessible via the Service API",
|
||||
"appMenus.logAndAnn": "Logs & Annotations",
|
||||
"appMenus.logs": "Logs",
|
||||
"appMenus.overview": "Monitoring",
|
||||
"appMenus.promptEng": "Orchestrate",
|
||||
"appModes.chatApp": "Chat App",
|
||||
"appModes.completionApp": "Text Generator",
|
||||
"avatar.deleteDescription": "Are you sure you want to remove your profile picture? Your account will use the default initial avatar.",
|
||||
"avatar.deleteTitle": "Remove Avatar",
|
||||
"chat.citation.characters": "Characters:",
|
||||
"chat.citation.hitCount": "Retrieval count:",
|
||||
"chat.citation.hitScore": "Retrieval Score:",
|
||||
"chat.citation.linkToDataset": "Link to Knowledge",
|
||||
"chat.citation.title": "CITATIONS",
|
||||
"chat.citation.vectorHash": "Vector hash:",
|
||||
"chat.conversationName": "Conversation name",
|
||||
"chat.conversationNameCanNotEmpty": "Conversation name required",
|
||||
"chat.conversationNamePlaceholder": "Please input conversation name",
|
||||
"chat.inputDisabledPlaceholder": "Preview Only",
|
||||
"chat.inputPlaceholder": "Talk to {{botName}}",
|
||||
"chat.renameConversation": "Rename Conversation",
|
||||
"chat.resend": "Resend",
|
||||
"chat.thinking": "Thinking...",
|
||||
"chat.thought": "Thought",
|
||||
"compliance.gdpr": "GDPR DPA",
|
||||
"compliance.iso27001": "ISO 27001:2022 Certification",
|
||||
"compliance.professionalUpgradeTooltip": "Only available with a Team plan or above.",
|
||||
"compliance.sandboxUpgradeTooltip": "Only available with a Professional or Team plan.",
|
||||
"compliance.soc2Type1": "SOC 2 Type I Report",
|
||||
"compliance.soc2Type2": "SOC 2 Type II Report",
|
||||
"dataSource.add": "Add a data source",
|
||||
"dataSource.configure": "Configure",
|
||||
"dataSource.connect": "Connect",
|
||||
"dataSource.notion.addWorkspace": "Add workspace",
|
||||
"dataSource.notion.changeAuthorizedPages": "Change authorized pages",
|
||||
"dataSource.notion.connected": "Connected",
|
||||
"dataSource.notion.connectedWorkspace": "Connected workspace",
|
||||
"dataSource.notion.description": "Using Notion as a data source for the Knowledge.",
|
||||
"dataSource.notion.disconnected": "Disconnected",
|
||||
"dataSource.notion.integratedAlert": "Notion is integrated via internal credential, no need to re-authorize.",
|
||||
"dataSource.notion.pagesAuthorized": "Pages authorized",
|
||||
"dataSource.notion.remove": "Remove",
|
||||
"dataSource.notion.selector.addPages": "Add pages",
|
||||
"dataSource.notion.selector.noSearchResult": "No search results",
|
||||
"dataSource.notion.selector.pageSelected": "Pages Selected",
|
||||
"dataSource.notion.selector.preview": "PREVIEW",
|
||||
"dataSource.notion.selector.searchPages": "Search pages...",
|
||||
"dataSource.notion.sync": "Sync",
|
||||
"dataSource.notion.title": "Notion",
|
||||
"dataSource.website.active": "Active",
|
||||
"dataSource.website.configuredCrawlers": "Configured crawlers",
|
||||
"dataSource.website.description": "Import content from websites using web crawler.",
|
||||
"dataSource.website.inactive": "Inactive",
|
||||
"dataSource.website.title": "Website",
|
||||
"dataSource.website.with": "With",
|
||||
"datasetMenus.documents": "Documents",
|
||||
"datasetMenus.emptyTip": "This Knowledge has not been integrated within any application. Please refer to the document for guidance.",
|
||||
"datasetMenus.hitTesting": "Retrieval Testing",
|
||||
"datasetMenus.noRelatedApp": "No linked apps",
|
||||
"datasetMenus.pipeline": "Pipeline",
|
||||
"datasetMenus.relatedApp": "linked apps",
|
||||
"datasetMenus.settings": "Settings",
|
||||
"datasetMenus.viewDoc": "View documentation",
|
||||
"dynamicSelect.error": "Loading options failed",
|
||||
"dynamicSelect.loading": "Loading options...",
|
||||
"dynamicSelect.noData": "No options available",
|
||||
"dynamicSelect.selected": "{{count}} selected",
|
||||
"environment.development": "DEVELOPMENT",
|
||||
"environment.testing": "TESTING",
|
||||
"error": "Error",
|
||||
"errorMsg.fieldRequired": "{{field}} is required",
|
||||
"errorMsg.urlError": "url should start with http:// or https://",
|
||||
"feedback.content": "Feedback Content",
|
||||
"feedback.placeholder": "Please describe what went wrong or how we can improve...",
|
||||
"feedback.subtitle": "Please tell us what went wrong with this response",
|
||||
"feedback.title": "Provide Feedback",
|
||||
"fileUploader.fileExtensionBlocked": "This file type is blocked for security reasons",
|
||||
"fileUploader.fileExtensionNotSupport": "File extension not supported",
|
||||
"fileUploader.pasteFileLink": "Paste file link",
|
||||
"fileUploader.pasteFileLinkInputPlaceholder": "Enter URL...",
|
||||
"fileUploader.pasteFileLinkInvalid": "Invalid file link",
|
||||
"fileUploader.uploadDisabled": "File upload is disabled",
|
||||
"fileUploader.uploadFromComputer": "Local upload",
|
||||
"fileUploader.uploadFromComputerLimit": "Upload {{type}} cannot exceed {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "File reading failed, please try again.",
|
||||
"fileUploader.uploadFromComputerUploadError": "File upload failed, please upload again.",
|
||||
"imageInput.browse": "browse",
|
||||
"imageInput.dropImageHere": "Drop your image here, or",
|
||||
"imageInput.supportedFormats": "Supports PNG, JPG, JPEG, WEBP and GIF",
|
||||
"imageUploader.imageUpload": "Image Upload",
|
||||
"imageUploader.pasteImageLink": "Paste image link",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Paste image link here",
|
||||
"imageUploader.pasteImageLinkInvalid": "Invalid image link",
|
||||
"imageUploader.uploadFromComputer": "Upload from Computer",
|
||||
"imageUploader.uploadFromComputerLimit": "Upload images cannot exceed {{size}} MB",
|
||||
"imageUploader.uploadFromComputerReadError": "Image reading failed, please try again.",
|
||||
"imageUploader.uploadFromComputerUploadError": "Image upload failed, please upload again.",
|
||||
"integrations.connect": "Connect",
|
||||
"integrations.connected": "Connected",
|
||||
"integrations.github": "GitHub",
|
||||
"integrations.githubAccount": "Login with GitHub account",
|
||||
"integrations.google": "Google",
|
||||
"integrations.googleAccount": "Login with Google account",
|
||||
"label.optional": "(optional)",
|
||||
"language.displayLanguage": "Display Language",
|
||||
"language.timezone": "Time Zone",
|
||||
"license.expiring": "Expiring in one day",
|
||||
"license.expiring_plural": "Expiring in {{count}} days",
|
||||
"license.unlimited": "Unlimited",
|
||||
"loading": "Loading",
|
||||
"members.admin": "Admin",
|
||||
"members.adminTip": "Can build apps & manage team settings",
|
||||
"members.builder": "Builder",
|
||||
"members.builderTip": "Can build & edit own apps",
|
||||
"members.datasetOperator": "Knowledge Admin",
|
||||
"members.datasetOperatorTip": "Only can manage the knowledge base",
|
||||
"members.deleteMember": "Delete Member",
|
||||
"members.disInvite": "Cancel the invitation",
|
||||
"members.editor": "Editor",
|
||||
"members.editorTip": "Can build & edit apps",
|
||||
"members.email": "Email",
|
||||
"members.emailInvalid": "Invalid Email Format",
|
||||
"members.emailNotSetup": "Email server is not set up, so invitation emails cannot be sent. Please notify users of the invitation link that will be issued after invitation instead.",
|
||||
"members.emailPlaceholder": "Please input emails",
|
||||
"members.failedInvitationEmails": "Below users were not invited successfully",
|
||||
"members.invitationLink": "Invitation Link",
|
||||
"members.invitationSent": "Invitation sent",
|
||||
"members.invitationSentTip": "Invitation sent, and they can sign in to Dify to access your team data.",
|
||||
"members.invite": "Add",
|
||||
"members.inviteTeamMember": "Add team member",
|
||||
"members.inviteTeamMemberTip": "They can access your team data directly after signing in.",
|
||||
"members.invitedAsRole": "Invited as {{role}} user",
|
||||
"members.lastActive": "LAST ACTIVE",
|
||||
"members.name": "NAME",
|
||||
"members.normal": "Normal",
|
||||
"members.normalTip": "Only can use apps, can not build apps",
|
||||
"members.ok": "OK",
|
||||
"members.owner": "Owner",
|
||||
"members.pending": "Pending...",
|
||||
"members.removeFromTeam": "Remove from team",
|
||||
"members.removeFromTeamTip": "Will remove team access",
|
||||
"members.role": "ROLES",
|
||||
"members.sendInvite": "Send Invite",
|
||||
"members.setAdmin": "Set as administrator",
|
||||
"members.setBuilder": "Set as builder",
|
||||
"members.setEditor": "Set as editor",
|
||||
"members.setMember": "Set to ordinary member",
|
||||
"members.team": "Team",
|
||||
"members.transferModal.codeLabel": "Verification code",
|
||||
"members.transferModal.codePlaceholder": "Paste the 6-digit code",
|
||||
"members.transferModal.continue": "Continue",
|
||||
"members.transferModal.resend": "Resend",
|
||||
"members.transferModal.resendCount": "Resend in {{count}}s",
|
||||
"members.transferModal.resendTip": "Didn't receive a code?",
|
||||
"members.transferModal.sendTip": "If you continue, we'll send a verification code to <email>{{email}}</email> for re-authentication.",
|
||||
"members.transferModal.sendVerifyCode": "Send verification code",
|
||||
"members.transferModal.title": "Transfer workspace ownership",
|
||||
"members.transferModal.transfer": "Transfer workspace ownership",
|
||||
"members.transferModal.transferLabel": "Transfer workspace ownership to",
|
||||
"members.transferModal.transferPlaceholder": "Select a workspace member…",
|
||||
"members.transferModal.verifyContent": "Your current email is <email>{{email}}</email>.",
|
||||
"members.transferModal.verifyContent2": "We'll send a temporary verification code to this email for re-authentication.",
|
||||
"members.transferModal.verifyEmail": "Verify your current email",
|
||||
"members.transferModal.warning": "You're about to transfer ownership of “{{workspace}}”. This takes effect immediately and can't be undone.",
|
||||
"members.transferModal.warningTip": "You'll become an admin member, and the new owner will have full control.",
|
||||
"members.transferOwnership": "Transfer Ownership",
|
||||
"members.you": "(You)",
|
||||
"menus.account": "Account",
|
||||
"menus.appDetail": "App Detail",
|
||||
"menus.apps": "Studio",
|
||||
"menus.datasets": "Knowledge",
|
||||
"menus.datasetsTips": "COMING SOON: Import your own text data or write data in real-time via Webhook for LLM context enhancement.",
|
||||
"menus.explore": "Explore",
|
||||
"menus.exploreMarketplace": "Explore Marketplace",
|
||||
"menus.newApp": "New App",
|
||||
"menus.newDataset": "Create Knowledge",
|
||||
"menus.plugins": "Plugins",
|
||||
"menus.pluginsTips": "Integrate third-party plugins or create ChatGPT-compatible AI-Plugins.",
|
||||
"menus.status": "beta",
|
||||
"menus.tools": "Tools",
|
||||
"model.addMoreModel": "Go to settings to add more models",
|
||||
"model.capabilities": "MultiModal Capabilities",
|
||||
"model.params.frequency_penalty": "Frequency penalty",
|
||||
"model.params.frequency_penaltyTip": "How much to penalize new tokens based on their existing frequency in the text so far.\nDecreases the model's likelihood to repeat the same line verbatim.",
|
||||
"model.params.maxTokenSettingTip": "Your max token setting is high, potentially limiting space for prompts, queries, and data. Consider setting it below 2/3.",
|
||||
"model.params.max_tokens": "Max token",
|
||||
"model.params.max_tokensTip": "Used to limit the maximum length of the reply, in tokens. \nLarger values may limit the space left for prompt words, chat logs, and Knowledge. \nIt is recommended to set it below two-thirds\ngpt-4-1106-preview, gpt-4-vision-preview max token (input 128k output 4k)",
|
||||
"model.params.presence_penalty": "Presence penalty",
|
||||
"model.params.presence_penaltyTip": "How much to penalize new tokens based on whether they appear in the text so far.\nIncreases the model's likelihood to talk about new topics.",
|
||||
"model.params.setToCurrentModelMaxTokenTip": "Max token is updated to the 80% maximum token of the current model {{maxToken}}.",
|
||||
"model.params.stop_sequences": "Stop sequences",
|
||||
"model.params.stop_sequencesPlaceholder": "Enter sequence and press Tab",
|
||||
"model.params.stop_sequencesTip": "Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.",
|
||||
"model.params.temperature": "Temperature",
|
||||
"model.params.temperatureTip": "Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.",
|
||||
"model.params.top_p": "Top P",
|
||||
"model.params.top_pTip": "Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered.",
|
||||
"model.settingsLink": "Model Provider Settings",
|
||||
"model.tone.Balanced": "Balanced",
|
||||
"model.tone.Creative": "Creative",
|
||||
"model.tone.Custom": "Custom",
|
||||
"model.tone.Precise": "Precise",
|
||||
"modelName.claude-2": "Claude-2",
|
||||
"modelName.claude-instant-1": "Claude-Instant",
|
||||
"modelName.gpt-3.5-turbo": "GPT-3.5-Turbo",
|
||||
"modelName.gpt-3.5-turbo-16k": "GPT-3.5-Turbo-16K",
|
||||
"modelName.gpt-4": "GPT-4",
|
||||
"modelName.gpt-4-32k": "GPT-4-32K",
|
||||
"modelName.text-davinci-003": "Text-Davinci-003",
|
||||
"modelName.text-embedding-ada-002": "Text-Embedding-Ada-002",
|
||||
"modelName.whisper-1": "Whisper-1",
|
||||
"modelProvider.addApiKey": "Add your API key",
|
||||
"modelProvider.addConfig": "Add Config",
|
||||
"modelProvider.addModel": "Add Model",
|
||||
"modelProvider.addMoreModelProvider": "ADD MORE MODEL PROVIDER",
|
||||
"modelProvider.apiKey": "API-KEY",
|
||||
"modelProvider.apiKeyRateLimit": "Rate limit was reached, available after {{seconds}}s",
|
||||
"modelProvider.apiKeyStatusNormal": "APIKey status is normal",
|
||||
"modelProvider.auth.addApiKey": "Add API Key",
|
||||
"modelProvider.auth.addCredential": "Add credential",
|
||||
"modelProvider.auth.addModel": "Add model",
|
||||
"modelProvider.auth.addModelCredential": "Add model credential",
|
||||
"modelProvider.auth.addNewModel": "Add new model",
|
||||
"modelProvider.auth.addNewModelCredential": "Add new model credential",
|
||||
"modelProvider.auth.apiKeyModal.addModel": "Add model",
|
||||
"modelProvider.auth.apiKeyModal.desc": "After configuring credentials, all members within the workspace can use this model when orchestrating applications.",
|
||||
"modelProvider.auth.apiKeyModal.title": "API Key Authorization Configuration",
|
||||
"modelProvider.auth.apiKeys": "API Keys",
|
||||
"modelProvider.auth.authRemoved": "Auth removed",
|
||||
"modelProvider.auth.authorizationError": "Authorization error",
|
||||
"modelProvider.auth.configLoadBalancing": "Config Load Balancing",
|
||||
"modelProvider.auth.configModel": "Config model",
|
||||
"modelProvider.auth.credentialRemoved": "Credential removed",
|
||||
"modelProvider.auth.customModelCredentials": "Custom Model Credentials",
|
||||
"modelProvider.auth.customModelCredentialsDeleteTip": "Credential is in use and cannot be deleted",
|
||||
"modelProvider.auth.editModelCredential": "Edit model credential",
|
||||
"modelProvider.auth.manageCredentials": "Manage Credentials",
|
||||
"modelProvider.auth.modelCredential": "Model credential",
|
||||
"modelProvider.auth.modelCredentials": "Model credentials",
|
||||
"modelProvider.auth.providerManaged": "Provider managed",
|
||||
"modelProvider.auth.providerManagedTip": "The current configuration is hosted by the provider.",
|
||||
"modelProvider.auth.removeModel": "Remove Model",
|
||||
"modelProvider.auth.selectModelCredential": "Select a model credential",
|
||||
"modelProvider.auth.specifyModelCredential": "Specify model credential",
|
||||
"modelProvider.auth.specifyModelCredentialTip": "Use a configured model credential.",
|
||||
"modelProvider.auth.unAuthorized": "Unauthorized",
|
||||
"modelProvider.buyQuota": "Buy Quota",
|
||||
"modelProvider.callTimes": "Call times",
|
||||
"modelProvider.card.buyQuota": "Buy Quota",
|
||||
"modelProvider.card.callTimes": "Call times",
|
||||
"modelProvider.card.modelAPI": "{{modelName}} models are using the API Key.",
|
||||
"modelProvider.card.modelNotSupported": "{{modelName}} models are not installed.",
|
||||
"modelProvider.card.modelSupported": "{{modelName}} models are using this quota.",
|
||||
"modelProvider.card.onTrial": "On Trial",
|
||||
"modelProvider.card.paid": "Paid",
|
||||
"modelProvider.card.priorityUse": "Priority use",
|
||||
"modelProvider.card.quota": "QUOTA",
|
||||
"modelProvider.card.quotaExhausted": "Quota exhausted",
|
||||
"modelProvider.card.removeKey": "Remove API Key",
|
||||
"modelProvider.card.tip": "Message Credits supports models from {{modelNames}}. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.",
|
||||
"modelProvider.card.tokens": "Tokens",
|
||||
"modelProvider.collapse": "Collapse",
|
||||
"modelProvider.config": "Config",
|
||||
"modelProvider.configLoadBalancing": "Config Load Balancing",
|
||||
"modelProvider.configureTip": "Set up api-key or add model to use",
|
||||
"modelProvider.confirmDelete": "Confirm deletion?",
|
||||
"modelProvider.credits": "Message Credits",
|
||||
"modelProvider.defaultConfig": "Default Config",
|
||||
"modelProvider.deprecated": "Deprecated",
|
||||
"modelProvider.discoverMore": "Discover more in ",
|
||||
"modelProvider.editConfig": "Edit Config",
|
||||
"modelProvider.embeddingModel.key": "Embedding Model",
|
||||
"modelProvider.embeddingModel.required": "Embedding Model is required",
|
||||
"modelProvider.embeddingModel.tip": "Set the default model for document embedding processing of the Knowledge, both retrieval and import of the Knowledge use this Embedding model for vectorization processing. Switching will cause the vector dimension between the imported Knowledge and the question to be inconsistent, resulting in retrieval failure. To avoid retrieval failure, please do not switch this model at will.",
|
||||
"modelProvider.emptyProviderTip": "Please install a model provider first.",
|
||||
"modelProvider.emptyProviderTitle": "Model provider not set up",
|
||||
"modelProvider.encrypted.back": " technology.",
|
||||
"modelProvider.encrypted.front": "Your API KEY will be encrypted and stored using",
|
||||
"modelProvider.featureSupported": "{{feature}} supported",
|
||||
"modelProvider.freeQuota.howToEarn": "How to earn",
|
||||
"modelProvider.getFreeTokens": "Get free Tokens",
|
||||
"modelProvider.installDataSourceProvider": "Install data source providers",
|
||||
"modelProvider.installProvider": "Install model providers",
|
||||
"modelProvider.invalidApiKey": "Invalid API key",
|
||||
"modelProvider.item.deleteDesc": "{{modelName}} are being used as system reasoning models. Some functions will not be available after removal. Please confirm.",
|
||||
"modelProvider.item.freeQuota": "FREE QUOTA",
|
||||
"modelProvider.loadBalancing": "Load balancing",
|
||||
"modelProvider.loadBalancingDescription": "Configure multiple credentials for the model and invoke them automatically. ",
|
||||
"modelProvider.loadBalancingHeadline": "Load Balancing",
|
||||
"modelProvider.loadBalancingInfo": "By default, load balancing uses the Round-robin strategy. If rate limiting is triggered, a 1-minute cooldown period will be applied.",
|
||||
"modelProvider.loadBalancingLeastKeyWarning": "To enable load balancing at least 2 keys must be enabled.",
|
||||
"modelProvider.loadPresets": "Load Presets",
|
||||
"modelProvider.model": "Model",
|
||||
"modelProvider.modelAndParameters": "Model and Parameters",
|
||||
"modelProvider.modelHasBeenDeprecated": "This model has been deprecated",
|
||||
"modelProvider.models": "Models",
|
||||
"modelProvider.modelsNum": "{{num}} Models",
|
||||
"modelProvider.noModelFound": "No model found for {{model}}",
|
||||
"modelProvider.notConfigured": "The system model has not yet been fully configured",
|
||||
"modelProvider.parameters": "PARAMETERS",
|
||||
"modelProvider.parametersInvalidRemoved": "Some parameters are invalid and have been removed",
|
||||
"modelProvider.priorityUsing": "Prioritize using",
|
||||
"modelProvider.providerManaged": "Provider managed",
|
||||
"modelProvider.providerManagedDescription": "Use the single set of credentials provided by the model provider.",
|
||||
"modelProvider.quota": "Quota",
|
||||
"modelProvider.quotaTip": "Remaining available free tokens",
|
||||
"modelProvider.rerankModel.key": "Rerank Model",
|
||||
"modelProvider.rerankModel.tip": "Rerank model will reorder the candidate document list based on the semantic match with user query, improving the results of semantic ranking",
|
||||
"modelProvider.resetDate": "Reset on {{date}}",
|
||||
"modelProvider.searchModel": "Search model",
|
||||
"modelProvider.selectModel": "Select your model",
|
||||
"modelProvider.selector.emptySetting": "Please go to settings to configure",
|
||||
"modelProvider.selector.emptyTip": "No available models",
|
||||
"modelProvider.selector.rerankTip": "Please set up the Rerank model",
|
||||
"modelProvider.selector.tip": "This model has been removed. Please add a model or select another model.",
|
||||
"modelProvider.setupModelFirst": "Please set up your model first",
|
||||
"modelProvider.showModels": "Show Models",
|
||||
"modelProvider.showModelsNum": "Show {{num}} Models",
|
||||
"modelProvider.showMoreModelProvider": "Show more model provider",
|
||||
"modelProvider.speechToTextModel.key": "Speech-to-Text Model",
|
||||
"modelProvider.speechToTextModel.tip": "Set the default model for speech-to-text input in conversation.",
|
||||
"modelProvider.systemModelSettings": "System Model Settings",
|
||||
"modelProvider.systemModelSettingsLink": "Why is it necessary to set up a system model?",
|
||||
"modelProvider.systemReasoningModel.key": "System Reasoning Model",
|
||||
"modelProvider.systemReasoningModel.tip": "Set the default inference model to be used for creating applications, as well as features such as dialogue name generation and next question suggestion will also use the default inference model.",
|
||||
"modelProvider.toBeConfigured": "To be configured",
|
||||
"modelProvider.ttsModel.key": "Text-to-Speech Model",
|
||||
"modelProvider.ttsModel.tip": "Set the default model for text-to-speech input in conversation.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Upgrade your plan to enable Load Balancing.",
|
||||
"noData": "No data",
|
||||
"operation.add": "Add",
|
||||
"operation.added": "Added",
|
||||
"operation.audioSourceUnavailable": "AudioSource is unavailable",
|
||||
"operation.back": "Back",
|
||||
"operation.cancel": "Cancel",
|
||||
"operation.change": "Change",
|
||||
"operation.clear": "Clear",
|
||||
"operation.close": "Close",
|
||||
"operation.config": "Config",
|
||||
"operation.confirm": "Confirm",
|
||||
"operation.confirmAction": "Please confirm your action.",
|
||||
"operation.copied": "Copied",
|
||||
"operation.copy": "Copy",
|
||||
"operation.copyImage": "Copy Image",
|
||||
"operation.create": "Create",
|
||||
"operation.deSelectAll": "Deselect All",
|
||||
"operation.delete": "Delete",
|
||||
"operation.deleteApp": "Delete App",
|
||||
"operation.deleteConfirmTitle": "Delete?",
|
||||
"operation.download": "Download",
|
||||
"operation.downloadFailed": "Download failed. Please try again later.",
|
||||
"operation.downloadSuccess": "Download Completed.",
|
||||
"operation.duplicate": "Duplicate",
|
||||
"operation.edit": "Edit",
|
||||
"operation.format": "Format",
|
||||
"operation.getForFree": "Get for free",
|
||||
"operation.imageCopied": "Image copied",
|
||||
"operation.imageDownloaded": "Image downloaded",
|
||||
"operation.in": "in",
|
||||
"operation.learnMore": "Learn More",
|
||||
"operation.lineBreak": "Line break",
|
||||
"operation.log": "Log",
|
||||
"operation.more": "More",
|
||||
"operation.no": "No",
|
||||
"operation.noSearchCount": "0 {{content}}",
|
||||
"operation.noSearchResults": "No {{content}} were found",
|
||||
"operation.now": "Now",
|
||||
"operation.ok": "OK",
|
||||
"operation.openInNewTab": "Open in new tab",
|
||||
"operation.params": "Params",
|
||||
"operation.refresh": "Restart",
|
||||
"operation.regenerate": "Regenerate",
|
||||
"operation.reload": "Reload",
|
||||
"operation.remove": "Remove",
|
||||
"operation.rename": "Rename",
|
||||
"operation.reset": "Reset",
|
||||
"operation.resetKeywords": "Reset keywords",
|
||||
"operation.save": "Save",
|
||||
"operation.saveAndEnable": "Save & Enable",
|
||||
"operation.saveAndRegenerate": "Save & Regenerate Child Chunks",
|
||||
"operation.saving": "Saving...",
|
||||
"operation.search": "Search",
|
||||
"operation.searchCount": "Find {{count}} {{content}}",
|
||||
"operation.selectAll": "Select All",
|
||||
"operation.selectCount": "{{count}} Selected",
|
||||
"operation.send": "Send",
|
||||
"operation.settings": "Settings",
|
||||
"operation.setup": "Setup",
|
||||
"operation.skip": "Skip",
|
||||
"operation.submit": "Submit",
|
||||
"operation.sure": "I'm sure",
|
||||
"operation.view": "View",
|
||||
"operation.viewDetails": "View Details",
|
||||
"operation.viewMore": "VIEW MORE",
|
||||
"operation.yes": "Yes",
|
||||
"operation.zoomIn": "Zoom In",
|
||||
"operation.zoomOut": "Zoom Out",
|
||||
"pagination.perPage": "Items per page",
|
||||
"placeholder.input": "Please enter",
|
||||
"placeholder.search": "Search...",
|
||||
"placeholder.select": "Please select",
|
||||
"plugin.serpapi.apiKey": "API Key",
|
||||
"plugin.serpapi.apiKeyPlaceholder": "Enter your API key",
|
||||
"plugin.serpapi.keyFrom": "Get your SerpAPI key from SerpAPI Account Page",
|
||||
"promptEditor.context.item.desc": "Insert context template",
|
||||
"promptEditor.context.item.title": "Context",
|
||||
"promptEditor.context.modal.add": "Add Context ",
|
||||
"promptEditor.context.modal.footer": "You can manage contexts in the Context section below.",
|
||||
"promptEditor.context.modal.title": "{{num}} Knowledge in Context",
|
||||
"promptEditor.existed": "Already exists in the prompt",
|
||||
"promptEditor.history.item.desc": "Insert historical message template",
|
||||
"promptEditor.history.item.title": "Conversation History",
|
||||
"promptEditor.history.modal.assistant": "Hello! How can I assist you today?",
|
||||
"promptEditor.history.modal.edit": "Edit Conversation Role Names",
|
||||
"promptEditor.history.modal.title": "EXAMPLE",
|
||||
"promptEditor.history.modal.user": "Hello",
|
||||
"promptEditor.placeholder": "Write your prompt word here, enter '{' to insert a variable, enter '/' to insert a prompt content block",
|
||||
"promptEditor.query.item.desc": "Insert user query template",
|
||||
"promptEditor.query.item.title": "Query",
|
||||
"promptEditor.requestURL.item.desc": "Insert request URL",
|
||||
"promptEditor.requestURL.item.title": "Request URL",
|
||||
"promptEditor.variable.item.desc": "Insert Variables & External Tools",
|
||||
"promptEditor.variable.item.title": "Variables & External Tools",
|
||||
"promptEditor.variable.modal.add": "New variable",
|
||||
"promptEditor.variable.modal.addTool": "New tool",
|
||||
"promptEditor.variable.outputToolDisabledItem.desc": "Insert Variables",
|
||||
"promptEditor.variable.outputToolDisabledItem.title": "Variables",
|
||||
"provider.addKey": "Add Key",
|
||||
"provider.anthropic.enableTip": "To enable the Anthropic model, you need to bind to OpenAI or Azure OpenAI Service first.",
|
||||
"provider.anthropic.keyFrom": "Get your API key from Anthropic",
|
||||
"provider.anthropic.notEnabled": "Not enabled",
|
||||
"provider.anthropic.using": "The embedding capability is using",
|
||||
"provider.anthropicHosted.anthropicHosted": "Anthropic Claude",
|
||||
"provider.anthropicHosted.callTimes": "Call times",
|
||||
"provider.anthropicHosted.close": "Close",
|
||||
"provider.anthropicHosted.desc": "Powerful model, which excels at a wide range of tasks from sophisticated dialogue and creative content generation to detailed instruction.",
|
||||
"provider.anthropicHosted.exhausted": "QUOTA EXHAUSTED",
|
||||
"provider.anthropicHosted.onTrial": "ON TRIAL",
|
||||
"provider.anthropicHosted.trialQuotaTip": "Your Anthropic trial quota will expire on 2025/03/17 and will no longer be available thereafter. Please make use of it in time.",
|
||||
"provider.anthropicHosted.useYourModel": "Currently using own Model Provider.",
|
||||
"provider.anthropicHosted.usedUp": "Trial quota used up. Add own Model Provider.",
|
||||
"provider.apiKey": "API Key",
|
||||
"provider.apiKeyExceedBill": "This API KEY has no quota available, please read",
|
||||
"provider.azure.apiBase": "API Base",
|
||||
"provider.azure.apiBasePlaceholder": "The API Base URL of your Azure OpenAI Endpoint.",
|
||||
"provider.azure.apiKey": "API Key",
|
||||
"provider.azure.apiKeyPlaceholder": "Enter your API key here",
|
||||
"provider.azure.helpTip": "Learn Azure OpenAI Service",
|
||||
"provider.comingSoon": "Coming Soon",
|
||||
"provider.editKey": "Edit",
|
||||
"provider.encrypted.back": " technology.",
|
||||
"provider.encrypted.front": "Your API KEY will be encrypted and stored using",
|
||||
"provider.enterYourKey": "Enter your API key here",
|
||||
"provider.invalidApiKey": "Invalid API key",
|
||||
"provider.invalidKey": "Invalid OpenAI API key",
|
||||
"provider.openaiHosted.callTimes": "Call times",
|
||||
"provider.openaiHosted.close": "Close",
|
||||
"provider.openaiHosted.desc": "The OpenAI hosting service provided by Dify allows you to use models such as GPT-3.5. Before your trial quota is used up, you need to set up other model providers.",
|
||||
"provider.openaiHosted.exhausted": "QUOTA EXHAUSTED",
|
||||
"provider.openaiHosted.onTrial": "ON TRIAL",
|
||||
"provider.openaiHosted.openaiHosted": "Hosted OpenAI",
|
||||
"provider.openaiHosted.useYourModel": "Currently using own Model Provider.",
|
||||
"provider.openaiHosted.usedUp": "Trial quota used up. Add own Model Provider.",
|
||||
"provider.saveFailed": "Save api key failed",
|
||||
"provider.validatedError": "Validation failed: ",
|
||||
"provider.validating": "Validating key...",
|
||||
"settings.account": "My account",
|
||||
"settings.accountGroup": "GENERAL",
|
||||
"settings.apiBasedExtension": "API Extension",
|
||||
"settings.billing": "Billing",
|
||||
"settings.dataSource": "Data Source",
|
||||
"settings.generalGroup": "GENERAL",
|
||||
"settings.integrations": "Integrations",
|
||||
"settings.language": "Language",
|
||||
"settings.members": "Members",
|
||||
"settings.plugin": "Plugins",
|
||||
"settings.provider": "Model Provider",
|
||||
"settings.workplaceGroup": "WORKSPACE",
|
||||
"tag.addNew": "Add new tag",
|
||||
"tag.addTag": "Add tags",
|
||||
"tag.create": "Create",
|
||||
"tag.created": "Tag created successfully",
|
||||
"tag.delete": "Delete tag",
|
||||
"tag.deleteTip": "The tag is being used, delete it?",
|
||||
"tag.editTag": "Edit tags",
|
||||
"tag.failed": "Tag creation failed",
|
||||
"tag.manageTags": "Manage Tags",
|
||||
"tag.noTag": "No tags",
|
||||
"tag.noTagYet": "No tags yet",
|
||||
"tag.placeholder": "All Tags",
|
||||
"tag.selectorPlaceholder": "Type to search or create",
|
||||
"theme.auto": "system",
|
||||
"theme.dark": "dark",
|
||||
"theme.light": "light",
|
||||
"theme.theme": "Theme",
|
||||
"unit.char": "chars",
|
||||
"userProfile.about": "About",
|
||||
"userProfile.community": "Community",
|
||||
"userProfile.compliance": "Compliance",
|
||||
"userProfile.contactUs": "Contact Us",
|
||||
"userProfile.createWorkspace": "Create Workspace",
|
||||
"userProfile.emailSupport": "Email Support",
|
||||
"userProfile.forum": "Forum",
|
||||
"userProfile.github": "GitHub",
|
||||
"userProfile.helpCenter": "View Docs",
|
||||
"userProfile.logout": "Log out",
|
||||
"userProfile.roadmap": "Roadmap",
|
||||
"userProfile.settings": "Settings",
|
||||
"userProfile.support": "Support",
|
||||
"userProfile.workspace": "Workspace",
|
||||
"voice.language.arTN": "Tunisian Arabic",
|
||||
"voice.language.deDE": "German",
|
||||
"voice.language.enUS": "English",
|
||||
"voice.language.esES": "Spanish",
|
||||
"voice.language.faIR": "Farsi",
|
||||
"voice.language.frFR": "French",
|
||||
"voice.language.hiIN": "Hindi",
|
||||
"voice.language.idID": "Indonesian",
|
||||
"voice.language.itIT": "Italian",
|
||||
"voice.language.jaJP": "Japanese",
|
||||
"voice.language.koKR": "Korean",
|
||||
"voice.language.plPL": "Polish",
|
||||
"voice.language.ptBR": "Portuguese",
|
||||
"voice.language.roRO": "Romanian",
|
||||
"voice.language.ruRU": "Russian",
|
||||
"voice.language.slSI": "Slovenian",
|
||||
"voice.language.thTH": "Thai",
|
||||
"voice.language.trTR": "Türkçe",
|
||||
"voice.language.ukUA": "Ukrainian",
|
||||
"voice.language.viVN": "Vietnamese",
|
||||
"voice.language.zhHans": "Chinese",
|
||||
"voice.language.zhHant": "Traditional Chinese",
|
||||
"voiceInput.converting": "Converting to text...",
|
||||
"voiceInput.notAllow": "microphone not authorized",
|
||||
"voiceInput.speaking": "Speak now...",
|
||||
"you": "You"
|
||||
}
|
||||
22
web/i18n/nl-NL/custom.json
Normal file
22
web/i18n/nl-NL/custom.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"app.changeLogoTip": "SVG or PNG format with a minimum size of 80x80px",
|
||||
"app.title": "Customize app header brand",
|
||||
"apply": "Apply",
|
||||
"change": "Change",
|
||||
"custom": "Customization",
|
||||
"customize.contactUs": " contact us ",
|
||||
"customize.prefix": "To customize the brand logo within the app, please",
|
||||
"customize.suffix": "to upgrade to the Enterprise edition.",
|
||||
"restore": "Restore Defaults",
|
||||
"upgradeTip.des": "Upgrade your plan to customize your brand",
|
||||
"upgradeTip.prefix": "Upgrade your plan to",
|
||||
"upgradeTip.suffix": "customize your brand.",
|
||||
"upgradeTip.title": "Upgrade your plan",
|
||||
"upload": "Upload",
|
||||
"uploadedFail": "Image upload failed, please re-upload.",
|
||||
"uploading": "Uploading",
|
||||
"webapp.changeLogo": "Change Powered by Brand Image",
|
||||
"webapp.changeLogoTip": "SVG or PNG format with a minimum size of 40x40px",
|
||||
"webapp.removeBrand": "Remove Powered by Dify",
|
||||
"webapp.title": "Customize web app brand"
|
||||
}
|
||||
185
web/i18n/nl-NL/dataset-creation.json
Normal file
185
web/i18n/nl-NL/dataset-creation.json
Normal file
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"error.unavailable": "This Knowledge is not available",
|
||||
"firecrawl.apiKeyPlaceholder": "API key from firecrawl.dev",
|
||||
"firecrawl.configFirecrawl": "Configure 🔥Firecrawl",
|
||||
"firecrawl.getApiKeyLinkText": "Get your API key from firecrawl.dev",
|
||||
"jinaReader.apiKeyPlaceholder": "API key from jina.ai",
|
||||
"jinaReader.configJinaReader": "Configure Jina Reader",
|
||||
"jinaReader.getApiKeyLinkText": "Get your free API key at jina.ai",
|
||||
"otherDataSource.description": "Currently, Dify's knowledge base only has limited data sources. Contributing a data source to the Dify knowledge base is a fantastic way to help enhance the platform's flexibility and power for all users. Our contribution guide makes it easy to get started. Please click on the link below to learn more.",
|
||||
"otherDataSource.learnMore": "Learn more",
|
||||
"otherDataSource.title": "Connect to other data sources?",
|
||||
"stepOne.button": "Next",
|
||||
"stepOne.cancel": "Cancel",
|
||||
"stepOne.connect": "Go to connect",
|
||||
"stepOne.dataSourceType.file": "Import from file",
|
||||
"stepOne.dataSourceType.notion": "Sync from Notion",
|
||||
"stepOne.dataSourceType.web": "Sync from website",
|
||||
"stepOne.emptyDatasetCreation": "I want to create an empty Knowledge",
|
||||
"stepOne.filePreview": "File Preview",
|
||||
"stepOne.modal.cancelButton": "Cancel",
|
||||
"stepOne.modal.confirmButton": "Create",
|
||||
"stepOne.modal.failed": "Creation failed",
|
||||
"stepOne.modal.input": "Knowledge name",
|
||||
"stepOne.modal.nameLengthInvalid": "Name must be between 1 to 40 characters",
|
||||
"stepOne.modal.nameNotEmpty": "Name cannot be empty",
|
||||
"stepOne.modal.placeholder": "Please input",
|
||||
"stepOne.modal.tip": "An empty Knowledge will contain no documents, and you can upload documents any time.",
|
||||
"stepOne.modal.title": "Create an empty Knowledge",
|
||||
"stepOne.notionSyncTip": "To sync with Notion, connection to Notion must be established first.",
|
||||
"stepOne.notionSyncTitle": "Notion is not connected",
|
||||
"stepOne.pagePreview": "Page Preview",
|
||||
"stepOne.uploader.browse": "Browse",
|
||||
"stepOne.uploader.button": "Drag and drop file or folder, or",
|
||||
"stepOne.uploader.buttonSingleFile": "Drag and drop file, or",
|
||||
"stepOne.uploader.cancel": "Cancel",
|
||||
"stepOne.uploader.change": "Change",
|
||||
"stepOne.uploader.failed": "Upload failed",
|
||||
"stepOne.uploader.tip": "Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each. Max total {{totalCount}} files.",
|
||||
"stepOne.uploader.title": "Upload file",
|
||||
"stepOne.uploader.validation.count": "Multiple files not supported",
|
||||
"stepOne.uploader.validation.filesNumber": "You have reached the batch upload limit of {{filesNumber}}.",
|
||||
"stepOne.uploader.validation.size": "File too large. Maximum is {{size}}MB",
|
||||
"stepOne.uploader.validation.typeError": "File type not supported",
|
||||
"stepOne.website.chooseProvider": "Select a provider",
|
||||
"stepOne.website.configure": "Configure",
|
||||
"stepOne.website.configureFirecrawl": "Configure Firecrawl",
|
||||
"stepOne.website.configureJinaReader": "Configure Jina Reader",
|
||||
"stepOne.website.configureWatercrawl": "Configure Watercrawl",
|
||||
"stepOne.website.crawlSubPage": "Crawl sub-pages",
|
||||
"stepOne.website.exceptionErrorTitle": "An exception occurred while running crawling job:",
|
||||
"stepOne.website.excludePaths": "Exclude paths",
|
||||
"stepOne.website.extractOnlyMainContent": "Extract only main content (no headers, navs, footers, etc.)",
|
||||
"stepOne.website.fireCrawlNotConfigured": "Firecrawl is not configured",
|
||||
"stepOne.website.fireCrawlNotConfiguredDescription": "Configure Firecrawl with API key to use it.",
|
||||
"stepOne.website.firecrawlDoc": "Firecrawl docs",
|
||||
"stepOne.website.firecrawlTitle": "Extract web content with 🔥Firecrawl",
|
||||
"stepOne.website.includeOnlyPaths": "Include only paths",
|
||||
"stepOne.website.jinaReaderDoc": "Learn more about Jina Reader",
|
||||
"stepOne.website.jinaReaderDocLink": "https://jina.ai/reader",
|
||||
"stepOne.website.jinaReaderNotConfigured": "Jina Reader is not configured",
|
||||
"stepOne.website.jinaReaderNotConfiguredDescription": "Set up Jina Reader by entering your free API key for access.",
|
||||
"stepOne.website.jinaReaderTitle": "Convert the entire site to Markdown",
|
||||
"stepOne.website.limit": "Limit",
|
||||
"stepOne.website.maxDepth": "Max depth",
|
||||
"stepOne.website.maxDepthTooltip": "Maximum depth to crawl relative to the entered URL. Depth 0 just scrapes the page of the entered url, depth 1 scrapes the url and everything after enteredURL + one /, and so on.",
|
||||
"stepOne.website.options": "Options",
|
||||
"stepOne.website.preview": "Preview",
|
||||
"stepOne.website.resetAll": "Reset All",
|
||||
"stepOne.website.run": "Run",
|
||||
"stepOne.website.running": "Running",
|
||||
"stepOne.website.scrapTimeInfo": "Scraped {{total}} pages in total within {{time}}s",
|
||||
"stepOne.website.selectAll": "Select All",
|
||||
"stepOne.website.totalPageScraped": "Total pages scraped:",
|
||||
"stepOne.website.unknownError": "Unknown error",
|
||||
"stepOne.website.useSitemap": "Use sitemap",
|
||||
"stepOne.website.useSitemapTooltip": "Follow the sitemap to crawl the site. If not, Jina Reader will crawl iteratively based on page relevance, yielding fewer but higher-quality pages.",
|
||||
"stepOne.website.waterCrawlNotConfigured": "Watercrawl is not configured",
|
||||
"stepOne.website.waterCrawlNotConfiguredDescription": "Configure Watercrawl with API key to use it.",
|
||||
"stepOne.website.watercrawlDoc": "Watercrawl docs",
|
||||
"stepOne.website.watercrawlTitle": "Extract web content with Watercrawl",
|
||||
"stepThree.additionP1": "The document has been uploaded to the Knowledge",
|
||||
"stepThree.additionP2": ", you can find it in the document list of the Knowledge.",
|
||||
"stepThree.additionTitle": "🎉 Document uploaded",
|
||||
"stepThree.creationContent": "We automatically named the Knowledge, you can modify it at any time.",
|
||||
"stepThree.creationTitle": "🎉 Knowledge created",
|
||||
"stepThree.label": "Knowledge name",
|
||||
"stepThree.modelButtonCancel": "Cancel",
|
||||
"stepThree.modelButtonConfirm": "Confirm",
|
||||
"stepThree.modelContent": "If you need to resume processing later, you will continue from where you left off.",
|
||||
"stepThree.modelTitle": "Are you sure to stop embedding?",
|
||||
"stepThree.navTo": "Go to document",
|
||||
"stepThree.resume": "Resume processing",
|
||||
"stepThree.sideTipContent": "After finishing document indexing, you can manage and edit documents, run retrieval tests, and modify knowledge settings. Knowledge can then be integrated into your application as context, so make sure to adjust the Retrieval Setting to ensure optimal performance.",
|
||||
"stepThree.sideTipTitle": "What's next",
|
||||
"stepThree.stop": "Stop processing",
|
||||
"stepTwo.QALanguage": "Segment using",
|
||||
"stepTwo.QATip": "Enable this option will consume more tokens",
|
||||
"stepTwo.QATitle": "Segmenting in Question & Answer format",
|
||||
"stepTwo.auto": "Automatic",
|
||||
"stepTwo.autoDescription": "Automatically set chunk and preprocessing rules. Unfamiliar users are recommended to select this.",
|
||||
"stepTwo.calculating": "Calculating...",
|
||||
"stepTwo.cancel": "Cancel",
|
||||
"stepTwo.characters": "characters",
|
||||
"stepTwo.childChunkForRetrieval": "Child-chunk for Retrieval",
|
||||
"stepTwo.click": "Go to settings",
|
||||
"stepTwo.custom": "Custom",
|
||||
"stepTwo.customDescription": "Customize chunks rules, chunks length, and preprocessing rules, etc.",
|
||||
"stepTwo.datasetSettingLink": "Knowledge settings.",
|
||||
"stepTwo.economical": "Economical",
|
||||
"stepTwo.economicalTip": "Using 10 keywords per chunk for retrieval, no tokens are consumed at the expense of reduced retrieval accuracy.",
|
||||
"stepTwo.estimateCost": "Estimation",
|
||||
"stepTwo.estimateSegment": "Estimated chunks",
|
||||
"stepTwo.fileSource": "Preprocess documents",
|
||||
"stepTwo.fileUnit": " files",
|
||||
"stepTwo.fullDoc": "Full Doc",
|
||||
"stepTwo.fullDocTip": "The entire document is used as the parent chunk and retrieved directly. Please note that for performance reasons, text exceeding 10000 tokens will be automatically truncated.",
|
||||
"stepTwo.general": "General",
|
||||
"stepTwo.generalTip": "General text chunking mode, the chunks retrieved and recalled are the same.",
|
||||
"stepTwo.highQualityTip": "Once finishing embedding in High Quality mode, reverting to Economical mode is not available.",
|
||||
"stepTwo.indexMode": "Index Method",
|
||||
"stepTwo.indexSettingTip": "To change the index method & embedding model, please go to the ",
|
||||
"stepTwo.maxLength": "Maximum chunk length",
|
||||
"stepTwo.maxLengthCheck": "Maximum chunk length should be less than {{limit}}",
|
||||
"stepTwo.nextStep": "Save & Process",
|
||||
"stepTwo.notAvailableForParentChild": "Not available for Parent-child Index",
|
||||
"stepTwo.notAvailableForQA": "Not available for Q&A Index",
|
||||
"stepTwo.notionSource": "Preprocess pages",
|
||||
"stepTwo.notionUnit": " pages",
|
||||
"stepTwo.other": "and other ",
|
||||
"stepTwo.overlap": "Chunk overlap",
|
||||
"stepTwo.overlapCheck": "chunk overlap should not bigger than maximum chunk length",
|
||||
"stepTwo.overlapTip": "Setting the chunk overlap can maintain the semantic relevance between them, enhancing the retrieve effect. It is recommended to set 10%-25% of the maximum chunk size.",
|
||||
"stepTwo.paragraph": "Paragraph",
|
||||
"stepTwo.paragraphTip": "This mode splits the text in to paragraphs based on delimiters and the maximum chunk length, using the split text as the parent chunk for retrieval.",
|
||||
"stepTwo.parentChild": "Parent-child",
|
||||
"stepTwo.parentChildChunkDelimiterTip": "A delimiter is the character used to separate text. \\n is recommended for splitting parent chunks into small child chunks. You can also use special delimiters defined by yourself.",
|
||||
"stepTwo.parentChildDelimiterTip": "A delimiter is the character used to separate text. \\n\\n is recommended for splitting the original document into large parent chunks. You can also use special delimiters defined by yourself.",
|
||||
"stepTwo.parentChildTip": "When using the parent-child mode, the child-chunk is used for retrieval and the parent-chunk is used for recall as context.",
|
||||
"stepTwo.parentChunkForContext": "Parent-chunk for Context",
|
||||
"stepTwo.preview": "Preview",
|
||||
"stepTwo.previewButton": "Switching to Q&A format",
|
||||
"stepTwo.previewChunk": "Preview Chunk",
|
||||
"stepTwo.previewChunkCount": "{{count}} Estimated chunks",
|
||||
"stepTwo.previewChunkTip": "Click the 'Preview Chunk' button on the left to load the preview",
|
||||
"stepTwo.previewSwitchTipEnd": " consume additional tokens",
|
||||
"stepTwo.previewSwitchTipStart": "The current chunk preview is in text format, switching to a question-and-answer format preview will",
|
||||
"stepTwo.previewTitle": "Preview",
|
||||
"stepTwo.previewTitleButton": "Preview",
|
||||
"stepTwo.previousStep": "Previous step",
|
||||
"stepTwo.qaSwitchHighQualityTipContent": "Currently, only high-quality index method supports Q&A format chunking. Would you like to switch to high-quality mode?",
|
||||
"stepTwo.qaSwitchHighQualityTipTitle": "Q&A Format Requires High-quality Indexing Method",
|
||||
"stepTwo.qaTip": "When using structured Q&A data, you can create documents that pair questions with answers. These documents are indexed based on the question portion, allowing the system to retrieve relevant answers based on query similarity.",
|
||||
"stepTwo.qualified": "High Quality",
|
||||
"stepTwo.qualifiedTip": "Calling the embedding model to process documents for more precise retrieval helps LLM generate high-quality answers.",
|
||||
"stepTwo.recommend": "Recommend",
|
||||
"stepTwo.removeExtraSpaces": "Replace consecutive spaces, newlines and tabs",
|
||||
"stepTwo.removeStopwords": "Remove stopwords such as \"a\", \"an\", \"the\"",
|
||||
"stepTwo.removeUrlEmails": "Delete all URLs and email addresses",
|
||||
"stepTwo.reset": "Reset",
|
||||
"stepTwo.retrievalSettingTip": "To change the retrieval setting, please go to the ",
|
||||
"stepTwo.rules": "Text Pre-processing Rules",
|
||||
"stepTwo.save": "Save & Process",
|
||||
"stepTwo.segmentCount": "chunks",
|
||||
"stepTwo.segmentation": "Chunk Settings",
|
||||
"stepTwo.separator": "Delimiter",
|
||||
"stepTwo.separatorPlaceholder": "\\n\\n for paragraphs; \\n for lines",
|
||||
"stepTwo.separatorTip": "A delimiter is the character used to separate text. \\n\\n and \\n are commonly used delimiters for separating paragraphs and lines. Combined with commas (\\n\\n,\\n), paragraphs will be segmented by lines when exceeding the maximum chunk length. You can also use special delimiters defined by yourself (e.g. ***).",
|
||||
"stepTwo.sideTipP1": "When processing text data, chunk and cleaning are two important preprocessing steps.",
|
||||
"stepTwo.sideTipP2": "Segmentation splits long text into paragraphs so models can understand better. This improves the quality and relevance of model results.",
|
||||
"stepTwo.sideTipP3": "Cleaning removes unnecessary characters and formats, making Knowledge cleaner and easier to parse.",
|
||||
"stepTwo.sideTipP4": "Proper chunk and cleaning improve model performance, providing more accurate and valuable results.",
|
||||
"stepTwo.sideTipTitle": "Why chunk and preprocess?",
|
||||
"stepTwo.switch": "Switch",
|
||||
"stepTwo.useQALanguage": "Chunk using Q&A format in",
|
||||
"stepTwo.warning": "Please set up the model provider API key first.",
|
||||
"stepTwo.webpageUnit": " pages",
|
||||
"stepTwo.websiteSource": "Preprocess website",
|
||||
"steps.header.fallbackRoute": "Knowledge",
|
||||
"steps.one": "Data Source",
|
||||
"steps.three": "Execute & Finish",
|
||||
"steps.two": "Document Processing",
|
||||
"watercrawl.apiKeyPlaceholder": "API key from watercrawl.dev",
|
||||
"watercrawl.configWatercrawl": "Configure Watercrawl",
|
||||
"watercrawl.getApiKeyLinkText": "Get your API key from watercrawl.dev"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user