feat: improve workflow run observability
This commit is contained in:
@@ -211,6 +211,14 @@ def _resolve_handoff_target(
|
||||
return next_nodes[0] if next_nodes else None
|
||||
|
||||
|
||||
def _execution_log_event_type(status: Optional[str]) -> str:
|
||||
if status == 'waiting':
|
||||
return 'waiting_input'
|
||||
if status == 'failed':
|
||||
return 'error'
|
||||
return 'node_complete'
|
||||
|
||||
|
||||
def _make_execution_log_entry(
|
||||
node_map: Dict[str, Any],
|
||||
node_id: str,
|
||||
@@ -219,6 +227,7 @@ def _make_execution_log_entry(
|
||||
) -> dict:
|
||||
extra.setdefault('timestamp', datetime.now().isoformat())
|
||||
metadata = dict(extra.pop('metadata', {}) or {})
|
||||
event_type = _execution_log_event_type(extra.get('status'))
|
||||
collaboration = _node_collaboration_metadata(node_map, node_id, node_type)
|
||||
branch_id = extra.get('branch') or extra.get('branch_id')
|
||||
branch_label = extra.get('branch_label')
|
||||
@@ -231,7 +240,7 @@ def _make_execution_log_entry(
|
||||
node_map,
|
||||
node_id,
|
||||
node_type,
|
||||
event_type='node_complete',
|
||||
event_type=event_type,
|
||||
status=extra.get('status'),
|
||||
branch_id=branch_id,
|
||||
branch_label=branch_label,
|
||||
@@ -2508,6 +2517,13 @@ class AIWorkflowService:
|
||||
start_time = time.time()
|
||||
result = await node_instance.execute_async(context)
|
||||
elapsed = int((time.time() - start_time) * 1000)
|
||||
target_node_id = _resolve_handoff_target(
|
||||
current_node_id,
|
||||
result,
|
||||
edge_map,
|
||||
parallel_edge_map,
|
||||
node_map,
|
||||
)
|
||||
|
||||
# 处理节点事件
|
||||
if result.events:
|
||||
|
||||
@@ -226,6 +226,49 @@ export function useEventHandler(config: EventHandlerConfig) {
|
||||
!event.error &&
|
||||
(!event.status || event.status === 'success' || event.status === 'completed');
|
||||
|
||||
const getEventDisplayName = (event: StreamEvent) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
complete: '执行完成',
|
||||
loop_start: '循环开始',
|
||||
node_event: '节点事件',
|
||||
resume: '恢复执行',
|
||||
start: '开始执行',
|
||||
};
|
||||
return (
|
||||
event.node_label ||
|
||||
event.event?.message ||
|
||||
event.event?.content ||
|
||||
event.event?.type ||
|
||||
event.message ||
|
||||
event.content ||
|
||||
typeMap[event.type] ||
|
||||
event.type ||
|
||||
'运行事件'
|
||||
);
|
||||
};
|
||||
|
||||
const appendObservedStep = (
|
||||
event: StreamEvent,
|
||||
msgId: string,
|
||||
status: 'completed' | 'failed' | 'running' = 'completed',
|
||||
) => {
|
||||
currentSteps.value.push({
|
||||
type: status === 'failed' ? 'error' : 'observation',
|
||||
content: getEventDisplayName(event),
|
||||
node_id: event.node_id,
|
||||
node_type: event.node_type,
|
||||
...buildReasoningMeta(event),
|
||||
params: event.config || event.waiting_config || event.event || event.params,
|
||||
output: event.outputs || event.output || event.result,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
updateAssistantMessage(msgId, {
|
||||
reasoning_steps: [...currentSteps.value],
|
||||
...buildMessageMeta(event),
|
||||
});
|
||||
};
|
||||
|
||||
/** 处理流式事件 */
|
||||
const handleStreamEvent = (event: StreamEvent, msgId: string) => {
|
||||
switch (event.type) {
|
||||
@@ -600,7 +643,11 @@ export function useEventHandler(config: EventHandlerConfig) {
|
||||
(s) => s.node_id === event.node_id && s.type === 'parallel_start',
|
||||
);
|
||||
const branchResults = event.branch_results || {};
|
||||
const content = `并行分支完成:${getItemCount(branchResults)} 个分支`;
|
||||
const nodeSucceeded = isSuccessEvent(event);
|
||||
const content = nodeSucceeded
|
||||
? `并行分支完成:${getItemCount(branchResults)} 个分支`
|
||||
: `并行分支失败:${getStreamErrorMessage(event)}`;
|
||||
const nodeStatus = nodeSucceeded ? 'completed' : 'failed';
|
||||
if (existingIndex === -1) {
|
||||
currentSteps.value.push({
|
||||
type: 'parallel_complete',
|
||||
@@ -612,7 +659,7 @@ export function useEventHandler(config: EventHandlerConfig) {
|
||||
branch_results: branchResults,
|
||||
total_tokens: event.total_tokens,
|
||||
},
|
||||
status: 'completed',
|
||||
status: nodeStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
@@ -625,7 +672,7 @@ export function useEventHandler(config: EventHandlerConfig) {
|
||||
branch_results: branchResults,
|
||||
total_tokens: event.total_tokens,
|
||||
},
|
||||
status: 'completed',
|
||||
status: nodeStatus,
|
||||
};
|
||||
}
|
||||
updateAssistantMessage(msgId, {
|
||||
@@ -673,6 +720,8 @@ export function useEventHandler(config: EventHandlerConfig) {
|
||||
};
|
||||
messages.value.push(newMsg);
|
||||
}
|
||||
} else if (nodeEvent) {
|
||||
appendObservedStep(event, msgId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -719,6 +768,12 @@ export function useEventHandler(config: EventHandlerConfig) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'resume': {
|
||||
currentRunId.value = event.run_id || currentRunId.value;
|
||||
appendObservedStep(event, msgId, 'running');
|
||||
break;
|
||||
}
|
||||
|
||||
// ========== 对话流交互事件 ==========
|
||||
case 'waiting_input': {
|
||||
waitingForInput.value = true;
|
||||
@@ -752,6 +807,24 @@ export function useEventHandler(config: EventHandlerConfig) {
|
||||
running.value = false;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
if (
|
||||
event.type &&
|
||||
(event.content ||
|
||||
event.message ||
|
||||
event.error ||
|
||||
event.error_message ||
|
||||
event.event)
|
||||
) {
|
||||
appendObservedStep(
|
||||
event,
|
||||
msgId,
|
||||
event.error || event.error_message ? 'failed' : 'completed',
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -95,6 +95,115 @@ const selectedNodeLabel = computed(() => {
|
||||
const log = executionLogs.value.find((item) => item.node_id === nodeId);
|
||||
return log?.node_label || log?.node_id || nodeId;
|
||||
});
|
||||
const runEventTimeline = computed(() => {
|
||||
if (!run.value) return [];
|
||||
|
||||
const items: Array<{
|
||||
channel: string;
|
||||
elapsed?: number;
|
||||
eventType: string;
|
||||
index?: number;
|
||||
key: string;
|
||||
nodeId?: string;
|
||||
status: string;
|
||||
summary: string;
|
||||
timestamp?: string;
|
||||
title: string;
|
||||
}> = [
|
||||
{
|
||||
channel: triggerLabel(run.value.trigger_type),
|
||||
eventType: 'start',
|
||||
key: 'start',
|
||||
status: run.value.status === 'running' ? 'running' : 'completed',
|
||||
summary: previewValue(run.value.inputs),
|
||||
timestamp: run.value.started_at,
|
||||
title: '开始运行',
|
||||
},
|
||||
];
|
||||
|
||||
let estimatedOffset = 0;
|
||||
for (const [index, log] of executionLogs.value.entries()) {
|
||||
const communication = getCommunication(log);
|
||||
const collaboration = getCollaboration(log);
|
||||
const eventType =
|
||||
communication.event ||
|
||||
(log.status === 'waiting'
|
||||
? 'waiting_input'
|
||||
: log.status === 'failed'
|
||||
? 'error'
|
||||
: 'node_complete');
|
||||
const branch =
|
||||
communication.branch_label ||
|
||||
log.branch_label ||
|
||||
log.branch ||
|
||||
collaboration.branch_label;
|
||||
const subflowName = collaboration.subflow_name || getLogMeta(log).subflow_name;
|
||||
const targetName = getTargetName(log);
|
||||
const timestamp =
|
||||
log.timestamp ||
|
||||
communication.timestamp ||
|
||||
estimateLogTimestamp(estimatedOffset);
|
||||
estimatedOffset += Number(log.elapsed_time || 0);
|
||||
const summaryParts = [
|
||||
branch ? `分支:${branch}` : '',
|
||||
subflowName ? `子流程:${subflowName}` : '',
|
||||
targetName ? `交接:${targetName}` : '',
|
||||
communication.message ? `摘要:${communication.message}` : '',
|
||||
getLogError(log) ? `错误:${getLogError(log)}` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
items.push({
|
||||
channel: communication.channel || collaboration.collaboration_mode || log.node_type || '',
|
||||
elapsed: log.elapsed_time,
|
||||
eventType,
|
||||
index,
|
||||
key: `${log.node_id || 'event'}-${index}-${eventType}`,
|
||||
nodeId: log.node_id,
|
||||
status: normalizeRunEventStatus(eventType, log.status),
|
||||
summary: summaryParts.join(' · ') || previewValue(log.output),
|
||||
timestamp,
|
||||
title:
|
||||
log.node_label ||
|
||||
getActorName(log) ||
|
||||
getRunEventTypeLabel(eventType),
|
||||
});
|
||||
}
|
||||
|
||||
if (run.value.status === 'waiting') {
|
||||
items.push({
|
||||
channel: 'human_input',
|
||||
eventType: 'waiting_input',
|
||||
key: 'waiting',
|
||||
nodeId: run.value.current_node_id,
|
||||
status: 'waiting',
|
||||
summary: previewValue(run.value.waiting_config),
|
||||
title: '等待用户输入',
|
||||
});
|
||||
} else if (run.value.status === 'failed') {
|
||||
items.push({
|
||||
channel: 'error',
|
||||
eventType: 'error',
|
||||
key: 'failed',
|
||||
status: 'failed',
|
||||
summary: run.value.error_message || '工作流执行失败',
|
||||
timestamp: run.value.completed_at,
|
||||
title: '运行失败',
|
||||
});
|
||||
} else if (run.value.status === 'completed') {
|
||||
items.push({
|
||||
channel: 'workflow',
|
||||
elapsed: run.value.elapsed_time,
|
||||
eventType: 'complete',
|
||||
key: 'complete',
|
||||
status: 'completed',
|
||||
summary: previewValue(run.value.outputs),
|
||||
timestamp: run.value.completed_at,
|
||||
title: '运行完成',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
const agentSummaries = computed(() => {
|
||||
const agents = new Map<
|
||||
string,
|
||||
@@ -380,6 +489,29 @@ function getLogStatusText(status?: string) {
|
||||
return status ? map[status] || status : '-';
|
||||
}
|
||||
|
||||
function getRunEventTypeLabel(type?: string) {
|
||||
const map: Record<string, string> = {
|
||||
complete: '完成',
|
||||
error: '错误',
|
||||
llm_chunk: '模型输出',
|
||||
node_complete: '节点完成',
|
||||
node_event: '节点事件',
|
||||
node_start: '节点开始',
|
||||
parallel_complete: '并行完成',
|
||||
parallel_start: '并行开始',
|
||||
start: '开始',
|
||||
waiting_input: '等待输入',
|
||||
};
|
||||
return type ? map[type] || type : '-';
|
||||
}
|
||||
|
||||
function normalizeRunEventStatus(eventType?: string, status?: string) {
|
||||
if (eventType === 'waiting_input' || status === 'waiting') return 'waiting';
|
||||
if (eventType === 'error' || status === 'failed') return 'failed';
|
||||
if (eventType === 'start' || status === 'running') return 'running';
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
function previewValue(value: any) {
|
||||
if (value === undefined || value === null || value === '') return '-';
|
||||
if (typeof value === 'string') return value;
|
||||
@@ -483,6 +615,11 @@ function selectLog(nodeId?: string, index?: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function selectRunEvent(item: { index?: number; nodeId?: string }) {
|
||||
if (!item.nodeId) return;
|
||||
selectLog(item.nodeId, item.index);
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
@@ -566,6 +703,58 @@ defineExpose({ open });
|
||||
</ZqDescItem>
|
||||
</ZqDesc>
|
||||
|
||||
<div class="run-event-panel border-border rounded-lg border p-3">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-sm font-medium">运行事件</div>
|
||||
<div class="text-muted-foreground mt-1 text-xs">
|
||||
串联展示开始、节点、等待、错误、并行和子流程事件
|
||||
</div>
|
||||
</div>
|
||||
<ElTag size="small">{{ runEventTimeline.length }}</ElTag>
|
||||
</div>
|
||||
<div class="run-event-list">
|
||||
<button
|
||||
v-for="item in runEventTimeline"
|
||||
:key="item.key"
|
||||
class="run-event-item"
|
||||
:class="{
|
||||
'is-active': item.nodeId && item.nodeId === activeNodeId,
|
||||
'is-clickable': item.nodeId,
|
||||
}"
|
||||
type="button"
|
||||
@click="selectRunEvent(item)"
|
||||
>
|
||||
<span
|
||||
class="run-event-dot"
|
||||
:class="`is-${getLogStatusType(item.status)}`"
|
||||
></span>
|
||||
<span class="min-w-0">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate text-xs font-medium">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<ElTag size="small" type="info">
|
||||
{{ getRunEventTypeLabel(item.eventType) }}
|
||||
</ElTag>
|
||||
</span>
|
||||
<span class="text-muted-foreground mt-1 block truncate text-xs">
|
||||
{{ item.summary }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-muted-foreground shrink-0 text-right text-xs">
|
||||
<span class="block">{{ item.channel || '-' }}</span>
|
||||
<span class="block">
|
||||
{{ formatTimelineTime(item.timestamp) }}
|
||||
<template v-if="item.elapsed">
|
||||
· {{ formatDuration(item.elapsed) }}
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="run-report-grid grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_360px]">
|
||||
<div class="border-border bg-muted/30 rounded-lg border p-3">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
@@ -961,6 +1150,67 @@ defineExpose({ open });
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .run-event-list {
|
||||
display: grid;
|
||||
grid-auto-columns: minmax(240px, 320px);
|
||||
grid-auto-flow: column;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .run-event-item {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .run-event-item.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .run-event-item.is-clickable:hover {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .run-event-item.is-active {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-7);
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .run-event-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .run-event-dot.is-success {
|
||||
background: var(--el-color-success);
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .run-event-dot.is-danger {
|
||||
background: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .run-event-dot.is-warning {
|
||||
background: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .run-event-dot.is-primary {
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .collaboration-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1143,6 +1393,12 @@ defineExpose({ open });
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .run-event-list {
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: 1fr;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.workflow-run-detail-dialog .collaboration-flow {
|
||||
flex-wrap: wrap;
|
||||
overflow-x: visible;
|
||||
|
||||
Reference in New Issue
Block a user