feat: improve workflow run observability
This commit is contained in:
@@ -280,6 +280,7 @@ export interface WorkflowRunEvent {
|
||||
}
|
||||
|
||||
export interface ExecutionLogEntry {
|
||||
type?: string;
|
||||
node_id: string;
|
||||
node_type: string;
|
||||
node_label?: string;
|
||||
@@ -292,6 +293,7 @@ export interface ExecutionLogEntry {
|
||||
timestamp?: string;
|
||||
branch?: string;
|
||||
branch_label?: string;
|
||||
event?: WorkflowRunEvent;
|
||||
metadata?: Record<string, any>;
|
||||
events?: WorkflowRunEvent[];
|
||||
}
|
||||
|
||||
+136
-24
@@ -33,30 +33,75 @@ function formatDuration(ms?: number) {
|
||||
|
||||
function stringifyBrief(value: any, maxLength = 180) {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
const text =
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: JSON.stringify(value, null, 2);
|
||||
let text = '';
|
||||
if (typeof value === 'string') {
|
||||
text = value;
|
||||
} else {
|
||||
try {
|
||||
text = JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
text = String(value);
|
||||
}
|
||||
}
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
|
||||
}
|
||||
|
||||
function getCommunication(log: ExecutionLogEntry) {
|
||||
return log.metadata?.communication || {};
|
||||
return log.metadata?.communication || log.event?.communication || {};
|
||||
}
|
||||
|
||||
function getMetaItems(log: ExecutionLogEntry) {
|
||||
const metadata = log.metadata || {};
|
||||
const items: Array<{ label: string; type?: 'info' | 'primary' | 'success' | 'warning' }> = [];
|
||||
const items: Array<{
|
||||
label: string;
|
||||
type?: 'danger' | 'info' | 'primary' | 'success' | 'warning';
|
||||
}> = [];
|
||||
const communication = getCommunication(log);
|
||||
const collaboration =
|
||||
metadata.collaboration || log.event?.collaboration || {};
|
||||
const branchLabel =
|
||||
log.branch_label ||
|
||||
log.branch ||
|
||||
collaboration.branch_label ||
|
||||
collaboration.branch_id ||
|
||||
log.event?.branch_label ||
|
||||
log.event?.branch_id;
|
||||
const subflowName =
|
||||
metadata.subflow_name ||
|
||||
collaboration.subflow_name ||
|
||||
log.event?.subflow_name;
|
||||
const providerName =
|
||||
metadata.provider_name ||
|
||||
collaboration.provider_name ||
|
||||
log.event?.provider_name;
|
||||
const modelName =
|
||||
metadata.model ||
|
||||
metadata.model_name ||
|
||||
metadata.model_id ||
|
||||
collaboration.model ||
|
||||
collaboration.model_name ||
|
||||
collaboration.model_id ||
|
||||
log.event?.model ||
|
||||
log.event?.model_name ||
|
||||
log.event?.model_id;
|
||||
|
||||
if (log.branch_label || log.branch) {
|
||||
items.push({ label: `分支: ${log.branch_label || log.branch}`, type: 'primary' });
|
||||
if (log.type || metadata.stream_event) {
|
||||
items.push({
|
||||
label: getEventTypeLabel(log.type || log.event?.type),
|
||||
type: log.status === 'failed' ? 'danger' : 'info',
|
||||
});
|
||||
}
|
||||
if (metadata.subflow_name) {
|
||||
items.push({ label: `子流程: ${metadata.subflow_name}`, type: 'success' });
|
||||
if (branchLabel) {
|
||||
items.push({ label: `分支: ${branchLabel}`, type: 'primary' });
|
||||
}
|
||||
if (metadata.model || metadata.model_id) {
|
||||
items.push({ label: `模型: ${metadata.model || metadata.model_id}`, type: 'info' });
|
||||
if (subflowName) {
|
||||
items.push({ label: `子流程: ${subflowName}`, type: 'success' });
|
||||
}
|
||||
if (providerName) {
|
||||
items.push({ label: `提供商: ${providerName}`, type: 'info' });
|
||||
}
|
||||
if (modelName) {
|
||||
items.push({ label: `模型: ${modelName}`, type: 'info' });
|
||||
}
|
||||
if (log.tokens_used) {
|
||||
items.push({ label: `Token ${log.tokens_used}`, type: 'info' });
|
||||
@@ -99,34 +144,89 @@ function getEventTypeLabel(type?: string) {
|
||||
return labels[type || ''] || type || '事件';
|
||||
}
|
||||
|
||||
function countItems(value: any) {
|
||||
if (Array.isArray(value)) return value.length;
|
||||
if (value && typeof value === 'object') return Object.keys(value).length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getEventSummary(event: WorkflowRunEvent) {
|
||||
if (event.content) return event.content;
|
||||
if (event.message) return event.message;
|
||||
const eventType = event.type || event.event?.type;
|
||||
const waitingConfig = event.waiting_config || event.config;
|
||||
|
||||
if (event.error_message) return event.error_message;
|
||||
if (event.error) return event.error;
|
||||
if (eventType === 'waiting_input') {
|
||||
return stringifyBrief(
|
||||
waitingConfig?.title ||
|
||||
waitingConfig?.question ||
|
||||
waitingConfig?.content ||
|
||||
waitingConfig ||
|
||||
'等待用户输入',
|
||||
160,
|
||||
);
|
||||
}
|
||||
if (eventType === 'parallel_start') {
|
||||
return `启动 ${countItems(event.branches || event.branch_labels)} 个并行分支`;
|
||||
}
|
||||
if (eventType === 'parallel_complete') {
|
||||
return `完成 ${countItems(event.branch_results)} 个并行分支`;
|
||||
}
|
||||
if (eventType === 'node_start') {
|
||||
return `${event.node_label || event.node_id || '节点'} 开始执行`;
|
||||
}
|
||||
if (eventType === 'node_complete') {
|
||||
return stringifyBrief(
|
||||
event.outputs?.output ||
|
||||
event.outputs ||
|
||||
event.result ||
|
||||
`${event.node_label || event.node_id || '节点'} 执行完成`,
|
||||
160,
|
||||
);
|
||||
}
|
||||
if (eventType === 'complete') {
|
||||
return stringifyBrief(
|
||||
event.outputs?.output || event.outputs || '执行完成',
|
||||
160,
|
||||
);
|
||||
}
|
||||
if (event.content) return event.content;
|
||||
if (event.message) return event.message;
|
||||
if (event.event) return getEventSummary(event.event);
|
||||
return stringifyBrief(event, 140);
|
||||
}
|
||||
|
||||
function getLogEvents(log: ExecutionLogEntry): WorkflowRunEvent[] {
|
||||
const primaryEvent = log.event ? [log.event] : [];
|
||||
const directEvents = Array.isArray(log.events) ? log.events : [];
|
||||
const metadataEvents = Array.isArray(log.metadata?.events)
|
||||
? log.metadata.events
|
||||
: [];
|
||||
return [...directEvents, ...metadataEvents].slice(0, 4);
|
||||
return [...primaryEvent, ...directEvents, ...metadataEvents].filter(Boolean);
|
||||
}
|
||||
|
||||
function getVisibleEvents(log: ExecutionLogEntry) {
|
||||
return getLogEvents(log).slice(0, 8);
|
||||
}
|
||||
|
||||
function getHiddenEventCount(log: ExecutionLogEntry) {
|
||||
return Math.max(0, getLogEvents(log).length - 8);
|
||||
}
|
||||
|
||||
function getLogDetail(log: ExecutionLogEntry) {
|
||||
const communication = getCommunication(log);
|
||||
if (log.error) return log.error;
|
||||
if (log.event) return getEventSummary(log.event);
|
||||
if (log.status === 'waiting') {
|
||||
return stringifyBrief(log.metadata?.waiting_config || log.metadata?.config || log.output);
|
||||
return stringifyBrief(
|
||||
log.metadata?.waiting_config || log.metadata?.config || log.output,
|
||||
);
|
||||
}
|
||||
return stringifyBrief(
|
||||
log.output
|
||||
|| log.metadata?.branch_results
|
||||
|| log.metadata?.result
|
||||
|| communication.message,
|
||||
log.output ||
|
||||
log.metadata?.branch_results ||
|
||||
log.metadata?.result ||
|
||||
communication.message,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
@@ -147,7 +247,11 @@ function getLogDetail(log: ExecutionLogEntry) {
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-muted w-full rounded border px-3 py-2 text-left transition-colors"
|
||||
:class="activeNodeId === log.node_id ? 'border-primary bg-primary/5' : 'border-border'"
|
||||
:class="
|
||||
activeNodeId === log.node_id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border'
|
||||
"
|
||||
@click="emit('select', log.node_id)"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between gap-2">
|
||||
@@ -156,7 +260,9 @@ function getLogDetail(log: ExecutionLogEntry) {
|
||||
</span>
|
||||
<RunStatusTag :status="log.status" />
|
||||
</div>
|
||||
<div class="text-muted-foreground flex flex-wrap items-center gap-1 text-xs">
|
||||
<div
|
||||
class="text-muted-foreground flex flex-wrap items-center gap-1 text-xs"
|
||||
>
|
||||
<span>{{ log.node_type }}</span>
|
||||
<ElTag
|
||||
v-for="item in getMetaItems(log)"
|
||||
@@ -176,14 +282,14 @@ function getLogDetail(log: ExecutionLogEntry) {
|
||||
{{ getLogDetail(log) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getLogEvents(log).length"
|
||||
v-if="getVisibleEvents(log).length"
|
||||
class="bg-muted/40 border-border mt-2 rounded border px-2 py-1.5"
|
||||
>
|
||||
<div class="text-muted-foreground mb-1 text-xs font-medium">
|
||||
运行事件
|
||||
</div>
|
||||
<div
|
||||
v-for="(event, eventIndex) in getLogEvents(log)"
|
||||
v-for="(event, eventIndex) in getVisibleEvents(log)"
|
||||
:key="`${log.node_id}-event-${eventIndex}`"
|
||||
class="text-muted-foreground flex gap-2 text-xs"
|
||||
>
|
||||
@@ -194,6 +300,12 @@ function getLogDetail(log: ExecutionLogEntry) {
|
||||
{{ getEventSummary(event) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="getHiddenEventCount(log)"
|
||||
class="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
还有 {{ getHiddenEventCount(log) }} 条事件
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</ElTimelineItem>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Edge, Node } from '@vue-flow/core';
|
||||
|
||||
import type { ExecutionLogEntry, WorkflowRun } from '#/api/ai-platform/ai-platform';
|
||||
import type {
|
||||
ExecutionLogEntry,
|
||||
WorkflowRun,
|
||||
} from '#/api/ai-platform/ai-platform';
|
||||
|
||||
function mapLogStatus(
|
||||
status: string,
|
||||
@@ -8,6 +11,8 @@ function mapLogStatus(
|
||||
if (status === 'completed') return 'success';
|
||||
if (status === 'failed') return 'failed';
|
||||
if (status === 'waiting') return 'running';
|
||||
if (status === 'running') return 'running';
|
||||
if (status === 'pending') return 'pending';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
@@ -17,7 +22,29 @@ function mapEdgeStatus(
|
||||
if (status === 'completed') return 'completed';
|
||||
if (status === 'failed') return 'failed';
|
||||
if (status === 'waiting') return 'running';
|
||||
return 'completed';
|
||||
if (status === 'running') return 'running';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isStreamStartOnlyLog(log: ExecutionLogEntry) {
|
||||
const metadata = log.metadata || {};
|
||||
return Boolean(
|
||||
metadata.stream_event &&
|
||||
['node_start', 'parallel_start', 'start'].includes(log.type || ''),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldReplayLog(log: ExecutionLogEntry, logs: ExecutionLogEntry[]) {
|
||||
if (!log?.node_id) return false;
|
||||
|
||||
if (!isStreamStartOnlyLog(log)) return true;
|
||||
|
||||
return !logs.some(
|
||||
(item) =>
|
||||
item !== log &&
|
||||
item.node_id === log.node_id &&
|
||||
!isStreamStartOnlyLog(item),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNodeOutput(log: ExecutionLogEntry, run: WorkflowRun) {
|
||||
@@ -95,7 +122,9 @@ export function applyRunReplay(
|
||||
edges: Edge[],
|
||||
run: WorkflowRun,
|
||||
): { edges: Edge[]; nodes: Node[] } {
|
||||
const nodeMap = new Map(nodes.map((node) => [node.id, { ...node, data: { ...node.data } }]));
|
||||
const nodeMap = new Map(
|
||||
nodes.map((node) => [node.id, { ...node, data: { ...node.data } }]),
|
||||
);
|
||||
const edgeList = edges.map((edge) => ({
|
||||
...edge,
|
||||
data: { ...(edge.data || {}), status: undefined as string | undefined },
|
||||
@@ -103,18 +132,26 @@ export function applyRunReplay(
|
||||
|
||||
const logs = (run.execution_log || []) as ExecutionLogEntry[];
|
||||
const executedNodeIds: string[] = [];
|
||||
const executedNodeLogs: ExecutionLogEntry[] = [];
|
||||
|
||||
logs.forEach((log, logIndex) => {
|
||||
const replayLogs = logs.filter((log) => shouldReplayLog(log, logs));
|
||||
|
||||
replayLogs.forEach((log, logIndex) => {
|
||||
if (!log?.node_id) return;
|
||||
executedNodeIds.push(log.node_id);
|
||||
|
||||
const node = nodeMap.get(log.node_id);
|
||||
if (!node) return;
|
||||
if (executedNodeIds[executedNodeIds.length - 1] !== log.node_id) {
|
||||
executedNodeIds.push(log.node_id);
|
||||
executedNodeLogs.push(log);
|
||||
} else {
|
||||
executedNodeLogs[executedNodeLogs.length - 1] = log;
|
||||
}
|
||||
|
||||
const isWaiting = log.status === 'waiting';
|
||||
const isSuccess = log.status === 'completed';
|
||||
const nodeOutput = resolveNodeOutput(log, run);
|
||||
const nodeInputs = buildReplayInputs(logIndex, logs, run);
|
||||
const nodeInputs = buildReplayInputs(logIndex, replayLogs, run);
|
||||
|
||||
node.data = {
|
||||
...node.data,
|
||||
@@ -155,7 +192,7 @@ export function applyRunReplay(
|
||||
for (let i = 1; i < executedNodeIds.length; i += 1) {
|
||||
const sourceId = executedNodeIds[i - 1];
|
||||
const targetId = executedNodeIds[i];
|
||||
const log = logs[i];
|
||||
const log = executedNodeLogs[i];
|
||||
const edgeStatus = mapEdgeStatus(log?.status || 'completed');
|
||||
|
||||
edgeList.forEach((edge, index) => {
|
||||
|
||||
Reference in New Issue
Block a user