mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-30 22:27:13 +00:00
1111
This commit is contained in:
@@ -125,6 +125,19 @@ class WorkflowsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
return self.success(data=executions)
|
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
|
# Get workflow versions
|
||||||
@self.route('/<workflow_uuid>/versions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/<workflow_uuid>/versions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _(workflow_uuid: str) -> str:
|
async def _(workflow_uuid: str) -> str:
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ class PipelineService:
|
|||||||
|
|
||||||
return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
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:
|
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
|
||||||
from ....utils import paths as path_utils
|
from ....utils import paths as path_utils
|
||||||
|
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ class WorkflowService:
|
|||||||
'uuid': execution_uuid,
|
'uuid': execution_uuid,
|
||||||
'workflow_uuid': workflow_uuid,
|
'workflow_uuid': workflow_uuid,
|
||||||
'workflow_version': workflow_dict.get('version', 1),
|
'workflow_version': workflow_dict.get('version', 1),
|
||||||
'status': ExecutionStatus.PENDING.value,
|
'status': ExecutionStatus.RUNNING.value,
|
||||||
'trigger_type': trigger_type,
|
'trigger_type': trigger_type,
|
||||||
'trigger_data': trigger_data or {},
|
'trigger_data': trigger_data or {},
|
||||||
'variables': {},
|
'variables': {},
|
||||||
@@ -496,13 +496,7 @@ class WorkflowService:
|
|||||||
executions = result.all()
|
executions = result.all()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'executions': [
|
'executions': [self._serialize_execution(execution) for execution in executions],
|
||||||
self.ap.persistence_mgr.serialize_model(
|
|
||||||
persistence_workflow.WorkflowExecution,
|
|
||||||
execution
|
|
||||||
)
|
|
||||||
for execution in executions
|
|
||||||
],
|
|
||||||
'total': total,
|
'total': total,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,10 +513,17 @@ class WorkflowService:
|
|||||||
if execution is None:
|
if execution is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return self.ap.persistence_mgr.serialize_model(
|
data = self._serialize_execution(execution)
|
||||||
persistence_workflow.WorkflowExecution,
|
|
||||||
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]:
|
async def get_node_types(self) -> list[dict]:
|
||||||
"""Get all available node types"""
|
"""Get all available node types"""
|
||||||
@@ -838,6 +839,24 @@ class WorkflowService:
|
|||||||
|
|
||||||
return result
|
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(
|
async def update_workflow_extensions(
|
||||||
self,
|
self,
|
||||||
workflow_uuid: str,
|
workflow_uuid: str,
|
||||||
@@ -1111,6 +1130,8 @@ class WorkflowService:
|
|||||||
execution = await self.get_execution(execution_uuid)
|
execution = await self.get_execution(execution_uuid)
|
||||||
if execution is None:
|
if execution is None:
|
||||||
raise ValueError(f'Execution {execution_uuid} not found')
|
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(
|
query = sqlalchemy.select(persistence_workflow.WorkflowNodeExecution).where(
|
||||||
persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid
|
persistence_workflow.WorkflowNodeExecution.execution_uuid == execution_uuid
|
||||||
@@ -1121,12 +1142,29 @@ class WorkflowService:
|
|||||||
result = await self.ap.persistence_mgr.execute_async(query)
|
result = await self.ap.persistence_mgr.execute_async(query)
|
||||||
node_executions = result.all()
|
node_executions = result.all()
|
||||||
|
|
||||||
logs = [
|
logs = []
|
||||||
self.ap.persistence_mgr.serialize_model(
|
for node_exec in node_executions:
|
||||||
persistence_workflow.WorkflowNodeExecution,
|
serialized = self._serialize_node_execution(node_exec)
|
||||||
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')}"
|
||||||
|
)
|
||||||
|
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'),
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
for node_exec in node_executions
|
|
||||||
]
|
|
||||||
|
|
||||||
return {'logs': logs, 'total': len(logs)}
|
return {'logs': logs, 'total': len(logs)}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional, TYPE_CHECKING
|
from typing import Any, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
from .entities import (
|
from .entities import (
|
||||||
WorkflowDefinition,
|
WorkflowDefinition,
|
||||||
NodeDefinition,
|
NodeDefinition,
|
||||||
@@ -20,6 +22,7 @@ from .entities import (
|
|||||||
NodeStatus,
|
NodeStatus,
|
||||||
ExecutionStep,
|
ExecutionStep,
|
||||||
)
|
)
|
||||||
|
from ..entity.persistence import workflow as persistence_workflow
|
||||||
from .registry import NodeTypeRegistry
|
from .registry import NodeTypeRegistry
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -346,6 +349,8 @@ class WorkflowExecutor:
|
|||||||
logger.warning(f"Circular dependency detected at node: {node.id}")
|
logger.warning(f"Circular dependency detected at node: {node.id}")
|
||||||
context.node_states[node.id].status = NodeStatus.SKIPPED
|
context.node_states[node.id].status = NodeStatus.SKIPPED
|
||||||
context.node_states[node.id].error = "Circular dependency detected"
|
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
|
return
|
||||||
|
|
||||||
# Add node to current path
|
# Add node to current path
|
||||||
@@ -353,7 +358,10 @@ class WorkflowExecutor:
|
|||||||
|
|
||||||
# Check if node should be skipped
|
# Check if node should be skipped
|
||||||
if await self._should_skip_node(node, context):
|
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)
|
path.discard(node.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -469,6 +477,7 @@ class WorkflowExecutor:
|
|||||||
node_state.error = f"Unknown node type: {node.type}"
|
node_state.error = f"Unknown node type: {node.type}"
|
||||||
node_state.end_time = datetime.now()
|
node_state.end_time = datetime.now()
|
||||||
self._record_execution_step(node, node_state, context)
|
self._record_execution_step(node, node_state, context)
|
||||||
|
await self._persist_node_execution(node, node_state, context)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Resolve inputs
|
# Resolve inputs
|
||||||
@@ -482,6 +491,7 @@ class WorkflowExecutor:
|
|||||||
node_state.error = "; ".join(validation_errors)
|
node_state.error = "; ".join(validation_errors)
|
||||||
node_state.end_time = datetime.now()
|
node_state.end_time = datetime.now()
|
||||||
self._record_execution_step(node, node_state, context)
|
self._record_execution_step(node, node_state, context)
|
||||||
|
await self._persist_node_execution(node, node_state, context)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Execute with retries
|
# Execute with retries
|
||||||
@@ -523,6 +533,7 @@ class WorkflowExecutor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._record_execution_step(node, node_state, context)
|
self._record_execution_step(node, node_state, context)
|
||||||
|
await self._persist_node_execution(node, node_state, context)
|
||||||
|
|
||||||
async def _resolve_inputs(
|
async def _resolve_inputs(
|
||||||
self,
|
self,
|
||||||
@@ -738,6 +749,47 @@ class WorkflowExecutor:
|
|||||||
)
|
)
|
||||||
context.history.append(step)
|
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:
|
class ParallelExecutor:
|
||||||
"""Execute multiple branches in parallel"""
|
"""Execute multiple branches in parallel"""
|
||||||
@@ -997,8 +1049,8 @@ class DebugWorkflowExecutor(WorkflowExecutor):
|
|||||||
|
|
||||||
# Check if should skip
|
# Check if should skip
|
||||||
if await self._should_skip_node(node, context):
|
if await self._should_skip_node(node, context):
|
||||||
context.node_states[node.id].status = NodeStatus.SKIPPED
|
if context.node_states[node.id].status == NodeStatus.SKIPPED:
|
||||||
debug_state.add_log('info', f'Skipping node: {node.id}', node_id=node.id)
|
debug_state.add_log('info', f'Skipping node: {node.id}', node_id=node.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check breakpoint
|
# Check breakpoint
|
||||||
@@ -1076,6 +1128,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
|
|||||||
node_state.end_time = datetime.now()
|
node_state.end_time = datetime.now()
|
||||||
debug_state.add_log('error', f'Unknown node type: {node.type}', node_id=node.id)
|
debug_state.add_log('error', f'Unknown node type: {node.type}', node_id=node.id)
|
||||||
self._record_execution_step(node, node_state, context)
|
self._record_execution_step(node, node_state, context)
|
||||||
|
await self._persist_node_execution(node, node_state, context)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Resolve inputs
|
# Resolve inputs
|
||||||
@@ -1100,6 +1153,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
|
|||||||
node_id=node.id
|
node_id=node.id
|
||||||
)
|
)
|
||||||
self._record_execution_step(node, node_state, context)
|
self._record_execution_step(node, node_state, context)
|
||||||
|
await self._persist_node_execution(node, node_state, context)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Execute with retries
|
# Execute with retries
|
||||||
@@ -1147,6 +1201,7 @@ class DebugWorkflowExecutor(WorkflowExecutor):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._record_execution_step(node, node_state, context)
|
self._record_execution_step(node, node_state, context)
|
||||||
|
await self._persist_node_execution(node, node_state, context)
|
||||||
|
|
||||||
async def step_execute(
|
async def step_execute(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ class WorkflowNode(abc.ABC):
|
|||||||
'llm-model-selector': 'llm-model-selector',
|
'llm-model-selector': 'llm-model-selector',
|
||||||
'embedding-model-selector': 'embedding-model-selector',
|
'embedding-model-selector': 'embedding-model-selector',
|
||||||
'rerank-model-selector': 'rerank-model-selector',
|
'rerank-model-selector': 'rerank-model-selector',
|
||||||
|
'pipeline-selector': 'pipeline-selector',
|
||||||
'knowledge-base-selector': 'knowledge-base-selector',
|
'knowledge-base-selector': 'knowledge-base-selector',
|
||||||
'knowledge-base-multi-selector': 'knowledge-base-multi-selector',
|
'knowledge-base-multi-selector': 'knowledge-base-multi-selector',
|
||||||
'bot-selector': 'bot-selector',
|
'bot-selector': 'bot-selector',
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, ClassVar
|
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 ..entities import ExecutionContext
|
||||||
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
from ..node import WorkflowNode, workflow_node, NodePort, NodeConfig
|
||||||
|
|
||||||
@@ -30,7 +37,187 @@ class CallPipelineNode(WorkflowNode):
|
|||||||
config_schema: ClassVar[list[NodeConfig]] = []
|
config_schema: ClassVar[list[NodeConfig]] = []
|
||||||
|
|
||||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||||
query = inputs.get("query", "")
|
if not self.ap:
|
||||||
pipeline_uuid = self.get_config("pipeline_uuid", "")
|
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")
|
message = inputs.get("input")
|
||||||
if message in (None, ""):
|
if message in (None, ""):
|
||||||
message = inputs.get("response")
|
message = inputs.get("response")
|
||||||
|
if message in (None, ""):
|
||||||
|
message = inputs.get("content")
|
||||||
if message in (None, "") and context.message_context:
|
if message in (None, "") and context.message_context:
|
||||||
message = context.message_context.message_content
|
message = context.message_context.message_content
|
||||||
if message is None:
|
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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import i18n from '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
|
// Helper function to translate i18n key if the value is an i18n key string
|
||||||
const translateIfKey = (value: string | undefined): string | undefined => {
|
const translateIfKey = (value: string | undefined): string | undefined => {
|
||||||
@@ -228,6 +231,7 @@ export default function DynamicFormComponent({
|
|||||||
item.type === 'llm-model-selector' ||
|
item.type === 'llm-model-selector' ||
|
||||||
item.type === 'embedding-model-selector' ||
|
item.type === 'embedding-model-selector' ||
|
||||||
item.type === 'rerank-model-selector' ||
|
item.type === 'rerank-model-selector' ||
|
||||||
|
item.type === 'pipeline-selector' ||
|
||||||
item.type === 'knowledge-base-selector' ||
|
item.type === 'knowledge-base-selector' ||
|
||||||
item.type === 'bot-selector'
|
item.type === 'bot-selector'
|
||||||
) {
|
) {
|
||||||
@@ -286,6 +290,9 @@ export default function DynamicFormComponent({
|
|||||||
case 'select':
|
case 'select':
|
||||||
fieldSchema = z.string();
|
fieldSchema = z.string();
|
||||||
break;
|
break;
|
||||||
|
case 'pipeline-selector':
|
||||||
|
fieldSchema = z.string();
|
||||||
|
break;
|
||||||
case 'llm-model-selector':
|
case 'llm-model-selector':
|
||||||
fieldSchema = z.string();
|
fieldSchema = z.string();
|
||||||
break;
|
break;
|
||||||
@@ -490,9 +497,11 @@ export default function DynamicFormComponent({
|
|||||||
label={extractAndTranslateI18n(config.label)}
|
label={extractAndTranslateI18n(config.label)}
|
||||||
description={
|
description={
|
||||||
config.description
|
config.description
|
||||||
? (typeof config.description === 'string'
|
? typeof config.description === 'string'
|
||||||
? (config.description.startsWith('workflows.') ? String(t(config.description)) : config.description)
|
? config.description.startsWith('workflows.')
|
||||||
: extractAndTranslateI18n(config.description))
|
? String(t(config.description))
|
||||||
|
: config.description
|
||||||
|
: extractAndTranslateI18n(config.description)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
url={webhookUrl}
|
url={webhookUrl}
|
||||||
@@ -523,7 +532,9 @@ export default function DynamicFormComponent({
|
|||||||
{config.description && (
|
{config.description && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{typeof config.description === 'string'
|
{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)}
|
: extractAndTranslateI18n(config.description)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -554,37 +565,54 @@ export default function DynamicFormComponent({
|
|||||||
? extractAndTranslateI18n(config.label)
|
? extractAndTranslateI18n(config.label)
|
||||||
: config.name;
|
: config.name;
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{i18nLabel}{' '}
|
{i18nLabel}{' '}
|
||||||
{config.required && <span className="text-red-500">*</span>}
|
{config.required && (
|
||||||
</FormLabel>
|
<span className="text-red-500">*</span>
|
||||||
<FormControl>
|
)}
|
||||||
<div
|
</FormLabel>
|
||||||
className={
|
<FormControl>
|
||||||
isFieldDisabled ? 'pointer-events-none opacity-60' : ''
|
<div
|
||||||
}
|
className={
|
||||||
>
|
isFieldDisabled
|
||||||
<DynamicFormItemComponent
|
? 'pointer-events-none opacity-60'
|
||||||
config={config}
|
: ''
|
||||||
field={field}
|
}
|
||||||
onFileUploaded={onFileUploaded}
|
>
|
||||||
/>
|
<DynamicFormItemComponent
|
||||||
</div>
|
config={config}
|
||||||
</FormControl>
|
field={field}
|
||||||
{config.description && (() => {
|
onFileUploaded={onFileUploaded}
|
||||||
const desc = config.description;
|
/>
|
||||||
if (typeof desc === 'string') {
|
</div>
|
||||||
if (desc.startsWith('workflows.')) {
|
</FormControl>
|
||||||
return <p className="text-sm text-muted-foreground">{String(t(desc))}</p>;
|
{config.description &&
|
||||||
}
|
(() => {
|
||||||
return <p className="text-sm text-muted-foreground">{translateIfKey(desc) || desc}</p>;
|
const desc = config.description;
|
||||||
}
|
if (typeof desc === 'string') {
|
||||||
return <p className="text-sm text-muted-foreground">{extractAndTranslateI18n(desc)}</p>;
|
if (desc.startsWith('workflows.')) {
|
||||||
})()}
|
return (
|
||||||
<FormMessage />
|
<p className="text-sm text-muted-foreground">
|
||||||
</FormItem>
|
{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,
|
KnowledgeBase,
|
||||||
EmbeddingModel,
|
EmbeddingModel,
|
||||||
RerankModel,
|
RerankModel,
|
||||||
|
Pipeline,
|
||||||
PluginTool,
|
PluginTool,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Textarea } from '@/components/ui/textarea';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
@@ -65,12 +69,11 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||||
|
|
||||||
const resolveOptionLabel = (
|
const resolveOptionLabel = (label: unknown, fallback: string): string => {
|
||||||
label: unknown,
|
|
||||||
fallback: string,
|
|
||||||
): string => {
|
|
||||||
if (!label || typeof label !== 'object') return fallback;
|
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 = (
|
const getSelectedOptionLabel = (
|
||||||
@@ -87,7 +90,11 @@ const resolveModelLabel = (model: {
|
|||||||
name: string;
|
name: string;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
}): 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({
|
export default function DynamicFormItemComponent({
|
||||||
@@ -105,6 +112,7 @@ export default function DynamicFormItemComponent({
|
|||||||
const [rerankModels, setRerankModels] = useState<RerankModel[]>([]);
|
const [rerankModels, setRerankModels] = useState<RerankModel[]>([]);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||||
const [bots, setBots] = useState<Bot[]>([]);
|
const [bots, setBots] = useState<Bot[]>([]);
|
||||||
|
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
||||||
const [tools, setTools] = useState<PluginTool[]>([]);
|
const [tools, setTools] = useState<PluginTool[]>([]);
|
||||||
const [uploading, setUploading] = useState<boolean>(false);
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
||||||
@@ -258,6 +266,19 @@ export default function DynamicFormItemComponent({
|
|||||||
}
|
}
|
||||||
}, [config.type]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (config.type === DynamicFormItemType.TOOLS_SELECTOR) {
|
if (config.type === DynamicFormItemType.TOOLS_SELECTOR) {
|
||||||
httpClient
|
httpClient
|
||||||
@@ -308,7 +329,9 @@ export default function DynamicFormItemComponent({
|
|||||||
onClick={() => field.onChange(option.name)}
|
onClick={() => field.onChange(option.name)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0.5">
|
<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">
|
<span className="text-xs text-muted-foreground">
|
||||||
{option.name}
|
{option.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -320,7 +343,9 @@ export default function DynamicFormItemComponent({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <Input className="max-w-md" {...field} value={field.value ?? ''} />;
|
return (
|
||||||
|
<Input className="max-w-md" {...field} value={field.value ?? ''} />
|
||||||
|
);
|
||||||
|
|
||||||
case DynamicFormItemType.SECRET:
|
case DynamicFormItemType.SECRET:
|
||||||
const secretValue = typeof field.value === 'string' ? field.value : '';
|
const secretValue = typeof field.value === 'string' ? field.value : '';
|
||||||
@@ -346,16 +371,23 @@ export default function DynamicFormItemComponent({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => setSecretVisible((prev) => !prev)}
|
onClick={() => setSecretVisible((prev) => !prev)}
|
||||||
>
|
>
|
||||||
{secretVisible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
{secretVisible ? (
|
||||||
|
<EyeOff className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="size-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case DynamicFormItemType.TEXT:
|
case DynamicFormItemType.TEXT:
|
||||||
// Ensure value is always a string to avoid [object Object] display
|
// Ensure value is always a string to avoid [object Object] display
|
||||||
const textValue = typeof field.value === 'string'
|
const textValue =
|
||||||
? field.value
|
typeof field.value === 'string'
|
||||||
: (field.value != null ? JSON.stringify(field.value, null, 2) : '');
|
? field.value
|
||||||
|
: field.value != null
|
||||||
|
? JSON.stringify(field.value, null, 2)
|
||||||
|
: '';
|
||||||
return (
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
@@ -366,7 +398,9 @@ export default function DynamicFormItemComponent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case DynamicFormItemType.BOOLEAN:
|
case DynamicFormItemType.BOOLEAN:
|
||||||
return <Switch checked={!!field.value} onCheckedChange={field.onChange} />;
|
return (
|
||||||
|
<Switch checked={!!field.value} onCheckedChange={field.onChange} />
|
||||||
|
);
|
||||||
|
|
||||||
case DynamicFormItemType.STRING_ARRAY:
|
case DynamicFormItemType.STRING_ARRAY:
|
||||||
const arrayValue = Array.isArray(field.value) ? field.value : [];
|
const arrayValue = Array.isArray(field.value) ? field.value : [];
|
||||||
@@ -378,7 +412,9 @@ export default function DynamicFormItemComponent({
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
value={item ?? ''}
|
value={item ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = [...(Array.isArray(field.value) ? field.value : [])];
|
const newValue = [
|
||||||
|
...(Array.isArray(field.value) ? field.value : []),
|
||||||
|
];
|
||||||
newValue[index] = e.target.value;
|
newValue[index] = e.target.value;
|
||||||
field.onChange(newValue);
|
field.onChange(newValue);
|
||||||
}}
|
}}
|
||||||
@@ -389,9 +425,9 @@ export default function DynamicFormItemComponent({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newValue = (Array.isArray(field.value) ? field.value : []).filter(
|
const newValue = (
|
||||||
(_: string, i: number) => i !== index,
|
Array.isArray(field.value) ? field.value : []
|
||||||
);
|
).filter((_: string, i: number) => i !== index);
|
||||||
field.onChange(newValue);
|
field.onChange(newValue);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -404,7 +440,10 @@ export default function DynamicFormItemComponent({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full border-dashed text-muted-foreground hover:text-foreground"
|
className="w-full border-dashed text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
field.onChange([...(Array.isArray(field.value) ? field.value : []), '']);
|
field.onChange([
|
||||||
|
...(Array.isArray(field.value) ? field.value : []),
|
||||||
|
'',
|
||||||
|
]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="size-4 mr-1.5" />
|
<Plus className="size-4 mr-1.5" />
|
||||||
@@ -414,7 +453,10 @@ export default function DynamicFormItemComponent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case DynamicFormItemType.SELECT:
|
case DynamicFormItemType.SELECT:
|
||||||
const selectedOptionLabel = getSelectedOptionLabel(config.options, field.value);
|
const selectedOptionLabel = getSelectedOptionLabel(
|
||||||
|
config.options,
|
||||||
|
field.value,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={typeof field.value === 'string' ? field.value : ''}
|
value={typeof field.value === 'string' ? field.value : ''}
|
||||||
@@ -1063,7 +1105,8 @@ export default function DynamicFormItemComponent({
|
|||||||
const kbsByEngine = knowledgeBases.reduce(
|
const kbsByEngine = knowledgeBases.reduce(
|
||||||
(acc, kb) => {
|
(acc, kb) => {
|
||||||
const engineName = kb.knowledge_engine?.name
|
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');
|
: t('knowledge.unknownEngine');
|
||||||
if (!acc[engineName]) {
|
if (!acc[engineName]) {
|
||||||
acc[engineName] = [];
|
acc[engineName] = [];
|
||||||
@@ -1075,7 +1118,10 @@ export default function DynamicFormItemComponent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={field.value ?? '__none__'} onValueChange={field.onChange}>
|
<Select
|
||||||
|
value={field.value ?? '__none__'}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||||
{field.value && field.value !== '__none__' ? (
|
{field.value && field.value !== '__none__' ? (
|
||||||
(() => {
|
(() => {
|
||||||
@@ -1126,7 +1172,8 @@ export default function DynamicFormItemComponent({
|
|||||||
const multiKbsByEngine = knowledgeBases.reduce(
|
const multiKbsByEngine = knowledgeBases.reduce(
|
||||||
(acc, kb) => {
|
(acc, kb) => {
|
||||||
const engineName = kb.knowledge_engine?.name
|
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');
|
: t('knowledge.unknownEngine');
|
||||||
if (!acc[engineName]) {
|
if (!acc[engineName]) {
|
||||||
acc[engineName] = [];
|
acc[engineName] = [];
|
||||||
@@ -1312,6 +1359,43 @@ export default function DynamicFormItemComponent({
|
|||||||
</Select>
|
</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:
|
case DynamicFormItemType.TOOLS_SELECTOR:
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+72
-28
@@ -57,6 +57,7 @@ export default function WorkflowDebugDialog({
|
|||||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState(workflowId);
|
const [selectedWorkflowId, setSelectedWorkflowId] = useState(workflowId);
|
||||||
const [sessionType, setSessionType] = useState<'person' | 'group'>('person');
|
const [sessionType, setSessionType] = useState<'person' | 'group'>('person');
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const activeConnectionKeyRef = useRef<string | null>(null);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [showAtPopover, setShowAtPopover] = useState(false);
|
const [showAtPopover, setShowAtPopover] = useState(false);
|
||||||
const [hasAt, setHasAt] = useState(false);
|
const [hasAt, setHasAt] = useState(false);
|
||||||
@@ -82,7 +83,9 @@ export default function WorkflowDebugDialog({
|
|||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const scrollArea = document.querySelector('.workflow-scroll-area') as HTMLElement;
|
const scrollArea = document.querySelector(
|
||||||
|
'.workflow-scroll-area',
|
||||||
|
) as HTMLElement;
|
||||||
if (scrollArea) {
|
if (scrollArea) {
|
||||||
scrollArea.scrollTo({
|
scrollArea.scrollTo({
|
||||||
top: scrollArea.scrollHeight,
|
top: scrollArea.scrollHeight,
|
||||||
@@ -96,10 +99,11 @@ export default function WorkflowDebugDialog({
|
|||||||
const loadMessages = useCallback(
|
const loadMessages = useCallback(
|
||||||
async (workflowId: string) => {
|
async (workflowId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await backendClient.getWorkflowWebSocketHistoryMessages(
|
const response =
|
||||||
workflowId,
|
await backendClient.getWorkflowWebSocketHistoryMessages(
|
||||||
sessionType,
|
workflowId,
|
||||||
);
|
sessionType,
|
||||||
|
);
|
||||||
setMessages(response.messages);
|
setMessages(response.messages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load messages:', error);
|
console.error('Failed to load messages:', error);
|
||||||
@@ -109,23 +113,45 @@ export default function WorkflowDebugDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const initWebSocket = useCallback(
|
const initWebSocket = useCallback(
|
||||||
async (workflowId: string) => {
|
async (workflowId: string, nextSessionType: 'person' | 'group') => {
|
||||||
if (isInitializingRef.current) {
|
const connectionKey = `${workflowId}:${nextSessionType}`;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isInitializingRef.current &&
|
||||||
|
activeConnectionKeyRef.current === connectionKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
wsClientRef.current?.isConnected() &&
|
||||||
|
activeConnectionKeyRef.current === connectionKey
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isInitializingRef.current = true;
|
isInitializingRef.current = true;
|
||||||
|
activeConnectionKeyRef.current = connectionKey;
|
||||||
|
|
||||||
if (wsClientRef.current) {
|
if (wsClientRef.current) {
|
||||||
wsClientRef.current.disconnect();
|
wsClientRef.current.disconnect();
|
||||||
wsClientRef.current = null;
|
wsClientRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsClient = new WorkflowWebSocketClient(workflowId, sessionType);
|
setIsConnected(false);
|
||||||
|
|
||||||
|
const wsClient = new WorkflowWebSocketClient(
|
||||||
|
workflowId,
|
||||||
|
nextSessionType,
|
||||||
|
);
|
||||||
|
|
||||||
wsClient
|
wsClient
|
||||||
.onConnected(() => {
|
.onConnected(() => {
|
||||||
|
if (activeConnectionKeyRef.current !== connectionKey) {
|
||||||
|
wsClient.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
})
|
})
|
||||||
@@ -151,8 +177,10 @@ export default function WorkflowDebugDialog({
|
|||||||
})
|
})
|
||||||
.onError((error) => {
|
.onError((error) => {
|
||||||
console.error('WebSocket error:', error);
|
console.error('WebSocket error:', error);
|
||||||
setIsConnected(false);
|
if (activeConnectionKeyRef.current === connectionKey) {
|
||||||
isInitializingRef.current = false;
|
setIsConnected(false);
|
||||||
|
isInitializingRef.current = false;
|
||||||
|
}
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error && error.message
|
error instanceof Error && error.message
|
||||||
? error.message
|
? error.message
|
||||||
@@ -160,23 +188,33 @@ export default function WorkflowDebugDialog({
|
|||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
})
|
})
|
||||||
.onClose(() => {
|
.onClose(() => {
|
||||||
setIsConnected(false);
|
if (activeConnectionKeyRef.current === connectionKey) {
|
||||||
isInitializingRef.current = false;
|
setIsConnected(false);
|
||||||
|
isInitializingRef.current = false;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.onBroadcast((message) => {
|
.onBroadcast((message) => {
|
||||||
toast.info(message);
|
toast.info(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
await wsClient.connect();
|
await wsClient.connect();
|
||||||
|
|
||||||
|
if (activeConnectionKeyRef.current !== connectionKey) {
|
||||||
|
wsClient.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
wsClientRef.current = wsClient;
|
wsClientRef.current = wsClient;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('WebSocket connection failed:', error);
|
console.error('WebSocket connection failed:', error);
|
||||||
setIsConnected(false);
|
if (activeConnectionKeyRef.current === connectionKey) {
|
||||||
isInitializingRef.current = false;
|
setIsConnected(false);
|
||||||
toast.error(t('workflows.debugDialog.connectionFailed'));
|
isInitializingRef.current = false;
|
||||||
|
toast.error(t('workflows.debugDialog.connectionFailed'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sessionType, t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -186,30 +224,36 @@ export default function WorkflowDebugDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setSelectedWorkflowId(workflowId);
|
setSelectedWorkflowId(workflowId);
|
||||||
} else {
|
return;
|
||||||
if (wsClientRef.current) {
|
|
||||||
wsClientRef.current.disconnect();
|
|
||||||
wsClientRef.current = null;
|
|
||||||
setIsConnected(false);
|
|
||||||
isInitializingRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activeConnectionKeyRef.current = null;
|
||||||
|
if (wsClientRef.current) {
|
||||||
|
wsClientRef.current.disconnect();
|
||||||
|
wsClientRef.current = null;
|
||||||
|
}
|
||||||
|
setIsConnected(false);
|
||||||
|
isInitializingRef.current = false;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
activeConnectionKeyRef.current = null;
|
||||||
if (wsClientRef.current) {
|
if (wsClientRef.current) {
|
||||||
wsClientRef.current.disconnect();
|
wsClientRef.current.disconnect();
|
||||||
wsClientRef.current = null;
|
wsClientRef.current = null;
|
||||||
isInitializingRef.current = false;
|
|
||||||
}
|
}
|
||||||
|
setIsConnected(false);
|
||||||
|
isInitializingRef.current = false;
|
||||||
};
|
};
|
||||||
}, [open, workflowId]);
|
}, [open, workflowId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (!open) {
|
||||||
setMessages([]);
|
return;
|
||||||
loadMessages(selectedWorkflowId);
|
|
||||||
initWebSocket(selectedWorkflowId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMessages([]);
|
||||||
|
loadMessages(selectedWorkflowId);
|
||||||
|
initWebSocket(selectedWorkflowId, sessionType);
|
||||||
}, [sessionType, selectedWorkflowId, open, loadMessages, initWebSocket]);
|
}, [sessionType, selectedWorkflowId, open, loadMessages, initWebSocket]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+111
-33
@@ -68,7 +68,10 @@ export const sendMessageConfig: NodeConfigMeta = {
|
|||||||
default: 'text',
|
default: 'text',
|
||||||
options: [
|
options: [
|
||||||
{ name: 'text', label: { en_US: 'Text', zh_Hans: '文本' } },
|
{ 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: 'image', label: { en_US: 'Image', zh_Hans: '图片' } },
|
||||||
{ name: 'file', label: { en_US: 'File', zh_Hans: '文件' } },
|
{ name: 'file', label: { en_US: 'File', zh_Hans: '文件' } },
|
||||||
{ name: 'card', label: { en_US: 'Card', zh_Hans: '卡片' } },
|
{ name: 'card', label: { en_US: 'Card', zh_Hans: '卡片' } },
|
||||||
@@ -83,7 +86,8 @@ export const sendMessageConfig: NodeConfigMeta = {
|
|||||||
zh_Hans: '内容模板',
|
zh_Hans: '内容模板',
|
||||||
},
|
},
|
||||||
description: {
|
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: '消息内容模板(支持变量)。留空则使用输入。',
|
zh_Hans: '消息内容模板(支持变量)。留空则使用输入。',
|
||||||
},
|
},
|
||||||
required: false,
|
required: false,
|
||||||
@@ -263,7 +267,8 @@ export const httpRequestConfig: NodeConfigMeta = {
|
|||||||
zh_Hans: '请求体模板',
|
zh_Hans: '请求体模板',
|
||||||
},
|
},
|
||||||
description: {
|
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: '请求体模板(支持变量)。留空则使用输入。',
|
zh_Hans: '请求体模板(支持变量)。留空则使用输入。',
|
||||||
},
|
},
|
||||||
required: false,
|
required: false,
|
||||||
@@ -596,7 +601,10 @@ export const notificationConfig: NodeConfigMeta = {
|
|||||||
{ name: 'email', label: { en_US: 'Email', zh_Hans: '邮件' } },
|
{ name: 'email', label: { en_US: 'Email', zh_Hans: '邮件' } },
|
||||||
{ name: 'dingtalk', label: { en_US: 'DingTalk', zh_Hans: '钉钉' } },
|
{ name: 'dingtalk', label: { en_US: 'DingTalk', zh_Hans: '钉钉' } },
|
||||||
{ name: 'feishu', label: { en_US: 'Feishu', 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',
|
name: 'reply_mode',
|
||||||
type: DynamicFormItemType.SELECT,
|
type: DynamicFormItemType.SELECT,
|
||||||
label: { en_US: 'Reply Mode', zh_Hans: '回复模式' },
|
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,
|
required: true,
|
||||||
default: 'reply',
|
default: 'reply',
|
||||||
options: [
|
options: [
|
||||||
{ name: 'reply', label: { en_US: 'Quote Reply', zh_Hans: '引用回复' } },
|
{ 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',
|
name: 'message_template',
|
||||||
type: DynamicFormItemType.TEXT,
|
type: DynamicFormItemType.TEXT,
|
||||||
label: { en_US: 'Message Template', zh_Hans: '消息模板' },
|
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,
|
required: false,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
@@ -746,13 +764,25 @@ export const replyMessageConfig: NodeConfigMeta = {
|
|||||||
name: 'long_text_processing',
|
name: 'long_text_processing',
|
||||||
type: DynamicFormItemType.SELECT,
|
type: DynamicFormItemType.SELECT,
|
||||||
label: { en_US: 'Long Text Processing', zh_Hans: '长文本处理' },
|
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,
|
required: false,
|
||||||
default: 'truncate',
|
default: 'truncate',
|
||||||
options: [
|
options: [
|
||||||
{ name: 'truncate', label: { en_US: 'Truncate', zh_Hans: '截断' } },
|
{ 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',
|
name: 'storage_type',
|
||||||
type: DynamicFormItemType.SELECT,
|
type: DynamicFormItemType.SELECT,
|
||||||
label: { en_US: 'Storage Type', zh_Hans: '存储类型' },
|
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,
|
required: true,
|
||||||
default: 'variable',
|
default: 'variable',
|
||||||
options: [
|
options: [
|
||||||
{ name: 'variable', label: { en_US: 'Workflow Variable', zh_Hans: '工作流变量' } },
|
{
|
||||||
{ name: 'session', label: { en_US: 'Session Storage', zh_Hans: '会话存储' } },
|
name: 'variable',
|
||||||
{ name: 'persistent', label: { en_US: 'Persistent Storage', zh_Hans: '持久化存储' } },
|
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',
|
name: 'key',
|
||||||
type: DynamicFormItemType.STRING,
|
type: DynamicFormItemType.STRING,
|
||||||
label: { en_US: 'Key', zh_Hans: '键' },
|
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,
|
required: true,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
@@ -822,7 +867,10 @@ export const storeDataConfig: NodeConfigMeta = {
|
|||||||
name: 'ttl',
|
name: 'ttl',
|
||||||
type: DynamicFormItemType.INT,
|
type: DynamicFormItemType.INT,
|
||||||
label: { en_US: 'TTL (seconds)', zh_Hans: 'TTL(秒)' },
|
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,
|
required: false,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
@@ -855,22 +903,25 @@ export const callPipelineConfig: NodeConfigMeta = {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
createOutput('output', 'any', {
|
createOutput('response', 'string', {
|
||||||
description: 'Pipeline output',
|
description: 'Pipeline response',
|
||||||
label: { en_US: 'Output', zh_Hans: '输出' },
|
label: { en_US: 'Response', zh_Hans: '响应' },
|
||||||
}),
|
}),
|
||||||
createOutput('success', 'boolean', {
|
createOutput('result', 'object', {
|
||||||
description: 'Whether pipeline execution was successful',
|
description: 'Pipeline execution result',
|
||||||
label: { en_US: 'Success', zh_Hans: '成功' },
|
label: { en_US: 'Result', zh_Hans: '结果' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
configSchema: [
|
configSchema: [
|
||||||
{
|
{
|
||||||
id: 'pipeline_uuid',
|
id: 'pipeline_uuid',
|
||||||
name: 'pipeline_uuid',
|
name: 'pipeline_uuid',
|
||||||
type: DynamicFormItemType.STRING,
|
type: DynamicFormItemType.PIPELINE_SELECTOR,
|
||||||
label: { en_US: 'Pipeline', zh_Hans: 'Pipeline' },
|
label: { en_US: 'Pipeline', zh_Hans: '流水线' },
|
||||||
description: { en_US: 'UUID of the pipeline to invoke', zh_Hans: '要调用的 Pipeline UUID' },
|
description: {
|
||||||
|
en_US: 'Select the pipeline to invoke',
|
||||||
|
zh_Hans: '选择要调用的流水线',
|
||||||
|
},
|
||||||
required: true,
|
required: true,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
@@ -879,7 +930,10 @@ export const callPipelineConfig: NodeConfigMeta = {
|
|||||||
name: 'inherit_context',
|
name: 'inherit_context',
|
||||||
type: DynamicFormItemType.BOOLEAN,
|
type: DynamicFormItemType.BOOLEAN,
|
||||||
label: { en_US: 'Inherit Context', zh_Hans: '继承上下文' },
|
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,
|
required: false,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
@@ -932,7 +986,10 @@ export const setVariableConfig: NodeConfigMeta = {
|
|||||||
name: 'variable_name',
|
name: 'variable_name',
|
||||||
type: DynamicFormItemType.STRING,
|
type: DynamicFormItemType.STRING,
|
||||||
label: { en_US: 'Variable Name', zh_Hans: '变量名' },
|
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,
|
required: true,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
@@ -955,12 +1012,20 @@ export const setVariableConfig: NodeConfigMeta = {
|
|||||||
name: 'value_template',
|
name: 'value_template',
|
||||||
type: DynamicFormItemType.TEXT,
|
type: DynamicFormItemType.TEXT,
|
||||||
label: { en_US: 'Value Template', zh_Hans: '值模板' },
|
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,
|
required: false,
|
||||||
default: '',
|
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',
|
name: 'statement',
|
||||||
type: DynamicFormItemType.TEXT,
|
type: DynamicFormItemType.TEXT,
|
||||||
label: { en_US: 'Opening Statement', zh_Hans: '开场白' },
|
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,
|
required: true,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
@@ -1006,7 +1074,10 @@ export const openingStatementConfig: NodeConfigMeta = {
|
|||||||
name: 'suggested_questions',
|
name: 'suggested_questions',
|
||||||
type: DynamicFormItemType.STRING_ARRAY,
|
type: DynamicFormItemType.STRING_ARRAY,
|
||||||
label: { en_US: 'Suggested Questions', zh_Hans: '建议问题' },
|
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,
|
required: false,
|
||||||
default: [],
|
default: [],
|
||||||
},
|
},
|
||||||
@@ -1015,12 +1086,19 @@ export const openingStatementConfig: NodeConfigMeta = {
|
|||||||
name: 'show_suggestions',
|
name: 'show_suggestions',
|
||||||
type: DynamicFormItemType.BOOLEAN,
|
type: DynamicFormItemType.BOOLEAN,
|
||||||
label: { en_US: 'Show Suggestions', zh_Hans: '显示建议' },
|
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,
|
required: false,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
defaultConfig: { statement: '', suggested_questions: [], show_suggestions: true },
|
defaultConfig: {
|
||||||
|
statement: '',
|
||||||
|
suggested_questions: [],
|
||||||
|
show_suggestions: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+308
-151
@@ -19,7 +19,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Calendar,
|
Calendar,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp
|
ChevronUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -43,18 +43,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
Card,
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from '@/components/ui/tabs';
|
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -87,6 +77,7 @@ interface WorkflowStats {
|
|||||||
|
|
||||||
const statusIcons: Record<string, React.ElementType> = {
|
const statusIcons: Record<string, React.ElementType> = {
|
||||||
pending: Clock,
|
pending: Clock,
|
||||||
|
waiting: Clock,
|
||||||
running: Loader2,
|
running: Loader2,
|
||||||
completed: CheckCircle2,
|
completed: CheckCircle2,
|
||||||
failed: AlertCircle,
|
failed: AlertCircle,
|
||||||
@@ -94,9 +85,13 @@ const statusIcons: Record<string, React.ElementType> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
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',
|
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',
|
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',
|
cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||||
};
|
};
|
||||||
@@ -108,13 +103,36 @@ const logLevelColors: Record<string, string> = {
|
|||||||
debug: 'text-gray-600 dark:text-gray-400',
|
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({
|
export default function WorkflowExecutionsTab({
|
||||||
workflowId,
|
workflowId,
|
||||||
}: WorkflowExecutionsTabProps) {
|
}: WorkflowExecutionsTabProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [executions, setExecutions] = useState<WorkflowExecution[]>([]);
|
const [executions, setExecutions] = useState<WorkflowExecution[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecution | null>(null);
|
const [selectedExecution, setSelectedExecution] =
|
||||||
|
useState<WorkflowExecution | null>(null);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
@@ -165,18 +183,26 @@ export default function WorkflowExecutionsTab({
|
|||||||
}, [workflowId]);
|
}, [workflowId]);
|
||||||
|
|
||||||
// Load execution logs
|
// Load execution logs
|
||||||
const loadExecutionLogs = useCallback(async (executionUuid: string) => {
|
const loadExecutionLogs = useCallback(
|
||||||
setLogsLoading(true);
|
async (executionUuid: string) => {
|
||||||
try {
|
setLogsLoading(true);
|
||||||
const resp = await backendClient.getWorkflowExecutionLogs(workflowId, executionUuid, 200, 0);
|
try {
|
||||||
setExecutionLogs(resp.logs);
|
const resp = await backendClient.getWorkflowExecutionLogs(
|
||||||
} catch (err) {
|
workflowId,
|
||||||
console.error('Failed to load execution logs:', err);
|
executionUuid,
|
||||||
setExecutionLogs([]);
|
200,
|
||||||
} finally {
|
0,
|
||||||
setLogsLoading(false);
|
);
|
||||||
}
|
setExecutionLogs(resp.logs);
|
||||||
}, [workflowId]);
|
} catch (err) {
|
||||||
|
console.error('Failed to load execution logs:', err);
|
||||||
|
setExecutionLogs([]);
|
||||||
|
} finally {
|
||||||
|
setLogsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[workflowId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadExecutions();
|
loadExecutions();
|
||||||
@@ -189,7 +215,7 @@ export default function WorkflowExecutionsTab({
|
|||||||
|
|
||||||
// Status filter
|
// Status filter
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
filtered = filtered.filter(e => e.status === statusFilter);
|
filtered = filtered.filter((e) => e.status === statusFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date filter
|
// Date filter
|
||||||
@@ -199,7 +225,11 @@ export default function WorkflowExecutionsTab({
|
|||||||
|
|
||||||
switch (dateFilter) {
|
switch (dateFilter) {
|
||||||
case 'today':
|
case 'today':
|
||||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
startDate = new Date(
|
||||||
|
now.getFullYear(),
|
||||||
|
now.getMonth(),
|
||||||
|
now.getDate(),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case 'week':
|
case 'week':
|
||||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
@@ -211,9 +241,10 @@ export default function WorkflowExecutionsTab({
|
|||||||
startDate = new Date(0);
|
startDate = new Date(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered = filtered.filter(e => {
|
filtered = filtered.filter((e) => {
|
||||||
if (!e.started_at) return false;
|
const startedAt = getExecutionStartedAt(e);
|
||||||
return new Date(e.started_at) >= startDate;
|
if (!startedAt) return false;
|
||||||
|
return new Date(startedAt) >= startDate;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,49 +267,62 @@ export default function WorkflowExecutionsTab({
|
|||||||
}, [workflowId, loadExecutions, loadStats, t]);
|
}, [workflowId, loadExecutions, loadStats, t]);
|
||||||
|
|
||||||
// View execution details
|
// View execution details
|
||||||
const handleViewDetails = useCallback(async (executionUuid: string) => {
|
const handleViewDetails = useCallback(
|
||||||
try {
|
async (executionUuid: string) => {
|
||||||
const resp = await backendClient.getWorkflowExecution(workflowId, executionUuid);
|
try {
|
||||||
setSelectedExecution(resp.execution);
|
const resp = await backendClient.getWorkflowExecution(
|
||||||
setSelectedTab('details');
|
workflowId,
|
||||||
loadExecutionLogs(executionUuid);
|
executionUuid,
|
||||||
} catch (err: unknown) {
|
);
|
||||||
const msg = (err as { msg?: string })?.msg || String(err);
|
setSelectedExecution(resp.execution);
|
||||||
toast.error(`${t('workflows.executionDetails')}: ${msg}`);
|
setSelectedTab('details');
|
||||||
}
|
loadExecutionLogs(executionUuid);
|
||||||
}, [workflowId, loadExecutionLogs, t]);
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { msg?: string })?.msg || String(err);
|
||||||
|
toast.error(`${t('workflows.executionDetails')}: ${msg}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[workflowId, loadExecutionLogs, t],
|
||||||
|
);
|
||||||
|
|
||||||
// Cancel execution
|
// Cancel execution
|
||||||
const handleCancel = useCallback(async (executionUuid: string) => {
|
const handleCancel = useCallback(
|
||||||
try {
|
async (executionUuid: string) => {
|
||||||
await backendClient.cancelWorkflowExecution(workflowId, executionUuid);
|
try {
|
||||||
toast.success(t('common.cancel') + ' ✓');
|
await backendClient.cancelWorkflowExecution(workflowId, executionUuid);
|
||||||
loadExecutions();
|
toast.success(t('common.cancel') + ' ✓');
|
||||||
} catch (err: unknown) {
|
loadExecutions();
|
||||||
const msg = (err as { msg?: string })?.msg || String(err);
|
} catch (err: unknown) {
|
||||||
toast.error(`${t('common.cancel')}: ${msg}`);
|
const msg = (err as { msg?: string })?.msg || String(err);
|
||||||
}
|
toast.error(`${t('common.cancel')}: ${msg}`);
|
||||||
}, [workflowId, loadExecutions, t]);
|
}
|
||||||
|
},
|
||||||
|
[workflowId, loadExecutions, t],
|
||||||
|
);
|
||||||
|
|
||||||
// Rerun execution
|
// Rerun execution
|
||||||
const handleRerun = useCallback(async (executionUuid: string) => {
|
const handleRerun = useCallback(
|
||||||
setRerunning(executionUuid);
|
async (executionUuid: string) => {
|
||||||
try {
|
setRerunning(executionUuid);
|
||||||
await backendClient.rerunWorkflowExecution(workflowId, executionUuid);
|
try {
|
||||||
toast.success(t('workflows.rerun') + ' ✓');
|
await backendClient.rerunWorkflowExecution(workflowId, executionUuid);
|
||||||
loadExecutions();
|
toast.success(t('workflows.rerun') + ' ✓');
|
||||||
loadStats();
|
loadExecutions();
|
||||||
} catch (err: unknown) {
|
loadStats();
|
||||||
const msg = (err as { msg?: string })?.msg || String(err);
|
} catch (err: unknown) {
|
||||||
toast.error(`${t('workflows.rerun')}: ${msg}`);
|
const msg = (err as { msg?: string })?.msg || String(err);
|
||||||
} finally {
|
toast.error(`${t('workflows.rerun')}: ${msg}`);
|
||||||
setRerunning(null);
|
} finally {
|
||||||
}
|
setRerunning(null);
|
||||||
}, [workflowId, loadExecutions, loadStats, t]);
|
}
|
||||||
|
},
|
||||||
|
[workflowId, loadExecutions, loadStats, t],
|
||||||
|
);
|
||||||
|
|
||||||
// Format duration
|
// Format duration
|
||||||
const formatDuration = (seconds: number): string => {
|
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`;
|
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
const secs = seconds % 60;
|
const secs = seconds % 60;
|
||||||
@@ -295,7 +339,11 @@ export default function WorkflowExecutionsTab({
|
|||||||
<TrendingUp className="size-4" />
|
<TrendingUp className="size-4" />
|
||||||
<span className="font-medium">{t('workflows.statistics')}</span>
|
<span className="font-medium">{t('workflows.statistics')}</span>
|
||||||
</div>
|
</div>
|
||||||
{showStats ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
|
{showStats ? (
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
@@ -308,13 +356,19 @@ export default function WorkflowExecutionsTab({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<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>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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">
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -330,7 +384,8 @@ export default function WorkflowExecutionsTab({
|
|||||||
{((stats.success_rate ?? 0) * 100).toFixed(1)}%
|
{((stats.success_rate ?? 0) * 100).toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -363,7 +418,8 @@ export default function WorkflowExecutionsTab({
|
|||||||
</div>
|
</div>
|
||||||
{stats.last_execution_time && (
|
{stats.last_execution_time && (
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -387,12 +443,27 @@ export default function WorkflowExecutionsTab({
|
|||||||
<SelectValue placeholder={t('workflows.filterByStatus')} />
|
<SelectValue placeholder={t('workflows.filterByStatus')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{t('workflows.allStatuses')}</SelectItem>
|
<SelectItem value="all">
|
||||||
<SelectItem value="completed">{t('workflows.status.completed')}</SelectItem>
|
{t('workflows.allStatuses')}
|
||||||
<SelectItem value="running">{t('workflows.status.running')}</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="failed">{t('workflows.status.failed')}</SelectItem>
|
<SelectItem value="completed">
|
||||||
<SelectItem value="cancelled">{t('workflows.status.cancelled')}</SelectItem>
|
{t('workflows.status.completed')}
|
||||||
<SelectItem value="pending">{t('workflows.status.pending')}</SelectItem>
|
</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,7 +478,9 @@ export default function WorkflowExecutionsTab({
|
|||||||
<SelectItem value="all">{t('workflows.allTime')}</SelectItem>
|
<SelectItem value="all">{t('workflows.allTime')}</SelectItem>
|
||||||
<SelectItem value="today">{t('workflows.today')}</SelectItem>
|
<SelectItem value="today">{t('workflows.today')}</SelectItem>
|
||||||
<SelectItem value="week">{t('workflows.lastWeek')}</SelectItem>
|
<SelectItem value="week">{t('workflows.lastWeek')}</SelectItem>
|
||||||
<SelectItem value="month">{t('workflows.lastMonth')}</SelectItem>
|
<SelectItem value="month">
|
||||||
|
{t('workflows.lastMonth')}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,14 +488,24 @@ export default function WorkflowExecutionsTab({
|
|||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t('workflows.showingExecutions', {
|
{t('workflows.showingExecutions', {
|
||||||
shown: filteredExecutions.length,
|
shown: filteredExecutions.length,
|
||||||
total: total
|
total: total,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => { loadExecutions(); loadStats(); }} disabled={loading}>
|
<Button
|
||||||
<RefreshCw className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
loadExecutions();
|
||||||
|
loadStats();
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
{t('common.refresh')}
|
{t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleManualTrigger}>
|
<Button size="sm" onClick={handleManualTrigger}>
|
||||||
@@ -448,16 +531,26 @@ export default function WorkflowExecutionsTab({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredExecutions.length === 0 ? (
|
{filteredExecutions.length === 0 ? (
|
||||||
<TableRow>
|
<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')}
|
{loading ? t('common.loading') : t('workflows.noExecutions')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredExecutions.map((execution) => {
|
filteredExecutions.map((execution) => {
|
||||||
const StatusIcon = statusIcons[execution.status] || Clock;
|
const StatusIcon = statusIcons[execution.status] || Clock;
|
||||||
const duration = execution.completed_at && execution.started_at
|
const startedAt = getExecutionStartedAt(execution);
|
||||||
? Math.round((new Date(execution.completed_at).getTime() - new Date(execution.started_at).getTime()) / 1000)
|
const completedAt = getExecutionCompletedAt(execution);
|
||||||
: null;
|
const duration =
|
||||||
|
completedAt && startedAt
|
||||||
|
? Math.round(
|
||||||
|
(new Date(completedAt).getTime() -
|
||||||
|
new Date(startedAt).getTime()) /
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={execution.uuid}>
|
<TableRow key={execution.uuid}>
|
||||||
@@ -466,15 +559,15 @@ export default function WorkflowExecutionsTab({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge className={statusColors[execution.status]}>
|
<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}`)}
|
{t(`workflows.status.${execution.status}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{execution.trigger_type || '-'}</TableCell>
|
<TableCell>{execution.trigger_type || '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{execution.started_at
|
{startedAt ? new Date(startedAt).toLocaleString() : '-'}
|
||||||
? new Date(execution.started_at).toLocaleString()
|
|
||||||
: '-'}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{duration !== null ? `${duration}s` : '-'}
|
{duration !== null ? `${duration}s` : '-'}
|
||||||
@@ -488,7 +581,7 @@ export default function WorkflowExecutionsTab({
|
|||||||
>
|
>
|
||||||
{t('common.details')}
|
{t('common.details')}
|
||||||
</Button>
|
</Button>
|
||||||
{execution.status === 'running' && (
|
{isExecutionCancelable(execution) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -497,7 +590,8 @@ export default function WorkflowExecutionsTab({
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(execution.status === 'completed' || execution.status === 'failed') && (
|
{(execution.status === 'completed' ||
|
||||||
|
execution.status === 'failed') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -523,52 +617,80 @@ export default function WorkflowExecutionsTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Execution Details Dialog */}
|
{/* 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">
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('workflows.executionDetails')}</DialogTitle>
|
<DialogTitle>{t('workflows.executionDetails')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{selectedExecution?.uuid}</DialogDescription>
|
||||||
{selectedExecution?.uuid}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{selectedExecution && (
|
{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">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="details">{t('workflows.details')}</TabsTrigger>
|
<TabsTrigger value="details">
|
||||||
<TabsTrigger value="nodes">{t('workflows.nodeExecutions')}</TabsTrigger>
|
{t('workflows.details')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="nodes">
|
||||||
|
{t('workflows.nodeExecutions')}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="logs">
|
<TabsTrigger value="logs">
|
||||||
<FileText className="size-3 mr-1" />
|
<FileText className="size-3 mr-1" />
|
||||||
{t('workflows.logs')}
|
{t('workflows.logs')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</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 */}
|
{/* Summary */}
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">{t('workflows.status')}:</span>
|
<span className="text-muted-foreground">
|
||||||
<Badge className={`ml-2 ${statusColors[selectedExecution.status]}`}>
|
{t('workflows.status')}:
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
className={`ml-2 ${statusColors[selectedExecution.status]}`}
|
||||||
|
>
|
||||||
{t(`workflows.status.${selectedExecution.status}`)}
|
{t(`workflows.status.${selectedExecution.status}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">{t('workflows.triggerType')}:</span>
|
<span className="text-muted-foreground">
|
||||||
<span className="ml-2">{selectedExecution.trigger_type || '-'}</span>
|
{t('workflows.triggerType')}:
|
||||||
|
</span>
|
||||||
|
<span className="ml-2">
|
||||||
|
{selectedExecution.trigger_type || '-'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">{t('workflows.startedAt')}:</span>
|
<span className="text-muted-foreground">
|
||||||
|
{t('workflows.startedAt')}:
|
||||||
|
</span>
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
{selectedExecution.started_at
|
{getExecutionStartedAt(selectedExecution)
|
||||||
? new Date(selectedExecution.started_at).toLocaleString()
|
? new Date(
|
||||||
|
getExecutionStartedAt(selectedExecution)!,
|
||||||
|
).toLocaleString()
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">{t('workflows.completedAt')}:</span>
|
<span className="text-muted-foreground">
|
||||||
|
{t('workflows.completedAt')}:
|
||||||
|
</span>
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
{selectedExecution.completed_at
|
{getExecutionCompletedAt(selectedExecution)
|
||||||
? new Date(selectedExecution.completed_at).toLocaleString()
|
? new Date(
|
||||||
|
getExecutionCompletedAt(selectedExecution)!,
|
||||||
|
).toLocaleString()
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -589,7 +711,9 @@ export default function WorkflowExecutionsTab({
|
|||||||
{/* Result */}
|
{/* Result */}
|
||||||
{selectedExecution.result && (
|
{selectedExecution.result && (
|
||||||
<div>
|
<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]">
|
<pre className="bg-muted p-3 rounded text-xs overflow-x-auto max-h-[200px]">
|
||||||
{JSON.stringify(selectedExecution.result, null, 2)}
|
{JSON.stringify(selectedExecution.result, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -603,7 +727,7 @@ export default function WorkflowExecutionsTab({
|
|||||||
handleRerun(selectedExecution.uuid);
|
handleRerun(selectedExecution.uuid);
|
||||||
setSelectedExecution(null);
|
setSelectedExecution(null);
|
||||||
}}
|
}}
|
||||||
disabled={selectedExecution.status === 'running'}
|
disabled={isExecutionCancelable(selectedExecution)}
|
||||||
>
|
>
|
||||||
<RotateCcw className="size-4 mr-2" />
|
<RotateCcw className="size-4 mr-2" />
|
||||||
{t('workflows.rerunExecution')}
|
{t('workflows.rerunExecution')}
|
||||||
@@ -612,11 +736,13 @@ export default function WorkflowExecutionsTab({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="nodes" className="flex-1 overflow-auto mt-4">
|
<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]">
|
<ScrollArea className="h-[400px]">
|
||||||
<div className="space-y-2 pr-4">
|
<div className="space-y-2 pr-4">
|
||||||
{selectedExecution.node_executions.map((nodeExec) => {
|
{selectedExecution.node_executions.map((nodeExec) => {
|
||||||
const NodeStatusIcon = statusIcons[nodeExec.status] || Clock;
|
const NodeStatusIcon =
|
||||||
|
statusIcons[nodeExec.status] || Clock;
|
||||||
const isFailedNode = nodeExec.status === 'failed';
|
const isFailedNode = nodeExec.status === 'failed';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -630,14 +756,24 @@ export default function WorkflowExecutionsTab({
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
<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}
|
{nodeExec.node_id}
|
||||||
</span>
|
</span>
|
||||||
{typeof nodeExec.retry_count === 'number' && nodeExec.retry_count > 0 && (
|
{typeof nodeExec.retry_count === 'number' &&
|
||||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5">
|
nodeExec.retry_count > 0 && (
|
||||||
retry {nodeExec.retry_count}
|
<Badge
|
||||||
</Badge>
|
variant="outline"
|
||||||
)}
|
className="text-[10px] px-1.5 py-0 h-5"
|
||||||
|
>
|
||||||
|
retry {nodeExec.retry_count}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`text-xs mt-1 break-all ${
|
className={`text-xs mt-1 break-all ${
|
||||||
@@ -649,27 +785,35 @@ export default function WorkflowExecutionsTab({
|
|||||||
{nodeExec.node_type}
|
{nodeExec.node_type}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={`${statusColors[nodeExec.status]} shrink-0`}>
|
<Badge
|
||||||
|
className={`${statusColors[nodeExec.status]} shrink-0`}
|
||||||
|
>
|
||||||
<NodeStatusIcon className="size-3 mr-1" />
|
<NodeStatusIcon className="size-3 mr-1" />
|
||||||
{nodeExec.status}
|
{nodeExec.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{nodeExec.inputs && Object.keys(nodeExec.inputs).length > 0 && (
|
{nodeExec.inputs &&
|
||||||
<div className="mt-2">
|
Object.keys(nodeExec.inputs).length > 0 && (
|
||||||
<div className="text-xs text-muted-foreground mb-1">{t('workflows.inputs')}:</div>
|
<div className="mt-2">
|
||||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto max-h-[100px] whitespace-pre-wrap break-all">
|
<div className="text-xs text-muted-foreground mb-1">
|
||||||
{JSON.stringify(nodeExec.inputs, null, 2)}
|
{t('workflows.inputs')}:
|
||||||
</pre>
|
</div>
|
||||||
</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)}
|
||||||
{nodeExec.outputs && Object.keys(nodeExec.outputs).length > 0 && (
|
</pre>
|
||||||
<div className="mt-2">
|
</div>
|
||||||
<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">
|
{nodeExec.outputs &&
|
||||||
{JSON.stringify(nodeExec.outputs, null, 2)}
|
Object.keys(nodeExec.outputs).length > 0 && (
|
||||||
</pre>
|
<div className="mt-2">
|
||||||
</div>
|
<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 && (
|
{nodeExec.error && (
|
||||||
<div className="mt-2 rounded border border-destructive/20 bg-destructive/10 p-2">
|
<div className="mt-2 rounded border border-destructive/20 bg-destructive/10 p-2">
|
||||||
<div className="text-[11px] font-medium text-destructive mb-1">
|
<div className="text-[11px] font-medium text-destructive mb-1">
|
||||||
@@ -699,24 +843,37 @@ export default function WorkflowExecutionsTab({
|
|||||||
</div>
|
</div>
|
||||||
) : executionLogs.length > 0 ? (
|
) : executionLogs.length > 0 ? (
|
||||||
<ScrollArea className="h-[400px] border rounded">
|
<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) => (
|
{executionLogs.map((log) => (
|
||||||
<div
|
<div
|
||||||
key={log.id}
|
key={log.id}
|
||||||
className={`flex gap-2 p-1 hover:bg-muted/50 rounded ${logLevelColors[log.level]}`}
|
className="rounded-md border border-border/60 bg-muted/20 p-3"
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground shrink-0">
|
<div className="flex flex-wrap items-center gap-2 font-mono">
|
||||||
{new Date(log.timestamp).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
<span className="uppercase w-12 shrink-0 font-semibold">
|
|
||||||
[{log.level}]
|
|
||||||
</span>
|
|
||||||
{log.node_id && (
|
|
||||||
<span className="text-muted-foreground shrink-0">
|
<span className="text-muted-foreground shrink-0">
|
||||||
[{log.node_id}]
|
{log.timestamp
|
||||||
|
? new Date(log.timestamp).toLocaleTimeString()
|
||||||
|
: '-'}
|
||||||
</span>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -652,6 +652,8 @@ export interface WorkflowExecutionNodeInfo {
|
|||||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||||
started_at?: string;
|
started_at?: string;
|
||||||
completed_at?: string;
|
completed_at?: string;
|
||||||
|
start_time?: string;
|
||||||
|
end_time?: string;
|
||||||
inputs?: Record<string, unknown>;
|
inputs?: Record<string, unknown>;
|
||||||
outputs?: Record<string, unknown>;
|
outputs?: Record<string, unknown>;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -662,9 +664,17 @@ export interface WorkflowExecution {
|
|||||||
uuid: string;
|
uuid: string;
|
||||||
workflow_uuid: string;
|
workflow_uuid: string;
|
||||||
workflow_version: number;
|
workflow_version: number;
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
status:
|
||||||
|
| 'pending'
|
||||||
|
| 'waiting'
|
||||||
|
| 'running'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'cancelled';
|
||||||
started_at?: string;
|
started_at?: string;
|
||||||
completed_at?: string;
|
completed_at?: string;
|
||||||
|
start_time?: string;
|
||||||
|
end_time?: string;
|
||||||
trigger_type?: string;
|
trigger_type?: string;
|
||||||
trigger_data?: Record<string, unknown>;
|
trigger_data?: Record<string, unknown>;
|
||||||
variables?: Record<string, unknown>;
|
variables?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export enum DynamicFormItemType {
|
|||||||
LLM_MODEL_SELECTOR = 'llm-model-selector',
|
LLM_MODEL_SELECTOR = 'llm-model-selector',
|
||||||
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
|
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
|
||||||
RERANK_MODEL_SELECTOR = 'rerank-model-selector',
|
RERANK_MODEL_SELECTOR = 'rerank-model-selector',
|
||||||
|
PIPELINE_SELECTOR = 'pipeline-selector',
|
||||||
MODEL_FALLBACK_SELECTOR = 'model-fallback-selector',
|
MODEL_FALLBACK_SELECTOR = 'model-fallback-selector',
|
||||||
PROMPT_EDITOR = 'prompt-editor',
|
PROMPT_EDITOR = 'prompt-editor',
|
||||||
UNKNOWN = 'unknown',
|
UNKNOWN = 'unknown',
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export class WorkflowWebSocketClient {
|
|||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
private isConnecting = false;
|
private isConnecting = false;
|
||||||
private shouldReconnect = true;
|
private shouldReconnect = true;
|
||||||
|
private manualDisconnect = false;
|
||||||
|
private activeConnectPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
private onConnectedCallback?: (data: WorkflowWebSocketResponse) => void;
|
private onConnectedCallback?: (data: WorkflowWebSocketResponse) => void;
|
||||||
private onMessageCallback?: (data: WorkflowWebSocketMessage) => void;
|
private onMessageCallback?: (data: WorkflowWebSocketMessage) => void;
|
||||||
@@ -53,7 +55,12 @@ export class WorkflowWebSocketClient {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public connect(): Promise<string> {
|
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 {
|
try {
|
||||||
if (
|
if (
|
||||||
this.isConnecting ||
|
this.isConnecting ||
|
||||||
@@ -72,6 +79,7 @@ export class WorkflowWebSocketClient {
|
|||||||
|
|
||||||
this.isConnecting = true;
|
this.isConnecting = true;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
|
this.manualDisconnect = false;
|
||||||
this.clearReconnectTimer();
|
this.clearReconnectTimer();
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
@@ -123,6 +131,9 @@ export class WorkflowWebSocketClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onclose = (event) => {
|
this.ws.onclose = (event) => {
|
||||||
|
const wasManualClose =
|
||||||
|
this.manualDisconnect || event.reason === 'client-disconnect';
|
||||||
|
|
||||||
console.warn('[WorkflowWebSocket] connect:close', {
|
console.warn('[WorkflowWebSocket] connect:close', {
|
||||||
workflowId: this.workflowId,
|
workflowId: this.workflowId,
|
||||||
sessionType: this.sessionType,
|
sessionType: this.sessionType,
|
||||||
@@ -132,11 +143,19 @@ export class WorkflowWebSocketClient {
|
|||||||
wasClean: event.wasClean,
|
wasClean: event.wasClean,
|
||||||
reconnectAttempts: this.reconnectAttempts,
|
reconnectAttempts: this.reconnectAttempts,
|
||||||
maxReconnectAttempts: this.maxReconnectAttempts,
|
maxReconnectAttempts: this.maxReconnectAttempts,
|
||||||
|
wasManualClose,
|
||||||
});
|
});
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
|
this.ws = null;
|
||||||
|
this.connectionId = null;
|
||||||
this.onCloseCallback?.();
|
this.onCloseCallback?.();
|
||||||
|
|
||||||
|
if (wasManualClose) {
|
||||||
|
this.manualDisconnect = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.shouldReconnect &&
|
this.shouldReconnect &&
|
||||||
this.reconnectAttempts < this.maxReconnectAttempts
|
this.reconnectAttempts < this.maxReconnectAttempts
|
||||||
@@ -174,6 +193,15 @@ export class WorkflowWebSocketClient {
|
|||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.activeConnectPromise = connectPromise;
|
||||||
|
connectPromise.finally(() => {
|
||||||
|
if (this.activeConnectPromise === connectPromise) {
|
||||||
|
this.activeConnectPromise = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return connectPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMessage(data: WorkflowWebSocketResponse) {
|
private handleMessage(data: WorkflowWebSocketResponse) {
|
||||||
@@ -275,21 +303,27 @@ export class WorkflowWebSocketClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public disconnect() {
|
public disconnect() {
|
||||||
|
this.manualDisconnect = true;
|
||||||
this.shouldReconnect = false;
|
this.shouldReconnect = false;
|
||||||
this.clearReconnectTimer();
|
this.clearReconnectTimer();
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.reconnectAttempts = this.maxReconnectAttempts;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.connectionId = null;
|
||||||
|
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.stopHeartbeat();
|
|
||||||
this.reconnectAttempts = this.maxReconnectAttempts;
|
|
||||||
|
|
||||||
if (this.ws.readyState === WebSocket.OPEN) {
|
if (this.ws.readyState === WebSocket.OPEN) {
|
||||||
this.ws.send(JSON.stringify({ type: 'disconnect' }));
|
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.ws = null;
|
||||||
this.connectionId = null;
|
|
||||||
this.isConnecting = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -368,8 +368,10 @@ const zhHans = {
|
|||||||
selectWorkflow: '选择工作流',
|
selectWorkflow: '选择工作流',
|
||||||
noPipelinesFound: '暂无可用的流水线',
|
noPipelinesFound: '暂无可用的流水线',
|
||||||
noWorkflowsFound: '暂无可用的工作流',
|
noWorkflowsFound: '暂无可用的工作流',
|
||||||
pipelineBindingHelp: '流水线是传统的消息处理方式,通过预定义的阶段处理消息。',
|
pipelineBindingHelp:
|
||||||
workflowBindingHelp: '工作流提供可视化的节点编排,支持更灵活的消息处理逻辑。',
|
'流水线是传统的消息处理方式,通过预定义的阶段处理消息。',
|
||||||
|
workflowBindingHelp:
|
||||||
|
'工作流提供可视化的节点编排,支持更灵活的消息处理逻辑。',
|
||||||
adapterConfigDescription: '配置所选平台适配器',
|
adapterConfigDescription: '配置所选平台适配器',
|
||||||
dangerZone: '危险区域',
|
dangerZone: '危险区域',
|
||||||
dangerZoneDescription: '不可逆的操作',
|
dangerZoneDescription: '不可逆的操作',
|
||||||
@@ -1336,6 +1338,7 @@ const zhHans = {
|
|||||||
nodeExecutions: '节点执行记录',
|
nodeExecutions: '节点执行记录',
|
||||||
result: '执行结果',
|
result: '执行结果',
|
||||||
'status.pending': '等待中',
|
'status.pending': '等待中',
|
||||||
|
'status.waiting': '等待中',
|
||||||
'status.running': '执行中',
|
'status.running': '执行中',
|
||||||
'status.completed': '已完成',
|
'status.completed': '已完成',
|
||||||
'status.failed': '失败',
|
'status.failed': '失败',
|
||||||
@@ -1369,7 +1372,8 @@ const zhHans = {
|
|||||||
condition: '条件',
|
condition: '条件',
|
||||||
hasCondition: '已设置',
|
hasCondition: '已设置',
|
||||||
conditionPlaceholder: '输入条件表达式,如: output.success == true',
|
conditionPlaceholder: '输入条件表达式,如: output.success == true',
|
||||||
conditionHelp: '条件为空时,该连线将始终被执行。支持使用 {{变量名}} 引用上下文变量。',
|
conditionHelp:
|
||||||
|
'条件为空时,该连线将始终被执行。支持使用 {{变量名}} 引用上下文变量。',
|
||||||
deleteEdge: '删除连线',
|
deleteEdge: '删除连线',
|
||||||
deleteEdgeConfirm: '确认删除连线',
|
deleteEdgeConfirm: '确认删除连线',
|
||||||
deleteEdgeConfirmDesc: '删除后,该连线将被永久移除。',
|
deleteEdgeConfirmDesc: '删除后,该连线将被永久移除。',
|
||||||
|
|||||||
Reference in New Issue
Block a user