diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py index f1a15793c7..15f93d2774 100644 --- a/api/controllers/files/upload.py +++ b/api/controllers/files/upload.py @@ -87,7 +87,5 @@ class PluginUploadFileApi(Resource): except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError() - return tool_file, 201 - api.add_resource(PluginUploadFileApi, "/files/upload/for-plugin") diff --git a/api/core/helper/code_executor/template_transformer.py b/api/core/helper/code_executor/template_transformer.py index baa792b5bc..84f212a9c1 100644 --- a/api/core/helper/code_executor/template_transformer.py +++ b/api/core/helper/code_executor/template_transformer.py @@ -28,7 +28,7 @@ class TemplateTransformer(ABC): def extract_result_str_from_response(cls, response: str): result = re.search(rf"{cls._result_tag}(.*){cls._result_tag}", response, re.DOTALL) if not result: - raise ValueError("Failed to parse result") + raise ValueError(f"Failed to parse result: no result tag found in response. Response: {response[:200]}...") return result.group(1) @classmethod @@ -38,16 +38,53 @@ class TemplateTransformer(ABC): :param response: response :return: """ + try: - result = json.loads(cls.extract_result_str_from_response(response)) - except json.JSONDecodeError: - raise ValueError("failed to parse response") + result_str = cls.extract_result_str_from_response(response) + result = json.loads(result_str) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to parse JSON response: {str(e)}. Response content: {result_str[:200]}...") + except ValueError as e: + # Re-raise ValueError from extract_result_str_from_response + raise e + except Exception as e: + raise ValueError(f"Unexpected error during response transformation: {str(e)}") + + # Check if the result contains an error + if isinstance(result, dict) and "error" in result: + raise ValueError(f"JavaScript execution error: {result['error']}") + if not isinstance(result, dict): - raise ValueError("result must be a dict") + raise ValueError(f"Result must be a dict, got {type(result).__name__}") if not all(isinstance(k, str) for k in result): - raise ValueError("result keys must be strings") + raise ValueError("Result keys must be strings") + + # Post-process the result to convert scientific notation strings back to numbers + result = cls._post_process_result(result) return result + @classmethod + def _post_process_result(cls, result: dict[Any, Any]) -> dict[Any, Any]: + """ + Post-process the result to convert scientific notation strings back to numbers + """ + + def convert_scientific_notation(value): + if isinstance(value, str): + # Check if the string looks like scientific notation + if re.match(r"^-?\d+\.?\d*e[+-]\d+$", value, re.IGNORECASE): + try: + return float(value) + except ValueError: + pass + elif isinstance(value, dict): + return {k: convert_scientific_notation(v) for k, v in value.items()} + elif isinstance(value, list): + return [convert_scientific_notation(v) for v in value] + return value + + return convert_scientific_notation(result) # type: ignore[no-any-return] + @classmethod @abstractmethod def get_runner_script(cls) -> str: diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index f2fe306179..305a9190d5 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -317,9 +317,10 @@ class IndexingRunner: image_upload_file_ids = get_image_upload_file_ids(document.page_content) for upload_file_id in image_upload_file_ids: image_file = db.session.query(UploadFile).filter(UploadFile.id == upload_file_id).first() + if image_file is None: + continue try: - if image_file: - storage.delete(image_file.key) + storage.delete(image_file.key) except Exception: logging.exception( "Delete image_files failed while indexing_estimate, \ diff --git a/api/core/ops/aliyun_trace/__init__.py b/api/core/ops/aliyun_trace/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/aliyun_trace/aliyun_trace.py b/api/core/ops/aliyun_trace/aliyun_trace.py new file mode 100644 index 0000000000..163b5d0307 --- /dev/null +++ b/api/core/ops/aliyun_trace/aliyun_trace.py @@ -0,0 +1,486 @@ +import json +import logging +from collections.abc import Sequence +from typing import Optional +from urllib.parse import urljoin + +from opentelemetry.trace import Status, StatusCode +from sqlalchemy.orm import Session, sessionmaker + +from core.ops.aliyun_trace.data_exporter.traceclient import ( + TraceClient, + convert_datetime_to_nanoseconds, + convert_to_span_id, + convert_to_trace_id, + generate_span_id, +) +from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData +from core.ops.aliyun_trace.entities.semconv import ( + GEN_AI_COMPLETION, + GEN_AI_FRAMEWORK, + GEN_AI_MODEL_NAME, + GEN_AI_PROMPT, + GEN_AI_PROMPT_TEMPLATE_TEMPLATE, + GEN_AI_PROMPT_TEMPLATE_VARIABLE, + GEN_AI_RESPONSE_FINISH_REASON, + GEN_AI_SESSION_ID, + GEN_AI_SPAN_KIND, + GEN_AI_SYSTEM, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, + GEN_AI_USER_ID, + INPUT_VALUE, + OUTPUT_VALUE, + RETRIEVAL_DOCUMENT, + RETRIEVAL_QUERY, + TOOL_DESCRIPTION, + TOOL_NAME, + TOOL_PARAMETERS, + GenAISpanKind, +) +from core.ops.base_trace_instance import BaseTraceInstance +from core.ops.entities.config_entity import AliyunConfig +from core.ops.entities.trace_entity import ( + BaseTraceInfo, + DatasetRetrievalTraceInfo, + GenerateNameTraceInfo, + MessageTraceInfo, + ModerationTraceInfo, + SuggestedQuestionTraceInfo, + ToolTraceInfo, + WorkflowTraceInfo, +) +from core.rag.models.document import Document +from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository +from core.workflow.entities.workflow_node_execution import ( + WorkflowNodeExecution, + WorkflowNodeExecutionMetadataKey, + WorkflowNodeExecutionStatus, +) +from core.workflow.nodes import NodeType +from models import Account, App, EndUser, TenantAccountJoin, WorkflowNodeExecutionTriggeredFrom, db + +logger = logging.getLogger(__name__) + + +class AliyunDataTrace(BaseTraceInstance): + def __init__( + self, + aliyun_config: AliyunConfig, + ): + super().__init__(aliyun_config) + base_url = aliyun_config.endpoint.rstrip("/") + endpoint = urljoin(base_url, f"adapt_{aliyun_config.license_key}/api/otlp/traces") + self.trace_client = TraceClient(service_name=aliyun_config.app_name, endpoint=endpoint) + + def trace(self, trace_info: BaseTraceInfo): + if isinstance(trace_info, WorkflowTraceInfo): + self.workflow_trace(trace_info) + if isinstance(trace_info, MessageTraceInfo): + self.message_trace(trace_info) + if isinstance(trace_info, ModerationTraceInfo): + pass + if isinstance(trace_info, SuggestedQuestionTraceInfo): + self.suggested_question_trace(trace_info) + if isinstance(trace_info, DatasetRetrievalTraceInfo): + self.dataset_retrieval_trace(trace_info) + if isinstance(trace_info, ToolTraceInfo): + self.tool_trace(trace_info) + if isinstance(trace_info, GenerateNameTraceInfo): + pass + + def api_check(self): + return self.trace_client.api_check() + + def get_project_url(self): + try: + return self.trace_client.get_project_url() + except Exception as e: + logger.info(f"Aliyun get run url failed: {str(e)}", exc_info=True) + raise ValueError(f"Aliyun get run url failed: {str(e)}") + + def workflow_trace(self, trace_info: WorkflowTraceInfo): + trace_id = convert_to_trace_id(trace_info.workflow_run_id) + workflow_span_id = convert_to_span_id(trace_info.workflow_run_id, "workflow") + self.add_workflow_span(trace_id, workflow_span_id, trace_info) + + workflow_node_executions = self.get_workflow_node_executions(trace_info) + for node_execution in workflow_node_executions: + node_span = self.build_workflow_node_span(node_execution, trace_id, trace_info, workflow_span_id) + self.trace_client.add_span(node_span) + + def message_trace(self, trace_info: MessageTraceInfo): + message_data = trace_info.message_data + if message_data is None: + return + message_id = trace_info.message_id + + user_id = message_data.from_account_id + if message_data.from_end_user_id: + end_user_data: Optional[EndUser] = ( + db.session.query(EndUser).filter(EndUser.id == message_data.from_end_user_id).first() + ) + if end_user_data is not None: + user_id = end_user_data.session_id + + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + + trace_id = convert_to_trace_id(message_id) + message_span_id = convert_to_span_id(message_id, "message") + message_span = SpanData( + trace_id=trace_id, + parent_span_id=None, + span_id=message_span_id, + name="message", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: str(trace_info.outputs), + }, + status=status, + ) + self.trace_client.add_span(message_span) + + app_model_config = getattr(trace_info.message_data, "app_model_config", {}) + pre_prompt = getattr(app_model_config, "pre_prompt", "") + inputs_data = getattr(trace_info.message_data, "inputs", {}) + llm_span = SpanData( + trace_id=trace_id, + parent_span_id=message_span_id, + span_id=convert_to_span_id(message_id, "llm"), + name="llm", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value, + GEN_AI_FRAMEWORK: "dify", + GEN_AI_MODEL_NAME: trace_info.metadata.get("ls_model_name", ""), + GEN_AI_SYSTEM: trace_info.metadata.get("ls_provider", ""), + GEN_AI_USAGE_INPUT_TOKENS: str(trace_info.message_tokens), + GEN_AI_USAGE_OUTPUT_TOKENS: str(trace_info.answer_tokens), + GEN_AI_USAGE_TOTAL_TOKENS: str(trace_info.total_tokens), + GEN_AI_PROMPT_TEMPLATE_VARIABLE: json.dumps(inputs_data, ensure_ascii=False), + GEN_AI_PROMPT_TEMPLATE_TEMPLATE: pre_prompt, + GEN_AI_PROMPT: json.dumps(trace_info.inputs, ensure_ascii=False), + GEN_AI_COMPLETION: str(trace_info.outputs), + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: str(trace_info.outputs), + }, + status=status, + ) + self.trace_client.add_span(llm_span) + + def dataset_retrieval_trace(self, trace_info: DatasetRetrievalTraceInfo): + if trace_info.message_data is None: + return + message_id = trace_info.message_id + + documents_data = extract_retrieval_documents(trace_info.documents) + dataset_retrieval_span = SpanData( + trace_id=convert_to_trace_id(message_id), + parent_span_id=convert_to_span_id(message_id, "message"), + span_id=generate_span_id(), + name="dataset_retrieval", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.RETRIEVER.value, + GEN_AI_FRAMEWORK: "dify", + RETRIEVAL_QUERY: str(trace_info.inputs), + RETRIEVAL_DOCUMENT: json.dumps(documents_data, ensure_ascii=False), + INPUT_VALUE: str(trace_info.inputs), + OUTPUT_VALUE: json.dumps(documents_data, ensure_ascii=False), + }, + ) + self.trace_client.add_span(dataset_retrieval_span) + + def tool_trace(self, trace_info: ToolTraceInfo): + if trace_info.message_data is None: + return + message_id = trace_info.message_id + + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + + tool_span = SpanData( + trace_id=convert_to_trace_id(message_id), + parent_span_id=convert_to_span_id(message_id, "message"), + span_id=generate_span_id(), + name=trace_info.tool_name, + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.TOOL.value, + GEN_AI_FRAMEWORK: "dify", + TOOL_NAME: trace_info.tool_name, + TOOL_DESCRIPTION: json.dumps(trace_info.tool_config, ensure_ascii=False), + TOOL_PARAMETERS: json.dumps(trace_info.tool_inputs, ensure_ascii=False), + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: str(trace_info.tool_outputs), + }, + status=status, + ) + self.trace_client.add_span(tool_span) + + def get_workflow_node_executions(self, trace_info: WorkflowTraceInfo) -> Sequence[WorkflowNodeExecution]: + # through workflow_run_id get all_nodes_execution using repository + session_factory = sessionmaker(bind=db.engine) + # Find the app's creator account + with Session(db.engine, expire_on_commit=False) as session: + # Get the app to find its creator + app_id = trace_info.metadata.get("app_id") + if not app_id: + raise ValueError("No app_id found in trace_info metadata") + + app = session.query(App).filter(App.id == app_id).first() + if not app: + raise ValueError(f"App with id {app_id} not found") + + if not app.created_by: + raise ValueError(f"App with id {app_id} has no creator (created_by is None)") + + service_account = session.query(Account).filter(Account.id == app.created_by).first() + if not service_account: + raise ValueError(f"Creator account with id {app.created_by} not found for app {app_id}") + current_tenant = ( + session.query(TenantAccountJoin).filter_by(account_id=service_account.id, current=True).first() + ) + if not current_tenant: + raise ValueError(f"Current tenant not found for account {service_account.id}") + service_account.set_tenant_id(current_tenant.tenant_id) + workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository( + session_factory=session_factory, + user=service_account, + app_id=trace_info.metadata.get("app_id"), + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, + ) + # Get all executions for this workflow run + workflow_node_executions = workflow_node_execution_repository.get_by_workflow_run( + workflow_run_id=trace_info.workflow_run_id + ) + return workflow_node_executions + + def build_workflow_node_span( + self, node_execution: WorkflowNodeExecution, trace_id: int, trace_info: WorkflowTraceInfo, workflow_span_id: int + ): + try: + if node_execution.node_type == NodeType.LLM: + node_span = self.build_workflow_llm_span(trace_id, workflow_span_id, trace_info, node_execution) + elif node_execution.node_type == NodeType.KNOWLEDGE_RETRIEVAL: + node_span = self.build_workflow_retrieval_span(trace_id, workflow_span_id, trace_info, node_execution) + elif node_execution.node_type == NodeType.TOOL: + node_span = self.build_workflow_tool_span(trace_id, workflow_span_id, trace_info, node_execution) + else: + node_span = self.build_workflow_task_span(trace_id, workflow_span_id, trace_info, node_execution) + return node_span + except Exception: + return None + + def get_workflow_node_status(self, node_execution: WorkflowNodeExecution) -> Status: + span_status: Status = Status(StatusCode.UNSET) + if node_execution.status == WorkflowNodeExecutionStatus.SUCCEEDED: + span_status = Status(StatusCode.OK) + elif node_execution.status in [WorkflowNodeExecutionStatus.FAILED, WorkflowNodeExecutionStatus.EXCEPTION]: + span_status = Status(StatusCode.ERROR, str(node_execution.error)) + return span_status + + def build_workflow_task_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_SPAN_KIND: GenAISpanKind.TASK.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: json.dumps(node_execution.inputs, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(node_execution.outputs, ensure_ascii=False), + }, + status=self.get_workflow_node_status(node_execution), + ) + + def build_workflow_tool_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + tool_des = {} + if node_execution.metadata: + tool_des = node_execution.metadata.get(WorkflowNodeExecutionMetadataKey.TOOL_INFO, {}) + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.TOOL.value, + GEN_AI_FRAMEWORK: "dify", + TOOL_NAME: node_execution.title, + TOOL_DESCRIPTION: json.dumps(tool_des, ensure_ascii=False), + TOOL_PARAMETERS: json.dumps(node_execution.inputs if node_execution.inputs else {}, ensure_ascii=False), + INPUT_VALUE: json.dumps(node_execution.inputs if node_execution.inputs else {}, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(node_execution.outputs, ensure_ascii=False), + }, + status=self.get_workflow_node_status(node_execution), + ) + + def build_workflow_retrieval_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + input_value = "" + if node_execution.inputs: + input_value = str(node_execution.inputs.get("query", "")) + output_value = "" + if node_execution.outputs: + output_value = json.dumps(node_execution.outputs.get("result", []), ensure_ascii=False) + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.RETRIEVER.value, + GEN_AI_FRAMEWORK: "dify", + RETRIEVAL_QUERY: input_value, + RETRIEVAL_DOCUMENT: output_value, + INPUT_VALUE: input_value, + OUTPUT_VALUE: output_value, + }, + status=self.get_workflow_node_status(node_execution), + ) + + def build_workflow_llm_span( + self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution + ) -> SpanData: + process_data = node_execution.process_data or {} + outputs = node_execution.outputs or {} + return SpanData( + trace_id=trace_id, + parent_span_id=workflow_span_id, + span_id=convert_to_span_id(node_execution.id, "node"), + name=node_execution.title, + start_time=convert_datetime_to_nanoseconds(node_execution.created_at), + end_time=convert_datetime_to_nanoseconds(node_execution.finished_at), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value, + GEN_AI_FRAMEWORK: "dify", + GEN_AI_MODEL_NAME: process_data.get("model_name", ""), + GEN_AI_SYSTEM: process_data.get("model_provider", ""), + GEN_AI_USAGE_INPUT_TOKENS: str(outputs.get("usage", {}).get("prompt_tokens", 0)), + GEN_AI_USAGE_OUTPUT_TOKENS: str(outputs.get("usage", {}).get("completion_tokens", 0)), + GEN_AI_USAGE_TOTAL_TOKENS: str(outputs.get("usage", {}).get("total_tokens", 0)), + GEN_AI_PROMPT: json.dumps(process_data.get("prompts", []), ensure_ascii=False), + GEN_AI_COMPLETION: str(outputs.get("text", "")), + GEN_AI_RESPONSE_FINISH_REASON: outputs.get("finish_reason", ""), + INPUT_VALUE: json.dumps(process_data.get("prompts", []), ensure_ascii=False), + OUTPUT_VALUE: str(outputs.get("text", "")), + }, + status=self.get_workflow_node_status(node_execution), + ) + + def add_workflow_span(self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo): + message_span_id = None + if trace_info.message_id: + message_span_id = convert_to_span_id(trace_info.message_id, "message") + user_id = trace_info.metadata.get("user_id") + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + if message_span_id: # chatflow + message_span = SpanData( + trace_id=trace_id, + parent_span_id=None, + span_id=message_span_id, + name="message", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""), + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: trace_info.workflow_run_inputs.get("sys.query", ""), + OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False), + }, + status=status, + ) + self.trace_client.add_span(message_span) + + workflow_span = SpanData( + trace_id=trace_id, + parent_span_id=message_span_id, + span_id=workflow_span_id, + name="workflow", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_USER_ID: str(user_id), + GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value, + GEN_AI_FRAMEWORK: "dify", + INPUT_VALUE: json.dumps(trace_info.workflow_run_inputs, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False), + }, + status=status, + ) + self.trace_client.add_span(workflow_span) + + def suggested_question_trace(self, trace_info: SuggestedQuestionTraceInfo): + message_id = trace_info.message_id + status: Status = Status(StatusCode.OK) + if trace_info.error: + status = Status(StatusCode.ERROR, trace_info.error) + suggested_question_span = SpanData( + trace_id=convert_to_trace_id(message_id), + parent_span_id=convert_to_span_id(message_id, "message"), + span_id=convert_to_span_id(message_id, "suggested_question"), + name="suggested_question", + start_time=convert_datetime_to_nanoseconds(trace_info.start_time), + end_time=convert_datetime_to_nanoseconds(trace_info.end_time), + attributes={ + GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value, + GEN_AI_FRAMEWORK: "dify", + GEN_AI_MODEL_NAME: trace_info.metadata.get("ls_model_name", ""), + GEN_AI_SYSTEM: trace_info.metadata.get("ls_provider", ""), + GEN_AI_PROMPT: json.dumps(trace_info.inputs, ensure_ascii=False), + GEN_AI_COMPLETION: json.dumps(trace_info.suggested_question, ensure_ascii=False), + INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False), + OUTPUT_VALUE: json.dumps(trace_info.suggested_question, ensure_ascii=False), + }, + status=status, + ) + self.trace_client.add_span(suggested_question_span) + + +def extract_retrieval_documents(documents: list[Document]): + documents_data = [] + for document in documents: + document_data = { + "content": document.page_content, + "metadata": { + "dataset_id": document.metadata.get("dataset_id"), + "doc_id": document.metadata.get("doc_id"), + "document_id": document.metadata.get("document_id"), + }, + "score": document.metadata.get("score"), + } + documents_data.append(document_data) + return documents_data diff --git a/api/core/ops/aliyun_trace/data_exporter/__init__.py b/api/core/ops/aliyun_trace/data_exporter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/aliyun_trace/data_exporter/traceclient.py b/api/core/ops/aliyun_trace/data_exporter/traceclient.py new file mode 100644 index 0000000000..ba5ac3f420 --- /dev/null +++ b/api/core/ops/aliyun_trace/data_exporter/traceclient.py @@ -0,0 +1,200 @@ +import hashlib +import logging +import random +import socket +import threading +import uuid +from collections import deque +from collections.abc import Sequence +from datetime import datetime +from typing import Optional + +import requests +from opentelemetry import trace as trace_api +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.semconv.resource import ResourceAttributes + +from configs import dify_config +from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData + +INVALID_SPAN_ID = 0x0000000000000000 +INVALID_TRACE_ID = 0x00000000000000000000000000000000 + +logger = logging.getLogger(__name__) + + +class TraceClient: + def __init__( + self, + service_name: str, + endpoint: str, + max_queue_size: int = 1000, + schedule_delay_sec: int = 5, + max_export_batch_size: int = 50, + ): + self.endpoint = endpoint + self.resource = Resource( + attributes={ + ResourceAttributes.SERVICE_NAME: service_name, + ResourceAttributes.SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}", + ResourceAttributes.DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}", + ResourceAttributes.HOST_NAME: socket.gethostname(), + } + ) + self.span_builder = SpanBuilder(self.resource) + self.exporter = OTLPSpanExporter(endpoint=endpoint) + + self.max_queue_size = max_queue_size + self.schedule_delay_sec = schedule_delay_sec + self.max_export_batch_size = max_export_batch_size + + self.queue: deque = deque(maxlen=max_queue_size) + self.condition = threading.Condition(threading.Lock()) + self.done = False + + self.worker_thread = threading.Thread(target=self._worker, daemon=True) + self.worker_thread.start() + + self._spans_dropped = False + + def export(self, spans: Sequence[ReadableSpan]): + self.exporter.export(spans) + + def api_check(self): + try: + response = requests.head(self.endpoint, timeout=5) + if response.status_code == 405: + return True + else: + logger.debug(f"AliyunTrace API check failed: Unexpected status code: {response.status_code}") + return False + except requests.exceptions.RequestException as e: + logger.debug(f"AliyunTrace API check failed: {str(e)}") + raise ValueError(f"AliyunTrace API check failed: {str(e)}") + + def get_project_url(self): + return "https://arms.console.aliyun.com/#/llm" + + def add_span(self, span_data: SpanData): + if span_data is None: + return + span: ReadableSpan = self.span_builder.build_span(span_data) + with self.condition: + if len(self.queue) == self.max_queue_size: + if not self._spans_dropped: + logger.warning("Queue is full, likely spans will be dropped.") + self._spans_dropped = True + + self.queue.appendleft(span) + if len(self.queue) >= self.max_export_batch_size: + self.condition.notify() + + def _worker(self): + while not self.done: + with self.condition: + if len(self.queue) < self.max_export_batch_size and not self.done: + self.condition.wait(timeout=self.schedule_delay_sec) + self._export_batch() + + def _export_batch(self): + spans_to_export: list[ReadableSpan] = [] + with self.condition: + while len(spans_to_export) < self.max_export_batch_size and self.queue: + spans_to_export.append(self.queue.pop()) + + if spans_to_export: + try: + self.exporter.export(spans_to_export) + except Exception as e: + logger.debug(f"Error exporting spans: {e}") + + def shutdown(self): + with self.condition: + self.done = True + self.condition.notify_all() + self.worker_thread.join() + self._export_batch() + self.exporter.shutdown() + + +class SpanBuilder: + def __init__(self, resource): + self.resource = resource + self.instrumentation_scope = InstrumentationScope( + __name__, + "", + None, + None, + ) + + def build_span(self, span_data: SpanData) -> ReadableSpan: + span_context = trace_api.SpanContext( + trace_id=span_data.trace_id, + span_id=span_data.span_id, + is_remote=False, + trace_flags=trace_api.TraceFlags(trace_api.TraceFlags.SAMPLED), + trace_state=None, + ) + + parent_span_context = None + if span_data.parent_span_id is not None: + parent_span_context = trace_api.SpanContext( + trace_id=span_data.trace_id, + span_id=span_data.parent_span_id, + is_remote=False, + trace_flags=trace_api.TraceFlags(trace_api.TraceFlags.SAMPLED), + trace_state=None, + ) + + span = ReadableSpan( + name=span_data.name, + context=span_context, + parent=parent_span_context, + resource=self.resource, + attributes=span_data.attributes, + events=span_data.events, + links=span_data.links, + kind=trace_api.SpanKind.INTERNAL, + status=span_data.status, + start_time=span_data.start_time, + end_time=span_data.end_time, + instrumentation_scope=self.instrumentation_scope, + ) + return span + + +def generate_span_id() -> int: + span_id = random.getrandbits(64) + while span_id == INVALID_SPAN_ID: + span_id = random.getrandbits(64) + return span_id + + +def convert_to_trace_id(uuid_v4: Optional[str]) -> int: + try: + uuid_obj = uuid.UUID(uuid_v4) + return uuid_obj.int + except Exception as e: + raise ValueError(f"Invalid UUID input: {e}") + + +def convert_to_span_id(uuid_v4: Optional[str], span_type: str) -> int: + try: + uuid_obj = uuid.UUID(uuid_v4) + except Exception as e: + raise ValueError(f"Invalid UUID input: {e}") + combined_key = f"{uuid_obj.hex}-{span_type}" + hash_bytes = hashlib.sha256(combined_key.encode("utf-8")).digest() + span_id = int.from_bytes(hash_bytes[:8], byteorder="big", signed=False) + return span_id + + +def convert_datetime_to_nanoseconds(start_time_a: Optional[datetime]) -> Optional[int]: + if start_time_a is None: + return None + timestamp_in_seconds = start_time_a.timestamp() + timestamp_in_nanoseconds = int(timestamp_in_seconds * 1e9) + return timestamp_in_nanoseconds diff --git a/api/core/ops/aliyun_trace/entities/__init__.py b/api/core/ops/aliyun_trace/entities/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py b/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py new file mode 100644 index 0000000000..1caa822cd0 --- /dev/null +++ b/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py @@ -0,0 +1,21 @@ +from collections.abc import Sequence +from typing import Optional + +from opentelemetry import trace as trace_api +from opentelemetry.sdk.trace import Event, Status, StatusCode +from pydantic import BaseModel, Field + + +class SpanData(BaseModel): + model_config = {"arbitrary_types_allowed": True} + + trace_id: int = Field(..., description="The unique identifier for the trace.") + parent_span_id: Optional[int] = Field(None, description="The ID of the parent span, if any.") + span_id: int = Field(..., description="The unique identifier for this span.") + name: str = Field(..., description="The name of the span.") + attributes: dict[str, str] = Field(default_factory=dict, description="Attributes associated with the span.") + events: Sequence[Event] = Field(default_factory=list, description="Events recorded in the span.") + links: Sequence[trace_api.Link] = Field(default_factory=list, description="Links to other spans.") + status: Status = Field(default=Status(StatusCode.UNSET), description="The status of the span.") + start_time: Optional[int] = Field(..., description="The start time of the span in nanoseconds.") + end_time: Optional[int] = Field(..., description="The end time of the span in nanoseconds.") diff --git a/api/core/ops/aliyun_trace/entities/semconv.py b/api/core/ops/aliyun_trace/entities/semconv.py new file mode 100644 index 0000000000..5d70264320 --- /dev/null +++ b/api/core/ops/aliyun_trace/entities/semconv.py @@ -0,0 +1,64 @@ +from enum import Enum + +# public +GEN_AI_SESSION_ID = "gen_ai.session.id" + +GEN_AI_USER_ID = "gen_ai.user.id" + +GEN_AI_USER_NAME = "gen_ai.user.name" + +GEN_AI_SPAN_KIND = "gen_ai.span.kind" + +GEN_AI_FRAMEWORK = "gen_ai.framework" + + +# Chain +INPUT_VALUE = "input.value" + +OUTPUT_VALUE = "output.value" + + +# Retriever +RETRIEVAL_QUERY = "retrieval.query" + +RETRIEVAL_DOCUMENT = "retrieval.document" + + +# LLM +GEN_AI_MODEL_NAME = "gen_ai.model_name" + +GEN_AI_SYSTEM = "gen_ai.system" + +GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens" + +GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens" + +GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens" + +GEN_AI_PROMPT_TEMPLATE_TEMPLATE = "gen_ai.prompt_template.template" + +GEN_AI_PROMPT_TEMPLATE_VARIABLE = "gen_ai.prompt_template.variable" + +GEN_AI_PROMPT = "gen_ai.prompt" + +GEN_AI_COMPLETION = "gen_ai.completion" + +GEN_AI_RESPONSE_FINISH_REASON = "gen_ai.response.finish_reason" + +# Tool +TOOL_NAME = "tool.name" + +TOOL_DESCRIPTION = "tool.description" + +TOOL_PARAMETERS = "tool.parameters" + + +class GenAISpanKind(Enum): + CHAIN = "CHAIN" + RETRIEVER = "RETRIEVER" + RERANKER = "RERANKER" + LLM = "LLM" + EMBEDDING = "EMBEDDING" + TOOL = "TOOL" + AGENT = "AGENT" + TASK = "TASK" diff --git a/api/core/ops/entities/config_entity.py b/api/core/ops/entities/config_entity.py index 8a2ce58539..89ff0cfded 100644 --- a/api/core/ops/entities/config_entity.py +++ b/api/core/ops/entities/config_entity.py @@ -2,6 +2,8 @@ from enum import StrEnum from pydantic import BaseModel, ValidationInfo, field_validator +from core.ops.utils import validate_project_name, validate_url, validate_url_with_path + class TracingProviderEnum(StrEnum): ARIZE = "arize" @@ -10,14 +12,41 @@ class TracingProviderEnum(StrEnum): LANGSMITH = "langsmith" OPIK = "opik" WEAVE = "weave" + ALIYUN = "aliyun" class BaseTracingConfig(BaseModel): """ - Base model class for tracing + Base model class for tracing configurations """ - ... + @classmethod + def validate_endpoint_url(cls, v: str, default_url: str) -> str: + """ + Common endpoint URL validation logic + + Args: + v: URL value to validate + default_url: Default URL to use if input is None or empty + + Returns: + Validated and normalized URL + """ + return validate_url(v, default_url) + + @classmethod + def validate_project_field(cls, v: str, default_name: str) -> str: + """ + Common project name validation logic + + Args: + v: Project name to validate + default_name: Default name to use if input is None or empty + + Returns: + Validated project name + """ + return validate_project_name(v, default_name) class ArizeConfig(BaseTracingConfig): @@ -33,23 +62,12 @@ class ArizeConfig(BaseTracingConfig): @field_validator("project") @classmethod def project_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "default" - - return v + return cls.validate_project_field(v, "default") @field_validator("endpoint") @classmethod def endpoint_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://otlp.arize.com" - if not v.startswith(("https://", "http://")): - raise ValueError("endpoint must start with https:// or http://") - if "/" in v[8:]: - parts = v.split("/") - v = parts[0] + "//" + parts[2] - - return v + return cls.validate_endpoint_url(v, "https://otlp.arize.com") class PhoenixConfig(BaseTracingConfig): @@ -64,23 +82,12 @@ class PhoenixConfig(BaseTracingConfig): @field_validator("project") @classmethod def project_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "default" - - return v + return cls.validate_project_field(v, "default") @field_validator("endpoint") @classmethod def endpoint_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://app.phoenix.arize.com" - if not v.startswith(("https://", "http://")): - raise ValueError("endpoint must start with https:// or http://") - if "/" in v[8:]: - parts = v.split("/") - v = parts[0] + "//" + parts[2] - - return v + return cls.validate_endpoint_url(v, "https://app.phoenix.arize.com") class LangfuseConfig(BaseTracingConfig): @@ -94,13 +101,8 @@ class LangfuseConfig(BaseTracingConfig): @field_validator("host") @classmethod - def set_value(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://api.langfuse.com" - if not v.startswith("https://") and not v.startswith("http://"): - raise ValueError("host must start with https:// or http://") - - return v + def host_validator(cls, v, info: ValidationInfo): + return cls.validate_endpoint_url(v, "https://api.langfuse.com") class LangSmithConfig(BaseTracingConfig): @@ -114,13 +116,9 @@ class LangSmithConfig(BaseTracingConfig): @field_validator("endpoint") @classmethod - def set_value(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://api.smith.langchain.com" - if not v.startswith("https://"): - raise ValueError("endpoint must start with https://") - - return v + def endpoint_validator(cls, v, info: ValidationInfo): + # LangSmith only allows HTTPS + return validate_url(v, "https://api.smith.langchain.com", allowed_schemes=("https",)) class OpikConfig(BaseTracingConfig): @@ -136,22 +134,12 @@ class OpikConfig(BaseTracingConfig): @field_validator("project") @classmethod def project_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "Default Project" - - return v + return cls.validate_project_field(v, "Default Project") @field_validator("url") @classmethod def url_validator(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://www.comet.com/opik/api/" - if not v.startswith(("https://", "http://")): - raise ValueError("url must start with https:// or http://") - if not v.endswith("/api/"): - raise ValueError("url should ends with /api/") - - return v + return validate_url_with_path(v, "https://www.comet.com/opik/api/", required_suffix="/api/") class WeaveConfig(BaseTracingConfig): @@ -167,22 +155,44 @@ class WeaveConfig(BaseTracingConfig): @field_validator("endpoint") @classmethod - def set_value(cls, v, info: ValidationInfo): - if v is None or v == "": - v = "https://trace.wandb.ai" - if not v.startswith("https://"): - raise ValueError("endpoint must start with https://") + def endpoint_validator(cls, v, info: ValidationInfo): + # Weave only allows HTTPS for endpoint + return validate_url(v, "https://trace.wandb.ai", allowed_schemes=("https",)) + @field_validator("host") + @classmethod + def host_validator(cls, v, info: ValidationInfo): + if v is not None and v.strip() != "": + return validate_url(v, v, allowed_schemes=("https", "http")) return v - @field_validator("host") + +class AliyunConfig(BaseTracingConfig): + """ + Model class for Aliyun tracing config. + """ + + app_name: str = "dify_app" + license_key: str + endpoint: str + + @field_validator("app_name") + @classmethod + def app_name_validator(cls, v, info: ValidationInfo): + return cls.validate_project_field(v, "dify_app") + + @field_validator("license_key") @classmethod - def validate_host(cls, v, info: ValidationInfo): - if v is not None and v != "": - if not v.startswith(("https://", "http://")): - raise ValueError("host must start with https:// or http://") + def license_key_validator(cls, v, info: ValidationInfo): + if not v or v.strip() == "": + raise ValueError("License key cannot be empty") return v + @field_validator("endpoint") + @classmethod + def endpoint_validator(cls, v, info: ValidationInfo): + return cls.validate_endpoint_url(v, "https://tracing-analysis-dc-hz.aliyuncs.com") + OPS_FILE_PATH = "ops_trace/" OPS_TRACE_FAILED_KEY = "FAILED_OPS_TRACE" diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index d6d6b4a1d4..5c9b9d27b7 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -41,28 +41,6 @@ from tasks.ops_trace_task import process_trace_tasks class OpsTraceProviderConfigMap(dict[str, dict[str, Any]]): def __getitem__(self, provider: str) -> dict[str, Any]: match provider: - case TracingProviderEnum.ARIZE: - from core.ops.arize_phoenix_trace.arize_phoenix_trace import ArizePhoenixDataTrace - from core.ops.entities.config_entity import ArizeConfig - - return { - "config_class": ArizeConfig, - "secret_keys": ["api_key", "space_id"], - "other_keys": ["project", "endpoint"], - "trace_instance": ArizePhoenixDataTrace, - } - - case TracingProviderEnum.PHOENIX: - from core.ops.arize_phoenix_trace.arize_phoenix_trace import ArizePhoenixDataTrace - from core.ops.entities.config_entity import PhoenixConfig - - return { - "config_class": PhoenixConfig, - "secret_keys": ["api_key"], - "other_keys": ["project", "endpoint"], - "trace_instance": ArizePhoenixDataTrace, - } - case TracingProviderEnum.LANGFUSE: from core.ops.entities.config_entity import LangfuseConfig from core.ops.langfuse_trace.langfuse_trace import LangFuseDataTrace @@ -126,6 +104,17 @@ class OpsTraceProviderConfigMap(dict[str, dict[str, Any]]): "other_keys": ["project", "endpoint"], "trace_instance": ArizePhoenixDataTrace, } + case TracingProviderEnum.ALIYUN: + from core.ops.aliyun_trace.aliyun_trace import AliyunDataTrace + from core.ops.entities.config_entity import AliyunConfig + + return { + "config_class": AliyunConfig, + "secret_keys": ["license_key"], + "other_keys": ["endpoint", "app_name"], + "trace_instance": AliyunDataTrace, + } + case _: raise KeyError(f"Unsupported tracing provider: {provider}") diff --git a/api/core/ops/utils.py b/api/core/ops/utils.py index 8b06df1930..36d060afd2 100644 --- a/api/core/ops/utils.py +++ b/api/core/ops/utils.py @@ -1,6 +1,7 @@ from contextlib import contextmanager from datetime import datetime from typing import Optional, Union +from urllib.parse import urlparse from extensions.ext_database import db from models.model import Message @@ -60,3 +61,83 @@ def generate_dotted_order( return current_segment return f"{parent_dotted_order}.{current_segment}" + + +def validate_url(url: str, default_url: str, allowed_schemes: tuple = ("https", "http")) -> str: + """ + Validate and normalize URL with proper error handling + + Args: + url: The URL to validate + default_url: Default URL to use if input is None or empty + allowed_schemes: Tuple of allowed URL schemes (default: https, http) + + Returns: + Normalized URL string + + Raises: + ValueError: If URL format is invalid or scheme not allowed + """ + if not url or url.strip() == "": + return default_url + + # Parse URL to validate format + parsed = urlparse(url) + + # Check if scheme is allowed + if parsed.scheme not in allowed_schemes: + raise ValueError(f"URL scheme must be one of: {', '.join(allowed_schemes)}") + + # Reconstruct URL with only scheme, netloc (removing path, query, fragment) + normalized_url = f"{parsed.scheme}://{parsed.netloc}" + + return normalized_url + + +def validate_url_with_path(url: str, default_url: str, required_suffix: str | None = None) -> str: + """ + Validate URL that may include path components + + Args: + url: The URL to validate + default_url: Default URL to use if input is None or empty + required_suffix: Optional suffix that URL must end with + + Returns: + Validated URL string + + Raises: + ValueError: If URL format is invalid or doesn't match required suffix + """ + if not url or url.strip() == "": + return default_url + + # Parse URL to validate format + parsed = urlparse(url) + + # Check if scheme is allowed + if parsed.scheme not in ("https", "http"): + raise ValueError("URL must start with https:// or http://") + + # Check required suffix if specified + if required_suffix and not url.endswith(required_suffix): + raise ValueError(f"URL should end with {required_suffix}") + + return url + + +def validate_project_name(project: str, default_name: str) -> str: + """ + Validate and normalize project name + + Args: + project: Project name to validate + default_name: Default name to use if input is None or empty + + Returns: + Normalized project name + """ + if not project or project.strip() == "": + return default_name + + return project.strip() diff --git a/api/core/workflow/graph_engine/graph_engine.py b/api/core/workflow/graph_engine/graph_engine.py index 61a7a26652..5a2915e2d3 100644 --- a/api/core/workflow/graph_engine/graph_engine.py +++ b/api/core/workflow/graph_engine/graph_engine.py @@ -103,7 +103,7 @@ class GraphEngine: call_depth: int, graph: Graph, graph_config: Mapping[str, Any], - variable_pool: VariablePool, + graph_runtime_state: GraphRuntimeState, max_execution_steps: int, max_execution_time: int, thread_pool_id: Optional[str] = None, @@ -140,7 +140,7 @@ class GraphEngine: call_depth=call_depth, ) - self.graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) + self.graph_runtime_state = graph_runtime_state self.max_execution_steps = max_execution_steps self.max_execution_time = max_execution_time diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index 987f670acb..766cdb604f 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -1,4 +1,5 @@ import json +import uuid from collections.abc import Generator, Mapping, Sequence from typing import Any, Optional, cast @@ -13,7 +14,7 @@ from core.model_runtime.entities.model_entities import AIModelEntity, ModelType from core.plugin.impl.exc import PluginDaemonClientSideError from core.plugin.impl.plugin import PluginInstaller from core.provider_manager import ProviderManager -from core.tools.entities.tool_entities import ToolParameter, ToolProviderType +from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter, ToolProviderType from core.tools.tool_manager import ToolManager from core.variables.segments import StringSegment from core.workflow.entities.node_entities import NodeRunResult @@ -102,6 +103,32 @@ class AgentNode(ToolNode): try: # convert tool messages + agent_thoughts: list = [] + + thought_log_message = ToolInvokeMessage( + type=ToolInvokeMessage.MessageType.LOG, + message=ToolInvokeMessage.LogMessage( + id=str(uuid.uuid4()), + label=f"Agent Strategy: {cast(AgentNodeData, self.node_data).agent_strategy_name}", + parent_id=None, + error=None, + status=ToolInvokeMessage.LogMessage.LogStatus.START, + data={ + "strategy": cast(AgentNodeData, self.node_data).agent_strategy_name, + "parameters": parameters_for_log, + "thought_process": "Agent strategy execution started", + }, + metadata={ + "icon": self.agent_strategy_icon, + "agent_strategy": cast(AgentNodeData, self.node_data).agent_strategy_name, + }, + ), + ) + + def enhanced_message_stream(): + yield thought_log_message + + yield from message_stream yield from self._transform_message( message_stream, @@ -110,6 +137,7 @@ class AgentNode(ToolNode): "agent_strategy": cast(AgentNodeData, self.node_data).agent_strategy_name, }, parameters_for_log, + agent_thoughts, ) except PluginDaemonClientSideError as e: yield RunCompletedEvent( diff --git a/api/core/workflow/nodes/http_request/executor.py b/api/core/workflow/nodes/http_request/executor.py index b0a14229c5..8ac1ae8526 100644 --- a/api/core/workflow/nodes/http_request/executor.py +++ b/api/core/workflow/nodes/http_request/executor.py @@ -8,6 +8,7 @@ from typing import Any, Literal from urllib.parse import urlencode, urlparse import httpx +from json_repair import repair_json from configs import dify_config from core.file import file_manager @@ -178,7 +179,8 @@ class Executor: raise RequestBodyError("json body type should have exactly one item") json_string = self.variable_pool.convert_template(data[0].value).text try: - json_object = json.loads(json_string, strict=False) + repaired = repair_json(json_string) + json_object = json.loads(repaired, strict=False) except json.JSONDecodeError as e: raise RequestBodyError(f"Failed to parse JSON: {json_string}") from e self.json = json_object diff --git a/api/core/workflow/nodes/iteration/iteration_node.py b/api/core/workflow/nodes/iteration/iteration_node.py index 151efc28ec..c447f433aa 100644 --- a/api/core/workflow/nodes/iteration/iteration_node.py +++ b/api/core/workflow/nodes/iteration/iteration_node.py @@ -1,5 +1,6 @@ import contextvars import logging +import time import uuid from collections.abc import Generator, Mapping, Sequence from concurrent.futures import Future, wait @@ -133,8 +134,11 @@ class IterationNode(BaseNode[IterationNodeData]): variable_pool.add([self.node_id, "item"], iterator_list_value[0]) # init graph engine + from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.graph_engine import GraphEngine, GraphEngineThreadPool + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) + graph_engine = GraphEngine( tenant_id=self.tenant_id, app_id=self.app_id, @@ -146,7 +150,7 @@ class IterationNode(BaseNode[IterationNodeData]): call_depth=self.workflow_call_depth, graph=iteration_graph, graph_config=graph_config, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME, thread_pool_id=self.thread_pool_id, diff --git a/api/core/workflow/nodes/loop/loop_node.py b/api/core/workflow/nodes/loop/loop_node.py index 368d662a75..11fd7b6c2d 100644 --- a/api/core/workflow/nodes/loop/loop_node.py +++ b/api/core/workflow/nodes/loop/loop_node.py @@ -1,5 +1,6 @@ import json import logging +import time from collections.abc import Generator, Mapping, Sequence from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, Literal, cast @@ -101,8 +102,11 @@ class LoopNode(BaseNode[LoopNodeData]): loop_variable_selectors[loop_variable.label] = variable_selector inputs[loop_variable.label] = processed_segment.value + from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.graph_engine import GraphEngine + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) + graph_engine = GraphEngine( tenant_id=self.tenant_id, app_id=self.app_id, @@ -114,7 +118,7 @@ class LoopNode(BaseNode[LoopNodeData]): call_depth=self.workflow_call_depth, graph=loop_graph, graph_config=self.graph_config, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME, thread_pool_id=self.thread_pool_id, diff --git a/api/core/workflow/nodes/tool/tool_node.py b/api/core/workflow/nodes/tool/tool_node.py index a4be02d863..59b3b1e2ae 100644 --- a/api/core/workflow/nodes/tool/tool_node.py +++ b/api/core/workflow/nodes/tool/tool_node.py @@ -1,5 +1,5 @@ from collections.abc import Generator, Mapping, Sequence -from typing import Any, cast +from typing import Any, Optional, cast from sqlalchemy import select from sqlalchemy.orm import Session @@ -191,6 +191,7 @@ class ToolNode(BaseNode[ToolNodeData]): messages: Generator[ToolInvokeMessage, None, None], tool_info: Mapping[str, Any], parameters_for_log: dict[str, Any], + agent_thoughts: Optional[list] = None, ) -> Generator: """ Convert ToolInvokeMessages into tuple[plain_text, files] @@ -368,11 +369,35 @@ class ToolNode(BaseNode[ToolNodeData]): agent_logs.append(agent_log) yield agent_log - + # Add agent_logs to outputs['json'] to ensure frontend can access thinking process + json_output: dict[str, Any] = {} + if json: + if isinstance(json, list) and len(json) == 1: + # If json is a list with only one element, convert it to a dictionary + json_output = json[0] if isinstance(json[0], dict) else {"data": json[0]} + elif isinstance(json, list): + # If json is a list with multiple elements, create a dictionary containing all data + json_output = {"data": json} + + if agent_logs: + # Add agent_logs to json output + json_output["agent_logs"] = [ + { + "id": log.id, + "parent_id": log.parent_id, + "error": log.error, + "status": log.status, + "data": log.data, + "label": log.label, + "metadata": log.metadata, + "node_id": log.node_id, + } + for log in agent_logs + ] yield RunCompletedEvent( run_result=NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, - outputs={"text": text, "files": ArrayFileSegment(value=files), "json": json, **variables}, + outputs={"text": text, "files": ArrayFileSegment(value=files), "json": json_output, **variables}, metadata={ **agent_execution_metadata, WorkflowNodeExecutionMetadataKey.TOOL_INFO: tool_info, diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index c0e98db3db..2868dcb7de 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -69,6 +69,7 @@ class WorkflowEntry: raise ValueError("Max workflow call depth {} reached.".format(workflow_call_max_depth)) # init workflow run state + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) self.graph_engine = GraphEngine( tenant_id=tenant_id, app_id=app_id, @@ -80,7 +81,7 @@ class WorkflowEntry: call_depth=call_depth, graph=graph, graph_config=graph_config, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME, thread_pool_id=thread_pool_id, diff --git a/api/services/ops_service.py b/api/services/ops_service.py index c88accb9a5..dbeb4f1908 100644 --- a/api/services/ops_service.py +++ b/api/services/ops_service.py @@ -94,6 +94,16 @@ class OpsService: new_decrypt_tracing_config.update({"project_url": project_url}) except Exception: new_decrypt_tracing_config.update({"project_url": "https://wandb.ai/"}) + + if tracing_provider == "aliyun" and ( + "project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url") + ): + try: + project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider) + new_decrypt_tracing_config.update({"project_url": project_url}) + except Exception: + new_decrypt_tracing_config.update({"project_url": "https://arms.console.aliyun.com/"}) + trace_config_data.tracing_config = new_decrypt_tracing_config return trace_config_data.to_dict() diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index cac0a688cd..b70c8830ed 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -1,6 +1,7 @@ import os from flask import Flask +from packaging.version import Version from yarl import URL from configs.app_config import DifyConfig @@ -40,6 +41,9 @@ def test_dify_config(monkeypatch): assert config.WORKFLOW_PARALLEL_DEPTH_LIMIT == 3 + # values from pyproject.toml + assert Version(config.project.version) >= Version("1.0.0") + # NOTE: If there is a `.env` file in your Workspace, this test might not succeed as expected. # This is due to `pymilvus` loading all the variables from the `.env` file into `os.environ`. diff --git a/api/tests/unit_tests/core/ops/__init__.py b/api/tests/unit_tests/core/ops/__init__.py new file mode 100644 index 0000000000..bb92ccdec7 --- /dev/null +++ b/api/tests/unit_tests/core/ops/__init__.py @@ -0,0 +1 @@ +# Unit tests for core ops module diff --git a/api/tests/unit_tests/core/ops/test_config_entity.py b/api/tests/unit_tests/core/ops/test_config_entity.py new file mode 100644 index 0000000000..81cb04548d --- /dev/null +++ b/api/tests/unit_tests/core/ops/test_config_entity.py @@ -0,0 +1,385 @@ +import pytest +from pydantic import ValidationError + +from core.ops.entities.config_entity import ( + AliyunConfig, + ArizeConfig, + LangfuseConfig, + LangSmithConfig, + OpikConfig, + PhoenixConfig, + TracingProviderEnum, + WeaveConfig, +) + + +class TestTracingProviderEnum: + """Test cases for TracingProviderEnum""" + + def test_enum_values(self): + """Test that all expected enum values are present""" + assert TracingProviderEnum.ARIZE == "arize" + assert TracingProviderEnum.PHOENIX == "phoenix" + assert TracingProviderEnum.LANGFUSE == "langfuse" + assert TracingProviderEnum.LANGSMITH == "langsmith" + assert TracingProviderEnum.OPIK == "opik" + assert TracingProviderEnum.WEAVE == "weave" + assert TracingProviderEnum.ALIYUN == "aliyun" + + +class TestArizeConfig: + """Test cases for ArizeConfig""" + + def test_valid_config(self): + """Test valid Arize configuration""" + config = ArizeConfig( + api_key="test_key", space_id="test_space", project="test_project", endpoint="https://custom.arize.com" + ) + assert config.api_key == "test_key" + assert config.space_id == "test_space" + assert config.project == "test_project" + assert config.endpoint == "https://custom.arize.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = ArizeConfig() + assert config.api_key is None + assert config.space_id is None + assert config.project is None + assert config.endpoint == "https://otlp.arize.com" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = ArizeConfig(project="") + assert config.project == "default" + + def test_project_validation_none(self): + """Test project validation with None value""" + config = ArizeConfig(project=None) + assert config.project == "default" + + def test_endpoint_validation_empty(self): + """Test endpoint validation with empty value""" + config = ArizeConfig(endpoint="") + assert config.endpoint == "https://otlp.arize.com" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation normalizes URL by removing path""" + config = ArizeConfig(endpoint="https://custom.arize.com/api/v1") + assert config.endpoint == "https://custom.arize.com" + + def test_endpoint_validation_invalid_scheme(self): + """Test endpoint validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + ArizeConfig(endpoint="ftp://invalid.com") + + def test_endpoint_validation_no_scheme(self): + """Test endpoint validation rejects URLs without scheme""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + ArizeConfig(endpoint="invalid.com") + + +class TestPhoenixConfig: + """Test cases for PhoenixConfig""" + + def test_valid_config(self): + """Test valid Phoenix configuration""" + config = PhoenixConfig(api_key="test_key", project="test_project", endpoint="https://custom.phoenix.com") + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.endpoint == "https://custom.phoenix.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = PhoenixConfig() + assert config.api_key is None + assert config.project is None + assert config.endpoint == "https://app.phoenix.arize.com" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = PhoenixConfig(project="") + assert config.project == "default" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation normalizes URL by removing path""" + config = PhoenixConfig(endpoint="https://custom.phoenix.com/api/v1") + assert config.endpoint == "https://custom.phoenix.com" + + +class TestLangfuseConfig: + """Test cases for LangfuseConfig""" + + def test_valid_config(self): + """Test valid Langfuse configuration""" + config = LangfuseConfig(public_key="public_key", secret_key="secret_key", host="https://custom.langfuse.com") + assert config.public_key == "public_key" + assert config.secret_key == "secret_key" + assert config.host == "https://custom.langfuse.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = LangfuseConfig(public_key="public", secret_key="secret") + assert config.host == "https://api.langfuse.com" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + LangfuseConfig() + + with pytest.raises(ValidationError): + LangfuseConfig(public_key="public") + + with pytest.raises(ValidationError): + LangfuseConfig(secret_key="secret") + + def test_host_validation_empty(self): + """Test host validation with empty value""" + config = LangfuseConfig(public_key="public", secret_key="secret", host="") + assert config.host == "https://api.langfuse.com" + + +class TestLangSmithConfig: + """Test cases for LangSmithConfig""" + + def test_valid_config(self): + """Test valid LangSmith configuration""" + config = LangSmithConfig(api_key="test_key", project="test_project", endpoint="https://custom.smith.com") + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.endpoint == "https://custom.smith.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = LangSmithConfig(api_key="key", project="project") + assert config.endpoint == "https://api.smith.langchain.com" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + LangSmithConfig() + + with pytest.raises(ValidationError): + LangSmithConfig(api_key="key") + + with pytest.raises(ValidationError): + LangSmithConfig(project="project") + + def test_endpoint_validation_https_only(self): + """Test endpoint validation only allows HTTPS""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + LangSmithConfig(api_key="key", project="project", endpoint="http://insecure.com") + + +class TestOpikConfig: + """Test cases for OpikConfig""" + + def test_valid_config(self): + """Test valid Opik configuration""" + config = OpikConfig( + api_key="test_key", + project="test_project", + workspace="test_workspace", + url="https://custom.comet.com/opik/api/", + ) + assert config.api_key == "test_key" + assert config.project == "test_project" + assert config.workspace == "test_workspace" + assert config.url == "https://custom.comet.com/opik/api/" + + def test_default_values(self): + """Test default values are set correctly""" + config = OpikConfig() + assert config.api_key is None + assert config.project is None + assert config.workspace is None + assert config.url == "https://www.comet.com/opik/api/" + + def test_project_validation_empty(self): + """Test project validation with empty value""" + config = OpikConfig(project="") + assert config.project == "Default Project" + + def test_url_validation_empty(self): + """Test URL validation with empty value""" + config = OpikConfig(url="") + assert config.url == "https://www.comet.com/opik/api/" + + def test_url_validation_missing_suffix(self): + """Test URL validation requires /api/ suffix""" + with pytest.raises(ValidationError, match="URL should end with /api/"): + OpikConfig(url="https://custom.comet.com/opik/") + + def test_url_validation_invalid_scheme(self): + """Test URL validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL must start with https:// or http://"): + OpikConfig(url="ftp://custom.comet.com/opik/api/") + + +class TestWeaveConfig: + """Test cases for WeaveConfig""" + + def test_valid_config(self): + """Test valid Weave configuration""" + config = WeaveConfig( + api_key="test_key", + entity="test_entity", + project="test_project", + endpoint="https://custom.wandb.ai", + host="https://custom.host.com", + ) + assert config.api_key == "test_key" + assert config.entity == "test_entity" + assert config.project == "test_project" + assert config.endpoint == "https://custom.wandb.ai" + assert config.host == "https://custom.host.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = WeaveConfig(api_key="key", project="project") + assert config.entity is None + assert config.endpoint == "https://trace.wandb.ai" + assert config.host is None + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + WeaveConfig() + + with pytest.raises(ValidationError): + WeaveConfig(api_key="key") + + with pytest.raises(ValidationError): + WeaveConfig(project="project") + + def test_endpoint_validation_https_only(self): + """Test endpoint validation only allows HTTPS""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + WeaveConfig(api_key="key", project="project", endpoint="http://insecure.wandb.ai") + + def test_host_validation_optional(self): + """Test host validation is optional but validates when provided""" + config = WeaveConfig(api_key="key", project="project", host=None) + assert config.host is None + + config = WeaveConfig(api_key="key", project="project", host="") + assert config.host == "" + + config = WeaveConfig(api_key="key", project="project", host="https://valid.host.com") + assert config.host == "https://valid.host.com" + + def test_host_validation_invalid_scheme(self): + """Test host validation rejects invalid schemes when provided""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + WeaveConfig(api_key="key", project="project", host="ftp://invalid.host.com") + + +class TestAliyunConfig: + """Test cases for AliyunConfig""" + + def test_valid_config(self): + """Test valid Aliyun configuration""" + config = AliyunConfig( + app_name="test_app", + license_key="test_license_key", + endpoint="https://custom.tracing-analysis-dc-hz.aliyuncs.com", + ) + assert config.app_name == "test_app" + assert config.license_key == "test_license_key" + assert config.endpoint == "https://custom.tracing-analysis-dc-hz.aliyuncs.com" + + def test_default_values(self): + """Test default values are set correctly""" + config = AliyunConfig(license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + assert config.app_name == "dify_app" + + def test_missing_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValidationError): + AliyunConfig() + + with pytest.raises(ValidationError): + AliyunConfig(license_key="test_license") + + with pytest.raises(ValidationError): + AliyunConfig(endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + + def test_app_name_validation_empty(self): + """Test app_name validation with empty value""" + config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com", app_name="" + ) + assert config.app_name == "dify_app" + + def test_endpoint_validation_empty(self): + """Test endpoint validation with empty value""" + config = AliyunConfig(license_key="test_license", endpoint="") + assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com" + + def test_endpoint_validation_with_path(self): + """Test endpoint validation normalizes URL by removing path""" + config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces" + ) + assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com" + + def test_endpoint_validation_invalid_scheme(self): + """Test endpoint validation rejects invalid schemes""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + AliyunConfig(license_key="test_license", endpoint="ftp://invalid.tracing-analysis-dc-hz.aliyuncs.com") + + def test_endpoint_validation_no_scheme(self): + """Test endpoint validation rejects URLs without scheme""" + with pytest.raises(ValidationError, match="URL scheme must be one of"): + AliyunConfig(license_key="test_license", endpoint="invalid.tracing-analysis-dc-hz.aliyuncs.com") + + def test_license_key_required(self): + """Test that license_key is required and cannot be empty""" + with pytest.raises(ValidationError): + AliyunConfig(license_key="", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + + +class TestConfigIntegration: + """Integration tests for configuration classes""" + + def test_all_configs_can_be_instantiated(self): + """Test that all config classes can be instantiated with valid data""" + configs = [ + ArizeConfig(api_key="key"), + PhoenixConfig(api_key="key"), + LangfuseConfig(public_key="public", secret_key="secret"), + LangSmithConfig(api_key="key", project="project"), + OpikConfig(api_key="key"), + WeaveConfig(api_key="key", project="project"), + AliyunConfig(license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com"), + ] + + for config in configs: + assert config is not None + + def test_url_normalization_consistency(self): + """Test that URL normalization works consistently across configs""" + # Test that paths are removed from endpoints + arize_config = ArizeConfig(endpoint="https://arize.com/api/v1/test") + phoenix_config = PhoenixConfig(endpoint="https://phoenix.com/api/v2/") + aliyun_config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces" + ) + + assert arize_config.endpoint == "https://arize.com" + assert phoenix_config.endpoint == "https://phoenix.com" + assert aliyun_config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com" + + def test_project_default_values(self): + """Test that project default values are set correctly""" + arize_config = ArizeConfig(project="") + phoenix_config = PhoenixConfig(project="") + opik_config = OpikConfig(project="") + aliyun_config = AliyunConfig( + license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com", app_name="" + ) + + assert arize_config.project == "default" + assert phoenix_config.project == "default" + assert opik_config.project == "Default Project" + assert aliyun_config.app_name == "dify_app" diff --git a/api/tests/unit_tests/core/ops/test_utils.py b/api/tests/unit_tests/core/ops/test_utils.py new file mode 100644 index 0000000000..7cc2772acf --- /dev/null +++ b/api/tests/unit_tests/core/ops/test_utils.py @@ -0,0 +1,138 @@ +import pytest + +from core.ops.utils import validate_project_name, validate_url, validate_url_with_path + + +class TestValidateUrl: + """Test cases for validate_url function""" + + def test_valid_https_url(self): + """Test valid HTTPS URL""" + result = validate_url("https://example.com", "https://default.com") + assert result == "https://example.com" + + def test_valid_http_url(self): + """Test valid HTTP URL""" + result = validate_url("http://example.com", "https://default.com") + assert result == "http://example.com" + + def test_url_with_path_removed(self): + """Test that URL path is removed during normalization""" + result = validate_url("https://example.com/api/v1/test", "https://default.com") + assert result == "https://example.com" + + def test_url_with_query_removed(self): + """Test that URL query parameters are removed""" + result = validate_url("https://example.com?param=value", "https://default.com") + assert result == "https://example.com" + + def test_url_with_fragment_removed(self): + """Test that URL fragments are removed""" + result = validate_url("https://example.com#section", "https://default.com") + assert result == "https://example.com" + + def test_empty_url_returns_default(self): + """Test empty URL returns default""" + result = validate_url("", "https://default.com") + assert result == "https://default.com" + + def test_none_url_returns_default(self): + """Test None URL returns default""" + result = validate_url(None, "https://default.com") + assert result == "https://default.com" + + def test_whitespace_url_returns_default(self): + """Test whitespace URL returns default""" + result = validate_url(" ", "https://default.com") + assert result == "https://default.com" + + def test_invalid_scheme_raises_error(self): + """Test invalid scheme raises ValueError""" + with pytest.raises(ValueError, match="URL scheme must be one of"): + validate_url("ftp://example.com", "https://default.com") + + def test_no_scheme_raises_error(self): + """Test URL without scheme raises ValueError""" + with pytest.raises(ValueError, match="URL scheme must be one of"): + validate_url("example.com", "https://default.com") + + def test_custom_allowed_schemes(self): + """Test custom allowed schemes""" + result = validate_url("https://example.com", "https://default.com", allowed_schemes=("https",)) + assert result == "https://example.com" + + with pytest.raises(ValueError, match="URL scheme must be one of"): + validate_url("http://example.com", "https://default.com", allowed_schemes=("https",)) + + +class TestValidateUrlWithPath: + """Test cases for validate_url_with_path function""" + + def test_valid_url_with_path(self): + """Test valid URL with path""" + result = validate_url_with_path("https://example.com/api/v1", "https://default.com") + assert result == "https://example.com/api/v1" + + def test_valid_url_with_required_suffix(self): + """Test valid URL with required suffix""" + result = validate_url_with_path("https://example.com/api/", "https://default.com", required_suffix="/api/") + assert result == "https://example.com/api/" + + def test_url_without_required_suffix_raises_error(self): + """Test URL without required suffix raises error""" + with pytest.raises(ValueError, match="URL should end with /api/"): + validate_url_with_path("https://example.com/api", "https://default.com", required_suffix="/api/") + + def test_empty_url_returns_default(self): + """Test empty URL returns default""" + result = validate_url_with_path("", "https://default.com") + assert result == "https://default.com" + + def test_none_url_returns_default(self): + """Test None URL returns default""" + result = validate_url_with_path(None, "https://default.com") + assert result == "https://default.com" + + def test_invalid_scheme_raises_error(self): + """Test invalid scheme raises ValueError""" + with pytest.raises(ValueError, match="URL must start with https:// or http://"): + validate_url_with_path("ftp://example.com", "https://default.com") + + def test_no_scheme_raises_error(self): + """Test URL without scheme raises ValueError""" + with pytest.raises(ValueError, match="URL must start with https:// or http://"): + validate_url_with_path("example.com", "https://default.com") + + +class TestValidateProjectName: + """Test cases for validate_project_name function""" + + def test_valid_project_name(self): + """Test valid project name""" + result = validate_project_name("my-project", "default") + assert result == "my-project" + + def test_empty_project_name_returns_default(self): + """Test empty project name returns default""" + result = validate_project_name("", "default") + assert result == "default" + + def test_none_project_name_returns_default(self): + """Test None project name returns default""" + result = validate_project_name(None, "default") + assert result == "default" + + def test_whitespace_project_name_returns_default(self): + """Test whitespace project name returns default""" + result = validate_project_name(" ", "default") + assert result == "default" + + def test_project_name_with_whitespace_trimmed(self): + """Test project name with whitespace is trimmed""" + result = validate_project_name(" my-project ", "default") + assert result == "my-project" + + def test_custom_default_name(self): + """Test custom default name""" + result = validate_project_name("", "Custom Default") + assert result == "Custom Default" diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py b/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py index 7535ec4866..c288a5fa13 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py @@ -1,3 +1,4 @@ +import time from unittest.mock import patch import pytest @@ -19,6 +20,7 @@ from core.workflow.graph_engine.entities.event import ( NodeRunSucceededEvent, ) from core.workflow.graph_engine.entities.graph import Graph +from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.entities.runtime_route_state import RouteNodeState from core.workflow.graph_engine.graph_engine import GraphEngine from core.workflow.nodes.code.code_node import CodeNode @@ -172,6 +174,7 @@ def test_run_parallel_in_workflow(mock_close, mock_remove): system_variables={SystemVariableKey.FILES: [], SystemVariableKey.USER_ID: "aaa"}, user_inputs={"query": "hi"} ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -183,7 +186,7 @@ def test_run_parallel_in_workflow(mock_close, mock_remove): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) @@ -299,6 +302,7 @@ def test_run_parallel_in_chatflow(mock_close, mock_remove): user_inputs={}, ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -310,7 +314,7 @@ def test_run_parallel_in_chatflow(mock_close, mock_remove): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) @@ -479,6 +483,7 @@ def test_run_branch(mock_close, mock_remove): user_inputs={"uid": "takato"}, ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -490,7 +495,7 @@ def test_run_branch(mock_close, mock_remove): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) @@ -813,6 +818,7 @@ def test_condition_parallel_correct_output(mock_close, mock_remove, app): system_variables={SystemVariableKey.FILES: [], SystemVariableKey.USER_ID: "aaa"}, user_inputs={"query": "hi"} ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph_engine = GraphEngine( tenant_id="111", app_id="222", @@ -824,7 +830,7 @@ def test_condition_parallel_correct_output(mock_close, mock_remove, app): invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py b/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py index ff60d5974b..a6c553faf0 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py @@ -1,7 +1,9 @@ +import time from unittest.mock import patch from core.app.entities.app_invoke_entities import InvokeFrom from core.workflow.entities.node_entities import NodeRunResult, WorkflowNodeExecutionMetadataKey +from core.workflow.entities.variable_pool import VariablePool from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus from core.workflow.enums import SystemVariableKey from core.workflow.graph_engine.entities.event import ( @@ -11,6 +13,7 @@ from core.workflow.graph_engine.entities.event import ( NodeRunStreamChunkEvent, ) from core.workflow.graph_engine.entities.graph import Graph +from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState from core.workflow.graph_engine.graph_engine import GraphEngine from core.workflow.nodes.event.event import RunCompletedEvent, RunStreamChunkEvent from core.workflow.nodes.llm.node import LLMNode @@ -163,15 +166,16 @@ class ContinueOnErrorTestHelper: def create_test_graph_engine(graph_config: dict, user_inputs: dict | None = None): """Helper method to create a graph engine instance for testing""" graph = Graph.init(graph_config=graph_config) - variable_pool = { - "system_variables": { + variable_pool = VariablePool( + system_variables={ SystemVariableKey.QUERY: "clear", SystemVariableKey.FILES: [], SystemVariableKey.CONVERSATION_ID: "abababa", SystemVariableKey.USER_ID: "aaa", }, - "user_inputs": user_inputs or {"uid": "takato"}, - } + user_inputs=user_inputs or {"uid": "takato"}, + ) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) return GraphEngine( tenant_id="111", @@ -184,7 +188,7 @@ class ContinueOnErrorTestHelper: invoke_from=InvokeFrom.WEB_APP, call_depth=0, graph=graph, - variable_pool=variable_pool, + graph_runtime_state=graph_runtime_state, max_execution_steps=500, max_execution_time=1200, ) diff --git a/api/tests/unit_tests/libs/test_password.py b/api/tests/unit_tests/libs/test_password.py new file mode 100644 index 0000000000..79fc792cc5 --- /dev/null +++ b/api/tests/unit_tests/libs/test_password.py @@ -0,0 +1,74 @@ +import base64 +import binascii +import os + +import pytest + +from libs.password import compare_password, hash_password, valid_password + + +class TestValidPassword: + """Test password format validation""" + + def test_should_accept_valid_passwords(self): + """Test accepting valid password formats""" + assert valid_password("password123") == "password123" + assert valid_password("test1234") == "test1234" + assert valid_password("Test123456") == "Test123456" + + def test_should_reject_invalid_passwords(self): + """Test rejecting invalid password formats""" + # Too short + with pytest.raises(ValueError) as exc_info: + valid_password("abc123") + assert "Password must contain letters and numbers" in str(exc_info.value) + + # No numbers + with pytest.raises(ValueError): + valid_password("abcdefgh") + + # No letters + with pytest.raises(ValueError): + valid_password("12345678") + + # Empty + with pytest.raises(ValueError): + valid_password("") + + +class TestPasswordHashing: + """Test password hashing and comparison""" + + def setup_method(self): + """Setup test data""" + self.password = "test123password" + self.salt = os.urandom(16) + self.salt_base64 = base64.b64encode(self.salt).decode() + + password_hash = hash_password(self.password, self.salt) + self.password_hash_base64 = base64.b64encode(password_hash).decode() + + def test_should_verify_correct_password(self): + """Test correct password verification""" + result = compare_password(self.password, self.password_hash_base64, self.salt_base64) + assert result is True + + def test_should_reject_wrong_password(self): + """Test rejection of incorrect passwords""" + result = compare_password("wrongpassword", self.password_hash_base64, self.salt_base64) + assert result is False + + def test_should_handle_invalid_base64(self): + """Test handling of invalid base64 data""" + # Invalid base64 hash + with pytest.raises(binascii.Error): + compare_password(self.password, "invalid_base64!", self.salt_base64) + + # Invalid base64 salt + with pytest.raises(binascii.Error): + compare_password(self.password, self.password_hash_base64, "invalid_base64!") + + def test_should_be_case_sensitive(self): + """Test password case sensitivity""" + result = compare_password(self.password.upper(), self.password_hash_base64, self.salt_base64) + assert result is False diff --git a/docker/.env.example b/docker/.env.example index a024566c8f..e7dbecb413 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -958,7 +958,7 @@ NGINX_SSL_PROTOCOLS=TLSv1.1 TLSv1.2 TLSv1.3 # Nginx performance tuning NGINX_WORKER_PROCESSES=auto -NGINX_CLIENT_MAX_BODY_SIZE=15M +NGINX_CLIENT_MAX_BODY_SIZE=100M NGINX_KEEPALIVE_TIMEOUT=65 # Proxy settings diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index d45f8f8bfa..a34f96e945 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -265,7 +265,7 @@ services: NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key} NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3} NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto} - NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M} + NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M} NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65} NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 7f91fd8796..e48b5afd8c 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -420,7 +420,7 @@ x-shared-env: &shared-api-worker-env NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key} NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3} NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto} - NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M} + NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M} NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65} NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} @@ -780,7 +780,7 @@ services: NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key} NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3} NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto} - NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M} + NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M} NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65} NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx index 92ba068b2b..2afe451fe1 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import TracingIcon from './tracing-icon' import ProviderPanel from './provider-panel' -import type { ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' +import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' import { TracingProvider } from './type' import ProviderConfigModal from './provider-config-modal' import Indicator from '@/app/components/header/indicator' @@ -29,7 +29,8 @@ export type PopupProps = { langFuseConfig: LangFuseConfig | null opikConfig: OpikConfig | null weaveConfig: WeaveConfig | null - onConfigUpdated: (provider: TracingProvider, payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => void + aliyunConfig: AliyunConfig | null + onConfigUpdated: (provider: TracingProvider, payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => void onConfigRemoved: (provider: TracingProvider) => void } @@ -46,6 +47,7 @@ const ConfigPopup: FC = ({ langFuseConfig, opikConfig, weaveConfig, + aliyunConfig, onConfigUpdated, onConfigRemoved, }) => { @@ -69,7 +71,7 @@ const ConfigPopup: FC = ({ } }, [onChooseProvider]) - const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => { + const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => { onConfigUpdated(currentProvider!, payload) hideConfigModal() }, [currentProvider, hideConfigModal, onConfigUpdated]) @@ -79,8 +81,8 @@ const ConfigPopup: FC = ({ hideConfigModal() }, [currentProvider, hideConfigModal, onConfigRemoved]) - const providerAllConfigured = arizeConfig && phoenixConfig && langSmithConfig && langFuseConfig && opikConfig && weaveConfig - const providerAllNotConfigured = !arizeConfig && !phoenixConfig && !langSmithConfig && !langFuseConfig && !opikConfig && !weaveConfig + const providerAllConfigured = arizeConfig && phoenixConfig && langSmithConfig && langFuseConfig && opikConfig && weaveConfig && aliyunConfig + const providerAllNotConfigured = !arizeConfig && !phoenixConfig && !langSmithConfig && !langFuseConfig && !opikConfig && !weaveConfig && !aliyunConfig const switchContent = ( = ({ key="weave-provider-panel" /> ) + + const aliyunPanel = ( + + ) const configuredProviderPanel = () => { const configuredPanels: JSX.Element[] = [] @@ -188,6 +203,9 @@ const ConfigPopup: FC = ({ if (phoenixConfig) configuredPanels.push(phoenixPanel) + if (aliyunConfig) + configuredPanels.push(aliyunPanel) + return configuredPanels } @@ -212,6 +230,9 @@ const ConfigPopup: FC = ({ if (!weaveConfig) notConfiguredPanels.push(weavePanel) + if (!aliyunConfig) + notConfiguredPanels.push(aliyunPanel) + return notConfiguredPanels } @@ -226,6 +247,8 @@ const ConfigPopup: FC = ({ return langFuseConfig if (currentProvider === TracingProvider.opik) return opikConfig + if (currentProvider === TracingProvider.aliyun) + return aliyunConfig return weaveConfig } @@ -273,6 +296,7 @@ const ConfigPopup: FC = ({ {weavePanel} {arizePanel} {phoenixPanel} + {aliyunPanel} ) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts index 28433991e4..4c81b63ea2 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts @@ -7,4 +7,5 @@ export const docURL = { [TracingProvider.langfuse]: 'https://docs.langfuse.com', [TracingProvider.opik]: 'https://www.comet.com/docs/opik/tracing/integrations/dify#setup-instructions', [TracingProvider.weave]: 'https://weave-docs.wandb.ai/', + [TracingProvider.aliyun]: 'https://help.aliyun.com/zh/arms/tracing-analysis/untitled-document-1750672984680', } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx index c8286c9a51..8bf18904be 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -7,12 +7,12 @@ import { import { useTranslation } from 'react-i18next' import { usePathname } from 'next/navigation' import { useBoolean } from 'ahooks' -import type { ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' +import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' import { TracingProvider } from './type' import TracingIcon from './tracing-icon' import ConfigButton from './config-button' import cn from '@/utils/classnames' -import { ArizeIcon, LangfuseIcon, LangsmithIcon, OpikIcon, PhoenixIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing' +import { AliyunIcon, ArizeIcon, LangfuseIcon, LangsmithIcon, OpikIcon, PhoenixIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing' import Indicator from '@/app/components/header/indicator' import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps' import type { TracingStatus } from '@/models/app' @@ -69,6 +69,7 @@ const Panel: FC = () => { [TracingProvider.langfuse]: LangfuseIcon, [TracingProvider.opik]: OpikIcon, [TracingProvider.weave]: WeaveIcon, + [TracingProvider.aliyun]: AliyunIcon, } const InUseProviderIcon = inUseTracingProvider ? providerIconMap[inUseTracingProvider] : undefined @@ -78,7 +79,8 @@ const Panel: FC = () => { const [langFuseConfig, setLangFuseConfig] = useState(null) const [opikConfig, setOpikConfig] = useState(null) const [weaveConfig, setWeaveConfig] = useState(null) - const hasConfiguredTracing = !!(langSmithConfig || langFuseConfig || opikConfig || weaveConfig || arizeConfig || phoenixConfig) + const [aliyunConfig, setAliyunConfig] = useState(null) + const hasConfiguredTracing = !!(langSmithConfig || langFuseConfig || opikConfig || weaveConfig || arizeConfig || phoenixConfig || aliyunConfig) const fetchTracingConfig = async () => { const { tracing_config: arizeConfig, has_not_configured: arizeHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.arize }) @@ -99,6 +101,9 @@ const Panel: FC = () => { const { tracing_config: weaveConfig, has_not_configured: weaveHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.weave }) if (!weaveHasNotConfig) setWeaveConfig(weaveConfig as WeaveConfig) + const { tracing_config: aliyunConfig, has_not_configured: aliyunHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.aliyun }) + if (!aliyunHasNotConfig) + setAliyunConfig(aliyunConfig as AliyunConfig) } const handleTracingConfigUpdated = async (provider: TracingProvider) => { @@ -116,6 +121,8 @@ const Panel: FC = () => { setOpikConfig(tracing_config as OpikConfig) else if (provider === TracingProvider.weave) setWeaveConfig(tracing_config as WeaveConfig) + else if (provider === TracingProvider.aliyun) + setAliyunConfig(tracing_config as AliyunConfig) } const handleTracingConfigRemoved = (provider: TracingProvider) => { @@ -131,6 +138,8 @@ const Panel: FC = () => { setOpikConfig(null) else if (provider === TracingProvider.weave) setWeaveConfig(null) + else if (provider === TracingProvider.aliyun) + setAliyunConfig(null) if (provider === inUseTracingProvider) { handleTracingStatusChange({ enabled: false, @@ -191,6 +200,7 @@ const Panel: FC = () => { langFuseConfig={langFuseConfig} opikConfig={opikConfig} weaveConfig={weaveConfig} + aliyunConfig={aliyunConfig} onConfigUpdated={handleTracingConfigUpdated} onConfigRemoved={handleTracingConfigRemoved} controlShowPopup={controlShowPopup} @@ -228,6 +238,7 @@ const Panel: FC = () => { langFuseConfig={langFuseConfig} opikConfig={opikConfig} weaveConfig={weaveConfig} + aliyunConfig={aliyunConfig} onConfigUpdated={handleTracingConfigUpdated} onConfigRemoved={handleTracingConfigRemoved} controlShowPopup={controlShowPopup} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx index 67cac95964..318f1f61d6 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import Field from './field' -import type { ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' +import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type' import { TracingProvider } from './type' import { docURL } from './config' import { @@ -22,10 +22,10 @@ import Divider from '@/app/components/base/divider' type Props = { appId: string type: TracingProvider - payload?: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | null + payload?: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | null onRemoved: () => void onCancel: () => void - onSaved: (payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => void + onSaved: (payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => void onChosen: (provider: TracingProvider) => void } @@ -71,6 +71,12 @@ const weaveConfigTemplate = { host: '', } +const aliyunConfigTemplate = { + app_name: '', + license_key: '', + endpoint: '', +} + const ProviderConfigModal: FC = ({ appId, type, @@ -84,7 +90,7 @@ const ProviderConfigModal: FC = ({ const isEdit = !!payload const isAdd = !isEdit const [isSaving, setIsSaving] = useState(false) - const [config, setConfig] = useState((() => { + const [config, setConfig] = useState((() => { if (isEdit) return payload @@ -103,6 +109,9 @@ const ProviderConfigModal: FC = ({ else if (type === TracingProvider.opik) return opikConfigTemplate + else if (type === TracingProvider.aliyun) + return aliyunConfigTemplate + return weaveConfigTemplate })()) const [isShowRemoveConfirm, { @@ -183,6 +192,16 @@ const ProviderConfigModal: FC = ({ errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) }) } + if (type === TracingProvider.aliyun) { + const postData = config as AliyunConfig + if (!errorMessage && !postData.app_name) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'App Name' }) + if (!errorMessage && !postData.license_key) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'License Key' }) + if (!errorMessage && !postData.endpoint) + errorMessage = t('common.errorMsg.fieldRequired', { field: 'Endpoint' }) + } + return errorMessage }, [config, t, type]) const handleSave = useCallback(async () => { @@ -294,6 +313,31 @@ const ProviderConfigModal: FC = ({ /> )} + {type === TracingProvider.aliyun && ( + <> + + + + + )} {type === TracingProvider.weave && ( <> { [TracingProvider.langfuse]: LangfuseIconBig, [TracingProvider.opik]: OpikIconBig, [TracingProvider.weave]: WeaveIconBig, + [TracingProvider.aliyun]: AliyunIconBig, })[type] } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts index 7e934b74c6..78bca41ad2 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts @@ -5,6 +5,7 @@ export enum TracingProvider { langfuse = 'langfuse', opik = 'opik', weave = 'weave', + aliyun = 'aliyun', } export type ArizeConfig = { @@ -46,3 +47,9 @@ export type WeaveConfig = { endpoint: string host: string } + +export type AliyunConfig = { + app_name: string + license_key: string + endpoint: string +} diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index fb3a9087ca..acaae3f720 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -77,7 +77,7 @@ const ExtraInfo = ({ isMobile, relatedApps, expand }: IExtraInfoProps) => { )} - {isMobile &&
+ {isMobile &&
{relatedAppsTotal || '--'}
} diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index c6d0e776dd..c28cc20df5 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -10,7 +10,6 @@ import { RiFileCopy2Line, RiFileDownloadLine, RiFileUploadLine, - RiMoreLine, } from '@remixicon/react' import AppIcon from '../base/app-icon' import SwitchAppModal from '../app/switch-app-modal' @@ -35,7 +34,8 @@ import ContentDialog from '@/app/components/base/content-dialog' import Button from '@/app/components/base/button' import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView' import Divider from '../base/divider' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem' +import type { Operation } from './app-operations' +import AppOperations from './app-operations' export type IAppInfoProps = { expand: boolean @@ -186,14 +186,58 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx const { isCurrentWorkspaceEditor } = useAppContext() - const [showMore, setShowMore] = useState(false) - const handleTriggerMore = useCallback(() => { - setShowMore(true) - }, [setShowMore]) - if (!appDetail) return null + const operations = [ + { + id: 'edit', + title: t('app.editApp'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowEditModal(true) + }, + }, + { + id: 'duplicate', + title: t('app.duplicate'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowDuplicateModal(true) + }, + }, + { + id: 'export', + title: t('app.export'), + icon: , + onClick: exportCheck, + }, + (appDetail.mode !== 'agent-chat' && (appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow')) ? { + id: 'import', + title: t('workflow.common.importDSL'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowImportDSLModal(true) + }, + } : undefined, + (appDetail.mode !== 'agent-chat' && (appDetail.mode === 'completion' || appDetail.mode === 'chat')) ? { + id: 'switch', + title: t('app.switch'), + icon: , + onClick: () => { + setOpen(false) + onDetailExpand?.(false) + setShowSwitchModal(true) + }, + } : undefined, + ].filter((op): op is Operation => Boolean(op)) + return (
{!onlyShowDetail && ( @@ -259,88 +303,10 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
{appDetail.description}
)} {/* operations */} -
- - - - {appDetail.mode !== 'agent-chat' && - - - - -
- { - (appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow') - &&
{ - setOpen(false) - onDetailExpand?.(false) - setShowImportDSLModal(true) - }}> - - {t('workflow.common.importDSL')} -
- } - { - (appDetail.mode === 'completion' || appDetail.mode === 'chat') - &&
{ - setOpen(false) - onDetailExpand?.(false) - setShowSwitchModal(true) - }}> - - {t('app.switch')} -
- } -
-
-
} -
+
void +} + +const AppOperations = ({ operations, gap }: { + operations: Operation[] + gap: number +}) => { + const { t } = useTranslation() + const [visibleOpreations, setVisibleOperations] = useState([]) + const [moreOperations, setMoreOperations] = useState([]) + const [showMore, setShowMore] = useState(false) + const navRef = useRef(null) + const handleTriggerMore = useCallback(() => { + setShowMore(true) + }, [setShowMore]) + + useEffect(() => { + const moreElement = document.getElementById('more') + const navElement = document.getElementById('nav') + let width = 0 + const containerWidth = navElement?.clientWidth ?? 0 + const moreWidth = moreElement?.clientWidth ?? 0 + + if (containerWidth === 0 || moreWidth === 0) return + + const updatedEntries: Record = operations.reduce((pre, cur) => { + pre[cur.id] = false + return pre + }, {} as Record) + const childrens = Array.from(navRef.current!.children).slice(0, -1) + for (let i = 0; i < childrens.length; i++) { + const child: any = childrens[i] + const id = child.dataset.targetid + if (!id) break + const childWidth = child.clientWidth + + if (width + gap + childWidth + moreWidth <= containerWidth) { + updatedEntries[id] = true + width += gap + childWidth + } + else { + if (i === childrens.length - 1 && width + childWidth <= containerWidth) + updatedEntries[id] = true + else + updatedEntries[id] = false + break + } + } + setVisibleOperations(operations.filter(item => updatedEntries[item.id])) + setMoreOperations(operations.filter(item => !updatedEntries[item.id])) + }, [operations, gap]) + + return ( + <> + {!visibleOpreations.length && } +
+ {visibleOpreations.map(operation => + , + )} + {visibleOpreations.length < operations.length && + + + + +
+ {moreOperations.map(item =>
+ {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} + {item.title} +
)} +
+
+
} +
+ + ) +} + +export default AppOperations diff --git a/web/app/components/base/icons/assets/public/tracing/aliyun-icon-big.svg b/web/app/components/base/icons/assets/public/tracing/aliyun-icon-big.svg new file mode 100644 index 0000000000..210a1cd00b --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/aliyun-icon-big.svg @@ -0,0 +1 @@ + diff --git a/web/app/components/base/icons/assets/public/tracing/aliyun-icon.svg b/web/app/components/base/icons/assets/public/tracing/aliyun-icon.svg new file mode 100644 index 0000000000..6f7645301c --- /dev/null +++ b/web/app/components/base/icons/assets/public/tracing/aliyun-icon.svg @@ -0,0 +1 @@ + diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIcon.json b/web/app/components/base/icons/src/public/tracing/AliyunIcon.json new file mode 100644 index 0000000000..5cbb52c237 --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIcon.json @@ -0,0 +1,118 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + "fill": "none", + "version": "1.1", + "width": "65", + "height": "16", + "viewBox": "0 0 65 16" + }, + "children": [ + { + "type": "element", + "name": "defs", + "children": [ + { + "type": "element", + "name": "clipPath", + "attributes": { + "id": "master_svg0_42_34281" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "x": "0", + "y": "0", + "width": "19", + "height": "16", + "rx": "0" + } + } + ] + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "clip-path": "url(#master_svg0_42_34281)" + }, + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M4.06862,14.6667C3.79213,14.6667,3.45463,14.5688,3.05614,14.373C2.97908,14.3351,2.92692,14.3105,2.89968,14.2992C2.33193,14.0628,1.82911,13.7294,1.39123,13.2989C0.463742,12.3871,0,11.2874,0,10C0,8.71258,0.463742,7.61293,1.39123,6.70107C2.16172,5.94358,3.06404,5.50073,4.09819,5.37252C4.23172,3.98276,4.81755,2.77756,5.85569,1.75693C7.04708,0.585642,8.4857,0,10.1716,0C11.5256,0,12.743,0.396982,13.8239,1.19095C14.8847,1.97019,15.61,2.97855,16,4.21604L14.7045,4.61063C14.4016,3.64918,13.8374,2.86532,13.0121,2.25905C12.1719,1.64191,11.2251,1.33333,10.1716,1.33333C8.8602,1.33333,7.74124,1.7888,6.81467,2.69974C5.88811,3.61067,5.42483,4.71076,5.42483,6L5.42483,6.66667L4.74673,6.66667C3.81172,6.66667,3.01288,6.99242,2.35021,7.64393C1.68754,8.2954,1.35621,9.08076,1.35621,10C1.35621,10.9192,1.68754,11.7046,2.35021,12.3561C2.66354,12.6641,3.02298,12.9026,3.42852,13.0714C3.48193,13.0937,3.55988,13.13,3.66237,13.1803C3.87004,13.2823,4.00545,13.3333,4.06862,13.3333L4.06862,14.6667Z", + "fill-rule": "evenodd", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M13.458613505859375,7.779393492279053C12.975613505859375,7.717463492279053,12.484813505859375,7.686503492279053,11.993983505859376,7.686503492279053C11.152583505859376,7.686503492279053,10.303403505859375,7.779393492279053,9.493183505859374,7.941943492279053C8.682953505859375,8.104503492279052,7.903893505859375,8.359943492279053,7.155983505859375,8.654083492279053C6.657383505859375,8.870823492279053,6.158783505859375,9.128843492279053,5.660181505859375,9.428153492279053C5.332974751859375,9.621673492279053,5.239486705859375,10.070633492279054,5.434253505859375,10.395743492279053L7.413073505859375,13.298533492279052C7.639003505859375,13.623603492279052,8.090863505859375,13.716463492279052,8.418073505859375,13.523003492279052C8.547913505859375,13.435263492279052,8.763453505859374,13.326893492279053,9.064693505859374,13.197863492279053C9.516553505859374,13.004333492279052,9.976203505859374,12.872733492279053,10.459223505859375,12.779863492279052C10.942243505859375,12.679263492279052,11.433053505859375,12.617333492279052,11.955023505859375,12.617333492279052L13.380683505859375,7.810353492279052L13.458613505859375,7.779393492279053ZM15.273813505859374,8.135463492279053L15.016753505859375,5.333333492279053L13.458613505859375,7.787133492279053C13.817013505859375,7.818093492279052,14.144213505859375,7.880023492279053,14.494743505859375,7.949683492279053C14.494743505859375,7.944523492279053,14.754433505859375,8.006453492279054,15.273813505859374,8.135463492279053ZM12.064083505859376,12.648273492279053L11.378523505859375,14.970463492279054L12.515943505859376,16.00003349227905L14.074083505859376,15.643933492279054L14.525943505859376,13.027603492279052C14.198743505859374,12.934663492279054,13.879283505859375,12.834063492279054,13.552083505859375,12.772133492279053C13.069083505859375,12.717933492279052,12.578283505859375,12.648273492279053,12.064083505859376,12.648273492279053ZM18.327743505859374,9.428153492279053C17.829143505859374,9.128843492279053,17.330543505859374,8.870823492279053,16.831943505859375,8.654083492279053C16.348943505859374,8.460573492279053,15.826943505859376,8.267053492279054,15.305013505859375,8.135463492279053L15.305013505859375,8.267053492279054L14.463613505859374,13.043063492279053C14.596083505859376,13.105003492279053,14.759683505859375,13.135933492279053,14.884283505859376,13.205603492279053C15.185523505859376,13.334623492279052,15.401043505859375,13.443003492279052,15.530943505859375,13.530733492279053C15.858143505859376,13.724263492279054,16.341143505859375,13.623603492279052,16.535943505859375,13.306263492279053L18.514743505859375,10.403483492279053C18.779643505859376,10.039673492279054,18.686143505859377,9.621673492279053,18.327743505859374,9.428153492279053Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + } + ] + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M25.044,2.668L34.676,2.668L34.676,4.04L25.044,4.04L25.044,2.668ZM29.958,7.82Q29.258,9.066,28.355,10.41Q27.451999999999998,11.754,26.92,12.3L32.506,11.782Q31.442,10.158,30.84,9.346L32.058,8.562000000000001Q32.786,9.5,33.843,11.012Q34.9,12.524,35.516,13.546L34.214,14.526Q33.891999999999996,13.966,33.346000000000004,13.098Q32.016,13.182,29.734,13.378Q27.451999999999998,13.574,25.87,13.742L25.31,13.812L24.834,13.882L24.414,12.468Q24.708,12.37,24.862000000000002,12.265Q25.016,12.16,25.121,12.069Q25.226,11.978,25.268,11.936Q25.912,11.32,26.724,10.165Q27.536,9.01,28.208,7.82L23.854,7.82L23.854,6.434L35.866,6.434L35.866,7.82L29.958,7.82ZM42.656,7.414L42.656,8.576L41.354,8.576L41.354,1.814L42.656,1.87L42.656,7.036Q43.314,5.846,43.888000000000005,4.369Q44.462,2.892,44.714,1.6600000000000001L46.086,1.981999Q45.96,2.612,45.722,3.41L49.6,3.41L49.6,4.74L45.274,4.74Q44.616,6.56,43.706,8.128L42.656,7.414ZM38.596000000000004,2.346L39.884,2.402L39.884,8.212L38.596000000000004,8.212L38.596000000000004,2.346ZM46.184,4.964Q46.688,5.356,47.5,6.175Q48.312,6.994,48.788,7.582L47.751999999999995,8.59Q47.346000000000004,8.072,46.576,7.274Q45.806,6.476,45.204,5.902L46.184,4.964ZM48.41,9.01L48.41,12.706L49.894,12.706L49.894,13.966L37.391999999999996,13.966L37.391999999999996,12.706L38.848,12.706L38.848,9.01L48.41,9.01ZM41.676,10.256L40.164,10.256L40.164,12.706L41.676,12.706L41.676,10.256ZM42.908,12.706L44.364000000000004,12.706L44.364000000000004,10.256L42.908,10.256L42.908,12.706ZM45.582,12.706L47.108000000000004,12.706L47.108000000000004,10.256L45.582,10.256L45.582,12.706ZM54.906,7.456L55.116,8.394L54.178,8.814L54.178,12.818Q54.178,13.434,54.031,13.735Q53.884,14.036,53.534,14.162Q53.184,14.288,52.456,14.358L51.867999999999995,14.414L51.476,13.084L52.162,13.028Q52.512,13,52.652,12.958Q52.792,12.916,52.841,12.797Q52.89,12.678,52.89,12.384L52.89,9.36Q51.980000000000004,9.724,51.322,9.948L51.013999999999996,8.576Q51.798,8.324,52.89,7.876L52.89,5.524L51.42,5.524L51.42,4.166L52.89,4.166L52.89,1.7579989999999999L54.178,1.814L54.178,4.166L55.214,4.166L55.214,5.524L54.178,5.524L54.178,7.316L54.808,7.022L54.906,7.456ZM56.894,4.5440000000000005L56.894,6.098L55.564,6.098L55.564,3.256L58.686,3.256Q58.42,2.346,58.266,1.9260000000000002L59.624,1.7579989999999999Q59.848,2.276,60.142,3.256L63.25,3.256L63.25,6.098L61.962,6.098L61.962,4.5440000000000005L56.894,4.5440000000000005ZM59.008,6.322Q58.392,6.938,57.685,7.512Q56.978,8.086,55.956,8.841999999999999L55.242,7.764Q56.824,6.728,58.126,5.37L59.008,6.322ZM60.422,5.37Q61.024,5.776,62.095,6.581Q63.166,7.386,63.656,7.806L62.942,8.982Q62.368,8.45,61.332,7.652Q60.296,6.854,59.666,6.434L60.422,5.37ZM62.592,10.256L60.044,10.256L60.044,12.566L63.572,12.566L63.572,13.826L55.144,13.826L55.144,12.566L58.63,12.566L58.63,10.256L56.054,10.256L56.054,8.982L62.592,8.982L62.592,10.256Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + } + ] + } + ] + } + ] + }, + "name": "AliyunIcon" +} diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx b/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx new file mode 100644 index 0000000000..5b062b8a86 --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIcon.tsx @@ -0,0 +1,16 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AliyunIcon.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase' + +const Icon = React.forwardRef, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'AliyunIcon' + +export default Icon diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIconBig.json b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.json new file mode 100644 index 0000000000..ea60744daf --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.json @@ -0,0 +1,71 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + "fill": "none", + "version": "1.1", + "width": "96", + "height": "24", + "viewBox": "0 0 96 24" + }, + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M6.10294,22C5.68819,22,5.18195,21.8532,4.58421,21.5595C4.46861,21.5027,4.39038,21.4658,4.34951,21.4488C3.49789,21.0943,2.74367,20.5941,2.08684,19.9484C0.695613,18.5806,0,16.9311,0,15C0,13.0689,0.695612,11.4194,2.08684,10.0516C3.24259,8.91537,4.59607,8.2511,6.14728,8.05878C6.34758,5.97414,7.22633,4.16634,8.78354,2.63539C10.5706,0.878463,12.7286,0,15.2573,0C17.2884,0,19.1146,0.595472,20.7358,1.78642C22.327,2.95528,23.4151,4.46783,24,6.32406L22.0568,6.91594C21.6024,5.47377,20.7561,4.29798,19.5181,3.38858C18.2579,2.46286,16.8377,2,15.2573,2C13.2903,2,11.6119,2.6832,10.222,4.04961C8.83217,5.41601,8.13725,7.06614,8.13725,9L8.13725,10L7.12009,10C5.71758,10,4.51932,10.4886,3.52532,11.4659C2.53132,12.4431,2.03431,13.6211,2.03431,15C2.03431,16.3789,2.53132,17.5569,3.52532,18.5341C3.99531,18.9962,4.53447,19.3538,5.14278,19.6071C5.2229,19.6405,5.33983,19.695,5.49356,19.7705C5.80505,19.9235,6.00818,20,6.10294,20L6.10294,22Z", + "fill-rule": "evenodd", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M20.18796103515625,11.66909C19.46346103515625,11.5762,18.72726103515625,11.52975,17.991011035156248,11.52975C16.728921035156247,11.52975,15.45515103515625,11.66909,14.23981103515625,11.91292C13.02447103515625,12.156749999999999,11.85588103515625,12.539909999999999,10.73402103515625,12.98113C9.98612103515625,13.306239999999999,9.23822103515625,13.69327,8.49031803515625,14.14223C7.99950790415625,14.43251,7.85927603515625,15.10595,8.15142503515625,15.59361L11.11966103515625,19.9478C11.45855103515625,20.4354,12.13634103515625,20.5747,12.627151035156249,20.2845C12.821921035156251,20.152900000000002,13.14523103515625,19.990299999999998,13.59708103515625,19.796799999999998C14.27487103515625,19.506500000000003,14.964341035156249,19.3091,15.68887103515625,19.169800000000002C16.413401035156248,19.018900000000002,17.14962103515625,18.926000000000002,17.93258103515625,18.926000000000002L20.071061035156248,11.715530000000001L20.18796103515625,11.66909ZM22.91076103515625,12.20319L22.525161035156252,8L20.18796103515625,11.6807C20.72556103515625,11.72714,21.21636103515625,11.82003,21.74216103515625,11.92453C21.74216103515625,11.91679,22.13166103515625,12.00968,22.91076103515625,12.20319ZM18.09616103515625,18.9724L17.06782103515625,22.4557L18.773961035156248,24L21.11116103515625,23.465899999999998L21.788961035156248,19.5414C21.298161035156248,19.402,20.81896103515625,19.2511,20.32816103515625,19.1582C19.60366103515625,19.076900000000002,18.86746103515625,18.9724,18.09616103515625,18.9724ZM27.49166103515625,14.14223C26.74376103515625,13.69327,25.99586103515625,13.306239999999999,25.24796103515625,12.98113C24.52346103515625,12.69086,23.74046103515625,12.40058,22.95756103515625,12.20319L22.95756103515625,12.40058L21.69546103515625,19.5646C21.89416103515625,19.6575,22.139561035156248,19.7039,22.32646103515625,19.8084C22.77836103515625,20.0019,23.101661035156248,20.1645,23.29646103515625,20.2961C23.78726103515625,20.586399999999998,24.51176103515625,20.4354,24.80396103515625,19.959400000000002L27.77216103515625,15.605229999999999C28.16946103515625,15.05951,28.02926103515625,14.43251,27.49166103515625,14.14223Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + }, + { + "type": "element", + "name": "g", + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M35.785,3.8624638671875L50.233000000000004,3.8624638671875L50.233000000000004,5.9204638671875L35.785,5.9204638671875L35.785,3.8624638671875ZM43.156,11.5904638671875Q42.106,13.4594638671875,40.7515,15.4754638671875Q39.397,17.4914638671875,38.599000000000004,18.3104638671875L46.978,17.5334638671875Q45.382,15.0974638671875,44.479,13.8794638671875L46.306,12.7034638671875Q47.397999999999996,14.1104638671875,48.9835,16.3784638671875Q50.569,18.6464638671875,51.492999999999995,20.1794638671875L49.54,21.6494638671875Q49.057,20.8094638671875,48.238,19.5074638671875Q46.243,19.6334638671875,42.82,19.9274638671875Q39.397,20.2214638671875,37.024,20.4734638671875L36.184,20.5784638671875L35.47,20.6834638671875L34.84,18.5624638671875Q35.281,18.4154638671875,35.512,18.2579638671875Q35.743,18.1004638671875,35.9005,17.963963867187502Q36.058,17.8274638671875,36.121,17.7644638671875Q37.087,16.840463867187502,38.305,15.1079638671875Q39.522999999999996,13.3754638671875,40.531,11.5904638671875L34,11.5904638671875L34,9.5114638671875L52.018,9.5114638671875L52.018,11.5904638671875L43.156,11.5904638671875ZM62.203,10.9814638671875L62.203,12.7244638671875L60.25,12.7244638671875L60.25,2.5814638671875L62.203,2.6654638671875L62.203,10.4144638671875Q63.19,8.6294638671875,64.051,6.4139638671875Q64.912,4.1984638671875,65.28999999999999,2.3504638671875L67.348,2.8334628671875Q67.15899999999999,3.7784638671875,66.80199999999999,4.9754638671875L72.619,4.9754638671875L72.619,6.9704638671875L66.13,6.9704638671875Q65.143,9.7004638671875,63.778,12.0524638671875L62.203,10.9814638671875ZM56.113,3.3794638671875L58.045,3.4634638671875L58.045,12.1784638671875L56.113,12.1784638671875L56.113,3.3794638671875ZM67.495,7.3064638671875Q68.251,7.8944638671875,69.469,9.1229638671875Q70.687,10.3514638671875,71.40100000000001,11.2334638671875L69.84700000000001,12.7454638671875Q69.238,11.9684638671875,68.083,10.7714638671875Q66.928,9.5744638671875,66.025,8.7134638671875L67.495,7.3064638671875ZM70.834,13.3754638671875L70.834,18.9194638671875L73.06,18.9194638671875L73.06,20.8094638671875L54.307,20.8094638671875L54.307,18.9194638671875L56.491,18.9194638671875L56.491,13.3754638671875L70.834,13.3754638671875ZM60.733000000000004,15.2444638671875L58.465,15.2444638671875L58.465,18.9194638671875L60.733000000000004,18.9194638671875L60.733000000000004,15.2444638671875ZM62.581,18.9194638671875L64.765,18.9194638671875L64.765,15.2444638671875L62.581,15.2444638671875L62.581,18.9194638671875ZM66.592,18.9194638671875L68.881,18.9194638671875L68.881,15.2444638671875L66.592,15.2444638671875L66.592,18.9194638671875ZM80.578,11.0444638671875L80.893,12.4514638671875L79.48599999999999,13.0814638671875L79.48599999999999,19.0874638671875Q79.48599999999999,20.0114638671875,79.2655,20.4629638671875Q79.045,20.9144638671875,78.52000000000001,21.1034638671875Q77.995,21.2924638671875,76.90299999999999,21.3974638671875L76.021,21.4814638671875L75.43299999999999,19.4864638671875L76.462,19.4024638671875Q76.987,19.3604638671875,77.197,19.2974638671875Q77.407,19.2344638671875,77.4805,19.0559638671875Q77.554,18.8774638671875,77.554,18.4364638671875L77.554,13.9004638671875Q76.189,14.4464638671875,75.202,14.7824638671875L74.74000000000001,12.7244638671875Q75.916,12.3464638671875,77.554,11.6744638671875L77.554,8.1464638671875L75.34899999999999,8.1464638671875L75.34899999999999,6.1094638671875L77.554,6.1094638671875L77.554,2.4974628671875L79.48599999999999,2.5814638671875L79.48599999999999,6.1094638671875L81.03999999999999,6.1094638671875L81.03999999999999,8.1464638671875L79.48599999999999,8.1464638671875L79.48599999999999,10.8344638671875L80.431,10.3934638671875L80.578,11.0444638671875ZM83.56,6.6764638671875L83.56,9.0074638671875L81.565,9.0074638671875L81.565,4.7444638671875L86.24799999999999,4.7444638671875Q85.84899999999999,3.3794638671875,85.618,2.7494638671875L87.655,2.4974628671875Q87.991,3.2744638671875,88.432,4.7444638671875L93.094,4.7444638671875L93.094,9.0074638671875L91.162,9.0074638671875L91.162,6.6764638671875L83.56,6.6764638671875ZM86.731,9.3434638671875Q85.807,10.2674638671875,84.7465,11.1284638671875Q83.686,11.9894638671875,82.15299999999999,13.1234638671875L81.082,11.5064638671875Q83.455,9.9524638671875,85.408,7.9154638671875L86.731,9.3434638671875ZM88.852,7.9154638671875Q89.755,8.5244638671875,91.3615,9.731963867187499Q92.968,10.9394638671875,93.703,11.5694638671875L92.632,13.3334638671875Q91.771,12.5354638671875,90.217,11.3384638671875Q88.663,10.1414638671875,87.718,9.5114638671875L88.852,7.9154638671875ZM92.107,15.2444638671875L88.285,15.2444638671875L88.285,18.7094638671875L93.577,18.7094638671875L93.577,20.5994638671875L80.935,20.5994638671875L80.935,18.7094638671875L86.164,18.7094638671875L86.164,15.2444638671875L82.3,15.2444638671875L82.3,13.3334638671875L92.107,13.3334638671875L92.107,15.2444638671875Z", + "fill": "#FF6A00", + "fill-opacity": "1" + } + } + ] + } + ] + } + ] + }, + "name": "AliyunBigIcon" +} diff --git a/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx new file mode 100644 index 0000000000..0924f70fbd --- /dev/null +++ b/web/app/components/base/icons/src/public/tracing/AliyunIconBig.tsx @@ -0,0 +1,16 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import * as React from 'react' +import data from './AliyunIconBig.json' +import IconBase from '@/app/components/base/icons/IconBase' +import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase' + +const Icon = React.forwardRef, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'AliyunIconBig' + +export default Icon diff --git a/web/app/components/base/icons/src/public/tracing/index.ts b/web/app/components/base/icons/src/public/tracing/index.ts index 61a117e863..07e3385f46 100644 --- a/web/app/components/base/icons/src/public/tracing/index.ts +++ b/web/app/components/base/icons/src/public/tracing/index.ts @@ -11,3 +11,5 @@ export { default as PhoenixIcon } from './PhoenixIcon' export { default as TracingIcon } from './TracingIcon' export { default as WeaveIconBig } from './WeaveIconBig' export { default as WeaveIcon } from './WeaveIcon' +export { default as AliyunIconBig } from './AliyunIconBig' +export { default as AliyunIcon } from './AliyunIcon' diff --git a/web/app/components/base/markdown-blocks/form.tsx b/web/app/components/base/markdown-blocks/form.tsx index ab7e7cef53..b71193d8f9 100644 --- a/web/app/components/base/markdown-blocks/form.tsx +++ b/web/app/components/base/markdown-blocks/form.tsx @@ -28,6 +28,7 @@ enum SUPPORTED_TYPES { DATETIME = 'datetime', CHECKBOX = 'checkbox', SELECT = 'select', + HIDDEN = 'hidden', } const MarkdownForm = ({ node }: any) => { const { onSend } = useChatContext() @@ -37,8 +38,12 @@ const MarkdownForm = ({ node }: any) => { useEffect(() => { const initialValues: { [key: string]: any } = {} node.children.forEach((child: any) => { - if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) - initialValues[child.properties.name] = child.properties.value + if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) { + initialValues[child.properties.name] + = (child.tagName === SUPPORTED_TAGS.INPUT && child.properties.type === SUPPORTED_TYPES.HIDDEN) + ? (child.properties.value || '') + : child.properties.value + } }) setFormValues(initialValues) }, [node.children]) @@ -180,6 +185,17 @@ const MarkdownForm = ({ node }: any) => { ) } + if (child.properties.type === SUPPORTED_TYPES.HIDDEN) { + return ( + + ) + } + return ( = ({ if (!disabled) setOpen(!open) } - } className={classNames(`flex items-center h-9 w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-state-base-hover group-hover:bg-state-base-hover`, optionClassName)}> + } className={classNames(`flex h-9 w-full items-center rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6`, optionClassName)}>
{selectedItem?.name}
} = ({ value={item} className={({ active }: { active: boolean }) => classNames( - 'relative cursor-default select-none py-2 pl-3 pr-9 rounded-lg hover:bg-state-base-hover text-text-secondary', + 'relative cursor-default select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover', active ? 'bg-state-base-hover' : '', optionClassName, ) @@ -225,8 +225,8 @@ const SimpleSelect: FC = ({ if (listboxRef.current) onOpenChange?.(listboxRef.current.getAttribute('data-open') !== null) }) - }} className={classNames(`flex items-center w-full h-full rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-state-base-hover-alt group-hover/simple-select:bg-state-base-hover-alt ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}> - {selectedItem?.name ?? localPlaceholder} + }} className={classNames(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}> + {selectedItem?.name ?? localPlaceholder} {isLoading ? : (selectedItem && !notClearable) @@ -252,13 +252,13 @@ const SimpleSelect: FC = ({ )} {(!disabled) && ( - + {items.map((item: Item) => ( = ({ : (
@@ -358,7 +358,7 @@ const PortalSelect: FC = ({
{items.map((item: Item) => (
0 ? page : 1, + limit: (limit > 0 && limit <= 100) ? limit : 10, + keyword: keyword ? decodeURIComponent(keyword) : '', + } +} + +// Update the URL search string with the given query parameters. +function updateSearchParams(query: DocumentListQuery, searchParams: URLSearchParams) { + const { page, limit, keyword } = query || {} + + const hasNonDefaultParams = (page && page > 1) || (limit && limit !== 10) || (keyword && keyword.trim()) + + if (hasNonDefaultParams) { + searchParams.set('page', (page || 1).toString()) + searchParams.set('limit', (limit || 10).toString()) + } + else { + searchParams.delete('page') + searchParams.delete('limit') + } + + if (keyword && keyword.trim()) + searchParams.set('keyword', encodeURIComponent(keyword)) + else + searchParams.delete('keyword') +} + +function useDocumentListQueryState() { + const searchParams = useSearchParams() + const query = useMemo(() => parseParams(searchParams), [searchParams]) + + const router = useRouter() + const pathname = usePathname() + + // Helper function to update specific query parameters + const updateQuery = useCallback((updates: Partial) => { + const newQuery = { ...query, ...updates } + const params = new URLSearchParams() + updateSearchParams(newQuery, params) + const search = params.toString() + const queryString = search ? `?${search}` : '' + router.push(`${pathname}${queryString}`, { scroll: false }) + }, [query, router, pathname]) + + // Helper function to reset query to defaults + const resetQuery = useCallback(() => { + const params = new URLSearchParams() + updateSearchParams(DEFAULT_QUERY, params) + const search = params.toString() + const queryString = search ? `?${search}` : '' + router.push(`${pathname}${queryString}`, { scroll: false }) + }, [router, pathname]) + + return useMemo(() => ({ + query, + updateQuery, + resetQuery, + }), [query, updateQuery, resetQuery]) +} + +export default useDocumentListQueryState diff --git a/web/app/components/datasets/documents/index.tsx b/web/app/components/datasets/documents/index.tsx index 2840e5fa4a..676581a50f 100644 --- a/web/app/components/datasets/documents/index.tsx +++ b/web/app/components/datasets/documents/index.tsx @@ -26,6 +26,7 @@ import cn from '@/utils/classnames' import { useDocumentList, useInvalidDocumentDetailKey, useInvalidDocumentList } from '@/service/knowledge/use-document' import { useInvalid } from '@/service/use-base' import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment' +import useDocumentListQueryState from './hooks/use-document-list-query-state' import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata' import DatasetMetadataDrawer from '../metadata/metadata-dataset/dataset-metadata-drawer' import StatusWithAction from '../common/document-status-with-action/status-with-action' @@ -82,7 +83,6 @@ type IDocumentsProps = { } export const fetcher = (url: string) => get(url, {}, {}) -const DEFAULT_LIMIT = 10 const Documents: FC = ({ datasetId }) => { const { t } = useTranslation() @@ -91,8 +91,12 @@ const Documents: FC = ({ datasetId }) => { const isFreePlan = plan.type === 'sandbox' const [inputValue, setInputValue] = useState('') // the input value const [searchValue, setSearchValue] = useState('') - const [currPage, setCurrPage] = React.useState(0) - const [limit, setLimit] = useState(DEFAULT_LIMIT) + + // Use the new hook for URL state management + const { query, updateQuery } = useDocumentListQueryState() + const [currPage, setCurrPage] = React.useState(query.page - 1) // Convert to 0-based index + const [limit, setLimit] = useState(query.limit) + const router = useRouter() const { dataset } = useDatasetDetailContext() const [notionPageSelectorModalVisible, setNotionPageSelectorModalVisible] = useState(false) @@ -103,6 +107,45 @@ const Documents: FC = ({ datasetId }) => { const embeddingAvailable = !!dataset?.embedding_available const debouncedSearchValue = useDebounce(searchValue, { wait: 500 }) + // Initialize search value from URL on mount + useEffect(() => { + if (query.keyword) { + setInputValue(query.keyword) + setSearchValue(query.keyword) + } + }, []) // Only run on mount + + // Sync local state with URL query changes + useEffect(() => { + setCurrPage(query.page - 1) + setLimit(query.limit) + if (query.keyword !== searchValue) { + setInputValue(query.keyword) + setSearchValue(query.keyword) + } + }, [query]) + + // Update URL when pagination changes + const handlePageChange = (newPage: number) => { + setCurrPage(newPage) + updateQuery({ page: newPage + 1 }) // Convert to 1-based index + } + + // Update URL when limit changes + const handleLimitChange = (newLimit: number) => { + setLimit(newLimit) + setCurrPage(0) // Reset to first page when limit changes + updateQuery({ limit: newLimit, page: 1 }) + } + + // Update URL when search changes + useEffect(() => { + if (debouncedSearchValue !== query.keyword) { + setCurrPage(0) // Reset to first page when search changes + updateQuery({ keyword: debouncedSearchValue, page: 1 }) + } + }, [debouncedSearchValue, query.keyword, updateQuery]) + const { data: documentsRes, isFetching: isListLoading } = useDocumentList({ datasetId, query: { @@ -327,9 +370,9 @@ const Documents: FC = ({ datasetId }) => { pagination={{ total, limit, - onLimitChange: setLimit, + onLimitChange: handleLimitChange, current: currPage, - onChange: setCurrPage, + onChange: handlePageChange, }} onManageMetadata={showEditMetadataModal} /> diff --git a/web/app/components/datasets/documents/list.tsx b/web/app/components/datasets/documents/list.tsx index cb349ee01c..c54b6a1a37 100644 --- a/web/app/components/datasets/documents/list.tsx +++ b/web/app/components/datasets/documents/list.tsx @@ -11,6 +11,8 @@ import { RiEqualizer2Line, RiLoopLeftLine, RiMoreFill, + RiPauseCircleLine, + RiPlayCircleLine, } from '@remixicon/react' import { useContext } from 'use-context-selector' import { useRouter } from 'next/navigation' @@ -42,7 +44,7 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from ' import type { Props as PaginationProps } from '@/app/components/base/pagination' import Pagination from '@/app/components/base/pagination' import Checkbox from '@/app/components/base/checkbox' -import { useDocumentArchive, useDocumentDelete, useDocumentDisable, useDocumentEnable, useDocumentUnArchive, useSyncDocument, useSyncWebsite } from '@/service/knowledge/use-document' +import { useDocumentArchive, useDocumentDelete, useDocumentDisable, useDocumentEnable, useDocumentPause, useDocumentResume, useDocumentUnArchive, useSyncDocument, useSyncWebsite } from '@/service/knowledge/use-document' import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type' import useBatchEditDocumentMetadata from '../metadata/hooks/use-batch-edit-document-metadata' import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal' @@ -168,7 +170,7 @@ export const StatusItem: FC<{
} -type OperationName = 'delete' | 'archive' | 'enable' | 'disable' | 'sync' | 'un_archive' +type OperationName = 'delete' | 'archive' | 'enable' | 'disable' | 'sync' | 'un_archive' | 'pause' | 'resume' // operation action for list and detail export const OperationAction: FC<{ @@ -180,13 +182,14 @@ export const OperationAction: FC<{ id: string data_source_type: string doc_form: string + display_status?: string } datasetId: string onUpdate: (operationName?: string) => void scene?: 'list' | 'detail' className?: string }> = ({ embeddingAvailable, datasetId, detail, onUpdate, scene = 'list', className = '' }) => { - const { id, enabled = false, archived = false, data_source_type } = detail || {} + const { id, enabled = false, archived = false, data_source_type, display_status } = detail || {} const [showModal, setShowModal] = useState(false) const [deleting, setDeleting] = useState(false) const { notify } = useContext(ToastContext) @@ -199,6 +202,8 @@ export const OperationAction: FC<{ const { mutateAsync: deleteDocument } = useDocumentDelete() const { mutateAsync: syncDocument } = useSyncDocument() const { mutateAsync: syncWebsite } = useSyncWebsite() + const { mutateAsync: pauseDocument } = useDocumentPause() + const { mutateAsync: resumeDocument } = useDocumentResume() const isListScene = scene === 'list' const onOperate = async (operationName: OperationName) => { @@ -222,6 +227,12 @@ export const OperationAction: FC<{ else opApi = syncWebsite break + case 'pause': + opApi = pauseDocument + break + case 'resume': + opApi = resumeDocument + break default: opApi = deleteDocument setDeleting(true) @@ -323,6 +334,18 @@ export const OperationAction: FC<{ )} + {!archived && display_status?.toLowerCase() === 'indexing' && ( +
onOperate('pause')}> + + {t('datasetDocuments.list.action.pause')} +
+ )} + {!archived && display_status?.toLowerCase() === 'paused' && ( +
onOperate('resume')}> + + {t('datasetDocuments.list.action.resume')} +
+ )} {!archived &&
onOperate('archive')}> {t('datasetDocuments.list.action.archive')} @@ -575,7 +598,6 @@ const DocumentList: FC = ({ ) }} /> - {/* {doc.position} */} {index + 1}
@@ -626,7 +648,7 @@ const DocumentList: FC = ({ diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 08a61b7391..9b36fc6020 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -36,7 +36,7 @@ import { useDocLink } from '@/context/i18n' export default function AppSelector() { const itemClassName = ` - flex items-center w-full h-9 pl-3 pr-2 text-text-secondary system-md-regular + flex items-center w-full h-8 pl-3 pr-2 text-text-secondary system-md-regular rounded-lg hover:bg-state-base-hover cursor-pointer gap-1 ` const router = useRouter() @@ -87,24 +87,24 @@ export default function AppSelector() { backdrop-blur-sm focus:outline-none " > - -
-
-
- {userProfile.name} - {isEducationAccount && ( - - - EDU - - )} +
+ +
+
+
+ {userProfile.name} + {isEducationAccount && ( + + + EDU + + )} +
+
{userProfile.email}
-
{userProfile.email}
+
- -
- -
+ setAppDetail()} className={classNames(` - flex items-center h-7 px-2.5 cursor-pointer rounded-[10px] + flex h-7 cursor-pointer items-center rounded-[10px] px-2.5 ${isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text'} ${curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover'} `)} diff --git a/web/app/components/header/plugins-nav/index.tsx b/web/app/components/header/plugins-nav/index.tsx index b1f903bafb..7b28e27639 100644 --- a/web/app/components/header/plugins-nav/index.tsx +++ b/web/app/components/header/plugins-nav/index.tsx @@ -31,8 +31,8 @@ const PluginsNav = ({ )}>
diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index 5e4b621796..8d9f3a5b10 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -1,7 +1,7 @@ 'use client' import { useTheme } from 'next-themes' import { RiArrowRightUpLine } from '@remixicon/react' -import { getPluginLinkInMarketplace } from '../utils' +import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils' import Card from '@/app/components/plugins/card' import CardMoreInfo from '@/app/components/plugins/card/card-more-info' import type { Plugin } from '@/app/components/plugins/types' @@ -83,7 +83,7 @@ const CardWrapper = ({ return ( { + if (plugin.type === 'bundle') + return `/bundles/${plugin.org}/${plugin.name}` + return `/plugins/${plugin.org}/${plugin.name}` +} + export const getMarketplacePluginsByCollectionId = async (collectionId: string, query?: CollectionsAndPluginsSearchParams) => { let plugins = [] as Plugin[] diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx index 0970daab9c..b0b4f8a8bc 100644 --- a/web/app/components/tools/provider-list.tsx +++ b/web/app/components/tools/provider-list.tsx @@ -72,7 +72,7 @@ const ProviderList = () => { className='relative flex grow flex-col overflow-y-auto bg-background-body' >
{ const { t } = useTranslation() @@ -45,6 +48,8 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { const { data: strategyProviders } = useStrategyProviders() const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail) + const map = useNodesAvailableVarList(nodes) + const getCheckData = useCallback((data: CommonNodeType<{}>) => { let checkData = data if (data.type === BlockEnum.KnowledgeRetrieval) { @@ -70,6 +75,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { const node = nodes[i] let toolIcon let moreDataForCheckValid + let usedVars: ValueSelector[] = [] if (node.data.type === BlockEnum.Tool) { const { provider_type } = node.data @@ -84,8 +90,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { if (provider_type === CollectionType.workflow) toolIcon = workflowTools.find(tool => tool.id === node.data.provider_id)?.icon } - - if (node.data.type === BlockEnum.Agent) { + else if (node.data.type === BlockEnum.Agent) { const data = node.data as AgentNodeType const isReadyForCheckValid = !!strategyProviders const provider = strategyProviders?.find(provider => provider.declaration.identity.name === data.agent_strategy_provider_name) @@ -97,10 +102,34 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { isReadyForCheckValid, } } + else { + usedVars = getNodeUsedVars(node).filter(v => v.length > 0) + } if (node.type === CUSTOM_NODE) { const checkData = getCheckData(node.data) - const { errorMessage } = nodesExtraData[node.data.type].checkValid(checkData, t, moreDataForCheckValid) + let { errorMessage } = nodesExtraData[node.data.type].checkValid(checkData, t, moreDataForCheckValid) + + if (!errorMessage) { + const availableVars = map[node.id].availableVars + + for (const variable of usedVars) { + const isEnv = isENV(variable) + const isConvVar = isConversationVar(variable) + const isSysVar = isSystemVar(variable) + if (!isEnv && !isConvVar && !isSysVar) { + const usedNode = availableVars.find(v => v.nodeId === variable?.[0]) + if (usedNode) { + const usedVar = usedNode.vars.find(v => v.variable === variable?.[1]) + if (!usedVar) + errorMessage = t('workflow.errorMsg.invalidVariable') + } + else { + errorMessage = t('workflow.errorMsg.invalidVariable') + } + } + } + } if (errorMessage || !validNodes.find(n => n.id === node.id)) { list.push({ diff --git a/web/app/components/workflow/hooks/use-nodes-available-var-list.ts b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts new file mode 100644 index 0000000000..5efe2519ef --- /dev/null +++ b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts @@ -0,0 +1,75 @@ +import { + useIsChatMode, + useWorkflow, + useWorkflowVariables, +} from '@/app/components/workflow/hooks' +import { BlockEnum, type Node, type NodeOutPutVar, type ValueSelector, type Var } from '@/app/components/workflow/types' +type Params = { + onlyLeafNodeVar?: boolean + hideEnv?: boolean + hideChatVar?: boolean + filterVar: (payload: Var, selector: ValueSelector) => boolean + passedInAvailableNodes?: Node[] +} + +const getNodeInfo = (nodeId: string, nodes: Node[]) => { + const allNodes = nodes + const node = allNodes.find(n => n.id === nodeId) + const isInIteration = !!node?.data.isInIteration + const isInLoop = !!node?.data.isInLoop + const parentNodeId = node?.parentId + const parentNode = allNodes.find(n => n.id === parentNodeId) + return { + node, + isInIteration, + isInLoop, + parentNode, + } +} + +// TODO: loop type? +const useNodesAvailableVarList = (nodes: Node[], { + onlyLeafNodeVar, + filterVar, + hideEnv = false, + hideChatVar = false, + passedInAvailableNodes, +}: Params = { + onlyLeafNodeVar: false, + filterVar: () => true, + }) => { + const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() + const { getNodeAvailableVars } = useWorkflowVariables() + const isChatMode = useIsChatMode() + + const nodeAvailabilityMap: { [key: string ]: { availableVars: NodeOutPutVar[], availableNodes: Node[] } } = {} + + nodes.forEach((node) => { + const nodeId = node.id + const availableNodes = passedInAvailableNodes || (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranchIncludeParent(nodeId)) + if (node.data.type === BlockEnum.Loop) + availableNodes.push(node) + + const { + parentNode: iterationNode, + } = getNodeInfo(nodeId, nodes) + + const availableVars = getNodeAvailableVars({ + parentNode: iterationNode, + beforeNodes: availableNodes, + isChatMode, + filterVar, + hideEnv, + hideChatVar, + }) + const result = { + node, + availableVars, + availableNodes, + } + nodeAvailabilityMap[nodeId] = result + }) + return nodeAvailabilityMap +} + +export default useNodesAvailableVarList diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index a69f9a51a7..1058f29119 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -948,9 +948,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { break } case BlockEnum.Answer: { - res = (data as AnswerNodeType).variables?.map((v) => { - return v.value_selector - }) + res = matchNotSystemVars([(data as AnswerNodeType).answer]) break } case BlockEnum.LLM: { @@ -977,6 +975,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { res = (data as IfElseNodeType).conditions?.map((c) => { return c.variable_selector || [] }) || [] + res.push(...((data as IfElseNodeType).cases || []).flatMap(c => (c.conditions || [])).map(c => c.variable_selector || [])) break } case BlockEnum.Code: { @@ -996,6 +995,9 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { res = [payload.query_variable_selector] const varInInstructions = matchNotSystemVars([payload.instruction || '']) res.push(...varInInstructions) + + const classes = payload.classes.map(c => c.name) + res.push(...matchNotSystemVars(classes)) break } case BlockEnum.HttpRequest: { diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx index 73ee8262a0..9eb34ac7f2 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx @@ -75,8 +75,16 @@ const VarList: FC = ({ if (isSupportConstantValue) draft[index].variable_type = VarKindType.variable - if (!draft[index].variable) - draft[index].variable = value[value.length - 1] + if (!draft[index].variable) { + const variables = draft.map(v => v.variable) + let newVarName = value[value.length - 1] + let count = 1 + while (variables.includes(newVarName)) { + newVarName = `${value[value.length - 1]}_${count}` + count++ + } + draft[index].variable = newVarName + } } else { draft[index].variable_type = VarKindType.constant diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx index 83c5d60573..920757c6a6 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx @@ -98,7 +98,7 @@ const CodeEditor: FC = ({ }, []) return ( -
+
{!hideTopMenu && (
diff --git a/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx b/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx index d33e9361ad..0e8650d743 100644 --- a/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx +++ b/web/app/components/workflow/nodes/loop/components/loop-variables/item.tsx @@ -10,6 +10,8 @@ import type { LoopVariable, LoopVariablesComponentShape, } from '@/app/components/workflow/nodes/loop/types' +import { checkKeys, replaceSpaceWithUnderscreInVarNameInput } from '@/utils/var' +import Toast from '@/app/components/base/toast' type ItemProps = { item: LoopVariable @@ -21,7 +23,22 @@ const Item = ({ handleUpdateLoopVariable, }: ItemProps) => { const { t } = useTranslation() + + const checkVariableName = (value: string) => { + const { isValid, errorMessageKey } = checkKeys([value], false) + if (!isValid) { + Toast.notify({ + type: 'error', + message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: t('workflow.env.modal.name') }), + }) + return false + } + return true + } const handleUpdateItemLabel = useCallback((e: any) => { + replaceSpaceWithUnderscreInVarNameInput(e.target) + if (!!e.target.value && !checkVariableName(e.target.value)) + return handleUpdateLoopVariable(item.id, { label: e.target.value }) }, [item.id, handleUpdateLoopVariable]) @@ -44,6 +61,7 @@ const Item = ({ checkVariableName(e.target.value)} autoFocus={!item.label} placeholder={t('workflow.nodes.loop.variableName')} /> diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx index 6065037322..be4a6cb901 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/class-item.tsx @@ -1,11 +1,12 @@ 'use client' import type { FC } from 'react' -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import type { Topic } from '../types' import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import type { ValueSelector, Var } from '@/app/components/workflow/types' +import { uniqueId } from 'lodash-es' const i18nPrefix = 'workflow.nodes.questionClassifiers' @@ -29,6 +30,11 @@ const ClassItem: FC = ({ filterVar, }) => { const { t } = useTranslation() + const [instanceId, setInstanceId] = useState(uniqueId()) + + useEffect(() => { + setInstanceId(`${nodeId}-${uniqueId()}`) + }, [nodeId]) const handleNameChange = useCallback((value: string) => { onChange({ ...payload, name: value }) @@ -52,6 +58,7 @@ const ClassItem: FC = ({ nodesOutputVars={availableVars} availableNodes={availableNodesWithParent} readOnly={readonly} // ? + instanceId={instanceId} justVar // ? isSupportFileVar // ? /> diff --git a/web/app/signin/components/social-auth.tsx b/web/app/signin/components/social-auth.tsx index 4ae0b7f1a7..283c650f23 100644 --- a/web/app/signin/components/social-auth.tsx +++ b/web/app/signin/components/social-auth.tsx @@ -32,7 +32,7 @@ export default function SocialAuth(props: SocialAuthProps) { {t('login.withGitHub')} @@ -50,7 +50,7 @@ export default function SocialAuth(props: SocialAuthProps) { {t('login.withGoogle')} diff --git a/web/i18n/de-DE/dataset-documents.ts b/web/i18n/de-DE/dataset-documents.ts index f52220a669..baf2e69c74 100644 --- a/web/i18n/de-DE/dataset-documents.ts +++ b/web/i18n/de-DE/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Löschen', enableWarning: 'Archivierte Datei kann nicht aktiviert werden', sync: 'Synchronisieren', + resume: 'Fortsetzen', + pause: 'Pause', }, index: { enable: 'Aktivieren', diff --git a/web/i18n/en-US/app.ts b/web/i18n/en-US/app.ts index b6496e9b9c..c6f35d3df2 100644 --- a/web/i18n/en-US/app.ts +++ b/web/i18n/en-US/app.ts @@ -174,6 +174,10 @@ const translation = { title: 'Weave', description: 'Weave is an open-source platform for evaluating, testing, and monitoring LLM applications.', }, + aliyun: { + title: 'LLM observability', + description: 'The SaaS observability platform provided by Alibaba Cloud enables out of box monitoring, tracing, and evaluation of Dify applications.', + }, inUse: 'In use', configProvider: { title: 'Config ', diff --git a/web/i18n/en-US/dataset-documents.ts b/web/i18n/en-US/dataset-documents.ts index 7d0a3541a0..9bcefd6358 100644 --- a/web/i18n/en-US/dataset-documents.ts +++ b/web/i18n/en-US/dataset-documents.ts @@ -30,6 +30,8 @@ const translation = { delete: 'Delete', enableWarning: 'Archived file cannot be enabled', sync: 'Sync', + pause: 'Pause', + resume: 'Resume', }, index: { enable: 'Enable', diff --git a/web/i18n/es-ES/dataset-documents.ts b/web/i18n/es-ES/dataset-documents.ts index 53a6663847..ee6c5bcf74 100644 --- a/web/i18n/es-ES/dataset-documents.ts +++ b/web/i18n/es-ES/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'Eliminar', enableWarning: 'El archivo archivado no puede habilitarse', sync: 'Sincronizar', + resume: 'Reanudar', + pause: 'Pausa', }, index: { enable: 'Habilitar', diff --git a/web/i18n/fa-IR/dataset-documents.ts b/web/i18n/fa-IR/dataset-documents.ts index 048cb5163f..8da7ba8959 100644 --- a/web/i18n/fa-IR/dataset-documents.ts +++ b/web/i18n/fa-IR/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'حذف', enableWarning: 'فایل بایگانی شده نمی‌تواند فعال شود', sync: 'همگام‌سازی', + resume: 'ادامه', + pause: 'مکث', }, index: { enable: 'فعال کردن', diff --git a/web/i18n/fr-FR/dataset-documents.ts b/web/i18n/fr-FR/dataset-documents.ts index d8c0fe4af7..7960a3ed02 100644 --- a/web/i18n/fr-FR/dataset-documents.ts +++ b/web/i18n/fr-FR/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Supprimer', enableWarning: 'Le fichier archivé ne peut pas être activé', sync: 'Synchroniser', + pause: 'Pause', + resume: 'Reprendre', }, index: { enable: 'Activer', diff --git a/web/i18n/hi-IN/dataset-documents.ts b/web/i18n/hi-IN/dataset-documents.ts index 1fcdd49449..4713ea1a89 100644 --- a/web/i18n/hi-IN/dataset-documents.ts +++ b/web/i18n/hi-IN/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'हटाएँ', enableWarning: 'संग्रहित फाइल को सक्रिय नहीं किया जा सकता', sync: 'सिंक्रोनाइज़ करें', + resume: 'रिज़्यूमे', + pause: 'रोकें', }, index: { enable: 'सक्रिय करें', diff --git a/web/i18n/it-IT/dataset-documents.ts b/web/i18n/it-IT/dataset-documents.ts index 2881e1fcee..1651719a27 100644 --- a/web/i18n/it-IT/dataset-documents.ts +++ b/web/i18n/it-IT/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'Elimina', enableWarning: 'Il file archiviato non può essere abilitato', sync: 'Sincronizza', + resume: 'Riassumere', + pause: 'Pausa', }, index: { enable: 'Abilita', diff --git a/web/i18n/ja-JP/dataset-documents.ts b/web/i18n/ja-JP/dataset-documents.ts index e28425dc8f..ac7a94fafc 100644 --- a/web/i18n/ja-JP/dataset-documents.ts +++ b/web/i18n/ja-JP/dataset-documents.ts @@ -30,6 +30,8 @@ const translation = { delete: '削除', enableWarning: 'アーカイブされたファイルは有効にできません', sync: '同期', + pause: '一時停止', + resume: '再開', }, index: { enable: '有効にする', diff --git a/web/i18n/ko-KR/app.ts b/web/i18n/ko-KR/app.ts index fab6d8cba5..e9634bfbde 100644 --- a/web/i18n/ko-KR/app.ts +++ b/web/i18n/ko-KR/app.ts @@ -16,7 +16,8 @@ const translation = { importDSL: 'DSL 파일 가져오기', createFromConfigFile: 'DSL 파일에서 생성하기', deleteAppConfirmTitle: '이 앱을 삭제하시겠습니까?', - deleteAppConfirmContent: '앱을 삭제하면 복구할 수 없습니다. 사용자는 더 이상 앱에 액세스할 수 없으며 모든 프롬프트 설정 및 로그가 영구적으로 삭제됩니다.', + deleteAppConfirmContent: + '앱을 삭제하면 복구할 수 없습니다. 사용자는 더 이상 앱에 액세스할 수 없으며 모든 프롬프트 설정 및 로그가 영구적으로 삭제됩니다.', appDeleted: '앱이 삭제되었습니다', appDeleteFailed: '앱 삭제 실패', join: '커뮤니티에 참여하기', @@ -26,20 +27,25 @@ const translation = { startFromBlank: '빈 상태로 시작', startFromTemplate: '템플릿에서 시작', captionAppType: '어떤 종류의 앱을 만들어 보시겠어요?', - chatbotDescription: '대화형 어플리케이션을 만듭니다. 질문과 답변 형식을 사용하여 다단계 대화를 지원합니다.', - completionDescription: '프롬프트를 기반으로 품질 높은 텍스트를 생성하는 어플리케이션을 만듭니다. 기사, 요약, 번역 등을 생성할 수 있습니다.', + chatbotDescription: + '대화형 어플리케이션을 만듭니다. 질문과 답변 형식을 사용하여 다단계 대화를 지원합니다.', + completionDescription: + '프롬프트를 기반으로 품질 높은 텍스트를 생성하는 어플리케이션을 만듭니다. 기사, 요약, 번역 등을 생성할 수 있습니다.', completionWarning: '이 종류의 앱은 더 이상 지원되지 않습니다.', agentDescription: '작업을 자동으로 완료하는 지능형 에이전트를 만듭니다.', - workflowDescription: '고도로 사용자 지정 가능한 워크플로우에 기반한 고품질 텍스트 생성 어플리케이션을 만듭니다. 경험 있는 사용자를 위한 것입니다.', + workflowDescription: + '고도로 사용자 지정 가능한 워크플로우에 기반한 고품질 텍스트 생성 어플리케이션을 만듭니다. 경험 있는 사용자를 위한 것입니다.', workflowWarning: '현재 베타 버전입니다.', chatbotType: '챗봇 오케스트레이션 방식', basic: '기본', basicTip: '초보자용. 나중에 Chatflow 로 전환할 수 있습니다.', basicFor: '초보자용', - basicDescription: '기본 오케스트레이션은 내장된 프롬프트를 수정할 수 없고 간단한 설정을 사용하여 챗봇 앱을 오케스트레이션합니다. 초보자용입니다.', + basicDescription: + '기본 오케스트레이션은 내장된 프롬프트를 수정할 수 없고 간단한 설정을 사용하여 챗봇 앱을 오케스트레이션합니다. 초보자용입니다.', advanced: 'Chatflow', advancedFor: '고급 사용자용', - advancedDescription: '워크플로우 오케스트레이션은 워크플로우 형식으로 챗봇을 오케스트레이션하며 내장된 프롬프트를 편집할 수 있는 고급 사용자 정의 기능을 제공합니다. 경험이 많은 사용자용입니다.', + advancedDescription: + '워크플로우 오케스트레이션은 워크플로우 형식으로 챗봇을 오케스트레이션하며 내장된 프롬프트를 편집할 수 있는 고급 사용자 정의 기능을 제공합니다. 경험이 많은 사용자용입니다.', captionName: '앱 아이콘과 이름', appNamePlaceholder: '앱 이름을 입력하세요', captionDescription: '설명', @@ -47,10 +53,12 @@ const translation = { useTemplate: '이 템플릿 사용', previewDemo: '데모 미리보기', chatApp: '어시스턴트', - chatAppIntro: '대화형 어플리케이션을 만들고 싶어요. 이 어플리케이션은 질문과 답변 형식을 사용하여 다단계 대화를 지원합니다.', + chatAppIntro: + '대화형 어플리케이션을 만들고 싶어요. 이 어플리케이션은 질문과 답변 형식을 사용하여 다단계 대화를 지원합니다.', agentAssistant: '새로운 에이전트 어시스턴트', completeApp: '텍스트 생성기', - completeAppIntro: '프롬프트를 기반으로 품질 높은 텍스트를 생성하는 어플리케이션을 만들고 싶어요. 기사, 요약, 번역 등을 생성합니다.', + completeAppIntro: + '프롬프트를 기반으로 품질 높은 텍스트를 생성하는 어플리케이션을 만들고 싶어요. 기사, 요약, 번역 등을 생성합니다.', showTemplates: '템플릿 선택', hideTemplates: '모드 선택으로 돌아가기', Create: '만들기', @@ -66,13 +74,16 @@ const translation = { appCreateDSLErrorTitle: '버전 비호환성', appCreateDSLErrorPart2: '계속하시겠습니까?', appCreateDSLErrorPart3: '현재 응용 프로그램 DSL 버전:', - appCreateDSLWarning: '주의: DSL 버전 차이는 특정 기능에 영향을 미칠 수 있습니다.', - appCreateDSLErrorPart1: 'DSL 버전에서 상당한 차이가 감지되었습니다. 강제로 가져오면 응용 프로그램이 오작동할 수 있습니다.', + appCreateDSLWarning: + '주의: DSL 버전 차이는 특정 기능에 영향을 미칠 수 있습니다.', + appCreateDSLErrorPart1: + 'DSL 버전에서 상당한 차이가 감지되었습니다. 강제로 가져오면 응용 프로그램이 오작동할 수 있습니다.', chooseAppType: '앱 유형 선택', forBeginners: '초보자용 기본 앱 유형', forAdvanced: '고급 사용자용', chatbotShortDescription: '간단한 설정으로 LLM 기반 챗봇', - workflowUserDescription: '드래그 앤 드롭으로 자율 AI 워크플로우를 시각적으로 구축', + workflowUserDescription: + '드래그 앤 드롭으로 자율 AI 워크플로우를 시각적으로 구축', noTemplateFoundTip: '다른 키워드를 사용하여 검색해 보십시오.', noIdeaTip: '아이디어가 없으신가요? 템플릿을 확인해 보세요', optional: '선택적', @@ -80,16 +91,20 @@ const translation = { completionShortDescription: '텍스트 생성 작업을 위한 AI 도우미', learnMore: '더 알아보세요', foundResults: '{{개수}} 결과', - agentShortDescription: '추론 및 자율적인 도구 사용 기능이 있는 지능형 에이전트', + agentShortDescription: + '추론 및 자율적인 도구 사용 기능이 있는 지능형 에이전트', advancedShortDescription: '다중 대화를 위해 강화된 워크플로우', noAppsFound: '앱을 찾을 수 없습니다.', foundResult: '{{개수}} 결과', - completionUserDescription: '간단한 구성으로 텍스트 생성 작업을 위한 AI 도우미를 빠르게 구축합니다.', - chatbotUserDescription: '간단한 구성으로 LLM 기반 챗봇을 빠르게 구축할 수 있습니다. 나중에 Chatflow 로 전환할 수 있습니다.', + completionUserDescription: + '간단한 구성으로 텍스트 생성 작업을 위한 AI 도우미를 빠르게 구축합니다.', + chatbotUserDescription: + '간단한 구성으로 LLM 기반 챗봇을 빠르게 구축할 수 있습니다. 나중에 Chatflow 로 전환할 수 있습니다.', workflowShortDescription: '지능형 자동화를 위한 에이전트 플로우', - agentUserDescription: '작업 목표를 달성하기 위해 반복적인 추론과 자율적인 도구를 사용할 수 있는 지능형 에이전트입니다.', + agentUserDescription: + '작업 목표를 달성하기 위해 반복적인 추론과 자율적인 도구를 사용할 수 있는 지능형 에이전트입니다.', advancedUserDescription: '메모리 기능과 챗봇 인터페이스를 갖춘 워크플로우', - dropDSLToCreateApp: '여기에 DSL 파일을 드롭하여 앱을 만드세요.', + dropDSLToCreateApp: '여기에 DSL 파일을 드롭하여 앱을 불러오세요.', }, editApp: '정보 편집하기', editAppTitle: '앱 정보 편집하기', @@ -102,7 +117,8 @@ const translation = { image: '이미지', }, switch: '워크플로우 오케스트레이션으로 전환하기', - switchTipStart: '새로운 앱의 복사본이 생성되어 새로운 복사본이 워크플로우 오케스트레이션으로 전환됩니다. 새로운 복사본은 ', + switchTipStart: + '새로운 앱의 복사본이 생성되어 새로운 복사본이 워크플로우 오케스트레이션으로 전환됩니다. 새로운 복사본은 ', switchTip: '전환을 허용하지 않습니다', switchTipEnd: ' 기본적인 오케스트레이션으로 되돌릴 수 없습니다.', switchLabel: '생성될 앱의 복사본', @@ -126,7 +142,8 @@ const translation = { disabled: '비활성화됨', disabledTip: '먼저 제공업체를 구성해 주세요', enabled: '서비스 중', - tracingDescription: 'LLM 호출, 컨텍스트, 프롬프트, HTTP 요청 등 앱 실행의 전체 컨텍스트를 제 3 자 추적 플랫폼에 캡처합니다.', + tracingDescription: + 'LLM 호출, 컨텍스트, 프롬프트, HTTP 요청 등 앱 실행의 전체 컨텍스트를 제 3 자 추적 플랫폼에 캡처합니다.', configProviderTitle: { configured: '구성됨', notConfigured: '추적을 활성화하려면 제공업체를 구성하세요', @@ -134,19 +151,23 @@ const translation = { }, arize: { title: 'Arize', - description: '엔터프라이즈급 LLM 가시성, 온라인 및 오프라인 평가, 모니터링 및 실험—OpenTelemetry를 기반으로 합니다. LLM 및 에이전트 기반 애플리케이션을 위해 특별히 설계되었습니다.', + description: + '엔터프라이즈급 LLM 가시성, 온라인 및 오프라인 평가, 모니터링 및 실험—OpenTelemetry를 기반으로 합니다. LLM 및 에이전트 기반 애플리케이션을 위해 특별히 설계되었습니다.', }, phoenix: { title: 'Phoenix', - description: '오픈소스 및 OpenTelemetry 기반의 가시성, 평가, 프롬프트 엔지니어링 및 실험 플랫폼으로, LLM 워크플로우 및 에이전트를 지원합니다.', + description: + '오픈소스 및 OpenTelemetry 기반의 가시성, 평가, 프롬프트 엔지니어링 및 실험 플랫폼으로, LLM 워크플로우 및 에이전트를 지원합니다.', }, langsmith: { title: 'LangSmith', - description: 'LLM 기반 애플리케이션 수명 주기의 모든 단계를 위한 올인원 개발자 플랫폼.', + description: + 'LLM 기반 애플리케이션 수명 주기의 모든 단계를 위한 올인원 개발자 플랫폼.', }, langfuse: { title: 'Langfuse', - description: 'LLM 애플리케이션을 디버그하고 개선하기 위한 추적, 평가, 프롬프트 관리 및 메트릭.', + description: + 'LLM 애플리케이션을 디버그하고 개선하기 위한 추적, 평가, 프롬프트 관리 및 메트릭.', }, inUse: '사용 중', configProvider: { @@ -157,22 +178,27 @@ const translation = { secretKey: '비밀 키', viewDocsLink: '{{key}} 문서 보기', removeConfirmTitle: '{{key}} 구성을 제거하시겠습니까?', - removeConfirmContent: '현재 구성이 사용 중입니다. 제거하면 추적 기능이 꺼집니다.', + removeConfirmContent: + '현재 구성이 사용 중입니다. 제거하면 추적 기능이 꺼집니다.', }, view: '보기', opik: { title: '오픽', - description: 'Opik 은 LLM 애플리케이션을 평가, 테스트 및 모니터링하기 위한 오픈 소스 플랫폼입니다.', + description: + 'Opik 은 LLM 애플리케이션을 평가, 테스트 및 모니터링하기 위한 오픈 소스 플랫폼입니다.', }, weave: { title: '직조하다', - description: 'Weave 는 LLM 애플리케이션을 평가하고 테스트하며 모니터링하기 위한 오픈 소스 플랫폼입니다.', + description: + 'Weave 는 LLM 애플리케이션을 평가하고 테스트하며 모니터링하기 위한 오픈 소스 플랫폼입니다.', }, }, answerIcon: { - description: 'web app 아이콘을 사용하여 공유 응용 프로그램에서 바꿀🤖지 여부', + description: + 'web app 아이콘을 사용하여 공유 응용 프로그램에서 바꿀🤖지 여부', title: 'web app 아이콘을 사용하여 🤖', - descriptionInExplore: 'Explore 에서 web app 아이콘을 사용하여 바꿀🤖지 여부', + descriptionInExplore: + 'Explore 에서 web app 아이콘을 사용하여 바꿀🤖지 여부', }, importFromDSL: 'DSL 에서 가져오기', importFromDSLFile: 'DSL 파일에서', @@ -211,8 +237,10 @@ const translation = { structured: '구조화된', configure: '설정하다', moreFillTip: '최대 10 단계 중첩을 표시합니다.', - modelNotSupportedTip: '현재 모델은 이 기능을 지원하지 않으며 자동으로 프롬프트 주입으로 다운그레이드됩니다.', - structuredTip: '구조화된 출력은 모델이 제공한 JSON 스키마를 항상 준수하는 응답을 생성하도록 보장하는 기능입니다.', + modelNotSupportedTip: + '현재 모델은 이 기능을 지원하지 않으며 자동으로 프롬프트 주입으로 다운그레이드됩니다.', + structuredTip: + '구조화된 출력은 모델이 제공한 JSON 스키마를 항상 준수하는 응답을 생성하도록 보장하는 기능입니다.', }, accessItemsDescription: { anyone: '누구나 웹 앱에 접근할 수 있습니다.', @@ -240,7 +268,8 @@ const translation = { members_one: '{{count}} 회원', members_other: '{{count}} 회원', noGroupsOrMembers: '선택된 그룹 또는 멤버가 없습니다.', - webAppSSONotEnabledTip: '웹 앱 인증 방법을 구성하려면 엔터프라이즈 관리자인에게 문의하십시오.', + webAppSSONotEnabledTip: + '웹 앱 인증 방법을 구성하려면 엔터프라이즈 관리자인에게 문의하십시오.', updateSuccess: '업데이트가 성공적으로 완료되었습니다.', description: '웹 앱 접근 권한 설정', }, diff --git a/web/i18n/ko-KR/billing.ts b/web/i18n/ko-KR/billing.ts index 87ccf27fe0..fbb2609adc 100644 --- a/web/i18n/ko-KR/billing.ts +++ b/web/i18n/ko-KR/billing.ts @@ -9,7 +9,7 @@ const translation = { buyPermissionDeniedTip: '구독하려면 엔터프라이즈 관리자에게 문의하세요', plansCommon: { title: '당신에게 맞는 요금제를 선택하세요', - yearlyTip: '연간 구독 시 2 개월 무료!', + yearlyTip: '연간 구독 시 2개월 무료!', mostPopular: '가장 인기 있는', planRange: { monthly: '월간', @@ -20,21 +20,25 @@ const translation = { save: '절약 ', free: '무료', currentPlan: '현재 요금제', - contractSales: '영업에 문의하기', + contractSales: '영업팀에 문의하기', contractOwner: '팀 관리자에게 문의하기', startForFree: '무료로 시작하기', getStartedWith: '시작하기 ', - contactSales: '영업에 문의하기', - talkToSales: '영업과 상담하기', + contactSales: '영업팀에 문의하기', + talkToSales: '영업팀과 상담하기', modelProviders: '모델 제공자', teamMembers: '팀 멤버', buildApps: '앱 만들기', vectorSpace: '벡터 공간', - vectorSpaceBillingTooltip: '1MB 당 약 120 만 글자의 벡터화된 데이터를 저장할 수 있습니다 (OpenAI Embeddings 을 기반으로 추정되며 모델에 따라 다릅니다).', - vectorSpaceTooltip: '벡터 공간은 LLM 이 데이터를 이해하는 데 필요한 장기 기억 시스템입니다.', + vectorSpaceBillingTooltip: + '1MB 당 약 120 만 글자의 벡터화된 데이터를 저장할 수 있습니다 (OpenAI Embeddings 을 기반으로 추정되며 모델에 따라 다릅니다).', + vectorSpaceTooltip: + '벡터 공간은 LLM 이 데이터를 이해하는 데 필요한 장기 기억 시스템입니다.', documentProcessingPriority: '문서 처리 우선순위', - documentProcessingPriorityTip: '더 높은 문서 처리 우선순위를 원하시면 요금제를 업그레이드하세요.', - documentProcessingPriorityUpgrade: '더 높은 정확성과 빠른 속도로 데이터를 처리합니다.', + documentProcessingPriorityTip: + '더 높은 문서 처리 우선순위를 원하시면 요금제를 업그레이드하세요.', + documentProcessingPriorityUpgrade: + '더 높은 정확성과 빠른 속도로 데이터를 처리합니다.', priority: { 'standard': '표준', 'priority': '우선', @@ -60,31 +64,35 @@ const translation = { workflow: '워크플로우', llmLoadingBalancing: 'LLM 로드 밸런싱', bulkUpload: '문서 대량 업로드', - llmLoadingBalancingTooltip: '모델에 여러 API 키를 추가하여 API 속도 제한을 효과적으로 우회할 수 있습니다.', + llmLoadingBalancingTooltip: + '모델에 여러 API 키를 추가하여 API 속도 제한을 효과적으로 우회할 수 있습니다.', }, comingSoon: '곧 출시 예정', member: '멤버', memberAfter: '멤버', messageRequest: { title: '메시지 크레딧', - tooltip: 'GPT 제외 다양한 요금제에서의 메시지 호출 쿼터 (gpt4 제외). 제한을 초과하는 메시지는 OpenAI API 키를 사용합니다.', + tooltip: + 'GPT 제외 다양한 요금제에서의 메시지 호출 쿼터 (gpt4 제외). 제한을 초과하는 메시지는 OpenAI API 키를 사용합니다.', titlePerMonth: '{{count,number}} 메시지/월', }, annotatedResponse: { title: '주석 응답 쿼터', - tooltip: '수동으로 편집 및 응답 주석 달기로 앱의 사용자 정의 가능한 고품질 질의응답 기능을 제공합니다 (채팅 앱에만 해당).', + tooltip: + '수동으로 편집 및 응답 주석 달기로 앱의 사용자 정의 가능한 고품질 질의응답 기능을 제공합니다 (채팅 앱에만 해당).', }, - ragAPIRequestTooltip: 'Dify 의 지식베이스 처리 기능을 호출하는 API 호출 수를 나타냅니다.', + ragAPIRequestTooltip: + 'Dify 의 지식베이스 처리 기능을 호출하는 API 호출 수를 나타냅니다.', receiptInfo: '팀 소유자 및 팀 관리자만 구독 및 청구 정보를 볼 수 있습니다', annotationQuota: 'Annotation Quota(주석 할당량)', documentsUploadQuota: '문서 업로드 할당량', - freeTrialTipPrefix: '가입하고 받으세요', + freeTrialTipPrefix: '요금제에 가입하고 ', comparePlanAndFeatures: '계획 및 기능 비교', documents: '{{count,number}} 지식 문서', apiRateLimit: 'API 요금 한도', cloud: '클라우드 서비스', unlimitedApiRate: 'API 호출 속도 제한 없음', - freeTrialTip: '200 회의 OpenAI 호출에 대한 무료 체험.', + freeTrialTip: '200 회의 OpenAI 호출 무료 체험을 받으세요. ', annualBilling: '연간 청구', getStarted: '시작하기', apiRateLimitUnit: '{{count,number}}/일', @@ -94,10 +102,13 @@ const translation = { teamMember_other: '{{count,number}} 팀원', teamMember_one: '{{count,number}} 팀원', priceTip: '작업 공간당/', - apiRateLimitTooltip: 'Dify API 를 통한 모든 요청에는 API 요금 한도가 적용되며, 여기에는 텍스트 생성, 채팅 대화, 워크플로 실행 및 문서 처리가 포함됩니다.', + apiRateLimitTooltip: + 'Dify API 를 통한 모든 요청에는 API 요금 한도가 적용되며, 여기에는 텍스트 생성, 채팅 대화, 워크플로 실행 및 문서 처리가 포함됩니다.', documentsRequestQuota: '{{count,number}}/분 지식 요청 비율 제한', - documentsTooltip: '지식 데이터 소스에서 가져올 수 있는 문서 수에 대한 쿼터.', - documentsRequestQuotaTooltip: '지식 기반 내에서 작업 공간이 분당 수행할 수 있는 총 작업 수를 지정합니다. 여기에는 데이터 세트 생성, 삭제, 업데이트, 문서 업로드, 수정, 보관 및 지식 기반 쿼리가 포함됩니다. 이 지표는 지식 기반 요청의 성능을 평가하는 데 사용됩니다. 예를 들어, 샌드박스 사용자가 1 분 이내에 10 회의 연속 히트 테스트를 수행하면, 해당 작업 공간은 다음 1 분 동안 데이터 세트 생성, 삭제, 업데이트 및 문서 업로드 또는 수정과 같은 작업을 수행하는 것이 일시적으로 제한됩니다.', + documentsTooltip: + '지식 데이터 소스에서 가져올 수 있는 문서 수에 대한 쿼터.', + documentsRequestQuotaTooltip: + '지식 기반 내에서 작업 공간이 분당 수행할 수 있는 총 작업 수를 지정합니다. 여기에는 데이터 세트 생성, 삭제, 업데이트, 문서 업로드, 수정, 보관 및 지식 기반 쿼리가 포함됩니다. 이 지표는 지식 기반 요청의 성능을 평가하는 데 사용됩니다. 예를 들어, 샌드박스 사용자가 1 분 이내에 10 회의 연속 히트 테스트를 수행하면, 해당 작업 공간은 다음 1 분 동안 데이터 세트 생성, 삭제, 업데이트 및 문서 업로드 또는 수정과 같은 작업을 수행하는 것이 일시적으로 제한됩니다.', }, plans: { sandbox: { @@ -108,9 +119,10 @@ const translation = { }, professional: { name: '프로페셔널', - description: '개인 및 소규모 팀을 위해 더 많은 파워를 저렴한 가격에 제공합니다.', + description: + '개인 및 소규모 팀을 위해 더 많은 파워를 저렴한 가격에 제공합니다.', includesTitle: '무료 플랜에 추가로 포함된 항목:', - for: '독립 개발자/소규모 팀을 위한', + for: '1인 개발자/소규모 팀을 위한', }, team: { name: '팀', @@ -120,7 +132,8 @@ const translation = { }, enterprise: { name: '엔터프라이즈', - description: '대규모 미션 크리티컬 시스템을 위한 완전한 기능과 지원을 제공합니다.', + description: + '대규모 미션 크리티컬 시스템을 위한 완전한 기능과 지원을 제공합니다.', includesTitle: '팀 플랜에 추가로 포함된 항목:', features: { 2: '독점 기업 기능', @@ -178,7 +191,8 @@ const translation = { contactUs: '문의하기', fullTip1: '업그레이드하여 더 많은 앱을 만들기', fullTip2: '계획 한도에 도달했습니다.', - fullTip2des: '비활성 애플리케이션을 정리하여 사용량을 줄이거나 저희에게 문의하는 것이 좋습니다.', + fullTip2des: + '비활성 애플리케이션을 정리하여 사용량을 줄이거나 저희에게 문의하는 것이 좋습니다.', fullTip1des: '이 계획에서 앱을 구축할 수 있는 한계에 도달했습니다.', }, annotatedResponse: { @@ -192,7 +206,8 @@ const translation = { teamMembers: '팀원들', buildApps: '앱 만들기', documentsUploadQuota: '문서 업로드 한도', - vectorSpaceTooltip: '고품질 색인 모드를 사용하는 문서는 지식 데이터 저장소 자원을 소모합니다. 지식 데이터 저장소가 한도에 도달하면 새 문서를 업로드할 수 없습니다.', + vectorSpaceTooltip: + '고품질 색인 모드를 사용하는 문서는 지식 데이터 저장소 자원을 소모합니다. 지식 데이터 저장소가 한도에 도달하면 새 문서를 업로드할 수 없습니다.', }, teamMembers: '팀원들', } diff --git a/web/i18n/ko-KR/dataset-documents.ts b/web/i18n/ko-KR/dataset-documents.ts index ee94a880a0..77c94c7466 100644 --- a/web/i18n/ko-KR/dataset-documents.ts +++ b/web/i18n/ko-KR/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: '삭제', enableWarning: '아카이브된 파일은 활성화할 수 없습니다.', sync: '동기화', + resume: '이력서', + pause: '일시 중지', }, index: { enable: '활성화', diff --git a/web/i18n/ko-KR/education.ts b/web/i18n/ko-KR/education.ts index eba00b0f9f..2dfc111d46 100644 --- a/web/i18n/ko-KR/education.ts +++ b/web/i18n/ko-KR/education.ts @@ -2,7 +2,8 @@ const translation = { toVerifiedTip: { end: 'Dify 프로페셔널 플랜을 위해.', coupon: '독점 100% 쿠폰', - front: '당신은 이제 교육 인증 상태를 받을 자격이 있습니다. 아래에 귀하의 교육 정보를 입력하여 과정을 완료하고 인증을 받으십시오.', + front: + '당신은 이제 교육 인증 상태를 받을 자격이 있습니다. 아래에 귀하의 교육 정보를 입력하여 과정을 완료하고 인증을 받으십시오.', }, form: { schoolName: { @@ -26,15 +27,18 @@ const translation = { privacyPolicy: '개인정보 보호정책', }, option: { - inSchool: '나는 제공된 기관에 재학 중이거나 고용되어 있음을 확인합니다. Dify 는 재학증명서나 고용증명서를 요청할 수 있습니다. 만약 내가 자격을 허위로 진술하면, 나는 내 교육 상태에 따라 처음 면제된 수수료를 지불하기로 동의합니다.', - age: '나는 최소한 18 세 이상임을 확인합니다.', + inSchool: + '나는 제공된 기관에 재학 중이거나 고용되어 있음을 확인합니다. Dify 는 재학증명서나 고용증명서를 요청할 수 있습니다. 만약 내가 자격을 허위로 진술하면, 나는 내 교육 상태에 따라 처음 면제된 수수료를 지불하기로 동의합니다.', + age: '만 18세 이상입니다.', }, title: '약관 및 동의사항', }, }, submit: '제출', - rejectContent: '안타깝게도, 귀하는 교육 인증 상태에 적합하지 않으므로 이 이메일 주소를 사용할 경우 Dify Professional Plan 의 독점 100% 쿠폰을 받을 수 없습니다.', - successContent: '귀하의 계정에 Dify Professional 플랜을 위한 100% 할인 쿠폰을 발급했습니다. 이 쿠폰은 1 년간 유효하므로 유효 기간 내에 사용해 주시기 바랍니다.', + rejectContent: + '안타깝게도, 귀하는 교육 인증 상태에 적합하지 않으므로 이 이메일 주소를 사용할 경우 Dify Professional Plan 의 독점 100% 쿠폰을 받을 수 없습니다.', + successContent: + '귀하의 계정에 Dify Professional 플랜을 위한 100% 할인 쿠폰을 발급했습니다. 이 쿠폰은 1 년간 유효하므로 유효 기간 내에 사용해 주시기 바랍니다.', currentSigned: '현재 로그인 중입니다', toVerified: '교육 인증 받기', rejectTitle: '귀하의 Dify 교육 인증이 거부되었습니다.', diff --git a/web/i18n/ko-KR/plugin-tags.ts b/web/i18n/ko-KR/plugin-tags.ts index ddd75ef3a6..bd6e2fed77 100644 --- a/web/i18n/ko-KR/plugin-tags.ts +++ b/web/i18n/ko-KR/plugin-tags.ts @@ -16,7 +16,7 @@ const translation = { image: '이미지', design: '디자인', business: '사업', - agent: '대리인', + agent: '에이전트', }, allTags: '모든 태그', searchTags: '검색 태그', diff --git a/web/i18n/ko-KR/time.ts b/web/i18n/ko-KR/time.ts index 172bb78bd6..1233dbf979 100644 --- a/web/i18n/ko-KR/time.ts +++ b/web/i18n/ko-KR/time.ts @@ -1,12 +1,12 @@ const translation = { daysInWeek: { + Sun: '일요일', + Mon: '월요일', + Tue: '화요일', Wed: '수요일', Thu: '목요일', - Fri: '자유', + Fri: '금요일', Sat: '토요일', - Sun: '태양', - Tue: '화요일', - Mon: '몬', }, months: { May: '5 월', @@ -26,7 +26,7 @@ const translation = { pickDate: '날짜 선택', cancel: '취소', ok: '좋아요', - now: '지금', + now: '오늘', }, title: { pickTime: '시간 선택', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index e3ae99d299..078d683ca2 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -4,9 +4,9 @@ const translation = { redo: '다시 실행', editing: '편집 중', autoSaved: '자동 저장됨', - unpublished: '미발행', - published: '발행됨', - publish: '발행', + unpublished: '게시되지 않음', + published: '게시됨', + publish: '게시하기', update: '업데이트', run: '실행', running: '실행 중', @@ -43,7 +43,8 @@ const translation = { previewPlaceholder: '디버깅을 시작하려면 아래 상자에 내용을 입력하세요', effectVarConfirm: { title: '변수 제거', - content: '변수가 다른 노드에서 사용되고 있습니다. 그래도 제거하시겠습니까?', + content: + '변수가 다른 노드에서 사용되고 있습니다. 그래도 제거하시겠습니까?', }, insertVarTip: '빠르게 삽입하려면 \'/\' 키를 누르세요', processData: '데이터 처리', @@ -58,7 +59,7 @@ const translation = { duplicate: '복제', pasteHere: '여기에 붙여넣기', pointerMode: '포인터 모드', - handMode: '핸드 모드', + handMode: '드래그 모드', model: '모델', workflowAsTool: '도구로서의 워크플로우', configureRequired: '구성 필요', @@ -73,7 +74,8 @@ const translation = { overwriteAndImport: '덮어쓰기 및 가져오기', importSuccess: '가져오기 성공', syncingData: '단 몇 초 만에 데이터를 동기화할 수 있습니다.', - importDSLTip: '현재 초안을 덮어씁니다. 가져오기 전에 워크플로를 백업으로 내보냅니다.', + importDSLTip: + '현재 초안을 덮어씁니다. 가져오기 전에 워크플로우를 백업으로 내보냅니다.', parallelTip: { click: { title: '클릭', @@ -95,9 +97,11 @@ const translation = { featuresDocLink: '더 알아보세요', fileUploadTip: '이미지 업로드 기능이 파일 업로드로 업그레이드되었습니다.', featuresDescription: '웹앱 사용자 경험 향상', - ImageUploadLegacyTip: '이제 시작 양식에서 파일 형식 변수를 만들 수 있습니다. 앞으로 이미지 업로드 기능은 더 이상 지원되지 않습니다.', + ImageUploadLegacyTip: + '이제 시작 양식에서 파일 형식 변수를 만들 수 있습니다. 앞으로 이미지 업로드 기능은 더 이상 지원되지 않습니다.', importWarning: '주의', - importWarningDetails: 'DSL 버전 차이는 특정 기능에 영향을 미칠 수 있습니다.', + importWarningDetails: + 'DSL 버전 차이는 특정 기능에 영향을 미칠 수 있습니다.', openInExplore: 'Explore 에서 열기', onFailure: '실패 시', addFailureBranch: '실패 분기 추가', @@ -118,7 +122,8 @@ const translation = { }, env: { envPanelTitle: '환경 변수', - envDescription: '환경 변수는 개인 정보와 자격 증명을 저장하는 데 사용될 수 있습니다. 이들은 읽기 전용이며 내보내기 중에 DSL 파일과 분리할 수 있습니다.', + envDescription: + '환경 변수는 개인 정보와 자격 증명을 저장하는 데 사용될 수 있습니다. 이들은 읽기 전용이며 내보내기 중에 DSL 파일과 분리할 수 있습니다.', envPanelButton: '변수 추가', modal: { title: '환경 변수 추가', @@ -128,7 +133,8 @@ const translation = { namePlaceholder: '환경 이름', value: '값', valuePlaceholder: '환경 값', - secretTip: '민감한 정보나 데이터를 정의하는 데 사용되며, DSL 설정은 유출 방지를 위해 구성됩니다.', + secretTip: + '민감한 정보나 데이터를 정의하는 데 사용되며, DSL 설정은 유출 방지를 위해 구성됩니다.', }, export: { title: '비밀 환경 변수를 내보내시겠습니까?', @@ -139,7 +145,8 @@ const translation = { }, chatVariable: { panelTitle: '대화 변수', - panelDescription: '대화 변수는 LLM 이 기억해야 할 대화 기록, 업로드된 파일, 사용자 선호도 등의 상호작용 정보를 저장하는 데 사용됩니다. 이들은 읽기 및 쓰기가 가능합니다.', + panelDescription: + '대화 변수는 LLM 이 기억해야 할 대화 기록, 업로드된 파일, 사용자 선호도 등의 상호작용 정보를 저장하는 데 사용됩니다. 이들은 읽기 및 쓰기가 가능합니다.', docLink: '자세한 내용은 문서를 참조하세요.', button: '변수 추가', modal: { @@ -169,7 +176,8 @@ const translation = { placeholder: '아직 아무 것도 변경하지 않았습니다', clearHistory: '기록 지우기', hint: '힌트', - hintText: '편집 작업이 변경 기록에 추적되며, 이 세션 동안 기기에 저장됩니다. 편집기를 떠나면 이 기록이 지워집니다.', + hintText: + '편집 작업이 변경 기록에 추적되며, 이 세션 동안 기기에 저장됩니다. 편집기를 떠나면 이 기록이 지워집니다.', stepBackward_one: '{{count}} 단계 뒤로', stepBackward_other: '{{count}} 단계 뒤로', stepForward_one: '{{count}} 단계 앞으로', @@ -203,7 +211,8 @@ const translation = { visionVariable: '비전 변수', }, invalidVariable: '잘못된 변수', - rerankModelRequired: 'Rerank Model 을 켜기 전에 설정에서 모델이 성공적으로 구성되었는지 확인하십시오.', + rerankModelRequired: + 'Rerank Model 을 켜기 전에 설정에서 모델이 성공적으로 구성되었는지 확인하십시오.', noValidTool: '{{field}} 유효한 도구가 선택되지 않았습니다.', toolParameterRequired: '{{field}}: 매개변수 [{{param}}] 이 필요합니다.', }, @@ -262,22 +271,34 @@ const translation = { 'end': '워크플로우의 종료 및 결과 유형을 정의합니다', 'answer': '대화의 답변 내용을 정의합니다', 'llm': '질문에 답하거나 자연어를 처리하기 위해 대형 언어 모델을 호출합니다', - 'knowledge-retrieval': '사용자 질문과 관련된 텍스트 콘텐츠를 지식 베이스에서 쿼리할 수 있습니다', - 'question-classifier': '사용자 질문의 분류 조건을 정의합니다. LLM 은 분류 설명을 기반으로 대화의 진행 방식을 정의할 수 있습니다', - 'if-else': 'if/else 조건을 기반으로 워크플로우를 두 가지 분기로 나눌 수 있습니다', + 'knowledge-retrieval': + '사용자 질문과 관련된 텍스트 콘텐츠를 지식 베이스에서 쿼리할 수 있습니다', + 'question-classifier': + '사용자 질문의 분류 조건을 정의합니다. LLM 은 분류 설명을 기반으로 대화의 진행 방식을 정의할 수 있습니다', + 'if-else': + 'if/else 조건을 기반으로 워크플로우를 두 가지 분기로 나눌 수 있습니다', 'code': '사용자 정의 논리를 구현하기 위해 Python 또는 NodeJS 코드를 실행합니다', - 'template-transform': 'Jinja 템플릿 구문을 사용하여 데이터를 문자열로 변환합니다', + 'template-transform': + 'Jinja 템플릿 구문을 사용하여 데이터를 문자열로 변환합니다', 'http-request': 'HTTP 프로토콜을 통해 서버 요청을 보낼 수 있습니다', - 'variable-assigner': '다중 분기 변수들을 하나의 변수로 집계하여 다운스트림 노드의 통합 구성을 가능하게 합니다.', - 'assigner': '변수 할당 노드는 쓰기 가능한 변수 (대화 변수 등) 에 값을 할당하는 데 사용됩니다.', - 'variable-aggregator': '다중 분기 변수들을 하나의 변수로 집계하여 다운스트림 노드의 통합 구성을 가능하게 합니다.', - 'iteration': '목록 객체에서 여러 단계를 수행하여 모든 결과가 출력될 때까지 반복합니다.', - 'parameter-extractor': '도구 호출 또는 HTTP 요청을 위해 자연어에서 구조화된 매개변수를 추출하기 위해 LLM 을 사용합니다.', - 'document-extractor': '업로드된 문서를 LLM 에서 쉽게 이해할 수 있는 텍스트 콘텐츠로 구문 분석하는 데 사용됩니다.', + 'variable-assigner': + '다중 분기 변수들을 하나의 변수로 집계하여 다운스트림 노드의 통합 구성을 가능하게 합니다.', + 'assigner': + '변수 할당 노드는 쓰기 가능한 변수 (대화 변수 등) 에 값을 할당하는 데 사용됩니다.', + 'variable-aggregator': + '다중 분기 변수들을 하나의 변수로 집계하여 다운스트림 노드의 통합 구성을 가능하게 합니다.', + 'iteration': + '목록 객체에서 여러 단계를 수행하여 모든 결과가 출력될 때까지 반복합니다.', + 'parameter-extractor': + '도구 호출 또는 HTTP 요청을 위해 자연어에서 구조화된 매개변수를 추출하기 위해 LLM 을 사용합니다.', + 'document-extractor': + '업로드된 문서를 LLM 에서 쉽게 이해할 수 있는 텍스트 콘텐츠로 구문 분석하는 데 사용됩니다.', 'list-operator': '배열 내용을 필터링하거나 정렬하는 데 사용됩니다.', - 'agent': '질문에 답하거나 자연어를 처리하기 위해 대규모 언어 모델을 호출하는 경우', + 'agent': + '질문에 답하거나 자연어를 처리하기 위해 대규모 언어 모델을 호출하는 경우', 'loop': '종료 조건이 충족되거나 최대 반복 횟수에 도달할 때까지 논리 루프를 실행합니다.', - 'loop-end': '"break"와 동일합니다. 이 노드는 구성 항목이 없습니다. 루프 본문이 이 노드에 도달하면 루프가 종료됩니다.', + 'loop-end': + '"break"와 동일합니다. 이 노드는 구성 항목이 없습니다. 루프 본문이 이 노드에 도달하면 루프가 종료됩니다.', }, operator: { zoomIn: '확대', @@ -304,7 +325,7 @@ const translation = { changeBlock: '노드 변경', addNextStep: '이 워크플로우에 다음 단계를 추가하세요.', minimize: '전체 화면 종료', - maximize: '캔버스를 최대화하다', + maximize: '캔버스 전체 화면', }, nodes: { common: { @@ -338,9 +359,12 @@ const translation = { failBranch: { title: '실패 분기', desc: '오류가 발생하면 예외 분기를 실행합니다', - customize: '캔버스로 이동하여 fail branch logic 를 사용자 지정합니다.', - inLog: '노드 예외는 실패 분기를 자동으로 실행합니다. 노드 출력은 오류 유형 및 오류 메시지를 반환하고 다운스트림으로 전달합니다.', - customizeTip: 'fail 분기가 활성화되면 노드에서 throw 된 예외가 프로세스를 종료하지 않습니다. 대신 미리 정의된 실패 분기를 자동으로 실행하여 오류 메시지, 보고서, 수정 사항을 유연하게 제공하거나 작업을 건너뛸 수 있습니다.', + customize: + '캔버스로 이동하여 fail branch logic 를 사용자 지정합니다.', + inLog: + '노드 예외는 실패 분기를 자동으로 실행합니다. 노드 출력은 오류 유형 및 오류 메시지를 반환하고 다운스트림으로 전달합니다.', + customizeTip: + 'fail 분기가 활성화되면 노드에서 throw 된 예외가 프로세스를 종료하지 않습니다. 대신 미리 정의된 실패 분기를 자동으로 실행하여 오류 메시지, 보고서, 수정 사항을 유연하게 제공하거나 작업을 건너뛸 수 있습니다.', }, partialSucceeded: { tip: '프로세스에 {{num}} 노드가 비정상적으로 실행 중입니다. 추적으로 이동하여 로그를 확인하십시오.', @@ -358,7 +382,7 @@ const translation = { retrySuccessful: '재시도 성공', retryFailed: '재시도 실패', retryFailedTimes: '{{times}} 재시도 실패', - times: '배', + times: '번', ms: '미에스', retries: '{{숫자}} 재시도', }, @@ -399,7 +423,8 @@ const translation = { variables: '변수', context: '컨텍스트', contextTooltip: '컨텍스트로 지식을 가져올 수 있습니다', - notSetContextInPromptTip: '컨텍스트 기능을 활성화하려면 PROMPT 에 컨텍스트 변수를 입력하세요.', + notSetContextInPromptTip: + '컨텍스트 기능을 활성화하려면 PROMPT 에 컨텍스트 변수를 입력하세요.', prompt: '프롬프트', roleDescription: { system: '대화를 위한 고급 지침 제공', @@ -443,8 +468,10 @@ const translation = { stringValidations: '문자열 검증', showAdvancedOptions: '고급 옵션 표시', promptPlaceholder: '당신의 JSON 스키마를 설명하세요...', - generationTip: '자연어를 사용하여 JSON 스키마를 신속하게 생성할 수 있습니다.', - resultTip: '여기 생성된 결과가 있습니다. 만약 만족하지 않으신다면, 돌아가서 프롬프트를 수정할 수 있습니다.', + generationTip: + '자연어를 사용하여 JSON 스키마를 신속하게 생성할 수 있습니다.', + resultTip: + '여기 생성된 결과가 있습니다. 만약 만족하지 않으신다면, 돌아가서 프롬프트를 수정할 수 있습니다.', regenerate: '재생하다', required: '필수', doc: '구조화된 출력에 대해 더 알아보세요.', @@ -470,7 +497,8 @@ const translation = { }, automatic: { desc: '쿼리 변수를 기반으로 메타데이터 필터링 조건을 자동으로 생성합니다.', - subTitle: '사용자 쿼리를 기반으로 메타데이터 필터링 조건을 자동으로 생성합니다.', + subTitle: + '사용자 쿼리를 기반으로 메타데이터 필터링 조건을 자동으로 생성합니다.', title: '자동', }, manual: { @@ -542,7 +570,8 @@ const translation = { inputVars: '입력 변수', outputVars: '출력 변수', advancedDependencies: '고급 종속성', - advancedDependenciesTip: '더 많은 시간이 소요되거나 기본으로 내장되지 않은 일부 미리 로드된 종속성을 여기에 추가하세요', + advancedDependenciesTip: + '더 많은 시간이 소요되거나 기본으로 내장되지 않은 일부 미리 로드된 종속성을 여기에 추가하세요', searchDependencies: '종속성 검색', }, templateTransform: { @@ -556,7 +585,8 @@ const translation = { ifElse: { if: 'If', else: 'Else', - elseDescription: 'If 조건이 충족되지 않을 때 실행할 논리를 정의하는 데 사용됩니다.', + elseDescription: + 'If 조건이 충족되지 않을 때 실행할 논리를 정의하는 데 사용됩니다.', and: '그리고', or: '또는', operator: '연산자', @@ -609,7 +639,8 @@ const translation = { array: '배열', }, aggregationGroup: '집계 그룹', - aggregationGroupTip: '이 기능을 활성화하면 변수 집계자가 여러 변수 집합을 집계할 수 있습니다.', + aggregationGroupTip: + '이 기능을 활성화하면 변수 집계자가 여러 변수 집합을 집계할 수 있습니다.', addGroup: '그룹 추가', outputVars: { varDescribe: '{{groupName}} 출력', @@ -645,7 +676,8 @@ const translation = { 'noAssignedVars': '사용 가능한 할당된 변수가 없습니다.', 'noVarTip': '"+" 버튼을 클릭하여 변수를 추가합니다.', 'setParameter': '매개 변수 설정...', - 'assignedVarsDescription': '할당된 변수는 대화 변수와 같은 쓰기 가능한 변수여야 합니다.', + 'assignedVarsDescription': + '할당된 변수는 대화 변수와 같은 쓰기 가능한 변수여야 합니다.', 'selectAssignedVariable': '할당된 변수 선택...', 'varNotSet': '변수가 설정되지 않음', }, @@ -677,7 +709,8 @@ const translation = { topicPlaceholder: '주제 이름을 작성하세요', addClass: '클래스 추가', instruction: '지시', - instructionTip: '질문 분류기가 질문을 더 잘 분류할 수 있도록 추가 지시를 입력하세요.', + instructionTip: + '질문 분류기가 질문을 더 잘 분류할 수 있도록 추가 지시를 입력하세요.', instructionPlaceholder: '지시를 작성하세요', }, parameterExtractor: { @@ -693,14 +726,17 @@ const translation = { description: '설명', descriptionPlaceholder: '추출 매개변수 설명', required: '필수', - requiredContent: '필수는 모델 추론을 위한 참고 용도로만 사용되며, 매개변수 출력의 필수 유효성 검사는 아닙니다.', + requiredContent: + '필수는 모델 추론을 위한 참고 용도로만 사용되며, 매개변수 출력의 필수 유효성 검사는 아닙니다.', }, extractParametersNotSet: '추출 매개변수가 설정되지 않음', instruction: '지시', - instructionTip: '매개변수 추출기가 매개변수를 추출하는 방법을 이해하는 데 도움이 되는 추가 지시를 입력하세요.', + instructionTip: + '매개변수 추출기가 매개변수를 추출하는 방법을 이해하는 데 도움이 되는 추가 지시를 입력하세요.', advancedSetting: '고급 설정', reasoningMode: '추론 모드', - reasoningModeTip: '모델의 함수 호출 또는 프롬프트에 대한 지시 응답 능력을 기반으로 적절한 추론 모드를 선택할 수 있습니다.', + reasoningModeTip: + '모델의 함수 호출 또는 프롬프트에 대한 지시 응답 능력을 기반으로 적절한 추론 모드를 선택할 수 있습니다.', isSuccess: '성공 여부. 성공 시 값은 1 이고, 실패 시 값은 0 입니다.', errorReason: '오류 원인', }, @@ -726,9 +762,12 @@ const translation = { error_other: '{{개수}} 오류', parallelModeEnableTitle: 'Parallel Mode Enabled(병렬 모드 사용)', parallelPanelDesc: '병렬 모드에서 반복의 작업은 병렬 실행을 지원합니다.', - parallelModeEnableDesc: '병렬 모드에서는 반복 내의 작업이 병렬 실행을 지원합니다. 오른쪽의 속성 패널에서 이를 구성할 수 있습니다.', - MaxParallelismDesc: '최대 병렬 처리는 단일 반복에서 동시에 실행되는 작업 수를 제어하는 데 사용됩니다.', - answerNodeWarningDesc: '병렬 모드 경고: 응답 노드, 대화 변수 할당 및 반복 내의 지속적인 읽기/쓰기 작업으로 인해 예외가 발생할 수 있습니다.', + parallelModeEnableDesc: + '병렬 모드에서는 반복 내의 작업이 병렬 실행을 지원합니다. 오른쪽의 속성 패널에서 이를 구성할 수 있습니다.', + MaxParallelismDesc: + '최대 병렬 처리는 단일 반복에서 동시에 실행되는 작업 수를 제어하는 데 사용됩니다.', + answerNodeWarningDesc: + '병렬 모드 경고: 응답 노드, 대화 변수 할당 및 반복 내의 지속적인 읽기/쓰기 작업으로 인해 예외가 발생할 수 있습니다.', }, note: { editor: { @@ -778,12 +817,14 @@ const translation = { agent: { strategy: { label: '에이전트 전략', - tooltip: '다양한 에이전트 전략은 시스템이 다단계 도구 호출을 계획하고 실행하는 방법을 결정합니다', + tooltip: + '다양한 에이전트 전략은 시스템이 다단계 도구 호출을 계획하고 실행하는 방법을 결정합니다', configureTip: '에이전트 전략을 구성하세요.', searchPlaceholder: '검색 에이전트 전략', shortLabel: '전략', selectTip: '에이전트 전략 선택', - configureTipDesc: '에이전트 전략을 구성한 후 이 노드는 나머지 구성을 자동으로 로드합니다. 이 전략은 다단계 도구 추론의 메커니즘에 영향을 미칩니다.', + configureTipDesc: + '에이전트 전략을 구성한 후 이 노드는 나머지 구성을 자동으로 로드합니다. 이 전략은 다단계 도구 추론의 메커니즘에 영향을 미칩니다.', }, pluginInstaller: { install: '설치하다', @@ -796,7 +837,8 @@ const translation = { }, modelNotSupport: { title: '지원되지 않는 모델', - descForVersionSwitch: '설치된 플러그인 버전은 이 모델을 제공하지 않습니다. 버전을 전환하려면 클릭합니다.', + descForVersionSwitch: + '설치된 플러그인 버전은 이 모델을 제공하지 않습니다. 버전을 전환하려면 클릭합니다.', desc: '설치된 플러그인 버전은 이 모델을 제공하지 않습니다.', }, modelSelectorTooltips: { @@ -823,13 +865,17 @@ const translation = { cancel: '취소', title: '플러그인 설치', }, - strategyNotFoundDescAndSwitchVersion: '설치된 플러그인 버전은 이 전략을 제공하지 않습니다. 버전을 전환하려면 클릭합니다.', + strategyNotFoundDescAndSwitchVersion: + '설치된 플러그인 버전은 이 전략을 제공하지 않습니다. 버전을 전환하려면 클릭합니다.', learnMore: '더 알아보세요', toolNotAuthorizedTooltip: '{{도구}} 권한이 부여되지 않음', - strategyNotFoundDesc: '설치된 플러그인 버전은 이 전략을 제공하지 않습니다.', + strategyNotFoundDesc: + '설치된 플러그인 버전은 이 전략을 제공하지 않습니다.', maxIterations: '최대 반복 횟수', - pluginNotFoundDesc: '이 플러그인은 GitHub 에서 설치됩니다. 플러그인으로 이동하여 다시 설치하십시오.', - pluginNotInstalledDesc: '이 플러그인은 GitHub 에서 설치됩니다. 플러그인으로 이동하여 다시 설치하십시오.', + pluginNotFoundDesc: + '이 플러그인은 GitHub 에서 설치됩니다. 플러그인으로 이동하여 다시 설치하십시오.', + pluginNotInstalledDesc: + '이 플러그인은 GitHub 에서 설치됩니다. 플러그인으로 이동하여 다시 설치하십시오.', strategyNotInstallTooltip: '{{strategy}}가 설치되지 않았습니다.', tools: '도구', unsupportedStrategy: '지원되지 않는 전략', @@ -868,9 +914,11 @@ const translation = { loopVariables: '루프 변수', setLoopVariables: '루프 범위 내에서 변수를 설정합니다.', initialLoopVariables: '초기 루프 변수', - breakConditionTip: '종료 조건과 대화 변수가 있는 루프 내에서만 변수를 참조할 수 있습니다.', + breakConditionTip: + '종료 조건과 대화 변수가 있는 루프 내에서만 변수를 참조할 수 있습니다.', currentLoopCount: '현재 루프 카운트: {{count}}', - loopMaxCountError: '유효한 최대 루프 수를 입력하십시오. 범위는 1 에서 {{maxCount}}입니다.', + loopMaxCountError: + '유효한 최대 루프 수를 입력하십시오. 범위는 1 에서 {{maxCount}}입니다.', totalLoopCount: '총 루프 횟수: {{count}}', variableName: '변수 이름', loopNode: '루프 노드', @@ -885,7 +933,8 @@ const translation = { conversationVars: '대화 변수', noVarsForOperation: '선택한 작업에 할당할 수 있는 변수가 없습니다.', noAssignedVars: '사용 가능한 할당된 변수가 없습니다.', - assignedVarsDescription: '할당된 변수는 다음과 같이 쓰기 가능한 변수여야 합니다.', + assignedVarsDescription: + '할당된 변수는 다음과 같이 쓰기 가능한 변수여야 합니다.', }, versionHistory: { filter: { @@ -899,7 +948,8 @@ const translation = { titleLengthLimit: '제목은 {{limit}}자를 초과할 수 없습니다.', title: '제목', releaseNotes: '릴리스 노트', - releaseNotesLengthLimit: '릴리스 노트는 {{limit}}자를 초과할 수 없습니다.', + releaseNotesLengthLimit: + '릴리스 노트는 {{limit}}자를 초과할 수 없습니다.', }, action: { updateFailure: '버전 업데이트에 실패했습니다.', @@ -914,8 +964,8 @@ const translation = { currentDraft: '현재 초안', releaseNotesPlaceholder: '변경된 내용을 설명하세요.', defaultName: '제목 없는 버전', - nameThisVersion: '이 버전의 이름을 지어주세요', - title: '버전들', + nameThisVersion: '이름 바꾸기', + title: '버전 기록', deletionTip: '삭제는 되돌릴 수 없으니, 확인해 주시기 바랍니다.', restorationTip: '버전 복원 후 현재 초안이 덮어쓰여질 것입니다.', }, @@ -943,7 +993,8 @@ const translation = { clearNode: '캐시된 변수를 지우기', resetConversationVar: '대화 변수를 기본 값으로 재설정합니다.', reset: '마지막 실행 값으로 재설정', - emptyTip: '캔버스에서 노드를 한 단계씩 실행한 후, 변수 검사에서 노드 변수의 현재 값을 볼 수 있습니다.', + emptyTip: + '캔버스에서 노드를 한 단계씩 실행한 후, 변수 검사에서 노드 변수의 현재 값을 볼 수 있습니다.', }, settingsTab: '설정', lastRunTab: '마지막 실행', diff --git a/web/i18n/pl-PL/dataset-documents.ts b/web/i18n/pl-PL/dataset-documents.ts index 78e427ba95..e581b2d090 100644 --- a/web/i18n/pl-PL/dataset-documents.ts +++ b/web/i18n/pl-PL/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Usuń', enableWarning: 'Zarchiwizowany plik nie może zostać włączony', sync: 'Synchronizuj', + resume: 'Wznawiać', + pause: 'Pauza', }, index: { enable: 'Włącz', diff --git a/web/i18n/pt-BR/dataset-documents.ts b/web/i18n/pt-BR/dataset-documents.ts index b8c06c1769..462a3c09ef 100644 --- a/web/i18n/pt-BR/dataset-documents.ts +++ b/web/i18n/pt-BR/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Excluir', enableWarning: 'O arquivo arquivado não pode ser habilitado', sync: 'Sincronizar', + resume: 'Retomar', + pause: 'Pausa', }, index: { enable: 'Habilitar', diff --git a/web/i18n/ro-RO/dataset-documents.ts b/web/i18n/ro-RO/dataset-documents.ts index e0f3e8b476..876ddde40b 100644 --- a/web/i18n/ro-RO/dataset-documents.ts +++ b/web/i18n/ro-RO/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Șterge', enableWarning: 'Fișierul arhivat nu poate fi activat', sync: 'Sincronizează', + pause: 'Pauză', + resume: 'Reia', }, index: { enable: 'Activează', diff --git a/web/i18n/ru-RU/dataset-documents.ts b/web/i18n/ru-RU/dataset-documents.ts index 6fadee3b26..bbc221fba7 100644 --- a/web/i18n/ru-RU/dataset-documents.ts +++ b/web/i18n/ru-RU/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'Удалить', enableWarning: 'Архивный файл не может быть включен', sync: 'Синхронизировать', + resume: 'Продовжити', + pause: 'Пауза', }, index: { enable: 'Включить', diff --git a/web/i18n/sl-SI/dataset-documents.ts b/web/i18n/sl-SI/dataset-documents.ts index 1a83335ad5..e76055ad79 100644 --- a/web/i18n/sl-SI/dataset-documents.ts +++ b/web/i18n/sl-SI/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'Izbriši', enableWarning: 'Arhivirane datoteke ni mogoče omogočiti', sync: 'Sinhroniziraj', + pause: 'Zaustavi', + resume: 'Nadaljuj', }, index: { enable: 'Omogoči', @@ -332,7 +334,7 @@ const translation = { previewTip: 'Predogled odstavkov bo na voljo po zaključku vdelave', hierarchical: 'Starš-otrok', childMaxTokens: 'Otrok', - pause: 'Pavza', + pause: 'Zaustavi', parentMaxTokens: 'Starš', }, segment: { diff --git a/web/i18n/th-TH/dataset-documents.ts b/web/i18n/th-TH/dataset-documents.ts index 87b43f31e2..966218ad93 100644 --- a/web/i18n/th-TH/dataset-documents.ts +++ b/web/i18n/th-TH/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'ลบ', enableWarning: 'ไม่สามารถเปิดใช้งานไฟล์ที่เก็บถาวรได้', sync: 'ซิงค์', + pause: 'หยุด', + resume: 'ดำเนิน', }, index: { enable: 'เปิด', diff --git a/web/i18n/tr-TR/dataset-documents.ts b/web/i18n/tr-TR/dataset-documents.ts index 2e00975178..e7118b8c9b 100644 --- a/web/i18n/tr-TR/dataset-documents.ts +++ b/web/i18n/tr-TR/dataset-documents.ts @@ -29,6 +29,8 @@ const translation = { delete: 'Sil', enableWarning: 'Arşivlenmiş dosya etkinleştirilemez', sync: 'Senkronize et', + pause: 'Duraklat', + resume: 'Devam Et', }, index: { enable: 'Etkinleştir', diff --git a/web/i18n/uk-UA/dataset-documents.ts b/web/i18n/uk-UA/dataset-documents.ts index e8464e5661..27642f1994 100644 --- a/web/i18n/uk-UA/dataset-documents.ts +++ b/web/i18n/uk-UA/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Видалити', enableWarning: 'Архівований файл неможливо активувати', sync: 'Синхронізувати', + pause: 'Пауза', + resume: 'Продовжити', }, index: { enable: 'Активувати', diff --git a/web/i18n/vi-VN/dataset-documents.ts b/web/i18n/vi-VN/dataset-documents.ts index 1cc050b804..94d4dec556 100644 --- a/web/i18n/vi-VN/dataset-documents.ts +++ b/web/i18n/vi-VN/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: 'Xóa', enableWarning: 'Tệp đã lưu trữ không thể được kích hoạt', sync: 'Đồng bộ', + pause: 'Tạm dừng', + resume: 'Tiếp tục', }, index: { enable: 'Kích hoạt', diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts index 07ecc018fb..904ec7e5fa 100644 --- a/web/i18n/zh-Hans/app.ts +++ b/web/i18n/zh-Hans/app.ts @@ -185,6 +185,10 @@ const translation = { title: '编织', description: 'Weave 是一个开源平台,用于评估、测试和监控大型语言模型应用程序。', }, + aliyun: { + title: '大模型可观测', + description: '阿里云提供的SaaS化可观测平台,一键开启Dify应用的监控追踪和评估。', + }, }, appSelector: { label: '应用', diff --git a/web/i18n/zh-Hans/dataset-documents.ts b/web/i18n/zh-Hans/dataset-documents.ts index 2eb883690b..659d8c5ee9 100644 --- a/web/i18n/zh-Hans/dataset-documents.ts +++ b/web/i18n/zh-Hans/dataset-documents.ts @@ -30,6 +30,8 @@ const translation = { delete: '删除', enableWarning: '归档的文件无法启用', sync: '同步', + pause: '暂停', + resume: '恢复', }, index: { enable: '启用中', diff --git a/web/i18n/zh-Hant/dataset-documents.ts b/web/i18n/zh-Hant/dataset-documents.ts index a79f3993e0..104777d0f6 100644 --- a/web/i18n/zh-Hant/dataset-documents.ts +++ b/web/i18n/zh-Hant/dataset-documents.ts @@ -28,6 +28,8 @@ const translation = { delete: '刪除', enableWarning: '歸檔的檔案無法啟用', sync: '同步', + resume: '恢復', + pause: '暫停', }, index: { enable: '啟用中', diff --git a/web/models/app.ts b/web/models/app.ts index 369cf32387..5798670426 100644 --- a/web/models/app.ts +++ b/web/models/app.ts @@ -1,9 +1,9 @@ -import type { ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, TracingProvider, WeaveConfig } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' +import type { AliyunConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, TracingProvider, WeaveConfig } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' import type { App, AppTemplate, SiteConfig } from '@/types/app' import type { Dependency } from '@/app/components/plugins/types' /* export type App = { - id: string + id: strin name: string description: string mode: AppMode @@ -166,5 +166,5 @@ export type TracingStatus = { export type TracingConfig = { tracing_provider: TracingProvider - tracing_config: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig + tracing_config: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig } diff --git a/web/service/knowledge/use-document.ts b/web/service/knowledge/use-document.ts index 6dabe7d872..e53a5ebde7 100644 --- a/web/service/knowledge/use-document.ts +++ b/web/service/knowledge/use-document.ts @@ -5,6 +5,7 @@ import { import { del, get, patch } from '../base' import { useInvalid } from '../use-base' import type { MetadataType, SortType } from '../datasets' +import { pauseDocIndexing, resumeDocIndexing } from '../datasets' import type { DocumentDetailResponse, DocumentListResponse, UpdateDocumentBatchParams } from '@/models/datasets' import { DocumentActionType } from '@/models/datasets' import type { CommonResponse } from '@/models/common' @@ -130,3 +131,23 @@ export const useDocumentMetadata = (payload: { export const useInvalidDocumentDetailKey = () => { return useInvalid(useDocumentDetailKey) } + +export const useDocumentPause = () => { + return useMutation({ + mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { + if (!datasetId || !documentId) + throw new Error('datasetId and documentId are required') + return pauseDocIndexing({ datasetId, documentId }) as Promise + }, + }) +} + +export const useDocumentResume = () => { + return useMutation({ + mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => { + if (!datasetId || !documentId) + throw new Error('datasetId and documentId are required') + return resumeDocIndexing({ datasetId, documentId }) as Promise + }, + }) +}