mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
1111
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 '')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '删除后,该连线将被永久移除。',
|
||||
|
||||
Reference in New Issue
Block a user