From 816210d74491eccb950d22d877f084751158646b Mon Sep 17 00:00:00 2001 From: Davide Delbianco Date: Tue, 8 Jul 2025 15:18:00 +0200 Subject: [PATCH 1/8] Expose LLM usage in workflows (#21766) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- api/core/ops/aliyun_trace/aliyun_trace.py | 7 ++--- .../arize_phoenix_trace.py | 26 ++++++++++++------- api/core/ops/langfuse_trace/langfuse_trace.py | 9 +++---- .../ops/langsmith_trace/langsmith_trace.py | 9 +++---- api/core/ops/opik_trace/opik_trace.py | 8 +++--- api/core/workflow/nodes/llm/node.py | 20 +++++++------- .../parameter_extractor_node.py | 7 ++++- .../question_classifier_node.py | 6 ++++- web/app/components/workflow/constants.ts | 12 +++++++++ .../components/workflow/nodes/llm/panel.tsx | 5 ++++ .../nodes/parameter-extractor/panel.tsx | 9 +++++-- .../nodes/question-classifier/panel.tsx | 5 ++++ web/i18n/de-DE/workflow.ts | 8 ++++-- web/i18n/en-US/workflow.ts | 8 ++++-- web/i18n/es-ES/workflow.ts | 8 ++++-- web/i18n/fa-IR/workflow.ts | 8 ++++-- web/i18n/fr-FR/workflow.ts | 8 ++++-- web/i18n/hi-IN/workflow.ts | 8 ++++-- web/i18n/it-IT/workflow.ts | 9 ++++--- web/i18n/ja-JP/workflow.ts | 8 ++++-- web/i18n/ko-KR/workflow.ts | 6 +++++ web/i18n/pl-PL/workflow.ts | 8 ++++-- web/i18n/pt-BR/workflow.ts | 8 ++++-- web/i18n/ro-RO/workflow.ts | 8 ++++-- web/i18n/ru-RU/workflow.ts | 8 ++++-- web/i18n/sl-SI/workflow.ts | 8 ++++-- web/i18n/th-TH/workflow.ts | 8 ++++-- web/i18n/tr-TR/workflow.ts | 8 ++++-- web/i18n/uk-UA/workflow.ts | 8 ++++-- web/i18n/vi-VN/workflow.ts | 8 ++++-- web/i18n/zh-Hans/workflow.ts | 8 ++++-- web/i18n/zh-Hant/workflow.ts | 8 ++++-- 32 files changed, 201 insertions(+), 81 deletions(-) diff --git a/api/core/ops/aliyun_trace/aliyun_trace.py b/api/core/ops/aliyun_trace/aliyun_trace.py index 163b5d0307..b18a6905fe 100644 --- a/api/core/ops/aliyun_trace/aliyun_trace.py +++ b/api/core/ops/aliyun_trace/aliyun_trace.py @@ -372,6 +372,7 @@ class AliyunDataTrace(BaseTraceInstance): ) -> SpanData: process_data = node_execution.process_data or {} outputs = node_execution.outputs or {} + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) return SpanData( trace_id=trace_id, parent_span_id=workflow_span_id, @@ -385,9 +386,9 @@ class AliyunDataTrace(BaseTraceInstance): 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_USAGE_INPUT_TOKENS: str(usage_data.get("prompt_tokens", 0)), + GEN_AI_USAGE_OUTPUT_TOKENS: str(usage_data.get("completion_tokens", 0)), + GEN_AI_USAGE_TOTAL_TOKENS: str(usage_data.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", ""), diff --git a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py index 0b6834acf3..ffda0885d4 100644 --- a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py +++ b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py @@ -213,11 +213,12 @@ class ArizePhoenixDataTrace(BaseTraceInstance): if model: node_metadata["ls_model_name"] = model - usage = json.loads(node_execution.outputs).get("usage", {}) if node_execution.outputs else {} - if usage: - node_metadata["total_tokens"] = usage.get("total_tokens", 0) - node_metadata["prompt_tokens"] = usage.get("prompt_tokens", 0) - node_metadata["completion_tokens"] = usage.get("completion_tokens", 0) + outputs = json.loads(node_execution.outputs).get("usage", {}) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + if usage_data: + node_metadata["total_tokens"] = usage_data.get("total_tokens", 0) + node_metadata["prompt_tokens"] = usage_data.get("prompt_tokens", 0) + node_metadata["completion_tokens"] = usage_data.get("completion_tokens", 0) elif node_execution.node_type == "dataset_retrieval": span_kind = OpenInferenceSpanKindValues.RETRIEVER.value elif node_execution.node_type == "tool": @@ -246,14 +247,19 @@ class ArizePhoenixDataTrace(BaseTraceInstance): if model: node_span.set_attribute(SpanAttributes.LLM_MODEL_NAME, model) - usage = json.loads(node_execution.outputs).get("usage", {}) if node_execution.outputs else {} - if usage: - node_span.set_attribute(SpanAttributes.LLM_TOKEN_COUNT_TOTAL, usage.get("total_tokens", 0)) + outputs = json.loads(node_execution.outputs).get("usage", {}) + usage_data = ( + process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + ) + if usage_data: + node_span.set_attribute( + SpanAttributes.LLM_TOKEN_COUNT_TOTAL, usage_data.get("total_tokens", 0) + ) node_span.set_attribute( - SpanAttributes.LLM_TOKEN_COUNT_PROMPT, usage.get("prompt_tokens", 0) + SpanAttributes.LLM_TOKEN_COUNT_PROMPT, usage_data.get("prompt_tokens", 0) ) node_span.set_attribute( - SpanAttributes.LLM_TOKEN_COUNT_COMPLETION, usage.get("completion_tokens", 0) + SpanAttributes.LLM_TOKEN_COUNT_COMPLETION, usage_data.get("completion_tokens", 0) ) finally: node_span.end(end_time=datetime_to_nanos(finished_at)) diff --git a/api/core/ops/langfuse_trace/langfuse_trace.py b/api/core/ops/langfuse_trace/langfuse_trace.py index 1d4ae49fc7..a3dbce0e59 100644 --- a/api/core/ops/langfuse_trace/langfuse_trace.py +++ b/api/core/ops/langfuse_trace/langfuse_trace.py @@ -181,12 +181,9 @@ class LangFuseDataTrace(BaseTraceInstance): prompt_tokens = 0 completion_tokens = 0 try: - if outputs.get("usage"): - prompt_tokens = outputs.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = outputs.get("usage", {}).get("completion_tokens", 0) - else: - prompt_tokens = process_data.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = process_data.get("usage", {}).get("completion_tokens", 0) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + prompt_tokens = usage_data.get("prompt_tokens", 0) + completion_tokens = usage_data.get("completion_tokens", 0) except Exception: logger.error("Failed to extract usage", exc_info=True) diff --git a/api/core/ops/langsmith_trace/langsmith_trace.py b/api/core/ops/langsmith_trace/langsmith_trace.py index 8a392940db..f94e5e49d7 100644 --- a/api/core/ops/langsmith_trace/langsmith_trace.py +++ b/api/core/ops/langsmith_trace/langsmith_trace.py @@ -206,12 +206,9 @@ class LangSmithDataTrace(BaseTraceInstance): prompt_tokens = 0 completion_tokens = 0 try: - if outputs.get("usage"): - prompt_tokens = outputs.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = outputs.get("usage", {}).get("completion_tokens", 0) - else: - prompt_tokens = process_data.get("usage", {}).get("prompt_tokens", 0) - completion_tokens = process_data.get("usage", {}).get("completion_tokens", 0) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + prompt_tokens = usage_data.get("prompt_tokens", 0) + completion_tokens = usage_data.get("completion_tokens", 0) except Exception: logger.error("Failed to extract usage", exc_info=True) diff --git a/api/core/ops/opik_trace/opik_trace.py b/api/core/ops/opik_trace/opik_trace.py index f4d2760ba5..8bedea20fb 100644 --- a/api/core/ops/opik_trace/opik_trace.py +++ b/api/core/ops/opik_trace/opik_trace.py @@ -222,10 +222,10 @@ class OpikDataTrace(BaseTraceInstance): ) try: - if outputs.get("usage"): - total_tokens = outputs["usage"].get("total_tokens", 0) - prompt_tokens = outputs["usage"].get("prompt_tokens", 0) - completion_tokens = outputs["usage"].get("completion_tokens", 0) + usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {}) + total_tokens = usage_data.get("total_tokens", 0) + prompt_tokens = usage_data.get("prompt_tokens", 0) + completion_tokens = usage_data.get("completion_tokens", 0) except Exception: logger.error("Failed to extract usage", exc_info=True) diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py index b5225ce548..9bfb402dc8 100644 --- a/api/core/workflow/nodes/llm/node.py +++ b/api/core/workflow/nodes/llm/node.py @@ -221,15 +221,6 @@ class LLMNode(BaseNode[LLMNodeData]): jinja2_variables=self.node_data.prompt_config.jinja2_variables, ) - process_data = { - "model_mode": model_config.mode, - "prompts": PromptMessageUtil.prompt_messages_to_prompt_for_saving( - model_mode=model_config.mode, prompt_messages=prompt_messages - ), - "model_provider": model_config.provider, - "model_name": model_config.model, - } - # handle invoke result generator = self._invoke_llm( node_data_model=self.node_data.model, @@ -253,6 +244,17 @@ class LLMNode(BaseNode[LLMNodeData]): elif isinstance(event, LLMStructuredOutput): structured_output = event + process_data = { + "model_mode": model_config.mode, + "prompts": PromptMessageUtil.prompt_messages_to_prompt_for_saving( + model_mode=model_config.mode, prompt_messages=prompt_messages + ), + "usage": jsonable_encoder(usage), + "finish_reason": finish_reason, + "model_provider": model_config.provider, + "model_name": model_config.model, + } + outputs = {"text": result_text, "usage": jsonable_encoder(usage), "finish_reason": finish_reason} if structured_output: outputs["structured_output"] = structured_output.structured_output diff --git a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py index 8d6c2d0a5c..25a534256b 100644 --- a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py +++ b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py @@ -253,7 +253,12 @@ class ParameterExtractorNode(BaseNode): status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=inputs, process_data=process_data, - outputs={"__is_success": 1 if not error else 0, "__reason": error, **result}, + outputs={ + "__is_success": 1 if not error else 0, + "__reason": error, + "__usage": jsonable_encoder(usage), + **result, + }, metadata={ WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens, WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price, diff --git a/api/core/workflow/nodes/question_classifier/question_classifier_node.py b/api/core/workflow/nodes/question_classifier/question_classifier_node.py index a518167cc6..74024ed90c 100644 --- a/api/core/workflow/nodes/question_classifier/question_classifier_node.py +++ b/api/core/workflow/nodes/question_classifier/question_classifier_node.py @@ -145,7 +145,11 @@ class QuestionClassifierNode(LLMNode): "model_provider": model_config.provider, "model_name": model_config.model, } - outputs = {"class_name": category_name, "class_id": category_id} + outputs = { + "class_name": category_name, + "class_id": category_id, + "usage": jsonable_encoder(usage), + } return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 304295cfbf..0ef4dc9dea 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -480,6 +480,10 @@ export const LLM_OUTPUT_STRUCT: Var[] = [ variable: 'text', type: VarType.string, }, + { + variable: 'usage', + type: VarType.object, + }, ] export const KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT: Var[] = [ @@ -501,6 +505,10 @@ export const QUESTION_CLASSIFIER_OUTPUT_STRUCT = [ variable: 'class_name', type: VarType.string, }, + { + variable: 'usage', + type: VarType.object, + }, ] export const HTTP_REQUEST_OUTPUT_STRUCT: Var[] = [ @@ -546,6 +554,10 @@ export const PARAMETER_EXTRACTOR_COMMON_STRUCT: Var[] = [ variable: '__reason', type: VarType.string, }, + { + variable: '__usage', + type: VarType.object, + }, ] export const FILE_STRUCT: Var[] = [ diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx index 2a71dffa11..471d65ef20 100644 --- a/web/app/components/workflow/nodes/llm/panel.tsx +++ b/web/app/components/workflow/nodes/llm/panel.tsx @@ -282,6 +282,11 @@ const Panel: FC> = ({ type='string' description={t(`${i18nPrefix}.outputVars.output`)} /> + {inputs.structured_output_enabled && ( <> diff --git a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx index e86a2e3764..a169217609 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/panel.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/panel.tsx @@ -190,12 +190,17 @@ const Panel: FC> = ({ + diff --git a/web/app/components/workflow/nodes/question-classifier/panel.tsx b/web/app/components/workflow/nodes/question-classifier/panel.tsx index 8f6f5eb76d..8cf9ec5f7c 100644 --- a/web/app/components/workflow/nodes/question-classifier/panel.tsx +++ b/web/app/components/workflow/nodes/question-classifier/panel.tsx @@ -129,6 +129,11 @@ const Panel: FC> = ({ type='string' description={t(`${i18nPrefix}.outputVars.className`)} /> + diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index 55f5bac0e1..e28577eec7 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Eingabevariablen', outputVars: { className: 'Klassennamen', + usage: 'Nutzungsinformationen des Modells', }, class: 'Klasse', classNamePlaceholder: 'Geben Sie Ihren Klassennamen ein', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Eingabevariable', + outputVars: { + isSuccess: 'Ist Erfolg. Bei Erfolg beträgt der Wert 1, bei Misserfolg beträgt der Wert 0.', + errorReason: 'Fehlergrund', + usage: 'Nutzungsinformationen des Modells', + }, extractParameters: 'Parameter extrahieren', importFromTool: 'Aus Tools importieren', addExtractParameter: 'Extraktionsparameter hinzufügen', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Erweiterte Einstellung', reasoningMode: 'Schlussfolgerungsmodus', reasoningModeTip: 'Sie können den entsprechenden Schlussfolgerungsmodus basierend auf der Fähigkeit des Modells wählen, auf Anweisungen zur Funktionsaufruf- oder Eingabeaufforderungen zu reagieren.', - isSuccess: 'Ist Erfolg. Bei Erfolg beträgt der Wert 1, bei Misserfolg beträgt der Wert 0.', - errorReason: 'Fehlergrund', }, iteration: { deleteTitle: 'Iterationsknoten löschen?', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index df0bb904fd..6518afd604 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -673,6 +673,7 @@ const translation = { inputVars: 'Input Variables', outputVars: { className: 'Class Name', + usage: 'Model Usage Information', }, class: 'Class', classNamePlaceholder: 'Write your class name', @@ -686,6 +687,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Input Variable', + outputVars: { + isSuccess: 'Is Success.On success the value is 1, on failure the value is 0.', + errorReason: 'Error Reason', + usage: 'Model Usage Information', + }, extractParameters: 'Extract Parameters', importFromTool: 'Import from tools', addExtractParameter: 'Add Extract Parameter', @@ -705,8 +711,6 @@ const translation = { advancedSetting: 'Advanced Setting', reasoningMode: 'Reasoning Mode', reasoningModeTip: 'You can choose the appropriate reasoning mode based on the model\'s ability to respond to instructions for function calling or prompts.', - isSuccess: 'Is Success.On success the value is 1, on failure the value is 0.', - errorReason: 'Error Reason', }, iteration: { deleteTitle: 'Delete Iteration Node?', diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index 580072895c..cff9a4ff5e 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -667,6 +667,7 @@ const translation = { inputVars: 'Variables de entrada', outputVars: { className: 'Nombre de la clase', + usage: 'Información de uso del modelo', }, class: 'Clase', classNamePlaceholder: 'Escribe el nombre de tu clase', @@ -680,6 +681,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Variable de entrada', + outputVars: { + isSuccess: 'Es éxito. En caso de éxito el valor es 1, en caso de fallo el valor es 0.', + errorReason: 'Motivo del error', + usage: 'Información de uso del modelo', + }, extractParameters: 'Extraer parámetros', importFromTool: 'Importar desde herramientas', addExtractParameter: 'Agregar parámetro de extracción', @@ -699,8 +705,6 @@ const translation = { advancedSetting: 'Configuración avanzada', reasoningMode: 'Modo de razonamiento', reasoningModeTip: 'Puede elegir el modo de razonamiento apropiado basado en la capacidad del modelo para responder a instrucciones para llamadas de funciones o indicaciones.', - isSuccess: 'Es éxito. En caso de éxito el valor es 1, en caso de fallo el valor es 0.', - errorReason: 'Motivo del error', }, iteration: { deleteTitle: '¿Eliminar nodo de iteración?', diff --git a/web/i18n/fa-IR/workflow.ts b/web/i18n/fa-IR/workflow.ts index 1f2d87a84b..884acb2388 100644 --- a/web/i18n/fa-IR/workflow.ts +++ b/web/i18n/fa-IR/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'متغیرهای ورودی', outputVars: { className: 'نام کلاس', + usage: 'اطلاعات استفاده از مدل', }, class: 'کلاس', classNamePlaceholder: 'نام کلاس خود را بنویسید', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'متغیر ورودی', + outputVars: { + isSuccess: 'موفقیت‌آمیز است. در صورت موفقیت مقدار 1 و در صورت شکست مقدار 0 است.', + errorReason: 'دلیل خطا', + usage: 'اطلاعات استفاده از مدل', + }, extractParameters: 'استخراج پارامترها', importFromTool: 'وارد کردن از ابزارها', addExtractParameter: 'افزودن پارامتر استخراج شده', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'تنظیمات پیشرفته', reasoningMode: 'حالت استدلال', reasoningModeTip: 'می‌توانید حالت استدلال مناسب را بر اساس توانایی مدل برای پاسخ به دستورات برای فراخوانی عملکردها یا پیشنهادات انتخاب کنید.', - isSuccess: 'موفقیت‌آمیز است. در صورت موفقیت مقدار 1 و در صورت شکست مقدار 0 است.', - errorReason: 'دلیل خطا', }, iteration: { deleteTitle: 'حذف نود تکرار؟', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index 0878d18374..aae437d813 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Variables de saisie', outputVars: { className: 'Nom de la classe', + usage: 'Informations sur l\'utilisation du modèle', }, class: 'Classe', classNamePlaceholder: 'Écrivez le nom de votre classe', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Variable de saisie', + outputVars: { + isSuccess: 'Est réussi. En cas de succès, la valeur est 1, en cas d\'échec, la valeur est 0.', + errorReason: 'Raison de l\'erreur', + usage: 'Informations sur l\'utilisation du modèle', + }, extractParameters: 'Extraire des paramètres', importFromTool: 'Importer des outils', addExtractParameter: 'Ajouter un paramètre d\'extraction', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Paramètre avancé', reasoningMode: 'Mode de raisonnement', reasoningModeTip: 'Vous pouvez choisir le mode de raisonnement approprié en fonction de la capacité du modèle à répondre aux instructions pour les appels de fonction ou les invites.', - isSuccess: 'Est réussi. En cas de succès, la valeur est 1, en cas d\'échec, la valeur est 0.', - errorReason: 'Raison de l\'erreur', }, iteration: { deleteTitle: 'Supprimer le nœud d\'itération?', diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index 316116dc02..0fc8449c27 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -685,6 +685,7 @@ const translation = { inputVars: 'इनपुट वेरिएबल्स', outputVars: { className: 'क्लास नाम', + usage: 'मॉडल उपयोग जानकारी', }, class: 'क्लास', classNamePlaceholder: 'अपना क्लास नाम लिखें', @@ -699,6 +700,11 @@ const translation = { }, parameterExtractor: { inputVar: 'इनपुट वेरिएबल', + outputVars: { + isSuccess: 'सफलता है। सफलता पर मान 1 है, असफलता पर मान 0 है।', + errorReason: 'त्रुटि का कारण', + usage: 'मॉडल उपयोग जानकारी', + }, extractParameters: 'पैरामीटर्स निकालें', importFromTool: 'उपकरणों से आयात करें', addExtractParameter: 'एक्सट्रेक्ट पैरामीटर जोड़ें', @@ -721,8 +727,6 @@ const translation = { reasoningMode: 'रीज़निंग मोड', reasoningModeTip: 'फ़ंक्शन कॉलिंग या प्रॉम्प्ट्स के लिए निर्देशों का जवाब देने की मॉडल की क्षमता के आधार पर उपयुक्त रीज़निंग मोड चुन सकते हैं।', - isSuccess: 'सफलता है। सफलता पर मान 1 है, असफलता पर मान 0 है।', - errorReason: 'त्रुटि का कारण', }, iteration: { deleteTitle: 'इटरेशन नोड हटाएं?', diff --git a/web/i18n/it-IT/workflow.ts b/web/i18n/it-IT/workflow.ts index 0092e25d38..90f9ab3f79 100644 --- a/web/i18n/it-IT/workflow.ts +++ b/web/i18n/it-IT/workflow.ts @@ -688,6 +688,7 @@ const translation = { inputVars: 'Variabili di Input', outputVars: { className: 'Nome Classe', + usage: 'Informazioni sull\'utilizzo del modello', }, class: 'Classe', classNamePlaceholder: 'Scrivi il nome della tua classe', @@ -702,6 +703,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Variabile di Input', + outputVars: { + isSuccess: 'È successo. In caso di successo il valore è 1, in caso di fallimento il valore è 0.', + errorReason: 'Motivo dell\'errore', + usage: 'Informazioni sull\'utilizzo del modello', + }, extractParameters: 'Estrai Parametri', importFromTool: 'Importa dagli strumenti', addExtractParameter: 'Aggiungi Parametro Estratto', @@ -724,9 +730,6 @@ const translation = { reasoningMode: 'Modalità di ragionamento', reasoningModeTip: 'Puoi scegliere la modalità di ragionamento appropriata in base alla capacità del modello di rispondere alle istruzioni per la chiamata delle funzioni o i prompt.', - isSuccess: - 'È successo. In caso di successo il valore è 1, in caso di fallimento il valore è 0.', - errorReason: 'Motivo dell\'errore', }, iteration: { deleteTitle: 'Eliminare Nodo Iterazione?', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 3c959669bf..370a251443 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -676,6 +676,7 @@ const translation = { inputVars: '入力変数', outputVars: { className: 'クラス名', + usage: 'モデル使用量', }, class: 'クラス', classNamePlaceholder: 'クラス名を入力してください', @@ -689,6 +690,11 @@ const translation = { }, parameterExtractor: { inputVar: '入力変数', + outputVars: { + isSuccess: '成功。成功した場合の値は 1、失敗した場合の値は 0 です。', + errorReason: 'エラーの理由', + usage: 'モデル使用量', + }, extractParameters: 'パラメーターを抽出', importFromTool: 'ツールからインポート', addExtractParameter: '抽出パラメーターを追加', @@ -708,8 +714,6 @@ const translation = { advancedSetting: '高度な設定', reasoningMode: '推論モード', reasoningModeTip: '関数呼び出しやプロンプトの指示に応答するモデルの能力に基づいて、適切な推論モードを選択できます。', - isSuccess: '成功。成功した場合の値は 1、失敗した場合の値は 0 です。', - errorReason: 'エラーの理由', }, iteration: { deleteTitle: 'イテレーションノードを削除しますか?', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index 078d683ca2..c429527627 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -701,6 +701,7 @@ const translation = { inputVars: '입력 변수', outputVars: { className: '클래스 이름', + usage: '모델 사용 정보', }, class: '클래스', classNamePlaceholder: '클래스 이름을 작성하세요', @@ -715,6 +716,11 @@ const translation = { }, parameterExtractor: { inputVar: '입력 변수', + outputVars: { + isSuccess: '성공 여부. 성공 시 값은 1 이고, 실패 시 값은 0 입니다.', + errorReason: '오류 원인', + usage: '모델 사용 정보', + }, extractParameters: '매개변수 추출', importFromTool: '도구에서 가져오기', addExtractParameter: '추출 매개변수 추가', diff --git a/web/i18n/pl-PL/workflow.ts b/web/i18n/pl-PL/workflow.ts index ca2e95bdf6..ecf5380f5e 100644 --- a/web/i18n/pl-PL/workflow.ts +++ b/web/i18n/pl-PL/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Zmienne wejściowe', outputVars: { className: 'Nazwa klasy', + usage: 'Informacje o użyciu modelu', }, class: 'Klasa', classNamePlaceholder: 'Napisz nazwę swojej klasy', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Zmienna wejściowa', + outputVars: { + isSuccess: 'Czy się udało. W przypadku sukcesu wartość wynosi 1, w przypadku niepowodzenia wartość wynosi 0.', + errorReason: 'Powód błędu', + usage: 'Informacje o użyciu modelu', + }, extractParameters: 'Wyodrębnij parametry', importFromTool: 'Importuj z narzędzi', addExtractParameter: 'Dodaj parametr wyodrębniania', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Zaawansowane ustawienia', reasoningMode: 'Tryb wnioskowania', reasoningModeTip: 'Możesz wybrać odpowiedni tryb wnioskowania w zależności od zdolności modelu do reagowania na instrukcje dotyczące wywoływania funkcji lub zapytań.', - isSuccess: 'Czy się udało. W przypadku sukcesu wartość wynosi 1, w przypadku niepowodzenia wartość wynosi 0.', - errorReason: 'Powód błędu', }, iteration: { deleteTitle: 'Usunąć węzeł iteracji?', diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index 2a86228499..0c5058a27c 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Variáveis de entrada', outputVars: { className: 'Nome da classe', + usage: 'Informações de uso do modelo', }, class: 'Classe', classNamePlaceholder: 'Escreva o nome da sua classe', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Variável de entrada', + outputVars: { + isSuccess: 'É sucesso. Em caso de sucesso, o valor é 1, em caso de falha, o valor é 0.', + errorReason: 'Motivo do erro', + usage: 'Informações de uso do modelo', + }, extractParameters: 'Extrair parâmetros', importFromTool: 'Importar das ferramentas', addExtractParameter: 'Adicionar parâmetro de extração', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Configuração avançada', reasoningMode: 'Modo de raciocínio', reasoningModeTip: 'Você pode escolher o modo de raciocínio apropriado com base na capacidade do modelo de responder a instruções para chamadas de função ou prompts.', - isSuccess: 'É sucesso. Em caso de sucesso, o valor é 1, em caso de falha, o valor é 0.', - errorReason: 'Motivo do erro', }, iteration: { deleteTitle: 'Excluir nó de iteração?', diff --git a/web/i18n/ro-RO/workflow.ts b/web/i18n/ro-RO/workflow.ts index 09282f4d1c..0c64f33dc4 100644 --- a/web/i18n/ro-RO/workflow.ts +++ b/web/i18n/ro-RO/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Variabile de intrare', outputVars: { className: 'Nume clasă', + usage: 'Informații de utilizare a modelului', }, class: 'Clasă', classNamePlaceholder: 'Scrieți numele clasei', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Variabilă de intrare', + outputVars: { + isSuccess: 'Este succes. În caz de succes valoarea este 1, în caz de eșec valoarea este 0.', + errorReason: 'Motivul erorii', + usage: 'Informații de utilizare a modelului', + }, extractParameters: 'Extrageți parametrii', importFromTool: 'Importă din instrumente', addExtractParameter: 'Adăugați parametru de extragere', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Setare avansată', reasoningMode: 'Mod de raționament', reasoningModeTip: 'Puteți alege modul de raționament potrivit în funcție de capacitatea modelului de a răspunde la instrucțiuni pentru apelarea funcțiilor sau prompturi.', - isSuccess: 'Este succes. În caz de succes valoarea este 1, în caz de eșec valoarea este 0.', - errorReason: 'Motivul erorii', }, iteration: { deleteTitle: 'Ștergeți nodul de iterație?', diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index b7b41562a9..0474dcac3b 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Входные переменные', outputVars: { className: 'Имя класса', + usage: 'Информация об использовании модели', }, class: 'Класс', classNamePlaceholder: 'Введите имя вашего класса', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Входная переменная', + outputVars: { + isSuccess: 'Успешно. В случае успеха значение равно 1, в случае сбоя - 0.', + errorReason: 'Причина ошибки', + usage: 'Информация об использовании модели', + }, extractParameters: 'Извлечь параметры', importFromTool: 'Импортировать из инструментов', addExtractParameter: 'Добавить параметр для извлечения', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Расширенные настройки', reasoningMode: 'Режим рассуждения', reasoningModeTip: 'Вы можете выбрать соответствующий режим рассуждения, основываясь на способности модели реагировать на инструкции для вызова функций или подсказки.', - isSuccess: 'Успешно. В случае успеха значение равно 1, в случае сбоя - 0.', - errorReason: 'Причина ошибки', }, iteration: { deleteTitle: 'Удалить узел итерации?', diff --git a/web/i18n/sl-SI/workflow.ts b/web/i18n/sl-SI/workflow.ts index 2dbcdb008b..117fc88718 100644 --- a/web/i18n/sl-SI/workflow.ts +++ b/web/i18n/sl-SI/workflow.ts @@ -667,6 +667,7 @@ const translation = { questionClassifiers: { outputVars: { className: 'Ime razreda', + usage: 'Informacije o uporabi modela', }, instruction: 'Navodilo', addClass: 'Dodaj razred', @@ -692,16 +693,19 @@ const translation = { requiredContent: 'Zahtevano se uporablja le kot referenca za sklepanje modela in ne kot obvezno validacijo izhodnih parametrov.', }, extractParameters: 'Izvleči parametre', - errorReason: 'Razlog za napako', instruction: 'Navodilo', instructionTip: 'Vnesite dodatna navodila, da pomagate izvleku parametrov razumeti, kako izvleči parametre.', reasoningMode: 'Način razmišljanja', - isSuccess: 'Ali je uspeh. Na uspehu je vrednost 1, na neuspehu je vrednost 0.', importFromTool: 'Uvoz iz orodij', advancedSetting: 'Napredno nastavitev', addExtractParameter: 'Dodaj parameter za ekstrakcijo', extractParametersNotSet: 'Parameterji za ekstrakcijo niso nastavljeni', inputVar: 'Vhodna spremenljivka', + outputVars: { + isSuccess: 'Ali je uspeh. Na uspehu je vrednost 1, na neuspehu je vrednost 0.', + errorReason: 'Razlog za napako', + usage: 'Informacije o uporabi modela', + }, reasoningModeTip: 'Lahko izberete ustrezen način razmišljanja glede na sposobnost modela, da se odzove na navodila za klic funkcij ali pozive.', }, iteration: { diff --git a/web/i18n/th-TH/workflow.ts b/web/i18n/th-TH/workflow.ts index f017b5d02e..1855bf655c 100644 --- a/web/i18n/th-TH/workflow.ts +++ b/web/i18n/th-TH/workflow.ts @@ -668,6 +668,7 @@ const translation = { inputVars: 'ตัวแปรอินพุต', outputVars: { className: 'ชื่อคลาส', + usage: 'ข้อมูลการใช้งานรุ่น', }, class: 'ประเภท', classNamePlaceholder: 'เขียนชื่อชั้นเรียนของคุณ', @@ -681,6 +682,11 @@ const translation = { }, parameterExtractor: { inputVar: 'ตัวแปรอินพุต', + outputVars: { + isSuccess: 'คือ Success เมื่อสําเร็จค่าคือ 1 เมื่อล้มเหลวค่าเป็น 0', + errorReason: 'สาเหตุข้อผิดพลาด', + usage: 'ข้อมูลการใช้งานรุ่น', + }, extractParameters: 'แยกพารามิเตอร์', importFromTool: 'นําเข้าจากเครื่องมือ', addExtractParameter: 'เพิ่มพารามิเตอร์การแยกข้อมูล', @@ -700,8 +706,6 @@ const translation = { advancedSetting: 'การตั้งค่าขั้นสูง', reasoningMode: 'โหมดการให้เหตุผล', reasoningModeTip: 'คุณสามารถเลือกโหมดการให้เหตุผลที่เหมาะสมตามความสามารถของโมเดลในการตอบสนองต่อคําแนะนําสําหรับการเรียกใช้ฟังก์ชันหรือข้อความแจ้ง', - isSuccess: 'คือ Success เมื่อสําเร็จค่าคือ 1 เมื่อล้มเหลวค่าเป็น 0', - errorReason: 'สาเหตุข้อผิดพลาด', }, iteration: { deleteTitle: 'ลบโหนดการทําซ้ํา?', diff --git a/web/i18n/tr-TR/workflow.ts b/web/i18n/tr-TR/workflow.ts index 0c55351c21..e48c46848b 100644 --- a/web/i18n/tr-TR/workflow.ts +++ b/web/i18n/tr-TR/workflow.ts @@ -670,6 +670,7 @@ const translation = { inputVars: 'Giriş Değişkenleri', outputVars: { className: 'Sınıf Adı', + usage: 'Model Kullanım Bilgileri', }, class: 'Sınıf', classNamePlaceholder: 'Sınıf adınızı yazın', @@ -683,6 +684,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Giriş Değişkeni', + outputVars: { + isSuccess: 'Başarılı mı. Başarılı olduğunda değer 1, başarısız olduğunda değer 0\'dır.', + errorReason: 'Hata Nedeni', + usage: 'Model Kullanım Bilgileri', + }, extractParameters: 'Parametreleri Çıkar', importFromTool: 'Araçlardan içe aktar', addExtractParameter: 'Çıkarma Parametresi Ekle', @@ -702,8 +708,6 @@ const translation = { advancedSetting: 'Gelişmiş Ayarlar', reasoningMode: 'Akıl Yürütme Modu', reasoningModeTip: 'Modelin fonksiyon çağırma veya istemler için talimatlara yanıt verme yeteneğine bağlı olarak uygun akıl yürütme modunu seçebilirsiniz.', - isSuccess: 'Başarılı mı. Başarılı olduğunda değer 1, başarısız olduğunda değer 0\'dır.', - errorReason: 'Hata Nedeni', }, iteration: { deleteTitle: 'Yineleme Düğümünü Sil?', diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index 110c82dc1a..65fac7f838 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Вхідні змінні', outputVars: { className: 'Назва класу', + usage: 'Інформація про використання моделі', }, class: 'Клас', classNamePlaceholder: 'Напишіть назву вашого класу', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Вхідна змінна', + outputVars: { + isSuccess: 'Є успіх. У разі успіху значення 1, у разі невдачі значення 0.', + errorReason: 'Причина помилки', + usage: 'Інформація про використання моделі', + }, extractParameters: 'Витягти параметри', importFromTool: 'Імпорт з інструментів', addExtractParameter: 'Додати параметр витягування', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Розширене налаштування', reasoningMode: 'Режим інференції', reasoningModeTip: 'Ви можете вибрати відповідний режим інференції залежно від здатності моделі реагувати на інструкції щодо викликів функцій або запитів.', - isSuccess: 'Є успіх. У разі успіху значення 1, у разі невдачі значення 0.', - errorReason: 'Причина помилки', }, iteration: { deleteTitle: 'Видалити вузол ітерації?', diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index 7c32f39350..f88fb6e2ab 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -669,6 +669,7 @@ const translation = { inputVars: 'Biến đầu vào', outputVars: { className: 'Tên lớp', + usage: 'Thông tin sử dụng mô hình', }, class: 'Lớp', classNamePlaceholder: 'Viết tên lớp của bạn', @@ -682,6 +683,11 @@ const translation = { }, parameterExtractor: { inputVar: 'Biến đầu vào', + outputVars: { + isSuccess: 'Thành công. Khi thành công giá trị là 1, khi thất bại giá trị là 0.', + errorReason: 'Lý do lỗi', + usage: 'Thông tin sử dụng mô hình', + }, extractParameters: 'Trích xuất tham số', importFromTool: 'Nhập từ công cụ', addExtractParameter: 'Thêm tham số trích xuất', @@ -701,8 +707,6 @@ const translation = { advancedSetting: 'Cài đặt nâng cao', reasoningMode: 'Chế độ suy luận', reasoningModeTip: 'Bạn có thể chọn chế độ suy luận phù hợp dựa trên khả năng của mô hình để phản hồi các hướng dẫn về việc gọi hàm hoặc prompt.', - isSuccess: 'Thành công. Khi thành công giá trị là 1, khi thất bại giá trị là 0.', - errorReason: 'Lý do lỗi', }, iteration: { deleteTitle: 'Xóa nút lặp?', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index aa9dcae261..ab8f621883 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -674,6 +674,7 @@ const translation = { inputVars: '输入变量', outputVars: { className: '分类名称', + usage: '模型用量信息', }, class: '分类', classNamePlaceholder: '输入你的分类名称', @@ -687,6 +688,11 @@ const translation = { }, parameterExtractor: { inputVar: '输入变量', + outputVars: { + isSuccess: '是否成功。成功时值为 1,失败时值为 0。', + errorReason: '错误原因', + usage: '模型用量信息', + }, extractParameters: '提取参数', importFromTool: '从工具导入', addExtractParameter: '添加提取参数', @@ -706,8 +712,6 @@ const translation = { advancedSetting: '高级设置', reasoningMode: '推理模式', reasoningModeTip: '你可以根据模型对于 Function calling 或 Prompt 的指令响应能力选择合适的推理模式', - isSuccess: '是否成功。成功时值为 1,失败时值为 0。', - errorReason: '错误原因', }, iteration: { deleteTitle: '删除迭代节点?', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 8262e60351..2504f02832 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -670,6 +670,7 @@ const translation = { inputVars: '輸入變量', outputVars: { className: '分類名稱', + usage: '模型用量信息', }, class: '分類', classNamePlaceholder: '輸入你的分類名稱', @@ -683,6 +684,11 @@ const translation = { }, parameterExtractor: { inputVar: '輸入變量', + outputVars: { + isSuccess: '是否成功。成功時值為 1,失敗時值為 0。', + errorReason: '錯誤原因', + usage: '模型用量信息', + }, extractParameters: '提取參數', importFromTool: '從工具導入', addExtractParameter: '添加提取參數', @@ -702,8 +708,6 @@ const translation = { advancedSetting: '高級設置', reasoningMode: '推理模式', reasoningModeTip: '你可以根據模型對於 Function calling 或 Prompt 的指令響應能力選擇合適的推理模式', - isSuccess: '是否成功。成功時值為 1,失敗時值為 0。', - errorReason: '錯誤原因', }, iteration: { deleteTitle: '刪除迭代節點?', From d61ea5a2de9e00b54381b9ab90d0104469b55f6d Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Tue, 8 Jul 2025 21:22:37 +0800 Subject: [PATCH 2/8] test: add comprehensive unit tests for UrlSigner (#22030) --- .../unit_tests/core/helper/test_url_signer.py | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 api/tests/unit_tests/core/helper/test_url_signer.py diff --git a/api/tests/unit_tests/core/helper/test_url_signer.py b/api/tests/unit_tests/core/helper/test_url_signer.py new file mode 100644 index 0000000000..5af24777de --- /dev/null +++ b/api/tests/unit_tests/core/helper/test_url_signer.py @@ -0,0 +1,194 @@ +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +import pytest + +from core.helper.url_signer import SignedUrlParams, UrlSigner + + +class TestUrlSigner: + """Test cases for UrlSigner class""" + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_generate_signed_url_params(self): + """Test generation of signed URL parameters with all required fields""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + params = UrlSigner.get_signed_url_params(sign_key, prefix) + + # Verify the returned object and required fields + assert isinstance(params, SignedUrlParams) + assert params.sign_key == sign_key + assert params.timestamp is not None + assert params.nonce is not None + assert params.sign is not None + + # Verify nonce format (32 character hex string) + assert len(params.nonce) == 32 + assert all(c in "0123456789abcdef" for c in params.nonce) + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_generate_complete_signed_url(self): + """Test generation of complete signed URL with query parameters""" + base_url = "https://example.com/api/test" + sign_key = "test-sign-key" + prefix = "test-prefix" + + signed_url = UrlSigner.get_signed_url(base_url, sign_key, prefix) + + # Parse URL and verify structure + parsed = urlparse(signed_url) + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == base_url + + # Verify query parameters + query_params = parse_qs(parsed.query) + assert "timestamp" in query_params + assert "nonce" in query_params + assert "sign" in query_params + + # Verify each parameter has exactly one value + assert len(query_params["timestamp"]) == 1 + assert len(query_params["nonce"]) == 1 + assert len(query_params["sign"]) == 1 + + # Verify parameter values are not empty + assert query_params["timestamp"][0] + assert query_params["nonce"][0] + assert query_params["sign"][0] + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_verify_valid_signature(self): + """Test verification of valid signature""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate and verify signature + params = UrlSigner.get_signed_url_params(sign_key, prefix) + + is_valid = UrlSigner.verify( + sign_key=sign_key, timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix=prefix + ) + + assert is_valid is True + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + @pytest.mark.parametrize( + ("field", "modifier"), + [ + ("sign_key", lambda _: "wrong-sign-key"), + ("timestamp", lambda t: str(int(t) + 1000)), + ("nonce", lambda _: "different-nonce-123456789012345"), + ("prefix", lambda _: "wrong-prefix"), + ("sign", lambda s: s + "tampered"), + ], + ) + def test_should_reject_invalid_signature_params(self, field, modifier): + """Test signature verification rejects invalid parameters""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate valid signed parameters + params = UrlSigner.get_signed_url_params(sign_key, prefix) + + # Prepare verification parameters + verify_params = { + "sign_key": sign_key, + "timestamp": params.timestamp, + "nonce": params.nonce, + "sign": params.sign, + "prefix": prefix, + } + + # Modify the specific field + verify_params[field] = modifier(verify_params[field]) + + # Verify should fail + is_valid = UrlSigner.verify(**verify_params) + assert is_valid is False + + @patch("configs.dify_config.SECRET_KEY", None) + def test_should_raise_error_without_secret_key(self): + """Test that signing fails when SECRET_KEY is not configured""" + with pytest.raises(Exception) as exc_info: + UrlSigner.get_signed_url_params("key", "prefix") + + assert "SECRET_KEY is not set" in str(exc_info.value) + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_generate_unique_signatures(self): + """Test that different inputs produce different signatures""" + params1 = UrlSigner.get_signed_url_params("key1", "prefix1") + params2 = UrlSigner.get_signed_url_params("key2", "prefix2") + + # Different inputs should produce different signatures + assert params1.sign != params2.sign + assert params1.nonce != params2.nonce + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_handle_special_characters(self): + """Test handling of special characters in parameters""" + special_cases = [ + "test with spaces", + "test/with/slashes", + "test中文字符", + ] + + for sign_key in special_cases: + params = UrlSigner.get_signed_url_params(sign_key, "prefix") + + # Should generate valid signature and verify correctly + is_valid = UrlSigner.verify( + sign_key=sign_key, timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix="prefix" + ) + assert is_valid is True + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_ensure_nonce_randomness(self): + """Test that nonce is random for each generation - critical for security""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate multiple nonces + nonces = set() + for _ in range(5): + params = UrlSigner.get_signed_url_params(sign_key, prefix) + nonces.add(params.nonce) + + # All nonces should be unique + assert len(nonces) == 5 + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + @patch("time.time", return_value=1234567890) + @patch("os.urandom", return_value=b"\xab\xcd\xef\x12\x34\x56\x78\x90\xab\xcd\xef\x12\x34\x56\x78\x90") + def test_should_produce_consistent_signatures(self, mock_urandom, mock_time): + """Test that same inputs produce same signature - ensures deterministic behavior""" + sign_key = "test-sign-key" + prefix = "test-prefix" + + # Generate signature multiple times with same inputs (time and nonce are mocked) + params1 = UrlSigner.get_signed_url_params(sign_key, prefix) + params2 = UrlSigner.get_signed_url_params(sign_key, prefix) + + # With mocked time and random, should produce identical results + assert params1.timestamp == params2.timestamp + assert params1.nonce == params2.nonce + assert params1.sign == params2.sign + + # Verify the signature is valid + assert UrlSigner.verify( + sign_key=sign_key, timestamp=params1.timestamp, nonce=params1.nonce, sign=params1.sign, prefix=prefix + ) + + @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345") + def test_should_handle_empty_strings(self): + """Test handling of empty string parameters - common edge case""" + # Empty sign_key and prefix should still work + params = UrlSigner.get_signed_url_params("", "") + assert params.sign is not None + + # Should verify correctly + is_valid = UrlSigner.verify( + sign_key="", timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix="" + ) + assert is_valid is True From 521488f9268838687d14698d99d2f74df615df7d Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Wed, 9 Jul 2025 09:28:26 +0800 Subject: [PATCH 3/8] Remove tow unused files (#22022) --- api/core/file/models.py | 2 +- api/core/file/upload_file_parser.py | 67 ----------------------------- api/core/helper/lru_cache.py | 22 ---------- 3 files changed, 1 insertion(+), 90 deletions(-) delete mode 100644 api/core/file/upload_file_parser.py delete mode 100644 api/core/helper/lru_cache.py diff --git a/api/core/file/models.py b/api/core/file/models.py index aa3b5f629c..f61334e7bc 100644 --- a/api/core/file/models.py +++ b/api/core/file/models.py @@ -51,7 +51,7 @@ class File(BaseModel): # It should be set to `ToolFile.id` when `transfer_method` is `tool_file`. related_id: Optional[str] = None filename: Optional[str] = None - extension: Optional[str] = Field(default=None, description="File extension, should contains dot") + extension: Optional[str] = Field(default=None, description="File extension, should contain dot") mime_type: Optional[str] = None size: int = -1 diff --git a/api/core/file/upload_file_parser.py b/api/core/file/upload_file_parser.py deleted file mode 100644 index 96b2884811..0000000000 --- a/api/core/file/upload_file_parser.py +++ /dev/null @@ -1,67 +0,0 @@ -import base64 -import logging -import time -from typing import Optional - -from configs import dify_config -from constants import IMAGE_EXTENSIONS -from core.helper.url_signer import UrlSigner -from extensions.ext_storage import storage - - -class UploadFileParser: - @classmethod - def get_image_data(cls, upload_file, force_url: bool = False) -> Optional[str]: - if not upload_file: - return None - - if upload_file.extension not in IMAGE_EXTENSIONS: - return None - - if dify_config.MULTIMODAL_SEND_FORMAT == "url" or force_url: - return cls.get_signed_temp_image_url(upload_file.id) - else: - # get image file base64 - try: - data = storage.load(upload_file.key) - except FileNotFoundError: - logging.exception(f"File not found: {upload_file.key}") - return None - - encoded_string = base64.b64encode(data).decode("utf-8") - return f"data:{upload_file.mime_type};base64,{encoded_string}" - - @classmethod - def get_signed_temp_image_url(cls, upload_file_id) -> str: - """ - get signed url from upload file - - :param upload_file_id: the id of UploadFile object - :return: - """ - base_url = dify_config.FILES_URL - image_preview_url = f"{base_url}/files/{upload_file_id}/image-preview" - - return UrlSigner.get_signed_url(url=image_preview_url, sign_key=upload_file_id, prefix="image-preview") - - @classmethod - def verify_image_file_signature(cls, upload_file_id: str, timestamp: str, nonce: str, sign: str) -> bool: - """ - verify signature - - :param upload_file_id: file id - :param timestamp: timestamp - :param nonce: nonce - :param sign: signature - :return: - """ - result = UrlSigner.verify( - sign_key=upload_file_id, timestamp=timestamp, nonce=nonce, sign=sign, prefix="image-preview" - ) - - # verify signature - if not result: - return False - - current_time = int(time.time()) - return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT diff --git a/api/core/helper/lru_cache.py b/api/core/helper/lru_cache.py deleted file mode 100644 index 81501d2e4e..0000000000 --- a/api/core/helper/lru_cache.py +++ /dev/null @@ -1,22 +0,0 @@ -from collections import OrderedDict -from typing import Any - - -class LRUCache: - def __init__(self, capacity: int): - self.cache: OrderedDict[Any, Any] = OrderedDict() - self.capacity = capacity - - def get(self, key: Any) -> Any: - if key not in self.cache: - return None - else: - self.cache.move_to_end(key) # move the key to the end of the OrderedDict - return self.cache[key] - - def put(self, key: Any, value: Any) -> None: - if key in self.cache: - self.cache.move_to_end(key) - self.cache[key] = value - if len(self.cache) > self.capacity: - self.cache.popitem(last=False) # pop the first item From e39236186dce9ad212a522faa4c49b624cd73762 Mon Sep 17 00:00:00 2001 From: kurokobo Date: Wed, 9 Jul 2025 11:12:40 +0900 Subject: [PATCH 4/8] feat: introduce new env ALLOW_UNSAFE_DATA_SCHEME to allow rendering data uri scheme (#21321) --- docker/.env.example | 3 +++ docker/docker-compose-template.yaml | 1 + docker/docker-compose.yaml | 2 ++ web/.env.example | 3 +++ web/app/components/base/markdown-blocks/utils.ts | 6 +++++- web/app/components/base/markdown/markdown-utils.ts | 4 ++++ web/app/layout.tsx | 1 + web/config/index.ts | 1 + web/docker/entrypoint.sh | 1 + web/types/feature.ts | 1 + 10 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docker/.env.example b/docker/.env.example index e7dbecb413..a403f25cb2 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -826,6 +826,9 @@ MAX_ITERATIONS_NUM=99 # The timeout for the text generation in millisecond TEXT_GENERATION_TIMEOUT_MS=60000 +# Allow rendering unsafe URLs which have "data:" scheme. +ALLOW_UNSAFE_DATA_SCHEME=false + # ------------------------------ # Environment Variables for db Service # ------------------------------ diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index a34f96e945..fd7c78c7e7 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -67,6 +67,7 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index e48b5afd8c..0a95251ff0 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -364,6 +364,7 @@ x-shared-env: &shared-api-worker-env MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10} MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} POSTGRES_USER: ${POSTGRES_USER:-${DB_USERNAME}} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}} POSTGRES_DB: ${POSTGRES_DB:-${DB_DATABASE}} @@ -582,6 +583,7 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} diff --git a/web/.env.example b/web/.env.example index c30064ffed..37bfc939eb 100644 --- a/web/.env.example +++ b/web/.env.example @@ -32,6 +32,9 @@ NEXT_PUBLIC_CSP_WHITELIST= # Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking NEXT_PUBLIC_ALLOW_EMBED= +# Allow rendering unsafe URLs which have "data:" scheme. +NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false + # Github Access Token, used for invoking Github API NEXT_PUBLIC_GITHUB_ACCESS_TOKEN= # The maximum number of top-k value for RAG. diff --git a/web/app/components/base/markdown-blocks/utils.ts b/web/app/components/base/markdown-blocks/utils.ts index 4e9e98dbed..d8df76aefc 100644 --- a/web/app/components/base/markdown-blocks/utils.ts +++ b/web/app/components/base/markdown-blocks/utils.ts @@ -1,3 +1,7 @@ +import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config' + export const isValidUrl = (url: string): boolean => { - return ['http:', 'https:', '//', 'mailto:'].some(prefix => url.startsWith(prefix)) + const validPrefixes = ['http:', 'https:', '//', 'mailto:'] + if (ALLOW_UNSAFE_DATA_SCHEME) validPrefixes.push('data:') + return validPrefixes.some(prefix => url.startsWith(prefix)) } diff --git a/web/app/components/base/markdown/markdown-utils.ts b/web/app/components/base/markdown/markdown-utils.ts index 209fcd0b32..0089bef0ac 100644 --- a/web/app/components/base/markdown/markdown-utils.ts +++ b/web/app/components/base/markdown/markdown-utils.ts @@ -4,6 +4,7 @@ * Includes preprocessing for LaTeX and custom "think" tags. */ import { flow } from 'lodash-es' +import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config' export const preprocessLaTeX = (content: string) => { if (typeof content !== 'string') @@ -86,5 +87,8 @@ export const customUrlTransform = (uri: string): string | undefined => { if (PERMITTED_SCHEME_REGEX.test(scheme)) return uri + if (ALLOW_UNSAFE_DATA_SCHEME && scheme === 'data:') + return uri + return undefined } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f3b2ca7f7f..525445db30 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -44,6 +44,7 @@ const LocaleLayout = async ({ [DatasetAttr.DATA_PUBLIC_LOOP_NODE_MAX_COUNT]: process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT, [DatasetAttr.DATA_PUBLIC_MAX_ITERATIONS_NUM]: process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM, [DatasetAttr.DATA_PUBLIC_MAX_TREE_DEPTH]: process.env.NEXT_PUBLIC_MAX_TREE_DEPTH, + [DatasetAttr.DATA_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME]: process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME, [DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_JINAREADER]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER, [DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_FIRECRAWL]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL, [DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL, diff --git a/web/config/index.ts b/web/config/index.ts index daaed412d0..af9a21e600 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -270,6 +270,7 @@ export const LOOP_NODE_MAX_COUNT = getNumberConfig(process.env.NEXT_PUBLIC_LOOP_ export const MAX_ITERATIONS_NUM = getNumberConfig(process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM, DatasetAttr.DATA_PUBLIC_MAX_ITERATIONS_NUM, 99) export const MAX_TREE_DEPTH = getNumberConfig(process.env.NEXT_PUBLIC_MAX_TREE_DEPTH, DatasetAttr.DATA_PUBLIC_MAX_TREE_DEPTH, 50) +export const ALLOW_UNSAFE_DATA_SCHEME = getBooleanConfig(process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME, DatasetAttr.DATA_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME, false) export const ENABLE_WEBSITE_JINAREADER = getBooleanConfig(process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER, DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_JINAREADER, true) export const ENABLE_WEBSITE_FIRECRAWL = getBooleanConfig(process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL, DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_FIRECRAWL, true) export const ENABLE_WEBSITE_WATERCRAWL = getBooleanConfig(process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL, DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL, false) diff --git a/web/docker/entrypoint.sh b/web/docker/entrypoint.sh index c9acbdd422..ef13011a71 100755 --- a/web/docker/entrypoint.sh +++ b/web/docker/entrypoint.sh @@ -26,6 +26,7 @@ export NEXT_TELEMETRY_DISABLED=${NEXT_TELEMETRY_DISABLED} export NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=${TEXT_GENERATION_TIMEOUT_MS} export NEXT_PUBLIC_CSP_WHITELIST=${CSP_WHITELIST} export NEXT_PUBLIC_ALLOW_EMBED=${ALLOW_EMBED} +export NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=${ALLOW_UNSAFE_DATA_SCHEME:-false} export NEXT_PUBLIC_TOP_K_MAX_VALUE=${TOP_K_MAX_VALUE} export NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH} export NEXT_PUBLIC_MAX_TOOLS_NUM=${MAX_TOOLS_NUM} diff --git a/web/types/feature.ts b/web/types/feature.ts index b2a80dbea2..5787c2661f 100644 --- a/web/types/feature.ts +++ b/web/types/feature.ts @@ -116,6 +116,7 @@ export enum DatasetAttr { DATA_PUBLIC_LOOP_NODE_MAX_COUNT = 'data-public-loop-node-max-count', DATA_PUBLIC_MAX_ITERATIONS_NUM = 'data-public-max-iterations-num', DATA_PUBLIC_MAX_TREE_DEPTH = 'data-public-max-tree-depth', + DATA_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME = 'data-public-allow-unsafe-data-scheme', DATA_PUBLIC_ENABLE_WEBSITE_JINAREADER = 'data-public-enable-website-jinareader', DATA_PUBLIC_ENABLE_WEBSITE_FIRECRAWL = 'data-public-enable-website-firecrawl', DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL = 'data-public-enable-website-watercrawl', From 3643ed101437f2e95b8e769c288c29b4e54e39aa Mon Sep 17 00:00:00 2001 From: Minamiyama Date: Wed, 9 Jul 2025 15:18:23 +0800 Subject: [PATCH 5/8] Feat: description field for env variables (#21556) --- api/fields/workflow_fields.py | 2 + .../nodes/_base/components/variable/utils.ts | 3 +- .../components/variable-modal.tsx | 10 ++-- .../workflow/panel/env-panel/env-item.tsx | 48 ++++++++++++------- .../panel/env-panel/variable-modal.tsx | 17 ++++++- web/app/components/workflow/types.ts | 1 + web/i18n/de-DE/workflow.ts | 2 + web/i18n/en-US/workflow.ts | 2 + web/i18n/es-ES/workflow.ts | 2 + web/i18n/fa-IR/workflow.ts | 2 + web/i18n/fr-FR/workflow.ts | 2 + web/i18n/hi-IN/workflow.ts | 2 + web/i18n/it-IT/workflow.ts | 2 + web/i18n/ja-JP/workflow.ts | 2 + web/i18n/ko-KR/workflow.ts | 2 + web/i18n/pl-PL/workflow.ts | 2 + web/i18n/pt-BR/workflow.ts | 2 + web/i18n/ro-RO/workflow.ts | 2 + web/i18n/ru-RU/workflow.ts | 2 + web/i18n/sl-SI/workflow.ts | 4 +- web/i18n/th-TH/workflow.ts | 2 + web/i18n/tr-TR/workflow.ts | 2 + web/i18n/uk-UA/workflow.ts | 2 + web/i18n/vi-VN/workflow.ts | 2 + web/i18n/zh-Hans/workflow.ts | 2 + web/i18n/zh-Hant/workflow.ts | 2 + 26 files changed, 97 insertions(+), 26 deletions(-) diff --git a/api/fields/workflow_fields.py b/api/fields/workflow_fields.py index 9f1bef3b36..f00ea71c54 100644 --- a/api/fields/workflow_fields.py +++ b/api/fields/workflow_fields.py @@ -17,6 +17,7 @@ class EnvironmentVariableField(fields.Raw): "name": value.name, "value": encrypter.obfuscated_token(value.value), "value_type": value.value_type.value, + "description": value.description, } if isinstance(value, Variable): return { @@ -24,6 +25,7 @@ class EnvironmentVariableField(fields.Raw): "name": value.name, "value": value.value, "value_type": value.value_type.value, + "description": value.description, } if isinstance(value, dict): value_type = value.get("value_type") 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 1058f29119..ac95f54757 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -462,6 +462,7 @@ const formatItem = ( return { variable: `env.${env.name}`, type: env.value_type, + description: env.description, } }) as Var[] break @@ -472,7 +473,7 @@ const formatItem = ( return { variable: `conversation.${chatVar.name}`, type: chatVar.value_type, - des: chatVar.description, + description: chatVar.description, } }) as Var[] break diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx index 347c83c155..869317ca6a 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.tsx @@ -80,7 +80,7 @@ const ChatVariableModal = ({ const [objectValue, setObjectValue] = React.useState([DEFAULT_OBJECT_VALUE]) const [editorContent, setEditorContent] = React.useState() const [editInJSON, setEditInJSON] = React.useState(false) - const [des, setDes] = React.useState('') + const [description, setDescription] = React.useState('') const editorMinHeight = useMemo(() => { if (type === ChatVarType.ArrayObject) @@ -237,7 +237,7 @@ const ChatVariableModal = ({ name, value_type: type, value: formatValue(value), - description: des, + description, }) onClose() } @@ -247,7 +247,7 @@ const ChatVariableModal = ({ setName(chatVar.name) setType(chatVar.value_type) setValue(chatVar.value) - setDes(chatVar.description) + setDescription(chatVar.description) setObjectValue(getObjectValue()) if (chatVar.value_type === ChatVarType.ArrayObject) { setEditorContent(JSON.stringify(chatVar.value)) @@ -385,9 +385,9 @@ const ChatVariableModal = ({