feat: improve workflow run observability
This commit is contained in:
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-24
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user