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="模型不存在")
if not model.is_active:
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":
if not agent.workflow_id:
raise HTTPException(status_code=400, detail="对话流模式需要配置工作流")
@@ -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[];
}
@@ -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) => {