From ada4c30f8540e319e671f8d403e41914a57561f5 Mon Sep 17 00:00:00 2001
From: Typer_Body
Date: Wed, 6 May 2026 01:03:34 +0800
Subject: [PATCH] 1111
---
.../controller/groups/workflows/workflows.py | 13 +
src/langbot/pkg/api/http/service/pipeline.py | 14 +
src/langbot/pkg/api/http/service/workflow.py | 82 ++-
src/langbot/pkg/workflow/executor.py | 61 +-
src/langbot/pkg/workflow/node.py | 1 +
.../pkg/workflow/nodes/call_pipeline.py | 193 ++++++-
.../pkg/workflow/nodes/reply_message.py | 2 +
.../dynamic-form/DynamicFormComponent.tsx | 100 ++--
.../dynamic-form/DynamicFormItemComponent.tsx | 130 ++++-
.../WorkflowDebugDialog.tsx | 100 +++-
.../node-configs/action-configs.ts | 146 +++--
.../WorkflowExecutionsTab.tsx | 521 ++++++++++++------
web/src/app/infra/entities/api/index.ts | 12 +-
web/src/app/infra/entities/form/dynamic.ts | 1 +
.../websocket/WorkflowWebSocketClient.ts | 58 +-
web/src/i18n/locales/zh-Hans.ts | 10 +-
16 files changed, 1097 insertions(+), 347 deletions(-)
diff --git a/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py b/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py
index ec019fc7..7496f6ee 100644
--- a/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py
+++ b/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py
@@ -125,6 +125,19 @@ class WorkflowsRouterGroup(group.RouterGroup):
)
return self.success(data=executions)
+ @self.route(
+ '//executions/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ )
+ async def _(workflow_uuid: str, execution_uuid: str) -> str:
+ execution = await self.ap.workflow_service.get_execution(execution_uuid)
+ if execution is None:
+ return self.http_status(404, -1, 'execution not found')
+ if execution.get('workflow_uuid') != workflow_uuid:
+ return self.http_status(404, -1, 'execution not found in workflow')
+ return self.success(data={'execution': execution})
+
# Get workflow versions
@self.route('//versions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(workflow_uuid: str) -> str:
diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py
index ad75ffe7..6fae190a 100644
--- a/src/langbot/pkg/api/http/service/pipeline.py
+++ b/src/langbot/pkg/api/http/service/pipeline.py
@@ -73,6 +73,20 @@ class PipelineService:
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
+ async def get_pipeline_by_name(self, pipeline_name: str) -> dict | None:
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
+ persistence_pipeline.LegacyPipeline.name == pipeline_name
+ )
+ )
+
+ pipeline = result.first()
+
+ if pipeline is None:
+ return None
+
+ return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
+
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
from ....utils import paths as path_utils
diff --git a/src/langbot/pkg/api/http/service/workflow.py b/src/langbot/pkg/api/http/service/workflow.py
index 24405467..869f1c25 100644
--- a/src/langbot/pkg/api/http/service/workflow.py
+++ b/src/langbot/pkg/api/http/service/workflow.py
@@ -284,7 +284,7 @@ class WorkflowService:
'uuid': execution_uuid,
'workflow_uuid': workflow_uuid,
'workflow_version': workflow_dict.get('version', 1),
- 'status': ExecutionStatus.PENDING.value,
+ 'status': ExecutionStatus.RUNNING.value,
'trigger_type': trigger_type,
'trigger_data': trigger_data or {},
'variables': {},
@@ -496,13 +496,7 @@ class WorkflowService:
executions = result.all()
return {
- 'executions': [
- self.ap.persistence_mgr.serialize_model(
- persistence_workflow.WorkflowExecution,
- execution
- )
- for execution in executions
- ],
+ 'executions': [self._serialize_execution(execution) for execution in executions],
'total': total,
}
@@ -519,10 +513,17 @@ class WorkflowService:
if execution is None:
return None
- return self.ap.persistence_mgr.serialize_model(
- persistence_workflow.WorkflowExecution,
- execution
- )
+ data = self._serialize_execution(execution)
+
+ node_exec_query = sqlalchemy.select(persistence_workflow.WorkflowNodeExecution).where(
+ persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid
+ ).order_by(persistence_workflow.WorkflowNodeExecution.id.asc())
+ node_exec_result = await self.ap.persistence_mgr.execute_async(node_exec_query)
+ node_executions = node_exec_result.all()
+ data['node_executions'] = [
+ self._serialize_node_execution(node_exec) for node_exec in node_executions
+ ]
+ return data
async def get_node_types(self) -> list[dict]:
"""Get all available node types"""
@@ -837,6 +838,24 @@ class WorkflowService:
result['variables'] = {}
return result
+
+ def _serialize_execution(self, execution) -> dict:
+ data = self.ap.persistence_mgr.serialize_model(
+ persistence_workflow.WorkflowExecution,
+ execution,
+ )
+ data['started_at'] = data.get('start_time')
+ data['completed_at'] = data.get('end_time')
+ return data
+
+ def _serialize_node_execution(self, node_execution) -> dict:
+ data = self.ap.persistence_mgr.serialize_model(
+ persistence_workflow.WorkflowNodeExecution,
+ node_execution,
+ )
+ data['started_at'] = data.get('start_time')
+ data['completed_at'] = data.get('end_time')
+ return data
async def update_workflow_extensions(
self,
@@ -1111,22 +1130,41 @@ class WorkflowService:
execution = await self.get_execution(execution_uuid)
if execution is None:
raise ValueError(f'Execution {execution_uuid} not found')
-
+ if execution.get('workflow_uuid') != workflow_uuid:
+ raise ValueError(f'Execution {execution_uuid} not found in workflow {workflow_uuid}')
+
query = sqlalchemy.select(persistence_workflow.WorkflowNodeExecution).where(
persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid
).order_by(
persistence_workflow.WorkflowNodeExecution.id.asc()
).limit(limit).offset(offset)
-
+
result = await self.ap.persistence_mgr.execute_async(query)
node_executions = result.all()
-
- logs = [
- self.ap.persistence_mgr.serialize_model(
- persistence_workflow.WorkflowNodeExecution,
- node_exec
+
+ logs = []
+ for node_exec in node_executions:
+ serialized = self._serialize_node_execution(node_exec)
+ timestamp = serialized.get('completed_at') or serialized.get('started_at') or execution.get('started_at')
+ level = 'error' if serialized.get('status') == 'failed' else 'info'
+ message = (
+ f"{serialized.get('node_type')}::{serialized.get('node_id')} - {serialized.get('status')}"
)
- for node_exec in node_executions
- ]
-
+ if serialized.get('error'):
+ message = f"{message} - {serialized.get('error')}"
+ logs.append(
+ {
+ 'id': str(serialized.get('id', serialized.get('node_id'))),
+ 'timestamp': timestamp,
+ 'level': level,
+ 'node_id': serialized.get('node_id'),
+ 'message': message,
+ 'data': {
+ 'inputs': serialized.get('inputs'),
+ 'outputs': serialized.get('outputs'),
+ 'retry_count': serialized.get('retry_count'),
+ },
+ }
+ )
+
return {'logs': logs, 'total': len(logs)}
diff --git a/src/langbot/pkg/workflow/executor.py b/src/langbot/pkg/workflow/executor.py
index e7d63f81..9250c44d 100644
--- a/src/langbot/pkg/workflow/executor.py
+++ b/src/langbot/pkg/workflow/executor.py
@@ -10,6 +10,8 @@ import uuid
from datetime import datetime
from typing import Any, Optional, TYPE_CHECKING
+import sqlalchemy
+
from .entities import (
WorkflowDefinition,
NodeDefinition,
@@ -20,6 +22,7 @@ from .entities import (
NodeStatus,
ExecutionStep,
)
+from ..entity.persistence import workflow as persistence_workflow
from .registry import NodeTypeRegistry
if TYPE_CHECKING:
@@ -346,6 +349,8 @@ class WorkflowExecutor:
logger.warning(f"Circular dependency detected at node: {node.id}")
context.node_states[node.id].status = NodeStatus.SKIPPED
context.node_states[node.id].error = "Circular dependency detected"
+ context.node_states[node.id].end_time = datetime.now()
+ await self._persist_node_execution(node, context.node_states[node.id], context)
return
# Add node to current path
@@ -353,7 +358,10 @@ class WorkflowExecutor:
# Check if node should be skipped
if await self._should_skip_node(node, context):
- context.node_states[node.id].status = NodeStatus.SKIPPED
+ existing_state = context.node_states[node.id]
+ if existing_state.status == NodeStatus.SKIPPED:
+ existing_state.end_time = existing_state.end_time or datetime.now()
+ await self._persist_node_execution(node, existing_state, context)
path.discard(node.id)
return
@@ -469,6 +477,7 @@ class WorkflowExecutor:
node_state.error = f"Unknown node type: {node.type}"
node_state.end_time = datetime.now()
self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
return
# Resolve inputs
@@ -482,6 +491,7 @@ class WorkflowExecutor:
node_state.error = "; ".join(validation_errors)
node_state.end_time = datetime.now()
self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
return
# Execute with retries
@@ -523,6 +533,7 @@ class WorkflowExecutor:
)
self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
async def _resolve_inputs(
self,
@@ -738,6 +749,47 @@ class WorkflowExecutor:
)
context.history.append(step)
+ async def _persist_node_execution(
+ self,
+ node: NodeDefinition,
+ node_state: NodeState,
+ context: ExecutionContext,
+ ):
+ """Persist node execution state for execution detail and logs."""
+ if not self.ap:
+ return
+
+ values = {
+ 'execution_uuid': context.execution_id,
+ 'node_id': node.id,
+ 'node_type': node.type,
+ 'status': node_state.status.value,
+ 'inputs': node_state.inputs,
+ 'outputs': node_state.outputs,
+ 'start_time': node_state.start_time,
+ 'end_time': node_state.end_time,
+ 'error': node_state.error,
+ 'retry_count': node_state.retry_count,
+ }
+
+ existing_query = sqlalchemy.select(persistence_workflow.WorkflowNodeExecution).where(
+ persistence_workflow.WorkflowNodeExecution.execution_uuid == context.execution_id,
+ persistence_workflow.WorkflowNodeExecution.node_id == node.id,
+ )
+ existing_result = await self.ap.persistence_mgr.execute_async(existing_query)
+ existing = existing_result.first()
+
+ if existing is None:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(persistence_workflow.WorkflowNodeExecution).values(**values)
+ )
+ else:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_workflow.WorkflowNodeExecution)
+ .where(persistence_workflow.WorkflowNodeExecution.id == existing.id)
+ .values(**values)
+ )
+
class ParallelExecutor:
"""Execute multiple branches in parallel"""
@@ -997,8 +1049,8 @@ class DebugWorkflowExecutor(WorkflowExecutor):
# Check if should skip
if await self._should_skip_node(node, context):
- context.node_states[node.id].status = NodeStatus.SKIPPED
- debug_state.add_log('info', f'Skipping node: {node.id}', node_id=node.id)
+ if context.node_states[node.id].status == NodeStatus.SKIPPED:
+ debug_state.add_log('info', f'Skipping node: {node.id}', node_id=node.id)
return
# Check breakpoint
@@ -1076,6 +1128,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
node_state.end_time = datetime.now()
debug_state.add_log('error', f'Unknown node type: {node.type}', node_id=node.id)
self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
return
# Resolve inputs
@@ -1100,6 +1153,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
node_id=node.id
)
self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
return
# Execute with retries
@@ -1147,6 +1201,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
)
self._record_execution_step(node, node_state, context)
+ await self._persist_node_execution(node, node_state, context)
async def step_execute(
self,
diff --git a/src/langbot/pkg/workflow/node.py b/src/langbot/pkg/workflow/node.py
index 717d8d06..fe22bccf 100644
--- a/src/langbot/pkg/workflow/node.py
+++ b/src/langbot/pkg/workflow/node.py
@@ -148,6 +148,7 @@ class WorkflowNode(abc.ABC):
'llm-model-selector': 'llm-model-selector',
'embedding-model-selector': 'embedding-model-selector',
'rerank-model-selector': 'rerank-model-selector',
+ 'pipeline-selector': 'pipeline-selector',
'knowledge-base-selector': 'knowledge-base-selector',
'knowledge-base-multi-selector': 'knowledge-base-multi-selector',
'bot-selector': 'bot-selector',
diff --git a/src/langbot/pkg/workflow/nodes/call_pipeline.py b/src/langbot/pkg/workflow/nodes/call_pipeline.py
index 6fc41675..8829564a 100644
--- a/src/langbot/pkg/workflow/nodes/call_pipeline.py
+++ b/src/langbot/pkg/workflow/nodes/call_pipeline.py
@@ -7,6 +7,13 @@ from __future__ import annotations
from typing import Any, ClassVar
+import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
+import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
+import langbot_plugin.api.entities.builtin.platform.events as platform_events
+import langbot_plugin.api.entities.builtin.platform.message as platform_message
+import langbot_plugin.api.entities.builtin.provider.session as provider_session
+
from ..entities import ExecutionContext
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
@@ -30,7 +37,187 @@ class CallPipelineNode(WorkflowNode):
config_schema: ClassVar[list[NodeConfig]] = []
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
- query = inputs.get("query", "")
- pipeline_uuid = self.get_config("pipeline_uuid", "")
+ if not self.ap:
+ raise RuntimeError('Application instance not available — cannot call pipeline')
- return {"response": f"[Pipeline {pipeline_uuid} response for: {query[:50]}...]", "result": {}}
+ raw_query = inputs.get('query', '')
+ query_text = str(raw_query or inputs.get('input') or '')
+ pipeline_ref = str(self.get_config('pipeline_uuid', '') or '').strip()
+
+ if not pipeline_ref:
+ raise ValueError('No pipeline configured for call pipeline node')
+
+ pipeline_data = await self.ap.pipeline_service.get_pipeline(pipeline_ref)
+ if pipeline_data is None:
+ pipeline_data = await self.ap.pipeline_service.get_pipeline_by_name(pipeline_ref)
+ if pipeline_data is None:
+ raise ValueError(f'Pipeline not found: {pipeline_ref}')
+
+ pipeline_uuid = str(pipeline_data.get('uuid', '') or '')
+ if not pipeline_uuid:
+ raise ValueError(f'Pipeline UUID missing for: {pipeline_ref}')
+
+ runtime_pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
+ if runtime_pipeline is None:
+ raise ValueError(f'Runtime pipeline not loaded: {pipeline_uuid}')
+
+ adapter = _WorkflowPipelineCaptureAdapter(context=context)
+ adapter.bot_account_id = 'workflow-call-pipeline'
+
+ message_event = self._build_message_event(query_text, context)
+ message_chain = message_event.message_chain
+ launcher_type = provider_session.LauncherTypes.GROUP if context.message_context and context.message_context.is_group else provider_session.LauncherTypes.PERSON
+ launcher_id = context.session_id or context.execution_id
+ sender_id = (
+ context.message_context.sender_id
+ if context.message_context and context.message_context.sender_id
+ else context.user_id or f'workflow_{context.execution_id}'
+ )
+
+ query = pipeline_query.Query(
+ bot_uuid=context.bot_id,
+ query_id=-1,
+ launcher_type=launcher_type,
+ launcher_id=launcher_id,
+ sender_id=sender_id,
+ message_event=message_event,
+ message_chain=message_chain,
+ variables={
+ '_called_from_workflow': True,
+ '_workflow_execution_id': context.execution_id,
+ '_workflow_id': context.workflow_id,
+ **dict(context.variables or {}),
+ },
+ resp_messages=[],
+ resp_message_chain=[],
+ adapter=adapter,
+ pipeline_uuid=pipeline_uuid,
+ )
+
+ await runtime_pipeline.run(query)
+
+ response_text = adapter.get_last_text_response()
+ result = {
+ 'pipeline_uuid': pipeline_uuid,
+ 'pipeline_name': pipeline_data.get('name', ''),
+ 'responses': adapter.responses,
+ 'query_text': query_text,
+ }
+
+ return {'response': response_text, 'result': result}
+
+ def _build_message_event(
+ self,
+ query_text: str,
+ context: ExecutionContext,
+ ) -> platform_events.MessageEvent:
+ message_chain_data = context.trigger_data.get('message_chain') or context.trigger_data.get('message', [])
+ if isinstance(message_chain_data, list) and message_chain_data:
+ message_chain = platform_message.MessageChain.model_validate(message_chain_data)
+ else:
+ message_chain = platform_message.MessageChain([platform_message.Plain(text=query_text)])
+
+ if context.message_context and context.message_context.is_group:
+ group = platform_entities.Group(
+ id=context.message_context.group_id or context.session_id or 'workflow_group',
+ name='Workflow Group',
+ permission=platform_entities.Permission.Member,
+ )
+ sender = platform_entities.GroupMember(
+ id=context.message_context.sender_id,
+ member_name=context.message_context.sender_name or 'Workflow User',
+ permission=platform_entities.Permission.Member,
+ group=group,
+ )
+ return platform_events.GroupMessage(
+ sender=sender,
+ message_chain=message_chain,
+ time=context.message_context.raw_message.get('time') if context.message_context.raw_message else None,
+ )
+
+ sender = platform_entities.Friend(
+ id=context.message_context.sender_id if context.message_context else context.user_id or 'workflow_user',
+ nickname=context.message_context.sender_name if context.message_context else 'Workflow User',
+ remark=context.message_context.sender_name if context.message_context else 'Workflow User',
+ )
+ return platform_events.FriendMessage(
+ sender=sender,
+ message_chain=message_chain,
+ time=context.message_context.raw_message.get('time') if context.message_context and context.message_context.raw_message else None,
+ )
+
+
+class _WorkflowPipelineCaptureAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
+ responses: list[dict[str, Any]] = []
+
+ def __init__(self, context: ExecutionContext):
+ super().__init__(config={}, logger=None)
+ self.context = context
+ self.responses = []
+
+ async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
+ payload = {
+ 'type': 'send',
+ 'target_type': target_type,
+ 'target_id': target_id,
+ 'content': str(message),
+ 'message_chain': message.model_dump(),
+ }
+ self.responses.append(payload)
+ return payload
+
+ async def reply_message(
+ self,
+ message_source: platform_events.MessageEvent,
+ message: platform_message.MessageChain,
+ quote_origin: bool = False,
+ ):
+ payload = {
+ 'type': 'reply',
+ 'content': str(message),
+ 'message_chain': message.model_dump(),
+ 'quote_origin': quote_origin,
+ }
+ self.responses.append(payload)
+ return payload
+
+ async def reply_message_chunk(
+ self,
+ message_source: platform_events.MessageEvent,
+ bot_message: dict,
+ message: platform_message.MessageChain,
+ quote_origin: bool = False,
+ is_final: bool = False,
+ ):
+ payload = {
+ 'type': 'reply_chunk',
+ 'content': str(message),
+ 'message_chain': message.model_dump(),
+ 'quote_origin': quote_origin,
+ 'is_final': is_final,
+ }
+ self.responses.append(payload)
+ return payload
+
+ async def create_message_card(self, message_id, event: platform_events.MessageEvent) -> bool:
+ return False
+
+ def register_listener(self, event_type, callback):
+ return None
+
+ def unregister_listener(self, event_type, callback):
+ return None
+
+ async def run_async(self):
+ return None
+
+ async def is_stream_output_supported(self) -> bool:
+ return False
+
+ async def kill(self) -> bool:
+ return True
+
+ def get_last_text_response(self) -> str:
+ if not self.responses:
+ return ''
+ return str(self.responses[-1].get('content', '') or '')
diff --git a/src/langbot/pkg/workflow/nodes/reply_message.py b/src/langbot/pkg/workflow/nodes/reply_message.py
index 5ae52320..091e77e5 100644
--- a/src/langbot/pkg/workflow/nodes/reply_message.py
+++ b/src/langbot/pkg/workflow/nodes/reply_message.py
@@ -38,6 +38,8 @@ class ReplyMessageNode(WorkflowNode):
message = inputs.get("input")
if message in (None, ""):
message = inputs.get("response")
+ if message in (None, ""):
+ message = inputs.get("content")
if message in (None, "") and context.message_context:
message = context.message_context.message_content
if message is None:
diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx
index 2e7ccea9..9a184816 100644
--- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx
+++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx
@@ -14,7 +14,10 @@ import DynamicFormItemComponent from '@/app/home/components/dynamic-form/Dynamic
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import i18n from 'i18next';
-import { resolveI18nLabel, maybeTranslateKey } from '@/app/home/workflows/components/workflow-editor/workflow-i18n';
+import {
+ resolveI18nLabel,
+ maybeTranslateKey,
+} from '@/app/home/workflows/components/workflow-editor/workflow-i18n';
// Helper function to translate i18n key if the value is an i18n key string
const translateIfKey = (value: string | undefined): string | undefined => {
@@ -228,6 +231,7 @@ export default function DynamicFormComponent({
item.type === 'llm-model-selector' ||
item.type === 'embedding-model-selector' ||
item.type === 'rerank-model-selector' ||
+ item.type === 'pipeline-selector' ||
item.type === 'knowledge-base-selector' ||
item.type === 'bot-selector'
) {
@@ -286,6 +290,9 @@ export default function DynamicFormComponent({
case 'select':
fieldSchema = z.string();
break;
+ case 'pipeline-selector':
+ fieldSchema = z.string();
+ break;
case 'llm-model-selector':
fieldSchema = z.string();
break;
@@ -490,9 +497,11 @@ export default function DynamicFormComponent({
label={extractAndTranslateI18n(config.label)}
description={
config.description
- ? (typeof config.description === 'string'
- ? (config.description.startsWith('workflows.') ? String(t(config.description)) : config.description)
- : extractAndTranslateI18n(config.description))
+ ? typeof config.description === 'string'
+ ? config.description.startsWith('workflows.')
+ ? String(t(config.description))
+ : config.description
+ : extractAndTranslateI18n(config.description)
: undefined
}
url={webhookUrl}
@@ -523,7 +532,9 @@ export default function DynamicFormComponent({
{config.description && (
{typeof config.description === 'string'
- ? (config.description.startsWith('workflows.') ? String(t(config.description)) : translateIfKey(config.description))
+ ? config.description.startsWith('workflows.')
+ ? String(t(config.description))
+ : translateIfKey(config.description)
: extractAndTranslateI18n(config.description)}
)}
@@ -554,37 +565,54 @@ export default function DynamicFormComponent({
? extractAndTranslateI18n(config.label)
: config.name;
return (
-
-
- {i18nLabel}{' '}
- {config.required && *}
-
-
-
-
-
-
- {config.description && (() => {
- const desc = config.description;
- if (typeof desc === 'string') {
- if (desc.startsWith('workflows.')) {
- return {String(t(desc))}
;
- }
- return {translateIfKey(desc) || desc}
;
- }
- return {extractAndTranslateI18n(desc)}
;
- })()}
-
-
- );
+
+
+ {i18nLabel}{' '}
+ {config.required && (
+ *
+ )}
+
+
+
+
+
+
+ {config.description &&
+ (() => {
+ const desc = config.description;
+ if (typeof desc === 'string') {
+ if (desc.startsWith('workflows.')) {
+ return (
+
+ {String(t(desc))}
+
+ );
+ }
+ return (
+
+ {translateIfKey(desc) || desc}
+
+ );
+ }
+ return (
+
+ {extractAndTranslateI18n(desc)}
+
+ );
+ })()}
+
+
+ );
}}
/>
);
diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx
index d6488653..ec7ffd66 100644
--- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx
+++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx
@@ -25,11 +25,15 @@ import {
KnowledgeBase,
EmbeddingModel,
RerankModel,
+ Pipeline,
PluginTool,
} from '@/app/infra/entities/api';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
-import { resolveI18nLabel, maybeTranslateKey } from '@/app/home/workflows/components/workflow-editor/workflow-i18n';
+import {
+ resolveI18nLabel,
+ maybeTranslateKey,
+} from '@/app/home/workflows/components/workflow-editor/workflow-i18n';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import {
@@ -65,12 +69,11 @@ import {
} from '@/components/ui/dropdown-menu';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
-const resolveOptionLabel = (
- label: unknown,
- fallback: string,
-): string => {
+const resolveOptionLabel = (label: unknown, fallback: string): string => {
if (!label || typeof label !== 'object') return fallback;
- return resolveI18nLabel(label as Record | I18nObject) || fallback;
+ return (
+ resolveI18nLabel(label as Record | I18nObject) || fallback
+ );
};
const getSelectedOptionLabel = (
@@ -87,7 +90,11 @@ const resolveModelLabel = (model: {
name: string;
display_name?: string;
}): string => {
- return maybeTranslateKey(model.display_name || model.name) || model.display_name || model.name;
+ return (
+ maybeTranslateKey(model.display_name || model.name) ||
+ model.display_name ||
+ model.name
+ );
};
export default function DynamicFormItemComponent({
@@ -105,6 +112,7 @@ export default function DynamicFormItemComponent({
const [rerankModels, setRerankModels] = useState([]);
const [knowledgeBases, setKnowledgeBases] = useState([]);
const [bots, setBots] = useState([]);
+ const [pipelines, setPipelines] = useState([]);
const [tools, setTools] = useState([]);
const [uploading, setUploading] = useState(false);
const [kbDialogOpen, setKbDialogOpen] = useState(false);
@@ -258,6 +266,19 @@ export default function DynamicFormItemComponent({
}
}, [config.type]);
+ useEffect(() => {
+ if (config.type === DynamicFormItemType.PIPELINE_SELECTOR) {
+ httpClient
+ .getPipelines()
+ .then((resp) => {
+ setPipelines(resp.pipelines);
+ })
+ .catch((err) => {
+ toast.error(t('pipelines.loadPipelinesFailed') + err.msg);
+ });
+ }
+ }, [config.type, t]);
+
useEffect(() => {
if (config.type === DynamicFormItemType.TOOLS_SELECTOR) {
httpClient
@@ -308,7 +329,9 @@ export default function DynamicFormItemComponent({
onClick={() => field.onChange(option.name)}
>
- {resolveOptionLabel(option.label, option.name)}
+
+ {resolveOptionLabel(option.label, option.name)}
+
{option.name}
@@ -320,7 +343,9 @@ export default function DynamicFormItemComponent({
);
}
- return ;
+ return (
+
+ );
case DynamicFormItemType.SECRET:
const secretValue = typeof field.value === 'string' ? field.value : '';
@@ -346,16 +371,23 @@ export default function DynamicFormItemComponent({
onMouseDown={(e) => e.preventDefault()}
onClick={() => setSecretVisible((prev) => !prev)}
>
- {secretVisible ? : }
+ {secretVisible ? (
+
+ ) : (
+
+ )}
);
case DynamicFormItemType.TEXT:
// Ensure value is always a string to avoid [object Object] display
- const textValue = typeof field.value === 'string'
- ? field.value
- : (field.value != null ? JSON.stringify(field.value, null, 2) : '');
+ const textValue =
+ typeof field.value === 'string'
+ ? field.value
+ : field.value != null
+ ? JSON.stringify(field.value, null, 2)
+ : '';
return (
;
+ return (
+
+ );
case DynamicFormItemType.STRING_ARRAY:
const arrayValue = Array.isArray(field.value) ? field.value : [];
@@ -378,7 +412,9 @@ export default function DynamicFormItemComponent({
className="flex-1"
value={item ?? ''}
onChange={(e) => {
- const newValue = [...(Array.isArray(field.value) ? field.value : [])];
+ const newValue = [
+ ...(Array.isArray(field.value) ? field.value : []),
+ ];
newValue[index] = e.target.value;
field.onChange(newValue);
}}
@@ -389,9 +425,9 @@ export default function DynamicFormItemComponent({
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => {
- const newValue = (Array.isArray(field.value) ? field.value : []).filter(
- (_: string, i: number) => i !== index,
- );
+ const newValue = (
+ Array.isArray(field.value) ? field.value : []
+ ).filter((_: string, i: number) => i !== index);
field.onChange(newValue);
}}
>
@@ -404,7 +440,10 @@ export default function DynamicFormItemComponent({
variant="outline"
className="w-full border-dashed text-muted-foreground hover:text-foreground"
onClick={() => {
- field.onChange([...(Array.isArray(field.value) ? field.value : []), '']);
+ field.onChange([
+ ...(Array.isArray(field.value) ? field.value : []),
+ '',
+ ]);
}}
>
@@ -414,7 +453,10 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.SELECT:
- const selectedOptionLabel = getSelectedOptionLabel(config.options, field.value);
+ const selectedOptionLabel = getSelectedOptionLabel(
+ config.options,
+ field.value,
+ );
return (
-
+
@@ -363,7 +418,8 @@ export default function WorkflowExecutionsTab({
{stats.last_execution_time && (
- {t('workflows.lastExecution')}: {new Date(stats.last_execution_time).toLocaleDateString()}
+ {t('workflows.lastExecution')}:{' '}
+ {new Date(stats.last_execution_time).toLocaleDateString()}
)}
@@ -387,16 +443,31 @@ export default function WorkflowExecutionsTab({
- {t('workflows.allStatuses')}
- {t('workflows.status.completed')}
- {t('workflows.status.running')}
- {t('workflows.status.failed')}
- {t('workflows.status.cancelled')}
- {t('workflows.status.pending')}
+
+ {t('workflows.allStatuses')}
+
+
+ {t('workflows.status.completed')}
+
+
+ {t('workflows.status.running')}
+
+
+ {t('workflows.status.failed')}
+
+
+ {t('workflows.status.cancelled')}
+
+
+ {t('workflows.status.pending')}
+
+
+ {t('workflows.status.waiting')}
+
-
+
@@ -407,22 +478,34 @@ export default function WorkflowExecutionsTab({
{t('workflows.allTime')}
{t('workflows.today')}
{t('workflows.lastWeek')}
- {t('workflows.lastMonth')}
+
+ {t('workflows.lastMonth')}
+
-
+
- {t('workflows.showingExecutions', {
- shown: filteredExecutions.length,
- total: total
+ {t('workflows.showingExecutions', {
+ shown: filteredExecutions.length,
+ total: total,
})}
-
+
-