feat: add workflow run audit graph component and enhance workflow run details
This commit is contained in:
@@ -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 {
|
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)
|
ret := BuildAIWorkflowRunWithContext(item, workflow, version, agent)
|
||||||
|
if version != nil {
|
||||||
|
ret.Definition = parseWorkflowDefinition(version.Definition)
|
||||||
|
}
|
||||||
ret.Nodes = BuildAIWorkflowNodeRunList(nodes)
|
ret.Nodes = BuildAIWorkflowNodeRunList(nodes)
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package builders
|
package builders
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"agent-desk/internal/ai/workflow/dsl"
|
||||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||||
"agent-desk/internal/models"
|
"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 {
|
func hasResponseVariable(items []workflowregistry.VariableSpec, name string) bool {
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item.Name == name {
|
if item.Name == name {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ type AIWorkflowRunResponse struct {
|
|||||||
ErrorMessage string `json:"errorMessage"`
|
ErrorMessage string `json:"errorMessage"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
Definition dsl.Definition `json:"definition"`
|
||||||
Nodes []AIWorkflowNodeRunResponse `json:"nodes,omitempty"`
|
Nodes []AIWorkflowNodeRunResponse `json:"nodes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,7 +163,18 @@ func TestAIWorkflowServiceRunListAndDetail(t *testing.T) {
|
|||||||
if err := sqls.DB().Create(&workflow).Error; err != nil {
|
if err := sqls.DB().Create(&workflow).Error; err != nil {
|
||||||
t.Fatalf("create workflow: %v", err)
|
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 {
|
if err := sqls.DB().Create(&version).Error; err != nil {
|
||||||
t.Fatalf("create workflow version: %v", err)
|
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"
|
} from "@/lib/api/admin"
|
||||||
import { formatDateTime } from "@/lib/utils"
|
import { formatDateTime } from "@/lib/utils"
|
||||||
import { useI18n } from "@/i18n/provider"
|
import { useI18n } from "@/i18n/provider"
|
||||||
|
import { WorkflowRunAuditGraph } from "./_components/workflow-run-audit-graph"
|
||||||
|
|
||||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||||
|
|
||||||
@@ -409,7 +410,9 @@ function WorkflowRunDetailDialog({
|
|||||||
{run.errorMessage}
|
{run.errorMessage}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<WorkflowRunAuditGraph run={run} />
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm font-medium">{t("workflowRun.nodeDetails")}</div>
|
||||||
{(run.nodes ?? []).map((node, index) => (
|
{(run.nodes ?? []).map((node, index) => (
|
||||||
<WorkflowNodeRunBlock key={node.id || node.nodeId || index} node={node} t={t} />
|
<WorkflowNodeRunBlock key={node.id || node.nodeId || index} node={node} t={t} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -544,6 +544,7 @@ export type AIWorkflowRun = {
|
|||||||
errorMessage: string
|
errorMessage: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
definition?: AIWorkflowDefinition
|
||||||
nodes?: AIWorkflowNodeRun[]
|
nodes?: AIWorkflowNodeRun[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2289,6 +2289,7 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"loadingDetail": "Loading workflow run detail",
|
"loadingDetail": "Loading workflow run detail",
|
||||||
"interruptNode": "Interrupt Node",
|
"interruptNode": "Interrupt Node",
|
||||||
|
"nodeDetails": "Node Run Details",
|
||||||
"emptyNodes": "No node records",
|
"emptyNodes": "No node records",
|
||||||
"notFound": "Workflow run not found",
|
"notFound": "Workflow run not found",
|
||||||
"input": "Input",
|
"input": "Input",
|
||||||
|
|||||||
@@ -2289,6 +2289,7 @@
|
|||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"loadingDetail": "加载流程执行详情中",
|
"loadingDetail": "加载流程执行详情中",
|
||||||
"interruptNode": "中断节点",
|
"interruptNode": "中断节点",
|
||||||
|
"nodeDetails": "节点运行明细",
|
||||||
"emptyNodes": "暂无节点记录",
|
"emptyNodes": "暂无节点记录",
|
||||||
"notFound": "未找到流程执行记录",
|
"notFound": "未找到流程执行记录",
|
||||||
"input": "输入",
|
"input": "输入",
|
||||||
|
|||||||
Reference in New Issue
Block a user