feat: add workflow run audit graph component and enhance workflow run details

This commit is contained in:
mlogclub
2026-06-25 21:42:14 +08:00
parent b806a2208b
commit d11080e14b
9 changed files with 501 additions and 1 deletions
+3
View File
@@ -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
}
@@ -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 {
@@ -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"`
}
+12 -1
View File
@@ -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)
}
@@ -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<string, unknown> & {
nodeId: string
nodeType: string
name: string
executed: boolean
statusName?: string
durationMs?: number
errorMessage?: string
selected?: boolean
}
type AuditNode = Node<AuditNodeData>
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<string, AIWorkflowNodeRun>()
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<AuditNode[]>(() => {
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<AuditEdge[]>(() => {
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 (
<div className="rounded-md border border-dashed bg-muted/20 px-3 py-8 text-center text-sm text-muted-foreground">
</div>
)
}
return (
<div className="grid min-h-[560px] overflow-hidden rounded-md border bg-background lg:grid-cols-[minmax(0,1fr)_390px]">
<div className="h-[560px] min-w-0 border-b bg-muted/10 lg:border-b-0 lg:border-r">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={auditNodeTypes}
edgeTypes={auditEdgeTypes}
defaultEdgeOptions={defaultEdgeOptions}
fitView
fitViewOptions={fitViewOptions}
nodesDraggable={false}
nodesConnectable={false}
edgesFocusable={false}
elementsSelectable
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
>
<Background />
<Controls showInteractive={false} />
</ReactFlow>
</div>
<AuditSidePanel
definitionNode={selectedDefinitionNode}
nodeRun={selectedNodeRun}
/>
</div>
)
}
function buildActiveEdgeIds(definition: AIWorkflowDefinition | undefined, nodeRuns: AIWorkflowNodeRun[]) {
const active = new Set<string>()
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<AuditEdge>) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: 0.18,
})
const executed = Boolean(data?.executed)
return (
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
className={cn(
"transition-all",
executed ? "!stroke-primary !stroke-[2.6px]" : "!stroke-muted-foreground/25 !stroke-[1.4px]"
)}
/>
)
}
function AuditCanvasNode({ data }: NodeProps<AuditNode>) {
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 (
<div className={cn("relative flex size-32 items-center justify-center opacity-60", executed && "opacity-100")}>
<Handle type="target" position={Position.Left} className="!size-2.5 !border-0 !bg-muted-foreground/50" />
<div
className={cn(
"absolute inset-4 rotate-45 rounded-lg border shadow-sm transition-all",
toneClass,
selected && "ring-4 ring-primary/15"
)}
/>
<div className="relative z-10 flex max-w-22 flex-col items-center text-center">
<GitBranchIcon className="mb-1 size-4" />
<div className="line-clamp-2 text-xs font-medium leading-tight">{data.name}</div>
<div className="mt-1 text-[10px] opacity-75">{data.statusName || "未执行"}</div>
</div>
<Handle type="source" position={Position.Right} className="!size-2.5 !border-0 !bg-muted-foreground/50" />
</div>
)
}
return (
<div
className={cn(
"w-52 overflow-hidden rounded-md border bg-background shadow-sm opacity-55 transition-all",
executed && "opacity-100",
selected && "ring-4 ring-primary/15"
)}
>
<Handle type="target" position={Position.Left} className="!size-2.5 !border-0 !bg-muted-foreground/50" />
<div className={cn("border-b px-3 py-2", toneClass)}>
<div className="flex items-center gap-2">
{failed ? <AlertTriangleIcon className="size-4 shrink-0" /> : <CheckCircle2Icon className="size-4 shrink-0" />}
<div className="min-w-0">
<div className="truncate text-sm font-medium">{data.name}</div>
<div className="truncate text-[11px] opacity-75">{data.nodeType}</div>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 px-3 py-2 text-xs text-muted-foreground">
<span>{data.statusName || "未执行"}</span>
{executed ? (
<span className="inline-flex items-center gap-1">
<TimerIcon className="size-3" />
{data.durationMs ?? 0} ms
</span>
) : null}
</div>
<Handle type="source" position={Position.Right} className="!size-2.5 !border-0 !bg-muted-foreground/50" />
</div>
)
}
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 (
<ScrollArea className="h-[560px]">
<div className="space-y-4 p-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<InfoIcon className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">{definitionNode?.name || nodeRun?.nodeId || "节点详情"}</h3>
</div>
<div className="text-xs text-muted-foreground">
{definitionNode?.id || nodeRun?.nodeId || "-"} · {definitionNode?.type || nodeRun?.nodeType || "unknown"}
</div>
</div>
{nodeRun ? (
<div className="grid grid-cols-2 gap-2 text-xs">
<AuditMeta label="状态" value={nodeRun.statusName || String(nodeRun.status)} />
<AuditMeta label="耗时" value={`${nodeRun.durationMs || 0} ms`} />
<AuditMeta label="开始" value={nodeRun.startedAt || "-"} />
<AuditMeta label="结束" value={nodeRun.endedAt || "-"} />
</div>
) : (
<div className="rounded-md border border-dashed bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
</div>
)}
{nodeRun?.errorMessage ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{nodeRun.errorMessage}
</div>
) : null}
{branchDecision ? <BranchDecisionBlock decision={branchDecision} /> : null}
<PreviewBlock title="输入" raw={nodeRun?.inputPreview ?? ""} value={inputValue} />
<PreviewBlock title="输出" raw={nodeRun?.outputPreview ?? ""} value={outputValue} />
</div>
</ScrollArea>
)
}
function AuditMeta({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-md border bg-muted/20 px-2 py-1.5">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="truncate font-medium">{value}</div>
</div>
)
}
function BranchDecisionBlock({ decision }: { decision: BranchDecision }) {
return (
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-2">
<div className="text-xs font-medium"></div>
<Badge variant="outline">{decision.selectedBranchName || decision.selectedBranchId || "default"}</Badge>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div>{decision.selectedTargetNodeId || "-"}</div>
<div>{decision.reason || "-"}</div>
</div>
{decision.evaluations?.length ? (
<div className="space-y-2 pt-1">
{decision.evaluations.map((item, index) => (
<div key={`${item.edgeId || item.branchId || index}`} className="rounded-md border bg-background px-2 py-1.5 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{item.branchName || item.branchId || item.edgeId || `条件 ${index + 1}`}</span>
<Badge variant={item.matched ? "default" : "secondary"}>{item.matched ? "命中" : "未命中"}</Badge>
</div>
<div className="mt-1 break-all text-muted-foreground">
{item.sourceNodeId}.{item.sourceField} {item.operator} {formatUnknown(item.rightValue)}
</div>
<div className="mt-1 break-all text-muted-foreground">
{formatUnknown(item.leftValue)}
</div>
</div>
))}
</div>
) : null}
</div>
)
}
function PreviewBlock({ title, raw, value }: { title: string; raw: string; value: unknown }) {
return (
<div className="min-w-0">
<div className="mb-1 text-xs font-medium text-muted-foreground">{title}</div>
{value !== null ? (
<JsonTreeViewer value={value} collapsed={2} />
) : raw.trim() ? (
<pre className="max-h-72 overflow-auto rounded-md border bg-muted/20 p-3 text-xs whitespace-pre-wrap break-all">
{raw}
</pre>
) : (
<div className="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">-</div>
)}
</div>
)
}
function extractBranchDecision(value: unknown): BranchDecision | null {
if (!value || typeof value !== "object") {
return null
}
const record = value as Record<string, unknown>
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)
}
}
@@ -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, string | number>) => string
@@ -409,7 +410,9 @@ function WorkflowRunDetailDialog({
{run.errorMessage}
</div>
) : null}
<WorkflowRunAuditGraph run={run} />
<div className="space-y-3">
<div className="text-sm font-medium">{t("workflowRun.nodeDetails")}</div>
{(run.nodes ?? []).map((node, index) => (
<WorkflowNodeRunBlock key={node.id || node.nodeId || index} node={node} t={t} />
))}
+1
View File
@@ -544,6 +544,7 @@ export type AIWorkflowRun = {
errorMessage: string
createdAt: string
updatedAt: string
definition?: AIWorkflowDefinition
nodes?: AIWorkflowNodeRun[]
}
+1
View File
@@ -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",
+1
View File
@@ -2289,6 +2289,7 @@
"close": "关闭",
"loadingDetail": "加载流程执行详情中",
"interruptNode": "中断节点",
"nodeDetails": "节点运行明细",
"emptyNodes": "暂无节点记录",
"notFound": "未找到流程执行记录",
"input": "输入",