This commit is contained in:
Typer_Body
2026-05-06 01:03:34 +08:00
parent 32c9eaff45
commit ada4c30f85
16 changed files with 1097 additions and 347 deletions

View File

@@ -125,6 +125,19 @@ class WorkflowsRouterGroup(group.RouterGroup):
)
return self.success(data=executions)
@self.route(
'/<workflow_uuid>/executions/<execution_uuid>',
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('/<workflow_uuid>/versions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(workflow_uuid: str) -> str:

View File

@@ -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

View File

@@ -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)}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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 '')

View File

@@ -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:

View File

@@ -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 && (
<p className="text-sm text-muted-foreground">
{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)}
</p>
)}
@@ -554,37 +565,54 @@ export default function DynamicFormComponent({
? extractAndTranslateI18n(config.label)
: config.name;
return (
<FormItem>
<FormLabel>
{i18nLabel}{' '}
{config.required && <span className="text-red-500">*</span>}
</FormLabel>
<FormControl>
<div
className={
isFieldDisabled ? 'pointer-events-none opacity-60' : ''
}
>
<DynamicFormItemComponent
config={config}
field={field}
onFileUploaded={onFileUploaded}
/>
</div>
</FormControl>
{config.description && (() => {
const desc = config.description;
if (typeof desc === 'string') {
if (desc.startsWith('workflows.')) {
return <p className="text-sm text-muted-foreground">{String(t(desc))}</p>;
}
return <p className="text-sm text-muted-foreground">{translateIfKey(desc) || desc}</p>;
}
return <p className="text-sm text-muted-foreground">{extractAndTranslateI18n(desc)}</p>;
})()}
<FormMessage />
</FormItem>
);
<FormItem>
<FormLabel>
{i18nLabel}{' '}
{config.required && (
<span className="text-red-500">*</span>
)}
</FormLabel>
<FormControl>
<div
className={
isFieldDisabled
? 'pointer-events-none opacity-60'
: ''
}
>
<DynamicFormItemComponent
config={config}
field={field}
onFileUploaded={onFileUploaded}
/>
</div>
</FormControl>
{config.description &&
(() => {
const desc = config.description;
if (typeof desc === 'string') {
if (desc.startsWith('workflows.')) {
return (
<p className="text-sm text-muted-foreground">
{String(t(desc))}
</p>
);
}
return (
<p className="text-sm text-muted-foreground">
{translateIfKey(desc) || desc}
</p>
);
}
return (
<p className="text-sm text-muted-foreground">
{extractAndTranslateI18n(desc)}
</p>
);
})()}
<FormMessage />
</FormItem>
);
}}
/>
);

View File

@@ -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<string, string> | I18nObject) || fallback;
return (
resolveI18nLabel(label as Record<string, string> | 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<RerankModel[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [bots, setBots] = useState<Bot[]>([]);
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [tools, setTools] = useState<PluginTool[]>([]);
const [uploading, setUploading] = useState<boolean>(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)}
>
<div className="flex flex-col gap-0.5">
<span>{resolveOptionLabel(option.label, option.name)}</span>
<span>
{resolveOptionLabel(option.label, option.name)}
</span>
<span className="text-xs text-muted-foreground">
{option.name}
</span>
@@ -320,7 +343,9 @@ export default function DynamicFormItemComponent({
</div>
);
}
return <Input className="max-w-md" {...field} value={field.value ?? ''} />;
return (
<Input className="max-w-md" {...field} value={field.value ?? ''} />
);
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 ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
{secretVisible ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</Button>
</div>
);
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 (
<Textarea
{...field}
@@ -366,7 +398,9 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.BOOLEAN:
return <Switch checked={!!field.value} onCheckedChange={field.onChange} />;
return (
<Switch checked={!!field.value} onCheckedChange={field.onChange} />
);
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 : []),
'',
]);
}}
>
<Plus className="size-4 mr-1.5" />
@@ -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 (
<Select
value={typeof field.value === 'string' ? field.value : ''}
@@ -1063,7 +1105,8 @@ export default function DynamicFormItemComponent({
const kbsByEngine = knowledgeBases.reduce(
(acc, kb) => {
const engineName = kb.knowledge_engine?.name
? resolveI18nLabel(kb.knowledge_engine.name) || t('knowledge.unknownEngine')
? resolveI18nLabel(kb.knowledge_engine.name) ||
t('knowledge.unknownEngine')
: t('knowledge.unknownEngine');
if (!acc[engineName]) {
acc[engineName] = [];
@@ -1075,7 +1118,10 @@ export default function DynamicFormItemComponent({
);
return (
<Select value={field.value ?? '__none__'} onValueChange={field.onChange}>
<Select
value={field.value ?? '__none__'}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
{field.value && field.value !== '__none__' ? (
(() => {
@@ -1126,7 +1172,8 @@ export default function DynamicFormItemComponent({
const multiKbsByEngine = knowledgeBases.reduce(
(acc, kb) => {
const engineName = kb.knowledge_engine?.name
? resolveI18nLabel(kb.knowledge_engine.name) || t('knowledge.unknownEngine')
? resolveI18nLabel(kb.knowledge_engine.name) ||
t('knowledge.unknownEngine')
: t('knowledge.unknownEngine');
if (!acc[engineName]) {
acc[engineName] = [];
@@ -1312,6 +1359,43 @@ export default function DynamicFormItemComponent({
</Select>
);
case DynamicFormItemType.PIPELINE_SELECTOR:
return (
<Select value={field.value ?? ''} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
{field.value ? (
(() => {
const selectedPipeline = pipelines.find(
(pipeline) => pipeline.uuid === field.value,
);
return (
<span className="truncate">
{selectedPipeline?.name ?? field.value}
</span>
);
})()
) : (
<SelectValue placeholder={t('bots.selectPipeline')} />
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{pipelines.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">
{t('bots.noPipelinesFound')}
</div>
) : (
pipelines.map((pipeline) => (
<SelectItem key={pipeline.uuid} value={pipeline.uuid ?? ''}>
{pipeline.name}
</SelectItem>
))
)}
</SelectGroup>
</SelectContent>
</Select>
);
case DynamicFormItemType.TOOLS_SELECTOR:
return (
<>

View File

@@ -57,6 +57,7 @@ export default function WorkflowDebugDialog({
const [selectedWorkflowId, setSelectedWorkflowId] = useState(workflowId);
const [sessionType, setSessionType] = useState<'person' | 'group'>('person');
const [messages, setMessages] = useState<Message[]>([]);
const activeConnectionKeyRef = useRef<string | null>(null);
const [inputValue, setInputValue] = useState('');
const [showAtPopover, setShowAtPopover] = useState(false);
const [hasAt, setHasAt] = useState(false);
@@ -82,7 +83,9 @@ export default function WorkflowDebugDialog({
const scrollToBottom = useCallback(() => {
setTimeout(() => {
const scrollArea = document.querySelector('.workflow-scroll-area') as HTMLElement;
const scrollArea = document.querySelector(
'.workflow-scroll-area',
) as HTMLElement;
if (scrollArea) {
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
@@ -96,10 +99,11 @@ export default function WorkflowDebugDialog({
const loadMessages = useCallback(
async (workflowId: string) => {
try {
const response = await backendClient.getWorkflowWebSocketHistoryMessages(
workflowId,
sessionType,
);
const response =
await backendClient.getWorkflowWebSocketHistoryMessages(
workflowId,
sessionType,
);
setMessages(response.messages);
} catch (error) {
console.error('Failed to load messages:', error);
@@ -109,23 +113,45 @@ export default function WorkflowDebugDialog({
);
const initWebSocket = useCallback(
async (workflowId: string) => {
if (isInitializingRef.current) {
async (workflowId: string, nextSessionType: 'person' | 'group') => {
const connectionKey = `${workflowId}:${nextSessionType}`;
if (
isInitializingRef.current &&
activeConnectionKeyRef.current === connectionKey
) {
return;
}
if (
wsClientRef.current?.isConnected() &&
activeConnectionKeyRef.current === connectionKey
) {
return;
}
try {
isInitializingRef.current = true;
activeConnectionKeyRef.current = connectionKey;
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
}
const wsClient = new WorkflowWebSocketClient(workflowId, sessionType);
setIsConnected(false);
const wsClient = new WorkflowWebSocketClient(
workflowId,
nextSessionType,
);
wsClient
.onConnected(() => {
if (activeConnectionKeyRef.current !== connectionKey) {
wsClient.disconnect();
return;
}
setIsConnected(true);
isInitializingRef.current = false;
})
@@ -151,8 +177,10 @@ export default function WorkflowDebugDialog({
})
.onError((error) => {
console.error('WebSocket error:', error);
setIsConnected(false);
isInitializingRef.current = false;
if (activeConnectionKeyRef.current === connectionKey) {
setIsConnected(false);
isInitializingRef.current = false;
}
const errorMessage =
error instanceof Error && error.message
? error.message
@@ -160,23 +188,33 @@ export default function WorkflowDebugDialog({
toast.error(errorMessage);
})
.onClose(() => {
setIsConnected(false);
isInitializingRef.current = false;
if (activeConnectionKeyRef.current === connectionKey) {
setIsConnected(false);
isInitializingRef.current = false;
}
})
.onBroadcast((message) => {
toast.info(message);
});
await wsClient.connect();
if (activeConnectionKeyRef.current !== connectionKey) {
wsClient.disconnect();
return;
}
wsClientRef.current = wsClient;
} catch (error) {
console.error('WebSocket connection failed:', error);
setIsConnected(false);
isInitializingRef.current = false;
toast.error(t('workflows.debugDialog.connectionFailed'));
if (activeConnectionKeyRef.current === connectionKey) {
setIsConnected(false);
isInitializingRef.current = false;
toast.error(t('workflows.debugDialog.connectionFailed'));
}
}
},
[sessionType, t],
[t],
);
useEffect(() => {
@@ -186,30 +224,36 @@ export default function WorkflowDebugDialog({
useEffect(() => {
if (open) {
setSelectedWorkflowId(workflowId);
} else {
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
setIsConnected(false);
isInitializingRef.current = false;
}
return;
}
activeConnectionKeyRef.current = null;
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
}
setIsConnected(false);
isInitializingRef.current = false;
return () => {
activeConnectionKeyRef.current = null;
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
isInitializingRef.current = false;
}
setIsConnected(false);
isInitializingRef.current = false;
};
}, [open, workflowId]);
useEffect(() => {
if (open) {
setMessages([]);
loadMessages(selectedWorkflowId);
initWebSocket(selectedWorkflowId);
if (!open) {
return;
}
setMessages([]);
loadMessages(selectedWorkflowId);
initWebSocket(selectedWorkflowId, sessionType);
}, [sessionType, selectedWorkflowId, open, loadMessages, initWebSocket]);
useEffect(() => {

View File

@@ -1,6 +1,6 @@
/**
* Action Node Configurations
*
*
* Defines configurations for action node types:
* - send_message: Send a message
* - http_request: Make HTTP requests
@@ -68,7 +68,10 @@ export const sendMessageConfig: NodeConfigMeta = {
default: 'text',
options: [
{ name: 'text', label: { en_US: 'Text', zh_Hans: '文本' } },
{ name: 'markdown', label: { en_US: 'Markdown', zh_Hans: 'Markdown 文本' } },
{
name: 'markdown',
label: { en_US: 'Markdown', zh_Hans: 'Markdown 文本' },
},
{ name: 'image', label: { en_US: 'Image', zh_Hans: '图片' } },
{ name: 'file', label: { en_US: 'File', zh_Hans: '文件' } },
{ name: 'card', label: { en_US: 'Card', zh_Hans: '卡片' } },
@@ -83,7 +86,8 @@ export const sendMessageConfig: NodeConfigMeta = {
zh_Hans: '内容模板',
},
description: {
en_US: 'Message content template (supports variables). Leave empty to use input.',
en_US:
'Message content template (supports variables). Leave empty to use input.',
zh_Hans: '消息内容模板(支持变量)。留空则使用输入。',
},
required: false,
@@ -263,7 +267,8 @@ export const httpRequestConfig: NodeConfigMeta = {
zh_Hans: '请求体模板',
},
description: {
en_US: 'Request body template (supports variables). Leave empty to use input.',
en_US:
'Request body template (supports variables). Leave empty to use input.',
zh_Hans: '请求体模板(支持变量)。留空则使用输入。',
},
required: false,
@@ -596,7 +601,10 @@ export const notificationConfig: NodeConfigMeta = {
{ name: 'email', label: { en_US: 'Email', zh_Hans: '邮件' } },
{ name: 'dingtalk', label: { en_US: 'DingTalk', zh_Hans: '钉钉' } },
{ name: 'feishu', label: { en_US: 'Feishu', zh_Hans: '飞书' } },
{ name: 'wechat_work', label: { en_US: 'WeChat Work', zh_Hans: '企业微信' } },
{
name: 'wechat_work',
label: { en_US: 'WeChat Work', zh_Hans: '企业微信' },
},
],
},
{
@@ -724,12 +732,18 @@ export const replyMessageConfig: NodeConfigMeta = {
name: 'reply_mode',
type: DynamicFormItemType.SELECT,
label: { en_US: 'Reply Mode', zh_Hans: '回复模式' },
description: { en_US: 'How to reply to the original message', zh_Hans: '如何回复原始消息' },
description: {
en_US: 'How to reply to the original message',
zh_Hans: '如何回复原始消息',
},
required: true,
default: 'reply',
options: [
{ name: 'reply', label: { en_US: 'Quote Reply', zh_Hans: '引用回复' } },
{ name: 'direct', label: { en_US: 'Direct Message', zh_Hans: '直接消息' } },
{
name: 'direct',
label: { en_US: 'Direct Message', zh_Hans: '直接消息' },
},
],
},
{
@@ -737,7 +751,11 @@ export const replyMessageConfig: NodeConfigMeta = {
name: 'message_template',
type: DynamicFormItemType.TEXT,
label: { en_US: 'Message Template', zh_Hans: '消息模板' },
description: { en_US: 'Reply content template (supports {{variable}} interpolation). Leave empty to use input.', zh_Hans: '回复内容模板(支持 {{variable}} 插值)。留空则使用输入。' },
description: {
en_US:
'Reply content template (supports {{variable}} interpolation). Leave empty to use input.',
zh_Hans: '回复内容模板(支持 {{variable}} 插值)。留空则使用输入。',
},
required: false,
default: '',
},
@@ -746,13 +764,25 @@ export const replyMessageConfig: NodeConfigMeta = {
name: 'long_text_processing',
type: DynamicFormItemType.SELECT,
label: { en_US: 'Long Text Processing', zh_Hans: '长文本处理' },
description: { en_US: 'How to handle long text that exceeds platform limits', zh_Hans: '如何处理超出平台限制的长文本' },
description: {
en_US: 'How to handle long text that exceeds platform limits',
zh_Hans: '如何处理超出平台限制的长文本',
},
required: false,
default: 'truncate',
options: [
{ name: 'truncate', label: { en_US: 'Truncate', zh_Hans: '截断' } },
{ name: 'split', label: { en_US: 'Split into multiple messages', zh_Hans: '拆分为多条消息' } },
{ name: 'forward', label: { en_US: 'Forward as file', zh_Hans: '转发为文件' } },
{
name: 'split',
label: {
en_US: 'Split into multiple messages',
zh_Hans: '拆分为多条消息',
},
},
{
name: 'forward',
label: { en_US: 'Forward as file', zh_Hans: '转发为文件' },
},
],
},
],
@@ -799,13 +829,25 @@ export const storeDataConfig: NodeConfigMeta = {
name: 'storage_type',
type: DynamicFormItemType.SELECT,
label: { en_US: 'Storage Type', zh_Hans: '存储类型' },
description: { en_US: 'Type of storage to use', zh_Hans: '要使用的存储类型' },
description: {
en_US: 'Type of storage to use',
zh_Hans: '要使用的存储类型',
},
required: true,
default: 'variable',
options: [
{ name: 'variable', label: { en_US: 'Workflow Variable', zh_Hans: '工作流变量' } },
{ name: 'session', label: { en_US: 'Session Storage', zh_Hans: '会话存储' } },
{ name: 'persistent', label: { en_US: 'Persistent Storage', zh_Hans: '持久化存储' } },
{
name: 'variable',
label: { en_US: 'Workflow Variable', zh_Hans: '工作流变量' },
},
{
name: 'session',
label: { en_US: 'Session Storage', zh_Hans: '会话存储' },
},
{
name: 'persistent',
label: { en_US: 'Persistent Storage', zh_Hans: '持久化存储' },
},
],
},
{
@@ -813,7 +855,10 @@ export const storeDataConfig: NodeConfigMeta = {
name: 'key',
type: DynamicFormItemType.STRING,
label: { en_US: 'Key', zh_Hans: '键' },
description: { en_US: 'Storage key (supports variable interpolation)', zh_Hans: '存储键(支持变量插值)' },
description: {
en_US: 'Storage key (supports variable interpolation)',
zh_Hans: '存储键(支持变量插值)',
},
required: true,
default: '',
},
@@ -822,7 +867,10 @@ export const storeDataConfig: NodeConfigMeta = {
name: 'ttl',
type: DynamicFormItemType.INT,
label: { en_US: 'TTL (seconds)', zh_Hans: 'TTL' },
description: { en_US: 'Time to live (0 = no expiry)', zh_Hans: '过期时间0 = 不过期)' },
description: {
en_US: 'Time to live (0 = no expiry)',
zh_Hans: '过期时间0 = 不过期)',
},
required: false,
default: 0,
},
@@ -855,22 +903,25 @@ export const callPipelineConfig: NodeConfigMeta = {
}),
],
outputs: [
createOutput('output', 'any', {
description: 'Pipeline output',
label: { en_US: 'Output', zh_Hans: '输出' },
createOutput('response', 'string', {
description: 'Pipeline response',
label: { en_US: 'Response', zh_Hans: '响应' },
}),
createOutput('success', 'boolean', {
description: 'Whether pipeline execution was successful',
label: { en_US: 'Success', zh_Hans: '成功' },
createOutput('result', 'object', {
description: 'Pipeline execution result',
label: { en_US: 'Result', zh_Hans: '结果' },
}),
],
configSchema: [
{
id: 'pipeline_uuid',
name: 'pipeline_uuid',
type: DynamicFormItemType.STRING,
label: { en_US: 'Pipeline', zh_Hans: 'Pipeline' },
description: { en_US: 'UUID of the pipeline to invoke', zh_Hans: '要调用的 Pipeline UUID' },
type: DynamicFormItemType.PIPELINE_SELECTOR,
label: { en_US: 'Pipeline', zh_Hans: '流水线' },
description: {
en_US: 'Select the pipeline to invoke',
zh_Hans: '选择要调用的流水线',
},
required: true,
default: '',
},
@@ -879,7 +930,10 @@ export const callPipelineConfig: NodeConfigMeta = {
name: 'inherit_context',
type: DynamicFormItemType.BOOLEAN,
label: { en_US: 'Inherit Context', zh_Hans: '继承上下文' },
description: { en_US: 'Pass the current workflow context to the pipeline', zh_Hans: '将当前工作流上下文传递给 Pipeline' },
description: {
en_US: 'Pass the current workflow context to the pipeline',
zh_Hans: '将当前工作流上下文传递给 Pipeline',
},
required: false,
default: true,
},
@@ -932,7 +986,10 @@ export const setVariableConfig: NodeConfigMeta = {
name: 'variable_name',
type: DynamicFormItemType.STRING,
label: { en_US: 'Variable Name', zh_Hans: '变量名' },
description: { en_US: 'Name of the variable to set', zh_Hans: '要设置的变量名' },
description: {
en_US: 'Name of the variable to set',
zh_Hans: '要设置的变量名',
},
required: true,
default: '',
},
@@ -955,12 +1012,20 @@ export const setVariableConfig: NodeConfigMeta = {
name: 'value_template',
type: DynamicFormItemType.TEXT,
label: { en_US: 'Value Template', zh_Hans: '值模板' },
description: { en_US: 'Value template (supports {{variable}} interpolation). Leave empty to use input.', zh_Hans: '值模板(支持 {{variable}} 插值)。留空则使用输入。' },
description: {
en_US:
'Value template (supports {{variable}} interpolation). Leave empty to use input.',
zh_Hans: '值模板(支持 {{variable}} 插值)。留空则使用输入。',
},
required: false,
default: '',
},
],
defaultConfig: { variable_name: '', variable_scope: 'workflow', value_template: '' },
defaultConfig: {
variable_name: '',
variable_scope: 'workflow',
value_template: '',
},
};
/**
@@ -997,7 +1062,10 @@ export const openingStatementConfig: NodeConfigMeta = {
name: 'statement',
type: DynamicFormItemType.TEXT,
label: { en_US: 'Opening Statement', zh_Hans: '开场白' },
description: { en_US: 'The opening statement to display', zh_Hans: '要显示的开场白' },
description: {
en_US: 'The opening statement to display',
zh_Hans: '要显示的开场白',
},
required: true,
default: '',
},
@@ -1006,7 +1074,10 @@ export const openingStatementConfig: NodeConfigMeta = {
name: 'suggested_questions',
type: DynamicFormItemType.STRING_ARRAY,
label: { en_US: 'Suggested Questions', zh_Hans: '建议问题' },
description: { en_US: 'List of suggested questions for the user', zh_Hans: '给用户的建议问题列表' },
description: {
en_US: 'List of suggested questions for the user',
zh_Hans: '给用户的建议问题列表',
},
required: false,
default: [],
},
@@ -1015,12 +1086,19 @@ export const openingStatementConfig: NodeConfigMeta = {
name: 'show_suggestions',
type: DynamicFormItemType.BOOLEAN,
label: { en_US: 'Show Suggestions', zh_Hans: '显示建议' },
description: { en_US: 'Whether to show suggested questions', zh_Hans: '是否显示建议问题' },
description: {
en_US: 'Whether to show suggested questions',
zh_Hans: '是否显示建议问题',
},
required: false,
default: true,
},
],
defaultConfig: { statement: '', suggested_questions: [], show_suggestions: true },
defaultConfig: {
statement: '',
suggested_questions: [],
show_suggestions: true,
},
};
/**

View File

@@ -5,13 +5,13 @@ import { WorkflowExecution } from '@/app/infra/entities/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
RefreshCw,
Play,
XCircle,
CheckCircle2,
Clock,
AlertCircle,
import {
RefreshCw,
Play,
XCircle,
CheckCircle2,
Clock,
AlertCircle,
Loader2,
FileText,
RotateCcw,
@@ -19,7 +19,7 @@ import {
TrendingUp,
Calendar,
ChevronDown,
ChevronUp
ChevronUp,
} from 'lucide-react';
import {
Table,
@@ -43,18 +43,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Collapsible,
CollapsibleContent,
@@ -87,6 +77,7 @@ interface WorkflowStats {
const statusIcons: Record<string, React.ElementType> = {
pending: Clock,
waiting: Clock,
running: Loader2,
completed: CheckCircle2,
failed: AlertCircle,
@@ -94,9 +85,13 @@ const statusIcons: Record<string, React.ElementType> = {
};
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
pending:
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
waiting:
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
completed:
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
};
@@ -108,29 +103,52 @@ const logLevelColors: Record<string, string> = {
debug: 'text-gray-600 dark:text-gray-400',
};
function getExecutionStartedAt(
execution: WorkflowExecution,
): string | undefined {
return (
(execution as WorkflowExecution & { start_time?: string }).start_time ||
execution.started_at
);
}
function getExecutionCompletedAt(
execution: WorkflowExecution,
): string | undefined {
return (
(execution as WorkflowExecution & { end_time?: string }).end_time ||
execution.completed_at
);
}
function isExecutionCancelable(execution: WorkflowExecution): boolean {
return ['pending', 'waiting', 'running'].includes(execution.status);
}
export default function WorkflowExecutionsTab({
workflowId,
}: WorkflowExecutionsTabProps) {
const { t } = useTranslation();
const [executions, setExecutions] = useState<WorkflowExecution[]>([]);
const [loading, setLoading] = useState(false);
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecution | null>(null);
const [selectedExecution, setSelectedExecution] =
useState<WorkflowExecution | null>(null);
const [total, setTotal] = useState(0);
// Filters
const [statusFilter, setStatusFilter] = useState<string>('all');
const [dateFilter, setDateFilter] = useState<string>('all');
// Statistics
const [stats, setStats] = useState<WorkflowStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [showStats, setShowStats] = useState(true);
// Logs
const [executionLogs, setExecutionLogs] = useState<ExecutionLog[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
const [selectedTab, setSelectedTab] = useState('details');
// Rerun
const [rerunning, setRerunning] = useState<string | null>(null);
@@ -165,18 +183,26 @@ export default function WorkflowExecutionsTab({
}, [workflowId]);
// Load execution logs
const loadExecutionLogs = useCallback(async (executionUuid: string) => {
setLogsLoading(true);
try {
const resp = await backendClient.getWorkflowExecutionLogs(workflowId, executionUuid, 200, 0);
setExecutionLogs(resp.logs);
} catch (err) {
console.error('Failed to load execution logs:', err);
setExecutionLogs([]);
} finally {
setLogsLoading(false);
}
}, [workflowId]);
const loadExecutionLogs = useCallback(
async (executionUuid: string) => {
setLogsLoading(true);
try {
const resp = await backendClient.getWorkflowExecutionLogs(
workflowId,
executionUuid,
200,
0,
);
setExecutionLogs(resp.logs);
} catch (err) {
console.error('Failed to load execution logs:', err);
setExecutionLogs([]);
} finally {
setLogsLoading(false);
}
},
[workflowId],
);
useEffect(() => {
loadExecutions();
@@ -186,20 +212,24 @@ export default function WorkflowExecutionsTab({
// Filter executions
const filteredExecutions = useMemo(() => {
let filtered = executions;
// Status filter
if (statusFilter !== 'all') {
filtered = filtered.filter(e => e.status === statusFilter);
filtered = filtered.filter((e) => e.status === statusFilter);
}
// Date filter
if (dateFilter !== 'all') {
const now = new Date();
let startDate: Date;
switch (dateFilter) {
case 'today':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
startDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
);
break;
case 'week':
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
@@ -210,13 +240,14 @@ export default function WorkflowExecutionsTab({
default:
startDate = new Date(0);
}
filtered = filtered.filter(e => {
if (!e.started_at) return false;
return new Date(e.started_at) >= startDate;
filtered = filtered.filter((e) => {
const startedAt = getExecutionStartedAt(e);
if (!startedAt) return false;
return new Date(startedAt) >= startDate;
});
}
return filtered;
}, [executions, statusFilter, dateFilter]);
@@ -236,49 +267,62 @@ export default function WorkflowExecutionsTab({
}, [workflowId, loadExecutions, loadStats, t]);
// View execution details
const handleViewDetails = useCallback(async (executionUuid: string) => {
try {
const resp = await backendClient.getWorkflowExecution(workflowId, executionUuid);
setSelectedExecution(resp.execution);
setSelectedTab('details');
loadExecutionLogs(executionUuid);
} catch (err: unknown) {
const msg = (err as { msg?: string })?.msg || String(err);
toast.error(`${t('workflows.executionDetails')}: ${msg}`);
}
}, [workflowId, loadExecutionLogs, t]);
const handleViewDetails = useCallback(
async (executionUuid: string) => {
try {
const resp = await backendClient.getWorkflowExecution(
workflowId,
executionUuid,
);
setSelectedExecution(resp.execution);
setSelectedTab('details');
loadExecutionLogs(executionUuid);
} catch (err: unknown) {
const msg = (err as { msg?: string })?.msg || String(err);
toast.error(`${t('workflows.executionDetails')}: ${msg}`);
}
},
[workflowId, loadExecutionLogs, t],
);
// Cancel execution
const handleCancel = useCallback(async (executionUuid: string) => {
try {
await backendClient.cancelWorkflowExecution(workflowId, executionUuid);
toast.success(t('common.cancel') + ' ✓');
loadExecutions();
} catch (err: unknown) {
const msg = (err as { msg?: string })?.msg || String(err);
toast.error(`${t('common.cancel')}: ${msg}`);
}
}, [workflowId, loadExecutions, t]);
const handleCancel = useCallback(
async (executionUuid: string) => {
try {
await backendClient.cancelWorkflowExecution(workflowId, executionUuid);
toast.success(t('common.cancel') + ' ✓');
loadExecutions();
} catch (err: unknown) {
const msg = (err as { msg?: string })?.msg || String(err);
toast.error(`${t('common.cancel')}: ${msg}`);
}
},
[workflowId, loadExecutions, t],
);
// Rerun execution
const handleRerun = useCallback(async (executionUuid: string) => {
setRerunning(executionUuid);
try {
await backendClient.rerunWorkflowExecution(workflowId, executionUuid);
toast.success(t('workflows.rerun') + ' ✓');
loadExecutions();
loadStats();
} catch (err: unknown) {
const msg = (err as { msg?: string })?.msg || String(err);
toast.error(`${t('workflows.rerun')}: ${msg}`);
} finally {
setRerunning(null);
}
}, [workflowId, loadExecutions, loadStats, t]);
const handleRerun = useCallback(
async (executionUuid: string) => {
setRerunning(executionUuid);
try {
await backendClient.rerunWorkflowExecution(workflowId, executionUuid);
toast.success(t('workflows.rerun') + ' ✓');
loadExecutions();
loadStats();
} catch (err: unknown) {
const msg = (err as { msg?: string })?.msg || String(err);
toast.error(`${t('workflows.rerun')}: ${msg}`);
} finally {
setRerunning(null);
}
},
[workflowId, loadExecutions, loadStats, t],
);
// Format duration
const formatDuration = (seconds: number): string => {
if (seconds === null || seconds === undefined || isNaN(seconds)) return '0.0s';
if (seconds === null || seconds === undefined || isNaN(seconds))
return '0.0s';
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
@@ -295,7 +339,11 @@ export default function WorkflowExecutionsTab({
<TrendingUp className="size-4" />
<span className="font-medium">{t('workflows.statistics')}</span>
</div>
{showStats ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
{showStats ? (
<ChevronUp className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -308,17 +356,23 @@ export default function WorkflowExecutionsTab({
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('workflows.totalExecutions', { count: stats.total_executions ?? 0 })}
{t('workflows.totalExecutions', {
count: stats.total_executions ?? 0,
})}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total_executions ?? 0}</div>
<div className="text-2xl font-bold">
{stats.total_executions ?? 0}
</div>
<p className="text-xs text-muted-foreground mt-1">
{t('workflows.successfulCount', { count: stats.successful_executions ?? 0 })}
{t('workflows.successfulCount', {
count: stats.successful_executions ?? 0,
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
@@ -330,11 +384,12 @@ export default function WorkflowExecutionsTab({
{((stats.success_rate ?? 0) * 100).toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground mt-1">
{stats.successful_executions ?? 0} / {stats.total_executions ?? 0}
{stats.successful_executions ?? 0} /{' '}
{stats.total_executions ?? 0}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
@@ -350,7 +405,7 @@ export default function WorkflowExecutionsTab({
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
@@ -363,7 +418,8 @@ export default function WorkflowExecutionsTab({
</div>
{stats.last_execution_time && (
<p className="text-xs text-muted-foreground mt-1">
{t('workflows.lastExecution')}: {new Date(stats.last_execution_time).toLocaleDateString()}
{t('workflows.lastExecution')}:{' '}
{new Date(stats.last_execution_time).toLocaleDateString()}
</p>
)}
</CardContent>
@@ -387,16 +443,31 @@ export default function WorkflowExecutionsTab({
<SelectValue placeholder={t('workflows.filterByStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('workflows.allStatuses')}</SelectItem>
<SelectItem value="completed">{t('workflows.status.completed')}</SelectItem>
<SelectItem value="running">{t('workflows.status.running')}</SelectItem>
<SelectItem value="failed">{t('workflows.status.failed')}</SelectItem>
<SelectItem value="cancelled">{t('workflows.status.cancelled')}</SelectItem>
<SelectItem value="pending">{t('workflows.status.pending')}</SelectItem>
<SelectItem value="all">
{t('workflows.allStatuses')}
</SelectItem>
<SelectItem value="completed">
{t('workflows.status.completed')}
</SelectItem>
<SelectItem value="running">
{t('workflows.status.running')}
</SelectItem>
<SelectItem value="failed">
{t('workflows.status.failed')}
</SelectItem>
<SelectItem value="cancelled">
{t('workflows.status.cancelled')}
</SelectItem>
<SelectItem value="pending">
{t('workflows.status.pending')}
</SelectItem>
<SelectItem value="waiting">
{t('workflows.status.waiting')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
<Select value={dateFilter} onValueChange={setDateFilter}>
@@ -407,22 +478,34 @@ export default function WorkflowExecutionsTab({
<SelectItem value="all">{t('workflows.allTime')}</SelectItem>
<SelectItem value="today">{t('workflows.today')}</SelectItem>
<SelectItem value="week">{t('workflows.lastWeek')}</SelectItem>
<SelectItem value="month">{t('workflows.lastMonth')}</SelectItem>
<SelectItem value="month">
{t('workflows.lastMonth')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground">
{t('workflows.showingExecutions', {
shown: filteredExecutions.length,
total: total
{t('workflows.showingExecutions', {
shown: filteredExecutions.length,
total: total,
})}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => { loadExecutions(); loadStats(); }} disabled={loading}>
<RefreshCw className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
<Button
variant="outline"
size="sm"
onClick={() => {
loadExecutions();
loadStats();
}}
disabled={loading}
>
<RefreshCw
className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`}
/>
{t('common.refresh')}
</Button>
<Button size="sm" onClick={handleManualTrigger}>
@@ -448,16 +531,26 @@ export default function WorkflowExecutionsTab({
<TableBody>
{filteredExecutions.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<TableCell
colSpan={6}
className="text-center py-8 text-muted-foreground"
>
{loading ? t('common.loading') : t('workflows.noExecutions')}
</TableCell>
</TableRow>
) : (
filteredExecutions.map((execution) => {
const StatusIcon = statusIcons[execution.status] || Clock;
const duration = execution.completed_at && execution.started_at
? Math.round((new Date(execution.completed_at).getTime() - new Date(execution.started_at).getTime()) / 1000)
: null;
const startedAt = getExecutionStartedAt(execution);
const completedAt = getExecutionCompletedAt(execution);
const duration =
completedAt && startedAt
? Math.round(
(new Date(completedAt).getTime() -
new Date(startedAt).getTime()) /
1000,
)
: null;
return (
<TableRow key={execution.uuid}>
@@ -466,15 +559,15 @@ export default function WorkflowExecutionsTab({
</TableCell>
<TableCell>
<Badge className={statusColors[execution.status]}>
<StatusIcon className={`size-3 mr-1 ${execution.status === 'running' ? 'animate-spin' : ''}`} />
<StatusIcon
className={`size-3 mr-1 ${execution.status === 'running' ? 'animate-spin' : ''}`}
/>
{t(`workflows.status.${execution.status}`)}
</Badge>
</TableCell>
<TableCell>{execution.trigger_type || '-'}</TableCell>
<TableCell>
{execution.started_at
? new Date(execution.started_at).toLocaleString()
: '-'}
{startedAt ? new Date(startedAt).toLocaleString() : '-'}
</TableCell>
<TableCell>
{duration !== null ? `${duration}s` : '-'}
@@ -488,7 +581,7 @@ export default function WorkflowExecutionsTab({
>
{t('common.details')}
</Button>
{execution.status === 'running' && (
{isExecutionCancelable(execution) && (
<Button
variant="ghost"
size="sm"
@@ -497,7 +590,8 @@ export default function WorkflowExecutionsTab({
{t('common.cancel')}
</Button>
)}
{(execution.status === 'completed' || execution.status === 'failed') && (
{(execution.status === 'completed' ||
execution.status === 'failed') && (
<Button
variant="ghost"
size="sm"
@@ -523,52 +617,80 @@ export default function WorkflowExecutionsTab({
</div>
{/* Execution Details Dialog */}
<Dialog open={!!selectedExecution} onOpenChange={() => setSelectedExecution(null)}>
<Dialog
open={!!selectedExecution}
onOpenChange={() => setSelectedExecution(null)}
>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('workflows.executionDetails')}</DialogTitle>
<DialogDescription>
{selectedExecution?.uuid}
</DialogDescription>
<DialogDescription>{selectedExecution?.uuid}</DialogDescription>
</DialogHeader>
{selectedExecution && (
<Tabs value={selectedTab} onValueChange={setSelectedTab} className="flex-1 flex flex-col overflow-hidden">
<Tabs
value={selectedTab}
onValueChange={setSelectedTab}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="details">{t('workflows.details')}</TabsTrigger>
<TabsTrigger value="nodes">{t('workflows.nodeExecutions')}</TabsTrigger>
<TabsTrigger value="details">
{t('workflows.details')}
</TabsTrigger>
<TabsTrigger value="nodes">
{t('workflows.nodeExecutions')}
</TabsTrigger>
<TabsTrigger value="logs">
<FileText className="size-3 mr-1" />
{t('workflows.logs')}
</TabsTrigger>
</TabsList>
<TabsContent value="details" className="flex-1 overflow-auto space-y-4 mt-4">
<TabsContent
value="details"
className="flex-1 overflow-auto space-y-4 mt-4"
>
{/* Summary */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">{t('workflows.status')}:</span>
<Badge className={`ml-2 ${statusColors[selectedExecution.status]}`}>
<span className="text-muted-foreground">
{t('workflows.status')}:
</span>
<Badge
className={`ml-2 ${statusColors[selectedExecution.status]}`}
>
{t(`workflows.status.${selectedExecution.status}`)}
</Badge>
</div>
<div>
<span className="text-muted-foreground">{t('workflows.triggerType')}:</span>
<span className="ml-2">{selectedExecution.trigger_type || '-'}</span>
<span className="text-muted-foreground">
{t('workflows.triggerType')}:
</span>
<span className="ml-2">
{selectedExecution.trigger_type || '-'}
</span>
</div>
<div>
<span className="text-muted-foreground">{t('workflows.startedAt')}:</span>
<span className="text-muted-foreground">
{t('workflows.startedAt')}:
</span>
<span className="ml-2">
{selectedExecution.started_at
? new Date(selectedExecution.started_at).toLocaleString()
{getExecutionStartedAt(selectedExecution)
? new Date(
getExecutionStartedAt(selectedExecution)!,
).toLocaleString()
: '-'}
</span>
</div>
<div>
<span className="text-muted-foreground">{t('workflows.completedAt')}:</span>
<span className="text-muted-foreground">
{t('workflows.completedAt')}:
</span>
<span className="ml-2">
{selectedExecution.completed_at
? new Date(selectedExecution.completed_at).toLocaleString()
{getExecutionCompletedAt(selectedExecution)
? new Date(
getExecutionCompletedAt(selectedExecution)!,
).toLocaleString()
: '-'}
</span>
</div>
@@ -589,34 +711,38 @@ export default function WorkflowExecutionsTab({
{/* Result */}
{selectedExecution.result && (
<div>
<h4 className="font-medium mb-2">{t('workflows.result')}</h4>
<h4 className="font-medium mb-2">
{t('workflows.result')}
</h4>
<pre className="bg-muted p-3 rounded text-xs overflow-x-auto max-h-[200px]">
{JSON.stringify(selectedExecution.result, null, 2)}
</pre>
</div>
)}
{/* Rerun button */}
<div className="flex justify-end pt-4">
<Button
<Button
onClick={() => {
handleRerun(selectedExecution.uuid);
setSelectedExecution(null);
}}
disabled={selectedExecution.status === 'running'}
disabled={isExecutionCancelable(selectedExecution)}
>
<RotateCcw className="size-4 mr-2" />
{t('workflows.rerunExecution')}
</Button>
</div>
</TabsContent>
<TabsContent value="nodes" className="flex-1 overflow-auto mt-4">
{selectedExecution.node_executions && selectedExecution.node_executions.length > 0 ? (
{selectedExecution.node_executions &&
selectedExecution.node_executions.length > 0 ? (
<ScrollArea className="h-[400px]">
<div className="space-y-2 pr-4">
{selectedExecution.node_executions.map((nodeExec) => {
const NodeStatusIcon = statusIcons[nodeExec.status] || Clock;
const NodeStatusIcon =
statusIcons[nodeExec.status] || Clock;
const isFailedNode = nodeExec.status === 'failed';
return (
<div
@@ -630,14 +756,24 @@ export default function WorkflowExecutionsTab({
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<span className={isFailedNode ? 'font-medium text-red-700 dark:text-red-300 break-all' : 'font-medium break-all'}>
<span
className={
isFailedNode
? 'font-medium text-red-700 dark:text-red-300 break-all'
: 'font-medium break-all'
}
>
{nodeExec.node_id}
</span>
{typeof nodeExec.retry_count === 'number' && nodeExec.retry_count > 0 && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5">
retry {nodeExec.retry_count}
</Badge>
)}
{typeof nodeExec.retry_count === 'number' &&
nodeExec.retry_count > 0 && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-5"
>
retry {nodeExec.retry_count}
</Badge>
)}
</div>
<div
className={`text-xs mt-1 break-all ${
@@ -649,27 +785,35 @@ export default function WorkflowExecutionsTab({
{nodeExec.node_type}
</div>
</div>
<Badge className={`${statusColors[nodeExec.status]} shrink-0`}>
<Badge
className={`${statusColors[nodeExec.status]} shrink-0`}
>
<NodeStatusIcon className="size-3 mr-1" />
{nodeExec.status}
</Badge>
</div>
{nodeExec.inputs && Object.keys(nodeExec.inputs).length > 0 && (
<div className="mt-2">
<div className="text-xs text-muted-foreground mb-1">{t('workflows.inputs')}:</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto max-h-[100px] whitespace-pre-wrap break-all">
{JSON.stringify(nodeExec.inputs, null, 2)}
</pre>
</div>
)}
{nodeExec.outputs && Object.keys(nodeExec.outputs).length > 0 && (
<div className="mt-2">
<div className="text-xs text-muted-foreground mb-1">{t('workflows.outputs')}:</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto max-h-[100px] whitespace-pre-wrap break-all">
{JSON.stringify(nodeExec.outputs, null, 2)}
</pre>
</div>
)}
{nodeExec.inputs &&
Object.keys(nodeExec.inputs).length > 0 && (
<div className="mt-2">
<div className="text-xs text-muted-foreground mb-1">
{t('workflows.inputs')}:
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto max-h-[100px] whitespace-pre-wrap break-all">
{JSON.stringify(nodeExec.inputs, null, 2)}
</pre>
</div>
)}
{nodeExec.outputs &&
Object.keys(nodeExec.outputs).length > 0 && (
<div className="mt-2">
<div className="text-xs text-muted-foreground mb-1">
{t('workflows.outputs')}:
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto max-h-[100px] whitespace-pre-wrap break-all">
{JSON.stringify(nodeExec.outputs, null, 2)}
</pre>
</div>
)}
{nodeExec.error && (
<div className="mt-2 rounded border border-destructive/20 bg-destructive/10 p-2">
<div className="text-[11px] font-medium text-destructive mb-1">
@@ -691,7 +835,7 @@ export default function WorkflowExecutionsTab({
</div>
)}
</TabsContent>
<TabsContent value="logs" className="flex-1 overflow-hidden mt-4">
{logsLoading ? (
<div className="flex items-center justify-center py-8">
@@ -699,24 +843,37 @@ export default function WorkflowExecutionsTab({
</div>
) : executionLogs.length > 0 ? (
<ScrollArea className="h-[400px] border rounded">
<div className="p-2 space-y-1 font-mono text-xs">
<div className="p-3 space-y-3 text-xs">
{executionLogs.map((log) => (
<div
key={log.id}
className={`flex gap-2 p-1 hover:bg-muted/50 rounded ${logLevelColors[log.level]}`}
<div
key={log.id}
className="rounded-md border border-border/60 bg-muted/20 p-3"
>
<span className="text-muted-foreground shrink-0">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span className="uppercase w-12 shrink-0 font-semibold">
[{log.level}]
</span>
{log.node_id && (
<div className="flex flex-wrap items-center gap-2 font-mono">
<span className="text-muted-foreground shrink-0">
[{log.node_id}]
{log.timestamp
? new Date(log.timestamp).toLocaleTimeString()
: '-'}
</span>
<span
className={`uppercase font-semibold ${logLevelColors[log.level]}`}
>
[{log.level}]
</span>
{log.node_id && (
<span className="text-muted-foreground break-all">
[{log.node_id}]
</span>
)}
</div>
<div className="mt-2 whitespace-pre-wrap break-words text-foreground font-mono">
{log.message}
</div>
{log.data && Object.keys(log.data).length > 0 && (
<pre className="mt-3 overflow-x-auto rounded bg-background/80 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-words font-mono">
{JSON.stringify(log.data, null, 2)}
</pre>
)}
<span className="flex-1 break-all">{log.message}</span>
</div>
))}
</div>

View File

@@ -652,6 +652,8 @@ export interface WorkflowExecutionNodeInfo {
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
started_at?: string;
completed_at?: string;
start_time?: string;
end_time?: string;
inputs?: Record<string, unknown>;
outputs?: Record<string, unknown>;
error?: string;
@@ -662,9 +664,17 @@ export interface WorkflowExecution {
uuid: string;
workflow_uuid: string;
workflow_version: number;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
status:
| 'pending'
| 'waiting'
| 'running'
| 'completed'
| 'failed'
| 'cancelled';
started_at?: string;
completed_at?: string;
start_time?: string;
end_time?: string;
trigger_type?: string;
trigger_data?: Record<string, unknown>;
variables?: Record<string, unknown>;

View File

@@ -37,6 +37,7 @@ export enum DynamicFormItemType {
LLM_MODEL_SELECTOR = 'llm-model-selector',
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
RERANK_MODEL_SELECTOR = 'rerank-model-selector',
PIPELINE_SELECTOR = 'pipeline-selector',
MODEL_FALLBACK_SELECTOR = 'model-fallback-selector',
PROMPT_EDITOR = 'prompt-editor',
UNKNOWN = 'unknown',

View File

@@ -39,6 +39,8 @@ export class WorkflowWebSocketClient {
private reconnectTimer: NodeJS.Timeout | null = null;
private isConnecting = false;
private shouldReconnect = true;
private manualDisconnect = false;
private activeConnectPromise: Promise<string> | null = null;
private onConnectedCallback?: (data: WorkflowWebSocketResponse) => void;
private onMessageCallback?: (data: WorkflowWebSocketMessage) => void;
@@ -53,7 +55,12 @@ export class WorkflowWebSocketClient {
) {}
public connect(): Promise<string> {
return new Promise((resolve, reject) => {
if (this.activeConnectPromise) {
console.warn('WebSocket连接请求进行中复用当前连接请求');
return this.activeConnectPromise;
}
const connectPromise = new Promise<string>((resolve, reject) => {
try {
if (
this.isConnecting ||
@@ -72,8 +79,9 @@ export class WorkflowWebSocketClient {
this.isConnecting = true;
this.shouldReconnect = true;
this.manualDisconnect = false;
this.clearReconnectTimer();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host =
import.meta.env.VITE_API_BASE_URL?.split('://')[1] ||
@@ -90,9 +98,9 @@ export class WorkflowWebSocketClient {
locationHost: window.location.host,
envBaseUrl: import.meta.env.VITE_API_BASE_URL,
});
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.debug('[WorkflowWebSocket] connect:open', {
workflowId: this.workflowId,
@@ -123,6 +131,9 @@ export class WorkflowWebSocketClient {
};
this.ws.onclose = (event) => {
const wasManualClose =
this.manualDisconnect || event.reason === 'client-disconnect';
console.warn('[WorkflowWebSocket] connect:close', {
workflowId: this.workflowId,
sessionType: this.sessionType,
@@ -132,11 +143,19 @@ export class WorkflowWebSocketClient {
wasClean: event.wasClean,
reconnectAttempts: this.reconnectAttempts,
maxReconnectAttempts: this.maxReconnectAttempts,
wasManualClose,
});
this.isConnecting = false;
this.stopHeartbeat();
this.ws = null;
this.connectionId = null;
this.onCloseCallback?.();
if (wasManualClose) {
this.manualDisconnect = false;
return;
}
if (
this.shouldReconnect &&
this.reconnectAttempts < this.maxReconnectAttempts
@@ -154,7 +173,7 @@ export class WorkflowWebSocketClient {
}, this.reconnectDelay * this.reconnectAttempts);
}
};
this.ws.onerror = (event) => {
console.error('[WorkflowWebSocket] connect:error', {
workflowId: this.workflowId,
@@ -174,6 +193,15 @@ export class WorkflowWebSocketClient {
reject(error);
}
});
this.activeConnectPromise = connectPromise;
connectPromise.finally(() => {
if (this.activeConnectPromise === connectPromise) {
this.activeConnectPromise = null;
}
});
return connectPromise;
}
private handleMessage(data: WorkflowWebSocketResponse) {
@@ -275,21 +303,27 @@ export class WorkflowWebSocketClient {
}
public disconnect() {
this.manualDisconnect = true;
this.shouldReconnect = false;
this.clearReconnectTimer();
this.stopHeartbeat();
this.reconnectAttempts = this.maxReconnectAttempts;
this.isConnecting = false;
this.connectionId = null;
if (this.ws) {
this.stopHeartbeat();
this.reconnectAttempts = this.maxReconnectAttempts;
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'disconnect' }));
}
this.ws.close(1000, 'client-disconnect');
if (
this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING
) {
this.ws.close(1000, 'client-disconnect');
}
this.ws = null;
this.connectionId = null;
this.isConnecting = false;
}
}

View File

@@ -368,8 +368,10 @@ const zhHans = {
selectWorkflow: '选择工作流',
noPipelinesFound: '暂无可用的流水线',
noWorkflowsFound: '暂无可用的工作流',
pipelineBindingHelp: '流水线是传统的消息处理方式,通过预定义的阶段处理消息。',
workflowBindingHelp: '工作流提供可视化的节点编排,支持更灵活的消息处理逻辑。',
pipelineBindingHelp:
'流水线是传统的消息处理方式,通过预定义的阶段处理消息。',
workflowBindingHelp:
'工作流提供可视化的节点编排,支持更灵活的消息处理逻辑。',
adapterConfigDescription: '配置所选平台适配器',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
@@ -1336,6 +1338,7 @@ const zhHans = {
nodeExecutions: '节点执行记录',
result: '执行结果',
'status.pending': '等待中',
'status.waiting': '等待中',
'status.running': '执行中',
'status.completed': '已完成',
'status.failed': '失败',
@@ -1369,7 +1372,8 @@ const zhHans = {
condition: '条件',
hasCondition: '已设置',
conditionPlaceholder: '输入条件表达式,如: output.success == true',
conditionHelp: '条件为空时,该连线将始终被执行。支持使用 {{变量名}} 引用上下文变量。',
conditionHelp:
'条件为空时,该连线将始终被执行。支持使用 {{变量名}} 引用上下文变量。',
deleteEdge: '删除连线',
deleteEdgeConfirm: '确认删除连线',
deleteEdgeConfirmDesc: '删除后,该连线将被永久移除。',