From d11080e14bab2c15d9d29aca9301cbf5fb9315d8 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Thu, 25 Jun 2026 21:42:14 +0800 Subject: [PATCH] feat: add workflow run audit graph component and enhance workflow run details --- internal/builders/ai_workflow_builder.go | 3 + internal/builders/ai_workflow_builder_test.go | 36 ++ .../pkg/dto/response/ai_workflow_response.go | 1 + internal/services/ai_workflow_service_test.go | 13 +- .../_components/workflow-run-audit-graph.tsx | 443 ++++++++++++++++++ web/app/dashboard/ai-workflow-runs/page.tsx | 3 + web/lib/api/admin.ts | 1 + web/messages/en-US.json | 1 + web/messages/zh-CN.json | 1 + 9 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx diff --git a/internal/builders/ai_workflow_builder.go b/internal/builders/ai_workflow_builder.go index ac8035d..5488626 100644 --- a/internal/builders/ai_workflow_builder.go +++ b/internal/builders/ai_workflow_builder.go @@ -134,6 +134,9 @@ func BuildAIWorkflowRunDetail(item *models.AIWorkflowRun, nodes []models.AIWorkf func BuildAIWorkflowRunDetailWithContext(item *models.AIWorkflowRun, nodes []models.AIWorkflowNodeRun, workflow *models.AIWorkflow, version *models.AIWorkflowVersion, agent *models.AIAgent) response.AIWorkflowRunResponse { ret := BuildAIWorkflowRunWithContext(item, workflow, version, agent) + if version != nil { + ret.Definition = parseWorkflowDefinition(version.Definition) + } ret.Nodes = BuildAIWorkflowNodeRunList(nodes) return ret } diff --git a/internal/builders/ai_workflow_builder_test.go b/internal/builders/ai_workflow_builder_test.go index 447aa26..dc99fb4 100644 --- a/internal/builders/ai_workflow_builder_test.go +++ b/internal/builders/ai_workflow_builder_test.go @@ -1,9 +1,11 @@ package builders import ( + "encoding/json" "testing" "time" + "agent-desk/internal/ai/workflow/dsl" workflowregistry "agent-desk/internal/ai/workflow/registry" "agent-desk/internal/models" ) @@ -65,6 +67,40 @@ func TestBuildAIWorkflowRunIncludesAuditDisplayFields(t *testing.T) { } } +func TestBuildAIWorkflowRunDetailIncludesPublishedDefinitionSnapshot(t *testing.T) { + definition := dsl.Definition{ + SchemaVersion: 1, + EntryNodeID: "start_1", + Nodes: []dsl.Node{ + {ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "开始"}, + {ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "运行时回复"}, + }, + Edges: []dsl.Edge{{ID: "edge_start_reply", Source: "start_1", Target: "reply_1"}}, + } + buf, err := json.Marshal(definition) + if err != nil { + t.Fatalf("marshal definition: %v", err) + } + + resp := BuildAIWorkflowRunDetailWithContext( + &models.AIWorkflowRun{ID: 9, WorkflowVersionID: 22, Status: 1, StartedAt: time.Now()}, + nil, + &models.AIWorkflow{Name: "当前 Workflow 草稿不应参与审计图"}, + &models.AIWorkflowVersion{Version: 3, Definition: string(buf)}, + &models.AIAgent{Name: "售后 Agent"}, + ) + + if resp.Definition.EntryNodeID != "start_1" { + t.Fatalf("expected run detail definition from published version, got %#v", resp.Definition) + } + if len(resp.Definition.Nodes) != 2 || resp.Definition.Nodes[1].Name != "运行时回复" { + t.Fatalf("expected published definition nodes, got %#v", resp.Definition.Nodes) + } + if len(resp.Definition.Edges) != 1 || resp.Definition.Edges[0].ID != "edge_start_reply" { + t.Fatalf("expected published definition edges, got %#v", resp.Definition.Edges) + } +} + func hasResponseVariable(items []workflowregistry.VariableSpec, name string) bool { for _, item := range items { if item.Name == name { diff --git a/internal/pkg/dto/response/ai_workflow_response.go b/internal/pkg/dto/response/ai_workflow_response.go index 1826e02..083070f 100644 --- a/internal/pkg/dto/response/ai_workflow_response.go +++ b/internal/pkg/dto/response/ai_workflow_response.go @@ -74,6 +74,7 @@ type AIWorkflowRunResponse struct { ErrorMessage string `json:"errorMessage"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` + Definition dsl.Definition `json:"definition"` Nodes []AIWorkflowNodeRunResponse `json:"nodes,omitempty"` } diff --git a/internal/services/ai_workflow_service_test.go b/internal/services/ai_workflow_service_test.go index 9dad78f..dfcd820 100644 --- a/internal/services/ai_workflow_service_test.go +++ b/internal/services/ai_workflow_service_test.go @@ -163,7 +163,18 @@ func TestAIWorkflowServiceRunListAndDetail(t *testing.T) { if err := sqls.DB().Create(&workflow).Error; err != nil { t.Fatalf("create workflow: %v", err) } - version := models.AIWorkflowVersion{WorkflowID: workflow.ID, Version: 7, Status: enums.StatusOk} + versionDefinition := validAIWorkflowDefinition() + versionDefinition.Nodes[1].Name = "运行时回复" + versionDefinitionJSON, err := json.Marshal(versionDefinition) + if err != nil { + t.Fatalf("marshal version definition: %v", err) + } + version := models.AIWorkflowVersion{ + WorkflowID: workflow.ID, + Version: 7, + Status: enums.StatusOk, + Definition: string(versionDefinitionJSON), + } if err := sqls.DB().Create(&version).Error; err != nil { t.Fatalf("create workflow version: %v", err) } diff --git a/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx b/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx new file mode 100644 index 0000000..2a8d203 --- /dev/null +++ b/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx @@ -0,0 +1,443 @@ +"use client" + +import "@xyflow/react/dist/style.css" + +import { + Background, + BaseEdge, + Controls, + Handle, + MarkerType, + Position, + ReactFlow, + getBezierPath, + type Edge, + type EdgeProps, + type Node, + type NodeProps, +} from "@xyflow/react" +import { + AlertTriangleIcon, + CheckCircle2Icon, + GitBranchIcon, + InfoIcon, + TimerIcon, +} from "lucide-react" +import { useMemo, useState } from "react" + +import { JsonTreeViewer } from "@/components/json-tree-viewer" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { cn } from "@/lib/utils" +import type { + AIWorkflowDefinition, + AIWorkflowNodeRun, + AIWorkflowRun, +} from "@/lib/api/admin" + +type AuditNodeData = Record & { + nodeId: string + nodeType: string + name: string + executed: boolean + statusName?: string + durationMs?: number + errorMessage?: string + selected?: boolean +} + +type AuditNode = Node +type AuditEdge = Edge<{ executed?: boolean }> + +type BranchDecision = { + selectedEdgeId?: string + selectedBranchId?: string + selectedBranchName?: string + selectedTargetNodeId?: string + reason?: string + evaluations?: BranchEvaluation[] +} + +type BranchEvaluation = { + edgeId?: string + branchId?: string + branchName?: string + targetNodeId?: string + sourceNodeId?: string + sourceField?: string + operator?: string + leftValue?: unknown + rightValue?: unknown + matched?: boolean +} + +const auditNodeTypes = { + auditNode: AuditCanvasNode, +} + +const auditEdgeTypes = { + auditEdge: AuditCanvasEdge, +} + +const fitViewOptions = { + padding: 0.18, + minZoom: 0.45, + maxZoom: 1, +} + +const defaultEdgeOptions = { + type: "auditEdge", + markerEnd: { + type: MarkerType.ArrowClosed, + }, +} + +export function WorkflowRunAuditGraph({ run }: { run: AIWorkflowRun }) { + const nodeRuns = run.nodes ?? [] + const nodeRunByNodeId = useMemo(() => { + const map = new Map() + for (const node of nodeRuns) { + map.set(node.nodeId, node) + } + return map + }, [nodeRuns]) + const activeEdgeIds = useMemo(() => buildActiveEdgeIds(run.definition, nodeRuns), [run.definition, nodeRuns]) + const firstExecutedNodeId = nodeRuns[0]?.nodeId ?? run.definition?.entryNodeId ?? "" + const [selectedNodeId, setSelectedNodeId] = useState(firstExecutedNodeId) + + const nodes = useMemo(() => { + return (run.definition?.nodes ?? []).map((node) => { + const nodeRun = nodeRunByNodeId.get(node.id) + return { + id: node.id, + type: "auditNode", + position: node.position ?? { x: 0, y: 0 }, + data: { + nodeId: node.id, + nodeType: node.type, + name: node.name || node.id, + executed: Boolean(nodeRun), + statusName: nodeRun?.statusName, + durationMs: nodeRun?.durationMs, + errorMessage: nodeRun?.errorMessage, + selected: selectedNodeId === node.id, + }, + } + }) + }, [nodeRunByNodeId, run.definition?.nodes, selectedNodeId]) + + const edges = useMemo(() => { + return (run.definition?.edges ?? []).map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + type: "auditEdge", + data: { + executed: activeEdgeIds.has(edge.id), + }, + })) + }, [activeEdgeIds, run.definition?.edges]) + + const selectedNodeRun = selectedNodeId ? nodeRunByNodeId.get(selectedNodeId) : undefined + const selectedDefinitionNode = run.definition?.nodes?.find((node) => node.id === selectedNodeId) + + if (!run.definition?.nodes?.length) { + return ( +
+ 流程定义快照缺失,仍可查看下方节点运行明细。 +
+ ) + } + + return ( +
+
+ setSelectedNodeId(node.id)} + > + + + +
+ +
+ ) +} + +function buildActiveEdgeIds(definition: AIWorkflowDefinition | undefined, nodeRuns: AIWorkflowNodeRun[]) { + const active = new Set() + const edges = definition?.edges ?? [] + for (let i = 0; i < nodeRuns.length - 1; i += 1) { + const source = nodeRuns[i]?.nodeId + const target = nodeRuns[i + 1]?.nodeId + const edge = edges.find((item) => item.source === source && item.target === target) + if (edge) { + active.add(edge.id) + } + } + return active +} + +function AuditCanvasEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerEnd, + data, +}: EdgeProps) { + const [edgePath] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + curvature: 0.18, + }) + const executed = Boolean(data?.executed) + return ( + + ) +} + +function AuditCanvasNode({ data }: NodeProps) { + const executed = Boolean(data.executed) + const failed = data.statusName === "failed" || Boolean(data.errorMessage) + const interrupted = data.statusName === "interrupted" + const selected = Boolean(data.selected) + const condition = data.nodeType === "condition" + const toneClass = failed + ? "border-destructive bg-destructive/5 text-destructive" + : interrupted + ? "border-amber-500 bg-amber-500/10 text-amber-700" + : executed + ? "border-emerald-500 bg-emerald-500/10 text-emerald-700" + : "border-border bg-muted/40 text-muted-foreground" + + if (condition) { + return ( +
+ +
+
+ +
{data.name}
+
{data.statusName || "未执行"}
+
+ +
+ ) + } + + return ( +
+ +
+
+ {failed ? : } +
+
{data.name}
+
{data.nodeType}
+
+
+
+
+ {data.statusName || "未执行"} + {executed ? ( + + + {data.durationMs ?? 0} ms + + ) : null} +
+ +
+ ) +} + +function AuditSidePanel({ + definitionNode, + nodeRun, +}: { + definitionNode?: AIWorkflowDefinition["nodes"][number] + nodeRun?: AIWorkflowNodeRun +}) { + const inputValue = safeParseJSON(nodeRun?.inputPreview ?? "") + const outputValue = safeParseJSON(nodeRun?.outputPreview ?? "") + const branchDecision = extractBranchDecision(outputValue) + + return ( + +
+
+
+ +

{definitionNode?.name || nodeRun?.nodeId || "节点详情"}

+
+
+ {definitionNode?.id || nodeRun?.nodeId || "-"} · {definitionNode?.type || nodeRun?.nodeType || "unknown"} +
+
+ + {nodeRun ? ( +
+ + + + +
+ ) : ( +
+ 本次运行没有执行该节点。 +
+ )} + + {nodeRun?.errorMessage ? ( +
+ {nodeRun.errorMessage} +
+ ) : null} + + {branchDecision ? : null} + + + +
+
+ ) +} + +function AuditMeta({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function BranchDecisionBlock({ decision }: { decision: BranchDecision }) { + return ( +
+
+
分支决策
+ {decision.selectedBranchName || decision.selectedBranchId || "default"} +
+
+
目标节点:{decision.selectedTargetNodeId || "-"}
+
原因:{decision.reason || "-"}
+
+ {decision.evaluations?.length ? ( +
+ {decision.evaluations.map((item, index) => ( +
+
+ {item.branchName || item.branchId || item.edgeId || `条件 ${index + 1}`} + {item.matched ? "命中" : "未命中"} +
+
+ {item.sourceNodeId}.{item.sourceField} {item.operator} {formatUnknown(item.rightValue)} +
+
+ 实际值:{formatUnknown(item.leftValue)} +
+
+ ))} +
+ ) : null} +
+ ) +} + +function PreviewBlock({ title, raw, value }: { title: string; raw: string; value: unknown }) { + return ( +
+
{title}
+ {value !== null ? ( + + ) : raw.trim() ? ( +
+          {raw}
+        
+ ) : ( +
-
+ )} +
+ ) +} + +function extractBranchDecision(value: unknown): BranchDecision | null { + if (!value || typeof value !== "object") { + return null + } + const record = value as Record + const decision = record.branchDecision + if (!decision || typeof decision !== "object") { + return null + } + return decision as BranchDecision +} + +function safeParseJSON(raw: string): unknown | null { + const trimmed = raw.trim() + if (!trimmed) { + return null + } + try { + return JSON.parse(trimmed) + } catch { + return null + } +} + +function formatUnknown(value: unknown) { + if (typeof value === "string") { + return value + } + if (value === null || value === undefined) { + return "-" + } + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} diff --git a/web/app/dashboard/ai-workflow-runs/page.tsx b/web/app/dashboard/ai-workflow-runs/page.tsx index 268c6bb..77bd30c 100644 --- a/web/app/dashboard/ai-workflow-runs/page.tsx +++ b/web/app/dashboard/ai-workflow-runs/page.tsx @@ -30,6 +30,7 @@ import { } from "@/lib/api/admin" import { formatDateTime } from "@/lib/utils" import { useI18n } from "@/i18n/provider" +import { WorkflowRunAuditGraph } from "./_components/workflow-run-audit-graph" type TFunction = (key: string, values?: Record) => string @@ -409,7 +410,9 @@ function WorkflowRunDetailDialog({ {run.errorMessage}
) : null} +
+
{t("workflowRun.nodeDetails")}
{(run.nodes ?? []).map((node, index) => ( ))} diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 6eb3673..ba6269d 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -544,6 +544,7 @@ export type AIWorkflowRun = { errorMessage: string createdAt: string updatedAt: string + definition?: AIWorkflowDefinition nodes?: AIWorkflowNodeRun[] } diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 19bcd47..7d0e684 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -2289,6 +2289,7 @@ "close": "Close", "loadingDetail": "Loading workflow run detail", "interruptNode": "Interrupt Node", + "nodeDetails": "Node Run Details", "emptyNodes": "No node records", "notFound": "Workflow run not found", "input": "Input", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 7f52cdd..8b619d3 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -2289,6 +2289,7 @@ "close": "关闭", "loadingDetail": "加载流程执行详情中", "interruptNode": "中断节点", + "nodeDetails": "节点运行明细", "emptyNodes": "暂无节点记录", "notFound": "未找到流程执行记录", "input": "输入",