Files
ai-agent/web/app/dashboard/ai-workflow-runs/_components/workflow-run-audit-graph.tsx
T
2026-06-27 21:27:57 +08:00

117 lines
4.5 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { EditorRenderer, FreeLayoutEditorProvider } from "@flowgram.ai/free-layout-editor"
import { AlertTriangleIcon, CheckCircle2Icon, TimerIcon } from "lucide-react"
import { JsonTreeViewer } from "@/components/json-tree-viewer"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import type { AIWorkflowNodeRun, AIWorkflowRun } from "@/lib/api/admin"
import { cn } from "@/lib/utils"
import { useFlowgramEditorProps } from "../../ai-workflows/_components/flowgram-editor-provider"
export function WorkflowRunAuditGraph({ run }: { run: AIWorkflowRun }) {
const nodeRuns = run.nodes ?? []
const firstNodeId = nodeRuns[0]?.nodeId ?? run.definition?.nodes?.[0]?.id ?? ""
const [selectedNodeId, setSelectedNodeId] = useState(firstNodeId)
const selectedNodeRun = nodeRuns.find((item) => item.nodeId === selectedNodeId) ?? nodeRuns[0]
const executedNodeIds = useMemo(() => new Set(nodeRuns.map((item) => item.nodeId)), [nodeRuns])
const editorProps = useFlowgramEditorProps({
definition: run.definition ?? { schemaVersion: 2, nodes: [], edges: [] },
nodeSpecs: [],
readonly: true,
})
return (
<div className="grid min-h-[520px] grid-cols-[minmax(0,1fr)_320px] overflow-hidden border">
<div className="relative min-w-0">
<FreeLayoutEditorProvider {...editorProps}>
<EditorRenderer className="h-full w-full" />
</FreeLayoutEditorProvider>
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="gap-1">
<CheckCircle2Icon className="size-3" />
已执行 {executedNodeIds.size}
</Badge>
{run.errorMessage ? (
<Badge variant="destructive" className="gap-1">
<AlertTriangleIcon className="size-3" />
异常
</Badge>
) : null}
</div>
</div>
<aside className="flex min-h-0 flex-col border-l bg-background">
<div className="border-b p-3">
<div className="text-sm font-medium">节点轨迹</div>
<div className="mt-1 text-xs text-muted-foreground">点击查看输入、输出和错误信息</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-2 p-3">
{nodeRuns.map((node) => (
<button
key={node.id || node.nodeId}
type="button"
className={cn(
"w-full rounded-md border p-2 text-left text-xs hover:bg-muted",
selectedNodeId === node.nodeId ? "border-primary bg-primary/5" : "bg-background"
)}
onClick={() => setSelectedNodeId(node.nodeId)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium">{node.nodeId}</span>
<Badge variant={node.errorMessage ? "destructive" : "secondary"}>{node.statusName}</Badge>
</div>
<div className="mt-1 flex items-center gap-1 text-muted-foreground">
<TimerIcon className="size-3" />
{node.durationMs ?? 0}ms
</div>
</button>
))}
</div>
</ScrollArea>
<div className="max-h-72 overflow-auto border-t p-3">
<NodeRunPreview nodeRun={selectedNodeRun} />
</div>
</aside>
</div>
)
}
function NodeRunPreview({ nodeRun }: { nodeRun?: AIWorkflowNodeRun }) {
if (!nodeRun) {
return <div className="text-xs text-muted-foreground">暂无节点执行记录。</div>
}
return (
<div className="space-y-3 text-xs">
{nodeRun.errorMessage ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-2 text-destructive">
{nodeRun.errorMessage}
</div>
) : null}
<PreviewBlock title="输入" value={nodeRun.inputPreview} />
<PreviewBlock title="输出" value={nodeRun.outputPreview} />
</div>
)
}
function PreviewBlock({ title, value }: { title: string; value?: string }) {
return (
<div>
<div className="mb-1 font-medium">{title}</div>
{value ? <JsonTreeViewer value={parsePreview(value)} /> : <div className="text-muted-foreground"></div>}
</div>
)
}
function parsePreview(value: string) {
try {
return JSON.parse(value)
} catch {
return value
}
}