xyflow change to flowgraam

This commit is contained in:
mlogclub
2026-06-27 21:27:57 +08:00
parent 228ad1902f
commit 689acc997c
32 changed files with 3154 additions and 5004 deletions
@@ -70,6 +70,7 @@ import {
IMConversationServiceMode,
Status,
} from "@/lib/generated/enums"
import { useWorkflowDefinitionHistory } from "../../ai-workflows/_components/use-workflow-definition-history"
import { WorkflowEditor } from "../../ai-workflows/_components/workflow-editor"
type DirectToolItem = CreateAIAgentPayload["directTools"][number]
@@ -88,25 +89,22 @@ type SectionKey =
| "workflow"
const fallbackDefinition: AIWorkflowDefinition = {
schemaVersion: 1,
entryNodeId: "start_1",
schemaVersion: 2,
nodes: [
{
id: "start_1",
type: "start",
name: "开始",
position: { x: 0, y: 80 },
config: {},
meta: { position: { x: 0, y: 80 } },
data: { title: "开始", config: {}, inputsValues: {} },
},
{
id: "end_1",
type: "end",
name: "结束",
position: { x: 260, y: 80 },
config: {},
meta: { position: { x: 260, y: 80 } },
data: { title: "结束", config: {}, inputsValues: {} },
},
],
edges: [{ id: "edge_start_end", source: "start_1", target: "end_1" }],
edges: [{ sourceNodeID: "start_1", targetNodeID: "end_1", sourcePortID: "edge_start_end" }],
}
function toText(value: string | number | undefined | null) {
@@ -156,8 +154,16 @@ export function AIAgentConfigWorkbench({
const [selectedSkillIds, setSelectedSkillIds] = useState<number[]>([])
const [directTools, setDirectTools] = useState<DirectToolItem[]>([])
const [definition, setDefinition] = useState<AIWorkflowDefinition>(fallbackDefinition)
const [workflowEditorKey, setWorkflowEditorKey] = useState(0)
const {
definition,
revision: workflowRevision,
canUndo: canUndoWorkflow,
canRedo: canRedoWorkflow,
replace: replaceWorkflowHistory,
update: updateWorkflowDefinition,
undo: undoWorkflowDefinition,
redo: redoWorkflowDefinition,
} = useWorkflowDefinitionHistory(fallbackDefinition)
const [aiConfigs, setAIConfigs] = useState<AIConfig[]>([])
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([])
@@ -175,9 +181,8 @@ export function AIAgentConfigWorkbench({
}, [agentId])
const replaceWorkflowDefinition = useCallback((nextDefinition: AIWorkflowDefinition) => {
setDefinition(nextDefinition)
setWorkflowEditorKey((current) => current + 1)
}, [])
replaceWorkflowHistory(nextDefinition)
}, [replaceWorkflowHistory])
const loadData = useCallback(async () => {
setLoading(true)
@@ -811,10 +816,14 @@ export function AIAgentConfigWorkbench({
{activeSection === "workflow" ? (
<WorkflowEditor
key={workflowEditorKey}
key={workflowRevision}
definition={definition}
nodeSpecs={nodeSpecs}
onDefinitionChange={setDefinition}
onDefinitionChange={updateWorkflowDefinition}
onUndo={undoWorkflowDefinition}
undoDisabled={!canUndoWorkflow || savingWorkflow || loading}
onRedo={redoWorkflowDefinition}
redoDisabled={!canRedoWorkflow || savingWorkflow || loading}
onRestoreDefault={restoreDefaultWorkflow}
restoreDefaultDisabled={savingWorkflow || loading}
onValidate={validateWorkflowDraft}
@@ -1,455 +1,116 @@
"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 { 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 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.12,
minZoom: 0.32,
maxZoom: 1,
}
const defaultEdgeOptions = {
type: "auditEdge",
markerEnd: {
type: MarkerType.ArrowClosed,
},
}
const auditLayoutScale = {
x: 1.35,
y: 1.15,
}
import { useFlowgramEditorProps } from "../../ai-workflows/_components/flowgram-editor-provider"
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: scaleAuditPosition(node.position),
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-[600px] overflow-hidden rounded-md border bg-background lg:grid-cols-[minmax(0,1fr)_390px]">
<div className="h-[600px] 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 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,
})
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-24 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-3 rotate-45 rounded-lg border shadow-sm transition-all",
toneClass,
selected && "ring-4 ring-primary/15"
)}
/>
<div className="relative z-10 flex max-w-18 flex-col items-center text-center">
<GitBranchIcon className="mb-0.5 size-3.5" />
<div className="line-clamp-2 text-[11px] font-medium leading-tight">{data.name}</div>
<div className="mt-1 text-[10px] opacity-75">{data.statusName || "未执行"}</div>
<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>
<Handle type="source" position={Position.Right} className="!size-2.5 !border-0 !bg-muted-foreground/50" />
</div>
)
}
return (
<div
className={cn(
"w-40 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-2.5 py-1.5", toneClass)}>
<div className="flex items-center gap-1.5">
{failed ? <AlertTriangleIcon className="size-3.5 shrink-0" /> : <CheckCircle2Icon className="size-3.5 shrink-0" />}
<div className="min-w-0">
<div className="truncate text-xs font-medium">{data.name}</div>
<div className="truncate text-[11px] opacity-75">{data.nodeType}</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>
</div>
<div className="flex items-center justify-between gap-2 px-2.5 py-1.5 text-[11px] 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" />
</aside>
</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-[600px]">
<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 scaleAuditPosition(position: AIWorkflowDefinition["nodes"][number]["position"] | undefined) {
return {
x: Math.round((position?.x ?? 0) * auditLayoutScale.x),
y: Math.round((position?.y ?? 0) * auditLayoutScale.y),
function NodeRunPreview({ nodeRun }: { nodeRun?: AIWorkflowNodeRun }) {
if (!nodeRun) {
return <div className="text-xs text-muted-foreground"></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 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, raw, value }: { title: string; raw: string; value: unknown }) {
function PreviewBlock({ title, value }: { title: string; value?: string }) {
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>
<div className="mb-1 font-medium">{title}</div>
{value ? <JsonTreeViewer value={parsePreview(value)} /> : <div className="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
}
function parsePreview(value: string) {
try {
return JSON.parse(trimmed)
return JSON.parse(value)
} 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)
}
}
@@ -0,0 +1,68 @@
"use client"
import { useMemo } from "react"
import {
type FreeLayoutProps,
type WorkflowJSON,
} from "@flowgram.ai/free-layout-editor"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
import { FlowgramNodeRenderer } from "./flowgram-node-renderer"
import { buildFlowgramNodeRegistries } from "./flowgram-node-registries"
export function useFlowgramEditorProps({
definition,
nodeSpecs,
readonly = false,
onDefinitionChange,
}: {
definition: AIWorkflowDefinition
nodeSpecs: AIWorkflowNodeSpec[]
readonly?: boolean
onDefinitionChange?: (definition: AIWorkflowDefinition) => void
}) {
return useMemo<FreeLayoutProps>(
() => ({
background: true,
readonly,
initialData: definition as WorkflowJSON,
nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs),
materials: {
renderDefaultNode: FlowgramNodeRenderer,
},
nodeEngine: {
enable: false,
},
history: {
enable: !readonly,
enableChangeNode: !readonly,
},
canDeleteNode: (_ctx, node) => {
const type = String(node.flowNodeType ?? "")
return type !== "start" && type !== "end"
},
canDeleteLine: () => !readonly,
onContentChange: (ctx) => {
if (readonly) {
return
}
onDefinitionChange?.(ctx.document.toJSON() as AIWorkflowDefinition)
},
onAllLayersRendered: (ctx) => {
void ctx.tools.fitView(false)
},
getNodeDefaultRegistry(type) {
return {
type,
meta: {
defaultExpanded: true,
},
}
},
plugins: () => [],
}),
[definition, nodeSpecs, onDefinitionChange, readonly]
)
}
@@ -0,0 +1,41 @@
import type { WorkflowNodeRegistry } from "@flowgram.ai/free-layout-editor"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): WorkflowNodeRegistry[] {
const seen = new Set<string>()
const specs = nodeSpecs.length > 0
? nodeSpecs
: [
{ type: "start", title: "开始" },
{ type: "end", title: "结束" },
]
return specs
.filter((spec) => {
if (!spec.type || seen.has(spec.type)) {
return false
}
seen.add(spec.type)
return true
})
.map((spec) => ({
type: spec.type,
meta: {
defaultExpanded: true,
deleteDisable: spec.type === "start" || spec.type === "end",
copyDisable: spec.type === "start" || spec.type === "end",
defaultPorts: defaultPortsForNodeType(spec.type),
},
}))
}
function defaultPortsForNodeType(type: string) {
if (type === "start") {
return [{ type: "output" as const }]
}
if (type === "end") {
return [{ type: "input" as const }]
}
return [{ type: "input" as const }, { type: "output" as const }]
}
@@ -0,0 +1,63 @@
import "@flowgram.ai/free-layout-editor/index.css"
import {
useNodeRender,
WorkflowNodeRenderer,
type WorkflowNodeProps,
} from "@flowgram.ai/free-layout-editor"
import {
BotIcon,
CircleStopIcon,
DatabaseIcon,
GitBranchIcon,
MessageSquareTextIcon,
SendIcon,
UserRoundIcon,
} from "lucide-react"
import type { ComponentType } from "react"
import { cn } from "@/lib/utils"
const iconByType: Record<string, ComponentType<{ className?: string }>> = {
start: UserRoundIcon,
conversation_understanding: BotIcon,
reply_policy: MessageSquareTextIcon,
condition: GitBranchIcon,
knowledge_retrieve: DatabaseIcon,
answerability_gate: GitBranchIcon,
llm_reply: BotIcon,
human_confirm: UserRoundIcon,
create_ticket: MessageSquareTextIcon,
handoff_to_human: UserRoundIcon,
send_reply: SendIcon,
end: CircleStopIcon,
}
export function FlowgramNodeRenderer(props: WorkflowNodeProps) {
const { selected, node } = useNodeRender()
const nodeType = String(node.flowNodeType ?? "")
const Icon = iconByType[nodeType] ?? BotIcon
const nodeJSON = node.toJSON?.() as { data?: { title?: string }; title?: string } | undefined
const title = nodeJSON?.data?.title || nodeJSON?.title || nodeType || "节点"
return (
<WorkflowNodeRenderer
node={props.node}
className={cn(
"w-[260px] rounded-md border bg-background shadow-sm transition-colors",
selected ? "border-primary ring-2 ring-primary/15" : "border-border"
)}
style={{ padding: 0 }}
>
<div className="flex items-start gap-3 p-3">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border bg-muted">
<Icon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium leading-5">{title}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{nodeType}</div>
</div>
</div>
</WorkflowNodeRenderer>
)
}
@@ -1,577 +1,363 @@
"use client"
import { useState } from "react"
import type { Node } from "@xyflow/react"
import { useEffect, useMemo, useState } from "react"
import { Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { OptionCombobox } from "@/components/option-combobox"
import { VariableSelector } from "./variable-selector"
import type {
WorkflowConditionBranch,
WorkflowNodeSpec,
WorkflowNodeConfig,
WorkflowVariableRef,
WorkflowVariableSpec,
WorkflowVariableSelector,
} from "./workflow-utils"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
import { cn } from "@/lib/utils"
type WorkflowNodeData = Record<string, unknown> & {
nodeType?: string
name?: string
title?: string
config?: WorkflowNodeConfig
inputs?: Record<string, WorkflowVariableSelector>
}
import { VariableSelector } from "./variable-selector"
import {
createConditionBranchID,
isRefValue,
normalizeNodeConfig,
type WorkflowConditionBranch,
type WorkflowVariableRef,
} from "./workflow-utils"
export type WorkflowBranchSummary = {
branchId: string
targetNodeId: string
targetName: string
conditionLabel: string
conditionSet: boolean
isDefault: boolean
targetNodeId?: string
targetName?: string
}
export function NodeConfigPanel({
node,
nodeSpec,
nodes,
availableVariables,
branchSummaries = [],
onChange,
onDelete,
}: {
node: Node<WorkflowNodeData> | null
nodeSpec?: WorkflowNodeSpec
availableVariables: WorkflowVariableRef[]
node: AIWorkflowDefinition["nodes"][number] | null
nodeSpec?: AIWorkflowNodeSpec
nodes: AIWorkflowDefinition["nodes"]
availableVariables?: WorkflowVariableRef[]
branchSummaries?: WorkflowBranchSummary[]
onChange: (nodeId: string, data: WorkflowNodeData) => void
onChange: (nodeId: string, data: AIWorkflowDefinition["nodes"][number]["data"]) => void
onDelete?: (nodeId: string) => void
}) {
const [configText, setConfigText] = useState("{}")
useEffect(() => {
setConfigText(JSON.stringify(node?.data?.config ?? {}, null, 2))
}, [node?.id, node?.data?.config])
const configError = useMemo(() => {
if (!node) {
return ""
}
try {
const parsed = JSON.parse(configText || "{}")
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? "" : "配置必须是 JSON 对象"
} catch {
return "JSON 格式错误"
}
}, [configText, node])
if (!node) {
return (
<div className="flex h-full items-center justify-center px-4 text-sm text-muted-foreground">
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
</div>
)
}
return (
<NodeConfigForm
key={node.id}
node={node}
nodeSpec={nodeSpec}
availableVariables={availableVariables}
branchSummaries={branchSummaries}
onChange={onChange}
/>
)
}
function NodeConfigForm({
node,
nodeSpec,
availableVariables,
branchSummaries,
onChange,
}: {
node: Node<WorkflowNodeData>
nodeSpec?: WorkflowNodeSpec
availableVariables: WorkflowVariableRef[]
branchSummaries: WorkflowBranchSummary[]
onChange: (nodeId: string, data: WorkflowNodeData) => void
}) {
const [name, setName] = useState(node.data.name ?? "")
const [configText, setConfigText] = useState(JSON.stringify(node.data.config ?? {}, null, 2))
const [inputs, setInputs] = useState<Record<string, WorkflowVariableSelector>>(
node.data.inputs ?? {}
)
const [error, setError] = useState("")
const inputsValues = node.data?.inputsValues ?? {}
const inputSchema = nodeSpec?.inputSchema ?? []
const outputSchema = nodeSpec?.outputSchema ?? []
const isConditionNode = node.data.nodeType === "condition"
const fallbackNodeName = nodeSpec?.title || node.data.title || node.data.nodeType || node.id
const panelTitle = name.trim() || node.data.name?.trim() || fallbackNodeName
const canDelete = node.type !== "start" && node.type !== "end"
const config = normalizeNodeConfig(node.data?.config)
const branches = config.branches ?? []
const commitChange = (next: Partial<WorkflowNodeData>) => {
const updateData = (data: Partial<AIWorkflowDefinition["nodes"][number]["data"]>) => {
onChange(node.id, {
...node.data,
name: name.trim() || fallbackNodeName,
config: node.data.config ?? {},
inputs,
...next,
...(node.data ?? {}),
...data,
})
}
const updateConfig = (nextConfig: Record<string, unknown>) => updateData({ config: nextConfig })
const updateBranch = (branch: WorkflowConditionBranch) => {
const nextBranches = branches.some((item) => item.id === branch.id)
? branches.map((item) => (item.id === branch.id ? branch : item))
: [...branches, branch]
updateConfig({ ...config, branches: nextBranches })
}
const deleteBranch = (branchId: string) => {
updateConfig({ ...config, branches: branches.filter((branch) => branch.id !== branchId) })
}
const addBranch = () => {
const targetNodeId = nodes.find((item) => item.id !== node.id && item.type !== "start")?.id ?? ""
updateBranch({
id: createConditionBranchID(branches),
name: "新分支",
targetNodeId,
condition: {
operator: "eq",
},
})
}
const handleApply = () => {
try {
const parsed = JSON.parse(configText || "{}") as Record<string, unknown>
setError("")
commitChange({ config: parsed })
} catch {
setError("Config must be valid JSON.")
}
}
return (
<div className="flex min-h-full flex-col">
<div className="sticky top-0 z-10 shrink-0 border-b border-border/60 bg-background">
<div className="px-4 pb-2 pt-4">
<Input
id="workflow-node-name"
value={name}
onChange={(event) => setName(event.target.value)}
onBlur={() => commitChange({ name: name.trim() || node.data.nodeType || node.id })}
className="h-8 border-0 bg-transparent px-0 text-sm font-semibold uppercase shadow-none focus-visible:ring-0"
aria-label="节点名称"
/>
<div className="mt-1 truncate text-xs text-muted-foreground">
{node.data.nodeType && node.data.nodeType !== panelTitle
? `${node.id} · ${node.data.nodeType}`
: node.id}
<div className="flex h-full flex-col">
<div className="border-b px-4 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{node.data?.title || nodeSpec?.title || node.type}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{node.id}</div>
</div>
{canDelete ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => onDelete?.(node.id)}
aria-label="删除节点"
>
<Trash2Icon className="size-4" />
</Button>
) : null}
</div>
</div>
<div className="flex flex-1 flex-col">
{isConditionNode ? (
<ConditionNodePanel
branches={node.data.config?.branches ?? []}
branchSummaries={branchSummaries}
availableVariables={availableVariables}
outputSchema={outputSchema}
onChange={(branches) => commitChange({ config: { ...(node.data.config ?? {}), branches } })}
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
<div className="space-y-2">
<Label htmlFor={`node-title-${node.id}`}></Label>
<Input
id={`node-title-${node.id}`}
value={node.data?.title ?? ""}
placeholder={nodeSpec?.title || node.type}
onChange={(event) => updateData({ title: event.target.value })}
/>
) : (
<>
{inputSchema.length > 0 ? (
<div className="space-y-3 border-b border-border/60 p-4">
<div className="text-sm font-semibold uppercase"></div>
{availableVariables.length === 0 ? (
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
</div>
) : null}
{inputSchema.map((input) => (
</div>
{inputSchema.length > 0 ? (
<div className="space-y-3">
<div className="text-sm font-medium"></div>
{inputSchema.map((input) => {
const value = inputsValues[input.name]
return (
<div key={input.name} className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">
{input.name}
{input.required ? <span className="text-destructive"> *</span> : null}
</Label>
<span className="text-xs text-muted-foreground">{input.type}</span>
</div>
<Label className="flex items-center gap-1">
<span>{input.label || input.name}</span>
{input.required ? <span className="text-destructive">*</span> : null}
</Label>
<VariableSelector
value={inputs[input.name]}
variables={availableVariables}
onChange={(value) => {
const nextInputs = {
...inputs,
[input.name]: value,
}
setInputs(nextInputs)
commitChange({
inputs: nextInputs,
value={isRefValue(value) ? value : undefined}
variables={availableVariables ?? []}
placeholder="选择变量"
onChange={(next) => {
updateData({
inputsValues: {
...inputsValues,
[input.name]: next,
},
})
}}
/>
{inputs[input.name] ? (
<div className="text-xs text-muted-foreground">
{inputs[input.name].nodeId}.{inputs[input.name].field}
</div>
) : null}
{input.description ? (
<div className="text-xs text-muted-foreground">{input.description}</div>
) : null}
</div>
))}
</div>
) : null}
<details className="border-b border-border/60 p-4">
<summary className="cursor-pointer text-sm font-medium"> JSON</summary>
<div className="mt-3 space-y-2">
<Textarea
id="workflow-node-config"
className="h-40 font-mono text-xs"
value={configText}
onChange={(event) => setConfigText(event.target.value)}
/>
{error ? <div className="text-xs text-destructive">{error}</div> : null}
<Button type="button" variant="outline" size="sm" onClick={handleApply}>
</Button>
</div>
</details>
{outputSchema.length > 0 ? (
<div className="space-y-2 p-4">
<div className="text-sm font-semibold uppercase"></div>
<div className="space-y-1 rounded-lg bg-muted/60 p-2">
{outputSchema.map((output) => (
<div key={output.name} className="space-y-0.5 rounded-sm px-1 py-0.5">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="truncate font-medium">{output.name}</span>
<span className="shrink-0 text-muted-foreground">{output.type}</span>
</div>
{output.description ? (
<div className="text-xs text-muted-foreground">{output.description}</div>
) : null}
</div>
))}
</div>
</div>
) : null}
</>
)}
</div>
</div>
)
}
function ConditionNodePanel({
branches,
branchSummaries,
availableVariables,
outputSchema,
onChange,
}: {
branches: WorkflowConditionBranch[]
branchSummaries: WorkflowBranchSummary[]
availableVariables: WorkflowVariableRef[]
outputSchema: WorkflowVariableSpec[]
onChange: (branches: WorkflowConditionBranch[]) => void
}) {
const summariesByBranchID = new Map(branchSummaries.map((item) => [item.branchId, item]))
const commitBranch = (branchId: string, patch: Partial<WorkflowConditionBranch>) => {
onChange(branches.map((branch) => (
branch.id === branchId ? normalizeBranch({ ...branch, ...patch }) : branch
)))
}
const addBranch = () => {
const index = branches.length + 1
const nextBranch = {
id: `branch_${index}`,
name: `分支 ${index}`,
targetNodeId: "",
condition: { operator: "eq" },
}
const defaultIndex = branches.findIndex((branch) => branch.default)
if (defaultIndex >= 0) {
onChange([
...branches.slice(0, defaultIndex),
nextBranch,
...branches.slice(defaultIndex),
])
return
}
onChange([
...branches,
nextBranch,
{
id: "default",
name: "其他情况",
targetNodeId: "",
default: true,
},
])
}
const deleteBranch = (branchId: string) => {
onChange(branches.filter((branch) => branch.id !== branchId || branch.default))
}
const moveBranch = (branchId: string, direction: -1 | 1) => {
const index = branches.findIndex((branch) => branch.id === branchId)
if (index < 0 || branches[index]?.default) {
return
}
const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= branches.length || branches[nextIndex]?.default) {
return
}
const next = [...branches]
const current = next[index]
next[index] = next[nextIndex]
next[nextIndex] = current
onChange(next)
}
return (
<>
<div className="space-y-3 border-b border-border/60 p-4">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold uppercase"></div>
<Button type="button" variant="outline" size="sm" onClick={addBranch}>
</Button>
</div>
{branches.length > 0 ? (
<div className="space-y-2">
{branches.map((branch, index) => {
const summary = summariesByBranchID.get(branch.id)
const condition = branch.condition ?? {}
const selectedVariable = findConditionVariable(availableVariables, condition.left)
const operatorOptions = getConditionOperatorOptions(selectedVariable)
const conditionRight = condition.right === undefined || condition.right === null
? ""
: String(condition.right)
return (
<div key={branch.id} className="space-y-3 rounded-xl bg-muted/60 p-3">
<div className="flex h-6 items-center justify-between gap-2 text-xs">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-[11px] font-semibold text-muted-foreground">
{branch.default ? "ELSE" : index === 0 ? "IF" : "ELIF"}
</span>
{!branch.default ? (
<span className="truncate text-[10px] font-semibold text-muted-foreground/80">
CASE {index + 1}
</span>
) : null}
</div>
<span className="shrink-0 rounded-md bg-background px-1.5 py-0.5 text-muted-foreground">
{branch.default ? "默认" : "条件"}
</span>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={branch.name ?? ""}
onChange={(event) => commitBranch(branch.id, { name: event.target.value })}
placeholder="例如:需要转人工"
className="h-8 bg-background"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<div className="rounded-md border border-dashed bg-background/70 px-2 py-2 text-xs text-muted-foreground">
{summary?.targetNodeId
? `已连接到:${summary.targetName}`
: "请从画布中该分支右侧连接点拖线到目标节点"}
</div>
</div>
{branch.default ? (
<div className="rounded-md bg-background/70 p-2 text-xs text-muted-foreground">
{summary?.targetName ?? (branch.targetNodeId || "未选择目标节点")}
</div>
) : (
<div className="space-y-3 rounded-lg bg-background p-2">
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<VariableSelector
value={condition.left}
variables={availableVariables}
onChange={(value) => commitBranch(branch.id, {
condition: { ...condition, left: value },
})}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<OptionCombobox
value={condition.operator ?? "eq"}
options={operatorOptions}
placeholder="选择判断方式"
searchPlaceholder="搜索判断方式"
emptyText="没有可用判断方式"
onChange={(value) => commitBranch(branch.id, {
condition: { ...condition, operator: value },
})}
/>
</div>
{!conditionOperatorWithoutRight(condition.operator ?? "eq") ? (
<ConditionRightControl
value={conditionRight}
variable={selectedVariable}
onChange={(right) => commitBranch(branch.id, {
condition: { ...condition, right },
})}
/>
) : null}
</div>
)}
<div className="flex flex-wrap gap-2">
{!branch.default && index > 0 ? (
<Button type="button" size="sm" variant="ghost" onClick={() => moveBranch(branch.id, -1)}>
</Button>
) : null}
{!branch.default && index < branches.findIndex((item) => item.default) - 1 ? (
<Button type="button" size="sm" variant="ghost" onClick={() => moveBranch(branch.id, 1)}>
</Button>
) : null}
{!branch.default ? (
<Button type="button" size="sm" variant="ghost" onClick={() => deleteBranch(branch.id)}>
</Button>
) : null}
</div>
<div className="line-clamp-2 rounded-md bg-background/70 px-2 py-1.5 text-xs text-muted-foreground">
{summary?.conditionLabel ?? "尚未完成分支配置"}
</div>
</div>
)
})}
</div>
) : (
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
</div>
)}
</div>
{outputSchema.length > 0 ? (
<div className="space-y-2 p-4">
<div className="text-sm font-semibold uppercase"></div>
<div className="space-y-1 rounded-lg bg-muted/60 p-2">
{outputSchema.map((output) => (
<div key={output.name} className="space-y-0.5 rounded-sm px-1 py-0.5">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="truncate font-medium">{output.name}</span>
<span className="shrink-0 text-muted-foreground">{output.type}</span>
</div>
{output.description ? (
<div className="text-xs text-muted-foreground">{output.description}</div>
) : null}
</div>
))}
</div>
) : null}
{node.type === "condition" || branches.length > 0 ? (
<ConditionBranchesEditor
branches={branches}
nodes={nodes}
currentNodeId={node.id}
variables={availableVariables ?? []}
onAdd={addBranch}
onChange={updateBranch}
onDelete={deleteBranch}
/>
) : null}
<div className="space-y-2">
<Label htmlFor={`node-config-${node.id}`}> JSON</Label>
<Textarea
id={`node-config-${node.id}`}
value={configText}
className="min-h-36 font-mono text-xs"
spellCheck={false}
onChange={(event) => {
const next = event.target.value
setConfigText(next)
try {
const parsed = JSON.parse(next || "{}")
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
updateData({ config: parsed as Record<string, unknown> })
}
} catch {
// The textarea keeps the draft while the user fixes invalid JSON.
}
}}
/>
{configError ? <div className="text-xs text-destructive">{configError}</div> : null}
</div>
) : null}
</>
)
}
const conditionOperators = [
{ value: "eq", label: "等于" },
{ value: "neq", label: "不等于" },
{ value: "contains", label: "包含" },
{ value: "exists", label: "存在" },
{ value: "not_exists", label: "不存在" },
{ value: "truthy", label: "为真" },
{ value: "is_true", label: "为真" },
{ value: "falsy", label: "为假" },
{ value: "is_false", label: "为假" },
{ value: "gt", label: "大于" },
{ value: "gte", label: "大于等于" },
{ value: "lt", label: "小于" },
{ value: "lte", label: "小于等于" },
]
function ConditionRightControl({
value,
variable,
onChange,
}: {
value: string
variable?: WorkflowVariableRef
onChange: (value: unknown) => void
}) {
const valueOptions = getConditionValueOptions(variable)
if (valueOptions.length > 0) {
return (
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<OptionCombobox
value={value}
options={valueOptions}
placeholder="选择比较值"
searchPlaceholder="搜索比较值"
emptyText="当前变量没有可选值"
onChange={(nextValue) => onChange(decodeConditionRight(nextValue, variable))}
/>
</div>
)
}
return (
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
type={variable?.type === "number" || variable?.type === "integer" ? "number" : "text"}
value={value}
onChange={(event) => onChange(normalizeConditionRight(event.target.value, variable))}
placeholder={variable ? `请输入${variable.label || variable.field}的比较值` : "请输入比较值"}
/>
</div>
)
}
function conditionOperatorWithoutRight(operator: string) {
return ["exists", "not_exists", "truthy", "is_true", "falsy", "is_false"].includes(operator)
}
function normalizeConditionRight(value: string, variable?: WorkflowVariableRef) {
const trimmed = value.trim()
if (variable?.type === "boolean") {
return trimmed === "true"
}
if (variable?.type === "number" || variable?.type === "integer") {
return trimmed === "" ? "" : Number(trimmed)
}
if (variable?.type === "string") {
return trimmed
}
if (trimmed === "true") return true
if (trimmed === "false") return false
if (trimmed !== "" && !Number.isNaN(Number(trimmed))) return Number(trimmed)
return trimmed
}
function findConditionVariable(
variables: WorkflowVariableRef[],
selector?: WorkflowVariableSelector
): WorkflowVariableRef | undefined {
if (!selector?.nodeId || !selector.field) {
return undefined
}
return variables.find((item) => item.nodeId === selector.nodeId && item.field === selector.field)
}
function getConditionOperatorOptions(variable?: WorkflowVariableRef) {
if (!variable?.operators?.length) {
return conditionOperators
}
const allowed = new Set(variable.operators)
return conditionOperators.filter((item) => allowed.has(item.value))
}
function getConditionValueOptions(variable?: WorkflowVariableRef) {
if (variable?.valueOptions?.length) {
return variable.valueOptions.map((item) => ({
value: encodeConditionRight(item.value),
label: item.label,
function ConditionBranchesEditor({
branches,
nodes,
currentNodeId,
variables,
onAdd,
onChange,
onDelete,
}: {
branches: WorkflowConditionBranch[]
nodes: AIWorkflowDefinition["nodes"]
currentNodeId: string
variables: WorkflowVariableRef[]
onAdd: () => void
onChange: (branch: WorkflowConditionBranch) => void
onDelete: (branchId: string) => void
}) {
const targetOptions = nodes
.filter((node) => node.id !== currentNodeId && node.type !== "start")
.map((node) => ({
value: node.id,
label: node.data?.title || node.type || node.id,
}))
}
if (variable?.type === "boolean") {
return [
{ value: "true", label: "" },
{ value: "false", label: "" },
]
}
return []
const operatorOptions = [
{ value: "eq", label: "等于" },
{ value: "neq", label: "不等于" },
{ value: "contains", label: "包含" },
{ value: "not_contains", label: "不包含" },
{ value: "gt", label: "大于" },
{ value: "gte", label: "大于等于" },
{ value: "lt", label: "小于" },
{ value: "lte", label: "小于等于" },
{ value: "exists", label: "存在" },
{ value: "empty", label: "为空" },
]
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium"></div>
<Button type="button" variant="outline" size="sm" className="h-7 px-2 text-xs" onClick={onAdd}>
</Button>
</div>
{branches.length === 0 ? (
<div className="rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground">
</div>
) : null}
<div className="space-y-3">
{branches.map((branch) => {
const condition = branch.condition ?? {}
return (
<div key={branch.id} className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<Input
value={branch.name ?? ""}
placeholder={branch.id}
className="h-8"
onChange={(event) => onChange({ ...branch, name: event.target.value })}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-muted-foreground hover:text-destructive"
onClick={() => onDelete(branch.id)}
>
</Button>
</div>
<div className="space-y-1.5">
<Label></Label>
<OptionCombobox
value={branch.targetNodeId}
options={targetOptions}
placeholder="选择目标节点"
onChange={(targetNodeId) => onChange({ ...branch, targetNodeId })}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={branch.default === true}
className="size-4"
onChange={(event) => onChange({
...branch,
default: event.target.checked,
condition: event.target.checked ? undefined : branch.condition,
})}
/>
</label>
{branch.default ? null : (
<div className="space-y-3">
<div className="space-y-1.5">
<Label></Label>
<VariableSelector
value={isRefValue(condition.left) ? condition.left : undefined}
variables={variables}
placeholder="选择变量"
onChange={(left) => onChange({
...branch,
condition: { ...condition, left },
})}
/>
</div>
<div className="grid grid-cols-[1fr_1fr] gap-2">
<div className="space-y-1.5">
<Label></Label>
<OptionCombobox
value={condition.operator ?? ""}
options={operatorOptions}
placeholder="选择操作符"
onChange={(operator) => onChange({
...branch,
condition: { ...condition, operator },
})}
/>
</div>
<div className={cn("space-y-1.5", ["exists", "empty"].includes(condition.operator ?? "") && "opacity-50")}>
<Label></Label>
<Input
value={stringifyConditionRight(condition.right)}
disabled={["exists", "empty"].includes(condition.operator ?? "")}
onChange={(event) => onChange({
...branch,
condition: { ...condition, right: event.target.value },
})}
/>
</div>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
function encodeConditionRight(value: unknown) {
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
function stringifyConditionRight(value: unknown) {
if (value === undefined || value === null) {
return ""
}
if (typeof value === "string") {
return value
}
return JSON.stringify(value)
}
function decodeConditionRight(value: string, variable?: WorkflowVariableRef) {
if (variable?.type === "boolean") {
return value === "true"
}
if (variable?.type === "number" || variable?.type === "integer") {
return Number(value)
}
const option = variable?.valueOptions?.find((item) => encodeConditionRight(item.value) === value)
return option ? option.value : value
}
function normalizeBranch(branch: WorkflowConditionBranch): WorkflowConditionBranch {
if (branch.default) {
const rest = { ...branch }
delete rest.condition
return rest
}
return {
...branch,
condition: branch.condition ?? { operator: "eq" },
}
}
@@ -0,0 +1,89 @@
"use client"
import { useCallback, useState } from "react"
import type { AIWorkflowDefinition } from "@/lib/api/admin"
type WorkflowDefinitionHistoryState = {
present: AIWorkflowDefinition
past: AIWorkflowDefinition[]
future: AIWorkflowDefinition[]
revision: number
}
export function useWorkflowDefinitionHistory(initialDefinition: AIWorkflowDefinition) {
const [state, setState] = useState<WorkflowDefinitionHistoryState>({
present: initialDefinition,
past: [],
future: [],
revision: 0,
})
const replace = useCallback((definition: AIWorkflowDefinition) => {
setState((current) => ({
present: definition,
past: [],
future: [],
revision: current.revision + 1,
}))
}, [])
const update = useCallback((definition: AIWorkflowDefinition) => {
setState((current) => {
if (sameWorkflowDefinition(current.present, definition)) {
return current
}
return {
present: definition,
past: [...current.past.slice(-49), current.present],
future: [],
revision: current.revision,
}
})
}, [])
const undo = useCallback(() => {
setState((current) => {
const previous = current.past[current.past.length - 1]
if (!previous) {
return current
}
return {
present: previous,
past: current.past.slice(0, -1),
future: [current.present, ...current.future.slice(0, 49)],
revision: current.revision + 1,
}
})
}, [])
const redo = useCallback(() => {
setState((current) => {
const next = current.future[0]
if (!next) {
return current
}
return {
present: next,
past: [...current.past.slice(-49), current.present],
future: current.future.slice(1),
revision: current.revision + 1,
}
})
}, [])
return {
definition: state.present,
revision: state.revision,
canUndo: state.past.length > 0,
canRedo: state.future.length > 0,
replace,
update,
undo,
redo,
}
}
function sameWorkflowDefinition(left: AIWorkflowDefinition, right: AIWorkflowDefinition) {
return JSON.stringify(left) === JSON.stringify(right)
}
@@ -2,33 +2,41 @@
import { OptionCombobox } from "@/components/option-combobox"
import type { WorkflowVariableRef, WorkflowVariableSelector } from "./workflow-utils"
import {
createRefValue,
refField,
refNodeId,
type WorkflowVariableRef,
type WorkflowVariableSelector,
} from "./workflow-utils"
export function VariableSelector({
value,
variables,
onChange,
placeholder = "选择变量",
}: {
value?: WorkflowVariableSelector
variables: WorkflowVariableRef[]
onChange: (value: WorkflowVariableSelector) => void
placeholder?: string
}) {
const options = variables.map((item) => ({
value: `${item.nodeId}.${item.field}`,
label: `${item.nodeName}.${item.label || item.field} · ${item.type}`,
const selected = value ? `${refNodeId(value)}.${refField(value)}` : ""
const options = variables.map((variable) => ({
value: `${variable.nodeId}.${variable.field}`,
label: `${variable.nodeName}.${variable.label || variable.field}`,
}))
const selectedValue = value?.nodeId && value.field ? `${value.nodeId}.${value.field}` : ""
return (
<OptionCombobox
value={selectedValue}
value={selected}
options={options}
placeholder="选择变量"
searchPlaceholder="搜索变量"
emptyText="没有可用上游变量"
onChange={(nextValue) => {
const [nodeId, ...fieldParts] = nextValue.split(".")
onChange({ nodeId, field: fieldParts.join(".") })
placeholder={placeholder}
onChange={(next) => {
const variable = variables.find((item) => `${item.nodeId}.${item.field}` === next)
if (variable) {
onChange(createRefValue(variable.nodeId, variable.field))
}
}}
/>
)
@@ -0,0 +1,71 @@
"use client"
import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin"
import { cn } from "@/lib/utils"
import { NodeConfigPanel } from "./node-config-panel"
import {
getAvailableVariables,
getNodeTitle,
type WorkflowNodeData,
} from "./workflow-utils"
export function WorkflowConfigSidebar({
definition,
nodeSpecs,
selectedNodeId,
onSelectNode,
onChangeNodeData,
onDeleteNode,
}: {
definition: AIWorkflowDefinition
nodeSpecs: AIWorkflowNodeSpec[]
selectedNodeId: string
onSelectNode: (nodeId: string) => void
onChangeNodeData: (nodeId: string, data: WorkflowNodeData) => void
onDeleteNode: (nodeId: string) => void
}) {
const selectedNode = definition.nodes.find((node) => node.id === selectedNodeId) ?? null
const selectedNodeSpec = selectedNode
? nodeSpecs.find((spec) => spec.type === selectedNode.type)
: undefined
const availableVariables = selectedNode
? getAvailableVariables(definition, selectedNode.id, nodeSpecs)
: []
return (
<aside className="flex w-80 shrink-0 flex-col border-l bg-background">
<div className="border-b px-3 py-2">
<div className="text-sm font-medium"></div>
</div>
<div className="max-h-48 overflow-y-auto border-b p-2">
<div className="space-y-1">
{definition.nodes.map((node) => (
<button
key={node.id}
type="button"
className={cn(
"flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted",
selectedNodeId === node.id && "bg-muted"
)}
onClick={() => onSelectNode(node.id)}
>
<span className="min-w-0 truncate">{getNodeTitle(node, nodeSpecs)}</span>
<span className="shrink-0 text-xs text-muted-foreground">{node.type}</span>
</button>
))}
</div>
</div>
<div className="min-h-0 flex-1">
<NodeConfigPanel
node={selectedNode}
nodeSpec={selectedNodeSpec}
nodes={definition.nodes}
availableVariables={availableVariables}
onChange={onChangeNodeData}
onDelete={onDeleteNode}
/>
</div>
</aside>
)
}
@@ -0,0 +1,95 @@
"use client"
import type { ReactNode } from "react"
import {
CheckCircle2Icon,
RotateCcwIcon,
SaveIcon,
SendIcon,
Undo2Icon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type { WorkflowDraftValidation } from "./workflow-utils"
export function WorkflowEditorToolbar({
validation,
nodeCount,
edgeCount,
toolbarExtra,
onUndo,
undoDisabled = false,
onRedo,
redoDisabled = false,
onRestoreDefault,
restoreDefaultDisabled = false,
onValidate,
validateDisabled = false,
onSaveDraft,
saveDraftDisabled = false,
onPublish,
publishDisabled = false,
}: {
validation: WorkflowDraftValidation
nodeCount: number
edgeCount: number
toolbarExtra?: ReactNode
onUndo?: () => void
undoDisabled?: boolean
onRedo?: () => void
redoDisabled?: boolean
onRestoreDefault?: () => void
restoreDefaultDisabled?: boolean
onValidate?: () => void
validateDisabled?: boolean
onSaveDraft?: () => void
saveDraftDisabled?: boolean
onPublish?: () => void
publishDisabled?: boolean
}) {
return (
<div className="flex h-10 shrink-0 items-center justify-between border-b bg-background px-2">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-sm px-2 py-1 text-xs",
validation.valid ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"
)}
>
<CheckCircle2Icon className="size-3" />
{validation.valid ? "本地检查通过" : `${validation.errors.length} 个本地问题`}
</span>
<span className="truncate text-xs text-muted-foreground">
{nodeCount} / {edgeCount} 线
</span>
</div>
<div className="flex items-center gap-1">
{toolbarExtra}
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={undoDisabled} onClick={onUndo}>
<Undo2Icon className="size-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={redoDisabled} onClick={onRedo}>
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={restoreDefaultDisabled} onClick={onRestoreDefault}>
<RotateCcwIcon className="size-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={validateDisabled} onClick={onValidate}>
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={saveDraftDisabled} onClick={onSaveDraft}>
<SaveIcon className="size-3.5" />
</Button>
<Button type="button" size="sm" className="h-7 px-2 text-xs" disabled={publishDisabled} onClick={onPublish}>
<SendIcon className="size-3.5" />
</Button>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
"use client"
import { PlusIcon } from "lucide-react"
import type { AIWorkflowNodeSpec } from "@/lib/api/admin"
export function WorkflowNodePalette({
nodeSpecs,
onAddNode,
}: {
nodeSpecs: AIWorkflowNodeSpec[]
onAddNode: (spec: AIWorkflowNodeSpec) => void
}) {
return (
<aside className="flex w-60 shrink-0 flex-col border-r bg-muted/20">
<div className="border-b px-3 py-2">
<div className="text-sm font-medium"></div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<div className="space-y-1">
{nodeSpecs.map((spec) => (
<button
key={spec.type}
type="button"
className="flex w-full items-start gap-2 rounded-md border bg-background px-2 py-2 text-left text-sm hover:bg-muted"
onClick={() => onAddNode(spec)}
>
<PlusIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block truncate font-medium">{spec.title || spec.type}</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{spec.description}</span>
</span>
</button>
))}
</div>
</div>
</aside>
)
}
@@ -1,8 +1,8 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
import ts from "typescript"
function plain(value) {
return JSON.parse(JSON.stringify(value))
@@ -26,12 +26,49 @@ async function loadModule() {
return sandbox.module.exports
}
describe("validateWorkflowDraft", () => {
it("rejects missing start", async () => {
const { validateWorkflowDraft } = await loadModule()
function workflowNode(id, type, position = { x: 0, y: 0 }, data = {}) {
return {
id,
type,
meta: { position },
data: {
title: type,
config: {},
inputsValues: {},
...data,
},
}
}
const result = validateWorkflowDraft({
nodes: [{ id: "end_1", type: "end", position: { x: 0, y: 0 }, data: {} }],
function workflowEdge(sourceNodeID, targetNodeID, extra = {}) {
return {
sourceNodeID,
targetNodeID,
...extra,
}
}
describe("FlowGram value helpers", () => {
it("creates and reads reference values", async () => {
const { createRefValue, isRefValue, refField, refNodeId } = await loadModule()
const value = createRefValue("start_1", "userMessage")
assert.deepEqual(plain(value), { type: "ref", content: ["start_1", "userMessage"] })
assert.equal(isRefValue(value), true)
assert.equal(refNodeId(value), "start_1")
assert.equal(refField(value), "userMessage")
assert.equal(isRefValue({ type: "constant", content: "hello" }), false)
})
})
describe("validateWorkflowDefinition", () => {
it("rejects a workflow without exactly one start node", async () => {
const { validateWorkflowDefinition } = await loadModule()
const result = validateWorkflowDefinition({
schemaVersion: 2,
nodes: [workflowNode("end_1", "end")],
edges: [],
})
@@ -39,35 +76,59 @@ describe("validateWorkflowDraft", () => {
assert.match(result.errors.join("\n"), /exactly one start/)
})
it("rejects dangling edge", async () => {
const { validateWorkflowDraft } = await loadModule()
it("rejects dangling FlowGram edges", async () => {
const { validateWorkflowDefinition } = await loadModule()
const result = validateWorkflowDraft({
nodes: [
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
{ id: "end_1", type: "end", position: { x: 200, y: 0 }, data: {} },
],
edges: [{ id: "e1", source: "start_1", target: "missing_1" }],
const result = validateWorkflowDefinition({
schemaVersion: 2,
nodes: [workflowNode("start_1", "start"), workflowNode("end_1", "end")],
edges: [workflowEdge("start_1", "missing_1")],
})
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /target node does not exist/)
assert.match(result.errors.join("\n"), /target node does not exist: missing_1/)
})
it("rejects missing required input mapping", async () => {
const { validateWorkflowDraft } = await loadModule()
it("rejects missing required inputs from node specs", async () => {
const { validateWorkflowDefinition } = await loadModule()
const result = validateWorkflowDraft(
const result = validateWorkflowDefinition(
{
schemaVersion: 2,
nodes: [
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
{ id: "reply_1", type: "send_reply", position: { x: 200, y: 0 }, data: {} },
{ id: "end_1", type: "end", position: { x: 400, y: 0 }, data: {} },
workflowNode("start_1", "start"),
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }, { title: "发送回复" }),
workflowNode("end_1", "end", { x: 480, y: 0 }),
],
edges: [
{ id: "e1", source: "start_1", target: "reply_1" },
{ id: "e2", source: "reply_1", target: "end_1" },
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
},
[
{
type: "send_reply",
title: "发送回复",
inputSchema: [{ name: "replyText", label: "回复内容", type: "string", required: true }],
},
]
)
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /发送回复 missing required input: 回复内容/)
})
it("accepts a valid schema v2 workflow", async () => {
const { createRefValue, validateWorkflowDefinition } = await loadModule()
const result = validateWorkflowDefinition(
{
schemaVersion: 2,
nodes: [
workflowNode("start_1", "start"),
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }, {
inputsValues: { replyText: createRefValue("start_1", "userMessage") },
}),
workflowNode("end_1", "end", { x: 480, y: 0 }),
],
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
},
[
{
@@ -77,129 +138,12 @@ describe("validateWorkflowDraft", () => {
]
)
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /缺少必填输入「replyText」/)
})
it("rejects condition branch target without matching branch handle edge", async () => {
const { getConditionBranchHandleId, validateWorkflowDraft } = await loadModule()
const result = validateWorkflowDraft({
nodes: [
{ id: "start_1", type: "workflowNode", position: { x: 0, y: 0 }, data: { nodeType: "start" } },
{
id: "condition_1",
type: "workflowNode",
position: { x: 200, y: 0 },
data: {
nodeType: "condition",
name: "Route",
config: {
branches: [
{
id: "direct",
name: "Direct",
targetNodeId: "send_1",
condition: {
left: { nodeId: "start_1", field: "userMessage" },
operator: "eq",
right: "hello",
},
},
{
id: "default",
name: "Else",
targetNodeId: "send_1",
default: true,
},
],
},
},
},
{ id: "send_1", type: "workflowNode", position: { x: 400, y: 0 }, data: { nodeType: "send_reply" } },
{ id: "end_1", type: "workflowNode", position: { x: 600, y: 0 }, data: { nodeType: "end" } },
],
edges: [
{ id: "e1", source: "start_1", target: "condition_1" },
{
id: "e2",
source: "condition_1",
target: "send_1",
sourceHandle: getConditionBranchHandleId("default"),
},
{ id: "e3", source: "send_1", target: "end_1" },
],
})
assert.equal(result.valid, false)
assert.match(result.errors.join("\n"), /对应分支连接点/)
})
})
describe("applyAutoInputMappings", () => {
it("maps start user message to knowledge retrieve query", async () => {
const { applyAutoInputMappings } = await loadModule()
const draft = applyAutoInputMappings(
{
nodes: [
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
{ id: "retrieve_1", type: "knowledge_retrieve", position: { x: 200, y: 0 }, data: {} },
],
edges: [{ id: "e1", source: "start_1", target: "retrieve_1" }],
},
"start_1",
"retrieve_1",
[
{
type: "start",
outputSchema: [{ name: "userMessage", type: "string", description: "Message" }],
},
{
type: "knowledge_retrieve",
inputSchema: [{ name: "query", type: "string", required: true }],
},
]
)
assert.deepEqual(plain(draft.nodes[1].data.inputs), {
query: { nodeId: "start_1", field: "userMessage" },
})
})
it("maps llm reply text to send reply content", async () => {
const { applyAutoInputMappings } = await loadModule()
const draft = applyAutoInputMappings(
{
nodes: [
{ id: "llm_1", type: "llm_reply", position: { x: 0, y: 0 }, data: {} },
{ id: "send_1", type: "send_reply", position: { x: 200, y: 0 }, data: {} },
],
edges: [{ id: "e1", source: "llm_1", target: "send_1" }],
},
"llm_1",
"send_1",
[
{
type: "llm_reply",
outputSchema: [{ name: "replyText", type: "string", description: "Reply" }],
},
{
type: "send_reply",
inputSchema: [{ name: "replyText", type: "string", required: true }],
},
]
)
assert.deepEqual(plain(draft.nodes[1].data.inputs), {
replyText: { nodeId: "llm_1", field: "replyText" },
})
assert.deepEqual(plain(result), { valid: true, errors: [] })
})
})
describe("createWorkflowNodeFromSpec", () => {
it("creates node at dropped canvas position with unique id", async () => {
it("creates a FlowGram schema v2 node with default inputs", async () => {
const { createWorkflowNodeFromSpec } = await loadModule()
const node = createWorkflowNodeFromSpec(
@@ -207,129 +151,56 @@ describe("createWorkflowNodeFromSpec", () => {
type: "llm_reply",
title: "AI 回复",
defaultInputs: {
userMessage: { nodeId: "start_1", field: "userMessage" },
userMessage: { type: "ref", content: ["start_1", "userMessage"] },
},
},
[
{ id: "llm_reply_1", type: "workflowNode", position: { x: 0, y: 0 }, data: {} },
],
[{ id: "llm_reply_1" }],
{ x: 120, y: 240 }
)
assert.deepEqual(plain(node), {
id: "llm_reply_2",
type: "workflowNode",
position: { x: 120, y: 240 },
type: "llm_reply",
meta: { position: { x: 120, y: 240 } },
data: {
nodeType: "llm_reply",
name: "AI 回复",
label: "AI 回复",
title: "AI 回复",
config: {},
inputs: {
userMessage: { nodeId: "start_1", field: "userMessage" },
inputsValues: {
userMessage: { type: "ref", content: ["start_1", "userMessage"] },
},
},
})
})
})
describe("calculateWorkflowHelperLines", () => {
it("snaps dragged node to a nearby horizontal alignment", async () => {
const { calculateWorkflowHelperLines } = await loadModule()
const result = calculateWorkflowHelperLines(
[
{ id: "start_1", position: { x: 100, y: 120 }, width: 220, height: 84 },
{ id: "reply_1", position: { x: 392, y: 124 }, width: 220, height: 84 },
],
{ id: "reply_1", position: { x: 392, y: 124 }, width: 220, height: 84 }
)
assert.deepEqual(plain(result), {
position: { x: 392, y: 120 },
horizontal: { y: 120, left: 100, width: 512 },
})
})
it("does not show helper lines outside the alignment threshold", async () => {
const { calculateWorkflowHelperLines } = await loadModule()
const result = calculateWorkflowHelperLines(
[
{ id: "start_1", position: { x: 100, y: 120 }, width: 220, height: 84 },
{ id: "reply_1", position: { x: 392, y: 132 }, width: 220, height: 84 },
],
{ id: "reply_1", position: { x: 392, y: 132 }, width: 220, height: 84 }
)
assert.deepEqual(plain(result), {
position: { x: 392, y: 132 },
})
})
})
describe("workflow history", () => {
it("undoes and redoes snapshots while clearing redo after a new edit", async () => {
const {
createWorkflowHistory,
pushWorkflowHistory,
undoWorkflowHistory,
redoWorkflowHistory,
} = await loadModule()
const first = {
nodes: [{ id: "start_1", position: { x: 0, y: 0 } }],
edges: [],
}
const second = {
nodes: [{ id: "start_1", position: { x: 100, y: 0 } }],
edges: [],
}
const third = {
nodes: [{ id: "start_1", position: { x: 200, y: 0 } }],
edges: [],
}
const branch = {
nodes: [{ id: "start_1", position: { x: 300, y: 0 } }],
edges: [],
}
let history = createWorkflowHistory()
history = pushWorkflowHistory(history, first)
history = pushWorkflowHistory(history, second)
const undone = undoWorkflowHistory(history, third)
assert.deepEqual(plain(undone.snapshot), second)
assert.equal(undone.history.past.length, 1)
assert.equal(undone.history.future.length, 1)
const redone = redoWorkflowHistory(undone.history, undone.snapshot)
assert.deepEqual(plain(redone.snapshot), third)
assert.equal(redone.history.past.length, 2)
assert.equal(redone.history.future.length, 0)
const branched = pushWorkflowHistory(undone.history, branch)
assert.equal(branched.future.length, 0)
})
})
describe("getAvailableVariables", () => {
it("exposes start outputs to retrieve node", async () => {
it("returns upstream output variables in dependency order", async () => {
const { getAvailableVariables } = await loadModule()
const variables = getAvailableVariables(
{
schemaVersion: 2,
nodes: [
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: { name: "Start" } },
{ id: "retrieve_1", type: "knowledge_retrieve", position: { x: 200, y: 0 }, data: {} },
workflowNode("start_1", "start", { x: 0, y: 0 }, { title: "开始" }),
workflowNode("retrieve_1", "knowledge_retrieve", { x: 240, y: 0 }, { title: "知识检索" }),
workflowNode("reply_1", "llm_reply", { x: 480, y: 0 }, { title: "AI 回复" }),
workflowNode("end_1", "end", { x: 720, y: 0 }),
],
edges: [
workflowEdge("start_1", "retrieve_1"),
workflowEdge("retrieve_1", "reply_1"),
workflowEdge("reply_1", "end_1"),
],
edges: [{ id: "e1", source: "start_1", target: "retrieve_1" }],
},
"retrieve_1",
"reply_1",
[
{
type: "start",
outputSchema: [{ name: "userMessage", type: "string", description: "Message" }],
outputSchema: [{ name: "userMessage", label: "用户消息", type: "string", description: "input" }],
},
{
type: "knowledge_retrieve",
outputSchema: [{ name: "documents", label: "文档", type: "array<object>", description: "docs" }],
},
]
)
@@ -337,318 +208,99 @@ describe("getAvailableVariables", () => {
assert.deepEqual(plain(variables), [
{
nodeId: "start_1",
nodeName: "Start",
nodeName: "开始",
field: "userMessage",
label: "用户消息",
type: "string",
description: "Message",
description: "input",
},
])
})
it("hides variables from downstream nodes", async () => {
const { getAvailableVariables } = await loadModule()
const variables = getAvailableVariables(
{
nodes: [
{ id: "start_1", type: "start", position: { x: 0, y: 0 }, data: {} },
{ id: "reply_1", type: "send_reply", position: { x: 200, y: 0 }, data: {} },
{ id: "end_1", type: "end", position: { x: 400, y: 0 }, data: {} },
],
edges: [
{ id: "e1", source: "start_1", target: "reply_1" },
{ id: "e2", source: "reply_1", target: "end_1" },
],
},
"reply_1",
[
{
type: "end",
outputSchema: [{ name: "status", type: "string", description: "Status" }],
},
]
)
assert.deepEqual(plain(variables), [])
})
it("preserves condition editor metadata from output specs", async () => {
const { getAvailableVariables } = await loadModule()
const variables = getAvailableVariables(
{
nodes: [
{ id: "policy_1", type: "workflowNode", position: { x: 0, y: 0 }, data: { nodeType: "reply_policy", name: "回复策略" } },
{ id: "condition_1", type: "workflowNode", position: { x: 200, y: 0 }, data: { nodeType: "condition" } },
],
edges: [{ id: "e1", source: "policy_1", target: "condition_1" }],
},
"condition_1",
[
{
type: "reply_policy",
outputSchema: [
{
name: "action",
label: "处理策略",
type: "string",
description: "Selected policy action.",
operators: ["eq", "neq"],
valueOptions: [{ value: "direct_reply", label: "直接回复客户" }],
},
],
},
]
)
assert.deepEqual(plain(variables), [
{
nodeId: "policy_1",
nodeName: "回复策略",
field: "action",
label: "处理策略",
type: "string",
description: "Selected policy action.",
operators: ["eq", "neq"],
valueOptions: [{ value: "direct_reply", label: "直接回复客户" }],
nodeId: "retrieve_1",
nodeName: "知识检索",
field: "documents",
label: "文档",
type: "array<object>",
description: "docs",
},
])
})
})
describe("toApiDefinition", () => {
it("updates condition branch target from branch handle connection", async () => {
const { applyConditionBranchConnection, getConditionBranchHandleId } = await loadModule()
const draft = applyConditionBranchConnection(
{
nodes: [
{
id: "condition_1",
type: "workflowNode",
position: { x: 0, y: 0 },
data: {
nodeType: "condition",
config: {
branches: [
{ id: "direct", name: "Direct", targetNodeId: "", condition: { operator: "eq" } },
{ id: "default", name: "Else", targetNodeId: "", default: true },
],
},
},
},
{ id: "send_1", type: "workflowNode", position: { x: 200, y: 0 }, data: { nodeType: "send_reply" } },
],
edges: [],
},
{
source: "condition_1",
target: "send_1",
sourceHandle: getConditionBranchHandleId("direct"),
}
)
assert.equal(draft.nodes[0].data.config.branches[0].targetNodeId, "send_1")
assert.equal(draft.nodes[0].data.config.branches[1].targetNodeId, "")
})
it("clears condition branch target when the branch edge is removed", async () => {
const { clearConditionBranchConnection, getConditionBranchHandleId } = await loadModule()
const draft = clearConditionBranchConnection(
{
nodes: [
{
id: "condition_1",
type: "workflowNode",
position: { x: 0, y: 0 },
data: {
nodeType: "condition",
config: {
branches: [
{ id: "direct", name: "Direct", targetNodeId: "send_1", condition: { operator: "eq" } },
{ id: "default", name: "Else", targetNodeId: "fallback_1", default: true },
],
},
},
},
],
edges: [],
},
{
id: "edge_condition_send",
source: "condition_1",
target: "send_1",
sourceHandle: getConditionBranchHandleId("direct"),
}
)
assert.equal(draft.nodes[0].data.config.branches[0].targetNodeId, "")
assert.equal(draft.nodes[0].data.config.branches[1].targetNodeId, "fallback_1")
})
it("keeps condition branches on the condition node config and exports plain edges", async () => {
const { toApiDefinition } = await loadModule()
const definition = toApiDefinition({
describe("workflow definition mutations", () => {
it("updates node data without changing unrelated nodes", async () => {
const { updateWorkflowNodeData } = await loadModule()
const definition = {
schemaVersion: 2,
nodes: [
{
id: "start_1",
type: "workflowNode",
position: { x: 0, y: 0 },
data: { nodeType: "start", name: "Start", config: {} },
},
{
id: "condition_1",
type: "workflowNode",
position: { x: 200, y: 0 },
data: {
nodeType: "condition",
name: "Route",
config: {
branches: [
{
id: "vip",
name: "VIP",
targetNodeId: "vip_reply",
condition: {
left: { nodeId: "start_1", field: "userMessage" },
operator: "eq",
right: "vip",
},
},
{
id: "default",
name: "Default",
targetNodeId: "normal_reply",
default: true,
},
],
},
},
},
{
id: "vip_reply",
type: "workflowNode",
position: { x: 400, y: 0 },
data: { nodeType: "llm_reply", name: "VIP", config: {} },
},
{
id: "normal_reply",
type: "workflowNode",
position: { x: 400, y: 160 },
data: { nodeType: "llm_reply", name: "Normal", config: {} },
},
],
edges: [
{ id: "e1", source: "start_1", target: "condition_1" },
{
id: "e2",
source: "condition_1",
target: "vip_reply",
data: {
condition: {
left: { nodeId: "start_1", field: "userMessage" },
operator: "eq",
right: "legacy",
},
},
},
{ id: "e3", source: "condition_1", target: "normal_reply" },
workflowNode("start_1", "start"),
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }),
],
edges: [workflowEdge("start_1", "reply_1")],
}
const next = updateWorkflowNodeData(definition, "reply_1", {
title: "发送回复",
config: { staticReply: "hello" },
inputsValues: {},
})
assert.deepEqual(plain(definition.edges), [
{ id: "e1", source: "start_1", target: "condition_1" },
{ id: "e2", source: "condition_1", target: "vip_reply" },
{ id: "e3", source: "condition_1", target: "normal_reply" },
])
assert.deepEqual(plain(definition.nodes[1].config.branches), [
{
id: "vip",
name: "VIP",
targetNodeId: "vip_reply",
condition: {
left: { nodeId: "start_1", field: "userMessage" },
operator: "eq",
right: "vip",
},
},
{
id: "default",
name: "Default",
targetNodeId: "normal_reply",
default: true,
},
])
})
it("preserves xyflow node positions", async () => {
const { toApiDefinition } = await loadModule()
const definition = toApiDefinition({
nodes: [
{
id: "start_1",
type: "start",
position: { x: 12, y: 34 },
data: { name: "Start", config: { enabled: true } },
},
{
id: "end_1",
type: "end",
position: { x: 240, y: 80 },
data: { name: "End", config: {} },
},
],
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
})
assert.deepEqual(plain(definition), {
schemaVersion: 1,
entryNodeId: "start_1",
nodes: [
{
id: "start_1",
type: "start",
name: "Start",
position: { x: 12, y: 34 },
config: { enabled: true },
},
{
id: "end_1",
type: "end",
name: "End",
position: { x: 240, y: 80 },
config: {},
},
],
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
assert.equal(next.nodes[0].data.title, "start")
assert.deepEqual(plain(next.nodes[1].data), {
title: "发送回复",
config: { staticReply: "hello" },
inputsValues: {},
})
})
it("uses node data type for xyflow default nodes", async () => {
const { toApiDefinition } = await loadModule()
const definition = toApiDefinition({
it("deletes normal nodes and related edges while keeping start and end protected", async () => {
const { deleteWorkflowNode } = await loadModule()
const definition = {
schemaVersion: 2,
nodes: [
{
id: "start_1",
type: "default",
position: { x: 0, y: 0 },
data: { nodeType: "start", name: "Start", config: {} },
},
{
id: "end_1",
type: "default",
position: { x: 200, y: 0 },
data: { nodeType: "end", name: "End", config: {} },
},
workflowNode("start_1", "start"),
workflowNode("reply_1", "send_reply", { x: 240, y: 0 }),
workflowNode("end_1", "end", { x: 480, y: 0 }),
],
edges: [{ id: "e1", source: "start_1", target: "end_1" }],
edges: [workflowEdge("start_1", "reply_1"), workflowEdge("reply_1", "end_1")],
}
const next = deleteWorkflowNode(definition, "reply_1")
assert.deepEqual(next.nodes.map((node) => node.id), ["start_1", "end_1"])
assert.deepEqual(next.edges, [])
const protectedDefinition = deleteWorkflowNode(definition, "start_1")
assert.deepEqual(protectedDefinition, definition)
})
it("upserts and deletes condition branches in node config", async () => {
const { deleteConditionBranch, upsertConditionBranch } = await loadModule()
const definition = {
schemaVersion: 2,
nodes: [
workflowNode("condition_1", "condition", { x: 240, y: 0 }, {
config: {
branches: [{ id: "default", name: "默认", targetNodeId: "end_1", default: true }],
},
}),
workflowNode("end_1", "end", { x: 480, y: 0 }),
],
edges: [workflowEdge("condition_1", "end_1")],
}
const updated = upsertConditionBranch(definition, "condition_1", {
id: "vip",
name: "VIP",
targetNodeId: "end_1",
condition: {
left: { type: "ref", content: ["start_1", "priority"] },
operator: "eq",
right: "vip",
},
})
assert.equal(definition.entryNodeId, "start_1")
assert.equal(definition.nodes[0].type, "start")
assert.deepEqual(plain(updated.nodes[0].data.config.branches.map((branch) => branch.id)), ["default", "vip"])
const deleted = deleteConditionBranch(updated, "condition_1", "default")
assert.deepEqual(plain(deleted.nodes[0].data.config.branches.map((branch) => branch.id)), ["vip"])
})
})
File diff suppressed because it is too large Load Diff