feat: improve workflow run observability

This commit is contained in:
2026-06-22 09:06:17 +08:00
parent 844e932930
commit 76ea0b9cde
4 changed files with 182 additions and 36 deletions
@@ -437,11 +437,6 @@ async def publish_agent(agent_id: str, db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=400, detail="模型不存在") raise HTTPException(status_code=400, detail="模型不存在")
if not model.is_active: if not model.is_active:
raise HTTPException(status_code=400, detail="模型已禁用") raise HTTPException(status_code=400, detail="模型已禁用")
elif not await _resolve_default_chat_model_id(db):
raise HTTPException(
status_code=400,
detail="请先在模型配置中启用一个 chat 模型,或为智能体指定模型",
)
elif agent.mode == "dialog_flow": elif agent.mode == "dialog_flow":
if not agent.workflow_id: if not agent.workflow_id:
raise HTTPException(status_code=400, detail="对话流模式需要配置工作流") raise HTTPException(status_code=400, detail="对话流模式需要配置工作流")
@@ -280,6 +280,7 @@ export interface WorkflowRunEvent {
} }
export interface ExecutionLogEntry { export interface ExecutionLogEntry {
type?: string;
node_id: string; node_id: string;
node_type: string; node_type: string;
node_label?: string; node_label?: string;
@@ -292,6 +293,7 @@ export interface ExecutionLogEntry {
timestamp?: string; timestamp?: string;
branch?: string; branch?: string;
branch_label?: string; branch_label?: string;
event?: WorkflowRunEvent;
metadata?: Record<string, any>; metadata?: Record<string, any>;
events?: WorkflowRunEvent[]; events?: WorkflowRunEvent[];
} }
@@ -33,30 +33,75 @@ function formatDuration(ms?: number) {
function stringifyBrief(value: any, maxLength = 180) { function stringifyBrief(value: any, maxLength = 180) {
if (value === undefined || value === null || value === '') return ''; if (value === undefined || value === null || value === '') return '';
const text = let text = '';
typeof value === 'string' if (typeof value === 'string') {
? value text = value;
: JSON.stringify(value, null, 2); } else {
try {
text = JSON.stringify(value, null, 2);
} catch {
text = String(value);
}
}
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text; return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
} }
function getCommunication(log: ExecutionLogEntry) { function getCommunication(log: ExecutionLogEntry) {
return log.metadata?.communication || {}; return log.metadata?.communication || log.event?.communication || {};
} }
function getMetaItems(log: ExecutionLogEntry) { function getMetaItems(log: ExecutionLogEntry) {
const metadata = log.metadata || {}; 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 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) { if (log.type || metadata.stream_event) {
items.push({ label: `分支: ${log.branch_label || log.branch}`, type: 'primary' }); items.push({
label: getEventTypeLabel(log.type || log.event?.type),
type: log.status === 'failed' ? 'danger' : 'info',
});
} }
if (metadata.subflow_name) { if (branchLabel) {
items.push({ label: `子流程: ${metadata.subflow_name}`, type: 'success' }); items.push({ label: `分支: ${branchLabel}`, type: 'primary' });
} }
if (metadata.model || metadata.model_id) { if (subflowName) {
items.push({ label: `模型: ${metadata.model || metadata.model_id}`, type: 'info' }); 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) { if (log.tokens_used) {
items.push({ label: `Token ${log.tokens_used}`, type: 'info' }); items.push({ label: `Token ${log.tokens_used}`, type: 'info' });
@@ -99,34 +144,89 @@ function getEventTypeLabel(type?: string) {
return labels[type || ''] || type || '事件'; 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) { function getEventSummary(event: WorkflowRunEvent) {
if (event.content) return event.content; const eventType = event.type || event.event?.type;
if (event.message) return event.message; const waitingConfig = event.waiting_config || event.config;
if (event.error_message) return event.error_message; if (event.error_message) return event.error_message;
if (event.error) return event.error; 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); if (event.event) return getEventSummary(event.event);
return stringifyBrief(event, 140); return stringifyBrief(event, 140);
} }
function getLogEvents(log: ExecutionLogEntry): WorkflowRunEvent[] { function getLogEvents(log: ExecutionLogEntry): WorkflowRunEvent[] {
const primaryEvent = log.event ? [log.event] : [];
const directEvents = Array.isArray(log.events) ? log.events : []; const directEvents = Array.isArray(log.events) ? log.events : [];
const metadataEvents = Array.isArray(log.metadata?.events) const metadataEvents = Array.isArray(log.metadata?.events)
? 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) { function getLogDetail(log: ExecutionLogEntry) {
const communication = getCommunication(log); const communication = getCommunication(log);
if (log.error) return log.error; if (log.error) return log.error;
if (log.event) return getEventSummary(log.event);
if (log.status === 'waiting') { 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( return stringifyBrief(
log.output log.output ||
|| log.metadata?.branch_results log.metadata?.branch_results ||
|| log.metadata?.result log.metadata?.result ||
|| communication.message, communication.message,
); );
} }
</script> </script>
@@ -147,7 +247,11 @@ function getLogDetail(log: ExecutionLogEntry) {
<button <button
type="button" type="button"
class="hover:bg-muted w-full rounded border px-3 py-2 text-left transition-colors" 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)" @click="emit('select', log.node_id)"
> >
<div class="mb-1 flex items-center justify-between gap-2"> <div class="mb-1 flex items-center justify-between gap-2">
@@ -156,7 +260,9 @@ function getLogDetail(log: ExecutionLogEntry) {
</span> </span>
<RunStatusTag :status="log.status" /> <RunStatusTag :status="log.status" />
</div> </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> <span>{{ log.node_type }}</span>
<ElTag <ElTag
v-for="item in getMetaItems(log)" v-for="item in getMetaItems(log)"
@@ -176,14 +282,14 @@ function getLogDetail(log: ExecutionLogEntry) {
{{ getLogDetail(log) }} {{ getLogDetail(log) }}
</div> </div>
<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" 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 class="text-muted-foreground mb-1 text-xs font-medium">
运行事件 运行事件
</div> </div>
<div <div
v-for="(event, eventIndex) in getLogEvents(log)" v-for="(event, eventIndex) in getVisibleEvents(log)"
:key="`${log.node_id}-event-${eventIndex}`" :key="`${log.node_id}-event-${eventIndex}`"
class="text-muted-foreground flex gap-2 text-xs" class="text-muted-foreground flex gap-2 text-xs"
> >
@@ -194,6 +300,12 @@ function getLogDetail(log: ExecutionLogEntry) {
{{ getEventSummary(event) }} {{ getEventSummary(event) }}
</span> </span>
</div> </div>
<div
v-if="getHiddenEventCount(log)"
class="text-muted-foreground mt-1 text-xs"
>
还有 {{ getHiddenEventCount(log) }} 条事件
</div>
</div> </div>
</button> </button>
</ElTimelineItem> </ElTimelineItem>
@@ -1,6 +1,9 @@
import type { Edge, Node } from '@vue-flow/core'; 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( function mapLogStatus(
status: string, status: string,
@@ -8,6 +11,8 @@ function mapLogStatus(
if (status === 'completed') return 'success'; if (status === 'completed') return 'success';
if (status === 'failed') return 'failed'; if (status === 'failed') return 'failed';
if (status === 'waiting') return 'running'; if (status === 'waiting') return 'running';
if (status === 'running') return 'running';
if (status === 'pending') return 'pending';
return 'success'; return 'success';
} }
@@ -17,7 +22,29 @@ function mapEdgeStatus(
if (status === 'completed') return 'completed'; if (status === 'completed') return 'completed';
if (status === 'failed') return 'failed'; if (status === 'failed') return 'failed';
if (status === 'waiting') return 'running'; 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) { function resolveNodeOutput(log: ExecutionLogEntry, run: WorkflowRun) {
@@ -95,7 +122,9 @@ export function applyRunReplay(
edges: Edge[], edges: Edge[],
run: WorkflowRun, run: WorkflowRun,
): { edges: Edge[]; nodes: Node[] } { ): { 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) => ({ const edgeList = edges.map((edge) => ({
...edge, ...edge,
data: { ...(edge.data || {}), status: undefined as string | undefined }, data: { ...(edge.data || {}), status: undefined as string | undefined },
@@ -103,18 +132,26 @@ export function applyRunReplay(
const logs = (run.execution_log || []) as ExecutionLogEntry[]; const logs = (run.execution_log || []) as ExecutionLogEntry[];
const executedNodeIds: string[] = []; 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; if (!log?.node_id) return;
executedNodeIds.push(log.node_id);
const node = nodeMap.get(log.node_id); const node = nodeMap.get(log.node_id);
if (!node) return; 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 isWaiting = log.status === 'waiting';
const isSuccess = log.status === 'completed'; const isSuccess = log.status === 'completed';
const nodeOutput = resolveNodeOutput(log, run); const nodeOutput = resolveNodeOutput(log, run);
const nodeInputs = buildReplayInputs(logIndex, logs, run); const nodeInputs = buildReplayInputs(logIndex, replayLogs, run);
node.data = { node.data = {
...node.data, ...node.data,
@@ -155,7 +192,7 @@ export function applyRunReplay(
for (let i = 1; i < executedNodeIds.length; i += 1) { for (let i = 1; i < executedNodeIds.length; i += 1) {
const sourceId = executedNodeIds[i - 1]; const sourceId = executedNodeIds[i - 1];
const targetId = executedNodeIds[i]; const targetId = executedNodeIds[i];
const log = logs[i]; const log = executedNodeLogs[i];
const edgeStatus = mapEdgeStatus(log?.status || 'completed'); const edgeStatus = mapEdgeStatus(log?.status || 'completed');
edgeList.forEach((edge, index) => { edgeList.forEach((edge, index) => {