Files
ai-video-fullstack/frontend/src/components/workflow/WorkflowCanvas.tsx
2026-08-03 15:17:01 +08:00

813 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
addEdge,
Background,
BackgroundVariant,
type Connection,
Controls,
type Edge,
type Node,
type NodeChange,
type OnConnectEnd,
Panel,
ReactFlow,
useEdgesState,
useNodesState,
useReactFlow,
} from "@xyflow/react";
import { Braces, Plus, Settings2 } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { EditableTitle } from "@/components/assistant-editor/editor-controls";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { AddNodeDialog } from "./AddNodeDialog";
import { edgeTypes } from "./ConditionEdge";
import { hasDefaultPath, newEdgeData } from "./edge-rules";
import {
ActiveNodeContext,
EdgeActionContext,
NodeActionContext,
NodeSpecsContext,
} from "./context";
import { nodeTypes } from "./GenericNode";
import { EdgeSettingsPanel } from "./panels/EdgeSettingsPanel";
import { GlobalSettingsPanel } from "./panels/GlobalSettingsPanel";
import { NodeSettingsPanel } from "./panels/NodeSettingsPanel";
import { WorkflowSidePanel } from "./WorkflowSidePanel";
import {
defaultGraph,
type NodeSpecMap,
type RuntimeNodeSpec,
type WorkflowGraph,
type WorkflowEdgeData,
type WorkflowNodeData,
type WorkflowNodeType,
} from "./specs";
import type { WorkflowEditorProps } from "./types";
import { workflowGraphWithSettings } from "./graph-settings";
let nodeSeq = 0;
function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
const data: WorkflowNodeData = {
name: spec.displayName,
};
if (spec.type === "agent") {
Object.assign(data, {
contextPolicy: "inherit",
inheritGlobalConfig: true,
entryMode: "wait_user",
});
} else if (spec.type === "action") {
Object.assign(data, {
arguments: {},
resultAssignmentMode: "inherit",
userInputPolicy: "queue",
});
} else if (spec.type === "message") {
Object.assign(data, {
speech: "",
title: "重要提示",
message: "",
confirmLabel: "确认",
completionPolicy: "playback",
});
}
for (const field of spec.fields) {
if (field.default !== undefined) data[field.key] = field.default;
}
return data;
}
function toFlow(graph: WorkflowGraph): { nodes: Node[]; edges: Edge[] } {
return {
nodes: graph.nodes.map((n) => ({
id: n.id,
type: n.type,
position: n.position,
data: n.data,
})),
edges: graph.edges.map((e) => ({
id: e.id,
type: "condition",
source: e.source,
target: e.target,
data: e.data ?? {},
})),
};
}
function fromFlow(nodes: Node[], edges: Edge[]): WorkflowGraph {
return {
specVersion: 3,
settings: {
globalPrompt: "",
defaultLlmResourceId: "",
defaultAsrResourceId: "",
defaultTtsResourceId: "",
visionEnabled: false,
visionModelResourceId: "",
toolIds: [],
knowledgeBaseId: "",
knowledgeMode: "automatic",
knowledgeTopN: 5,
knowledgeScoreThreshold: 0,
enableInterrupt: true,
turnConfig: defaultGraph().settings.turnConfig,
},
nodes: nodes.map((n) => ({
id: n.id,
type: n.type as WorkflowNodeType,
position: n.position,
data: n.data as WorkflowNodeData,
})),
edges: edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
data: (e.data ?? {
mode: "always",
priority: 10,
}) as WorkflowGraph["edges"][number]["data"],
})),
};
}
export function WorkflowCanvas({
value,
onChange,
settings,
onSettingsChange,
modelOptions,
activeNodeId,
dynamicVariablesOpen,
onDynamicVariablesOpenChange,
dynamicVariablesPanel,
editingNodeId,
onEditingNodeIdChange,
editingEdgeId,
onEditingEdgeIdChange,
settingsOpen,
onSettingsOpenChange: setSettingsOpen,
debugOpen,
onDebugOpenChange,
debugPanel,
specsByType,
toolOptions = [],
knowledgeOptions = [],
}: WorkflowEditorProps & { specsByType: NodeSpecMap }) {
const initial = useMemo(() => toFlow(value ?? defaultGraph()), [value]);
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
const [addOpen, setAddOpen] = useState(false);
const [addSourceId, setAddSourceId] = useState<string | null>(null);
const [addPosition, setAddPosition] = useState<{ x: number; y: number } | null>(null);
// Fast Refresh 会替换模块级对象,而保留 React Flow 实例。用 state 固定本次
// 挂载使用的映射,避免热更新后被误判为每次渲染都在创建新对象。
const [flowNodeTypes] = useState(nodeTypes);
const [flowEdgeTypes] = useState(edgeTypes);
const { screenToFlowPosition } = useReactFlow();
// 只在可持久化的画布内容真正变化时回传。React Flow 挂载时会写入节点尺寸等
// 内部状态,这些不属于 workflow graph不能因此把刚加载的助手标记为未保存。
const onChangeRef = useRef(onChange);
const lastSyncedGraphRef = useRef(
JSON.stringify(
workflowGraphWithSettings(
fromFlow(initial.nodes, initial.edges),
settings,
),
),
);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
const graph = workflowGraphWithSettings(fromFlow(nodes, edges), settings);
const nextGraphJson = JSON.stringify(graph);
if (nextGraphJson === lastSyncedGraphRef.current) return;
lastSyncedGraphRef.current = nextGraphJson;
onChangeRef.current?.(graph);
}, [nodes, edges, settings]);
const onConnect = useCallback(
(connection: Connection) => {
if (!connection.source || !connection.target) return;
const sourceType = nodes.find(
(node) => node.id === connection.source,
)?.type;
setEdges((eds) =>
addEdge(
{
...connection,
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
type: "condition",
animated: true,
data: newEdgeData(eds, connection.source, sourceType),
},
eds,
),
);
},
[nodes, setEdges],
);
// 连线约束:不能连入开始节点(无入边句柄),不能自连。
const isValidConnection = useCallback(
(c: Connection | Edge) => {
if (c.source === c.target) return false;
const source = nodes.find((n) => n.id === c.source);
const target = nodes.find((n) => n.id === c.target);
if (!source || !target) return false;
const sourceSpec = specsByType[source.type as string];
const targetSpec = specsByType[target.type as string];
if (!sourceSpec?.hasSource || !targetSpec?.hasTarget) return false;
if (edges.some((e) => e.source === c.source && e.target === c.target)) {
return false;
}
const sourceLimit = sourceSpec.constraints.maxOutgoing;
if (
sourceLimit !== undefined &&
edges.filter((e) => e.source === c.source).length >= sourceLimit
) {
return false;
}
const targetLimit = targetSpec.constraints.maxIncoming;
if (
targetLimit !== undefined &&
edges.filter((e) => e.target === c.target).length >= targetLimit
) {
return false;
}
return true;
},
[edges, nodes, specsByType],
);
const addNode = useCallback(
(spec: RuntimeNodeSpec) => {
nodeSeq += 1;
const id = `${spec.type}-${Date.now()}-${nodeSeq}`;
const source = addSourceId
? nodes.find((node) => node.id === addSourceId)
: undefined;
const position = addPosition
? { x: addPosition.x - 125, y: addPosition.y }
: screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
});
const data = defaultNodeData(spec);
setNodes((ns) => [...ns, { id, type: spec.type, position, data }]);
if (source) {
setEdges((currentEdges) => {
return addEdge(
{
id: `e-${source.id}-${id}-${Date.now()}`,
source: source.id,
target: id,
type: "condition",
animated: true,
data: newEdgeData(currentEdges, source.id, source.type),
},
currentEdges,
);
});
}
setAddOpen(false);
setAddSourceId(null);
setAddPosition(null);
setSettingsOpen(false);
onDynamicVariablesOpenChange(false);
onDebugOpenChange(false);
onEditingEdgeIdChange(null);
onEditingNodeIdChange(id);
},
[
addPosition,
addSourceId,
nodes,
onDebugOpenChange,
onDynamicVariablesOpenChange,
onEditingEdgeIdChange,
onEditingNodeIdChange,
screenToFlowPosition,
setEdges,
setNodes,
setSettingsOpen,
],
);
const updateNodeData = useCallback(
(id: string, patch: Partial<WorkflowNodeData>) => {
setNodes((ns) =>
ns.map((n) =>
n.id === id ? { ...n, data: { ...n.data, ...patch } } : n,
),
);
},
[setNodes],
);
const deleteNode = useCallback(
(id: string) => {
if (nodes.find((node) => node.id === id)?.type === "start") return;
setNodes((ns) => ns.filter((n) => n.id !== id));
setEdges((es) => es.filter((e) => e.source !== id && e.target !== id));
if (editingNodeId === id) onEditingNodeIdChange(null);
},
[editingNodeId, nodes, onEditingNodeIdChange, setNodes, setEdges],
);
const duplicateNode = useCallback(
(id: string) => {
const source = nodes.find((node) => node.id === id);
if (!source || source.type === "start") return;
const spec = specsByType[source.type as string];
if (!spec) return;
const limit = spec.constraints.maxInstances;
if (
limit !== undefined &&
nodes.filter((node) => node.type === source.type).length >= limit
) {
return;
}
nodeSeq += 1;
const duplicateId = `${source.type}-${Date.now()}-${nodeSeq}`;
const sourceData = source.data as WorkflowNodeData;
const duplicateData = structuredClone(sourceData);
duplicateData.name = `${sourceData.name || spec.displayName}(副本)`;
setNodes((currentNodes) => [
...currentNodes.map((node) => ({ ...node, selected: false })),
{
id: duplicateId,
type: source.type,
position: {
x: source.position.x + 40,
y: source.position.y + 40,
},
data: duplicateData,
selected: true,
},
]);
onEditingNodeIdChange(null);
},
[
nodes,
onEditingNodeIdChange,
setNodes,
specsByType,
],
);
const handleNodesChange = useCallback(
(changes: NodeChange[]) => {
const startIds = new Set(
nodes.filter((node) => node.type === "start").map((node) => node.id),
);
onNodesChange(
changes.filter(
(change) => change.type !== "remove" || !startIds.has(change.id),
),
);
},
[nodes, onNodesChange],
);
const updateEdgeData = useCallback(
(
id: string,
patch: Partial<WorkflowEdgeData>,
) => {
setEdges((es) =>
es.map((e) => {
if (e.id !== id) return e;
if (
patch.mode === "always" &&
hasDefaultPath(es, e.source, e.id)
) {
return e;
}
return { ...e, data: { ...(e.data ?? {}), ...patch } };
}),
);
},
[setEdges],
);
const deleteEdge = useCallback(
(id: string) => {
setEdges((es) => es.filter((e) => e.id !== id));
if (editingEdgeId === id) onEditingEdgeIdChange(null);
},
[editingEdgeId, onEditingEdgeIdChange, setEdges],
);
const canCreateFromSource = useCallback(
(id: string) => {
const source = nodes.find((node) => node.id === id);
if (!source) return false;
const spec = specsByType[source.type as string];
if (!spec?.hasSource) return false;
const outgoingCount = edges.filter((edge) => edge.source === id).length;
if (
spec.constraints.maxOutgoing !== undefined &&
outgoingCount >= spec.constraints.maxOutgoing
) {
return false;
}
return true;
},
[edges, nodes, specsByType],
);
const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => {
if (
connectionState.isValid ||
connectionState.toNode ||
connectionState.fromHandle?.type !== "source" ||
!connectionState.fromNode ||
!canCreateFromSource(connectionState.fromNode.id)
) {
return;
}
const pointer = "changedTouches" in event
? event.changedTouches[0]
: event;
if (!pointer) return;
setAddSourceId(connectionState.fromNode.id);
setAddPosition(
screenToFlowPosition({ x: pointer.clientX, y: pointer.clientY }),
);
setAddOpen(true);
},
[canCreateFromSource, screenToFlowPosition],
);
const openSettings = useCallback(() => {
onDebugOpenChange(false);
onDynamicVariablesOpenChange(false);
onEditingNodeIdChange(null);
onEditingEdgeIdChange(null);
setSettingsOpen(true);
}, [
onDebugOpenChange,
onDynamicVariablesOpenChange,
onEditingEdgeIdChange,
onEditingNodeIdChange,
setSettingsOpen,
]);
const nodeActions = useMemo(
() => ({
edit: (id: string) => {
setSettingsOpen(false);
onDynamicVariablesOpenChange(false);
onDebugOpenChange(false);
onEditingEdgeIdChange(null);
onEditingNodeIdChange(id);
},
duplicate: duplicateNode,
remove: deleteNode,
}),
[
deleteNode,
duplicateNode,
onDebugOpenChange,
onDynamicVariablesOpenChange,
onEditingEdgeIdChange,
onEditingNodeIdChange,
setSettingsOpen,
],
);
const edgeActions = useMemo(
() => ({
edit: (id: string) => {
setSettingsOpen(false);
onDynamicVariablesOpenChange(false);
onDebugOpenChange(false);
onEditingNodeIdChange(null);
onEditingEdgeIdChange(id);
},
remove: deleteEdge,
}),
[
deleteEdge,
onDebugOpenChange,
onDynamicVariablesOpenChange,
onEditingEdgeIdChange,
onEditingNodeIdChange,
setSettingsOpen,
],
);
const openDynamicVariables = useCallback(() => {
setSettingsOpen(false);
onDebugOpenChange(false);
onEditingNodeIdChange(null);
onEditingEdgeIdChange(null);
onDynamicVariablesOpenChange(true);
}, [
onDebugOpenChange,
onDynamicVariablesOpenChange,
onEditingEdgeIdChange,
onEditingNodeIdChange,
setSettingsOpen,
]);
const editingNode = nodes.find((n) => n.id === editingNodeId);
const editingSpec = editingNode ? specsByType[editingNode.type as string] : null;
const editingEdge = edges.find((e) => e.id === editingEdgeId);
const editingEdgeData = editingEdge?.data as WorkflowEdgeData | undefined;
const addableSpecs = Object.values(specsByType).filter((s) => s.addable);
const canAddSpec = useCallback(
(spec: RuntimeNodeSpec) => {
const limit = spec.constraints.maxInstances;
if (limit === undefined) return true;
return nodes.filter((node) => node.type === spec.type).length < limit;
},
[nodes],
);
return (
<NodeSpecsContext.Provider value={specsByType}>
<ActiveNodeContext.Provider value={activeNodeId ?? null}>
<NodeActionContext.Provider value={nodeActions}>
<EdgeActionContext.Provider value={edgeActions}>
<div className="relative h-full w-full min-h-[560px]">
<section className="relative h-full w-full overflow-hidden rounded-2xl border border-hairline bg-canvas-soft shadow-sm">
<div
aria-hidden
className="pointer-events-none absolute -right-24 -top-24 z-0 h-80 w-80 rounded-full opacity-30 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-sky), transparent 68%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-28 left-1/4 z-0 h-72 w-72 rounded-full opacity-25 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-lavender), transparent 68%)",
}}
/>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={flowNodeTypes}
edgeTypes={flowEdgeTypes}
onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectEnd={onConnectEnd}
onNodeClick={(_, node) => nodeActions.edit(node.id)}
onEdgeClick={(_, edge) => edgeActions.edit(edge.id)}
isValidConnection={isValidConnection}
onPaneClick={() => {
onEditingEdgeIdChange(null);
}}
fitView
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{ type: "condition", animated: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={22}
size={1}
color="var(--hairline-strong)"
/>
<Controls
className="!rounded-xl !border !border-hairline !bg-card !shadow-sm [&_button]:!border-hairline [&_button]:!bg-card [&_button]:!text-foreground"
/>
<Panel position="top-left">
<TooltipProvider>
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
className="h-10 w-10 rounded-full shadow-sm"
aria-label="添加节点"
onClick={() => {
onDynamicVariablesOpenChange(false);
setAddSourceId(null);
setAddPosition(null);
setAddOpen(true);
}}
>
<Plus size={17} />
</Button>
</TooltipTrigger>
<TooltipContent side="right"></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-10 w-10 rounded-full border-hairline-strong bg-card text-foreground shadow-sm hover:bg-surface-strong"
aria-label="工作流设置"
onClick={openSettings}
>
<Settings2 size={17} />
</Button>
</TooltipTrigger>
<TooltipContent side="right"></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-10 w-10 rounded-full border-hairline-strong bg-card text-foreground shadow-sm hover:bg-surface-strong"
aria-label="动态变量"
onClick={openDynamicVariables}
>
<Braces size={17} />
</Button>
</TooltipTrigger>
<TooltipContent side="right"></TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
</ReactFlow>
</section>
<AddNodeDialog
open={addOpen}
connectingFromSource={Boolean(addSourceId)}
specs={addableSpecs}
canAdd={canAddSpec}
onOpenChange={(open) => {
setAddOpen(open);
if (!open) {
setAddSourceId(null);
setAddPosition(null);
}
}}
onSelect={addNode}
/>
{(debugOpen ||
dynamicVariablesOpen ||
settingsOpen ||
(editingNode && editingSpec) ||
editingEdge) && (
<WorkflowSidePanel
title={
debugOpen
? undefined
: dynamicVariablesOpen
? "动态变量"
: settingsOpen
? "工作流设置"
: editingEdge
? (
<EditableTitle
value={editingEdgeData?.label ?? ""}
placeholder={
editingEdgeData?.mode === "llm"
? "大模型判断"
: editingEdgeData?.mode === "expression"
? "表达式"
: "默认路径"
}
editLabel="连接名称"
variant="panel"
allowEmpty
maxLength={64}
onChange={(label) =>
updateEdgeData(editingEdge.id, {
label: label || undefined,
})
}
/>
)
: editingNode && editingSpec
? (
<EditableTitle
value={
(editingNode.data as WorkflowNodeData).name ??
""
}
placeholder={editingSpec.displayName}
editLabel="节点名称"
variant="panel"
onChange={(name) =>
updateNodeData(editingNode.id, { name })
}
/>
)
: "编辑节点"
}
closeLabel={
dynamicVariablesOpen
? "关闭动态变量"
: settingsOpen
? "关闭工作流设置"
: editingEdge
? "关闭边编辑"
: "关闭节点编辑"
}
onClose={
debugOpen
? undefined
: () => {
if (dynamicVariablesOpen) {
onDynamicVariablesOpenChange(false);
} else if (settingsOpen) setSettingsOpen(false);
else if (editingEdge) onEditingEdgeIdChange(null);
else onEditingNodeIdChange(null);
}
}
>
{debugOpen ? (
debugPanel
) : dynamicVariablesOpen ? (
dynamicVariablesPanel
) : (
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto bg-canvas-soft px-4 pb-5 pt-4">
{settingsOpen ? (
<GlobalSettingsPanel
settings={settings}
onSettingsChange={onSettingsChange}
modelOptions={modelOptions}
toolOptions={toolOptions}
knowledgeOptions={knowledgeOptions}
/>
) : editingNode && editingSpec ? (
<NodeSettingsPanel
key={`${editingNode.id}:${(editingNode.data as WorkflowNodeData).name ?? ""}`}
panel
spec={editingSpec}
data={editingNode.data as WorkflowNodeData}
toolOptions={toolOptions}
knowledgeOptions={knowledgeOptions}
llmOptions={modelOptions.llm}
asrOptions={modelOptions.asr}
ttsOptions={modelOptions.tts}
visionOptions={modelOptions.vision}
workflowSettings={settings}
onChange={(patch) =>
updateNodeData(editingNode.id, patch)
}
/>
) : editingEdge ? (
<EdgeSettingsPanel
key={editingEdge.id}
edge={editingEdge}
sourceType={
nodes.find(
(node) => node.id === editingEdge.source,
)?.type as string | undefined
}
isOnlyOutgoing={
edges.filter(
(edge) => edge.source === editingEdge.source,
).length === 1
}
hasOtherDefaultPath={hasDefaultPath(
edges,
editingEdge.source,
editingEdge.id,
)}
onChange={(patch) =>
updateEdgeData(editingEdge.id, patch)
}
/>
) : null}
</div>
)}
</WorkflowSidePanel>
)}
</div>
</EdgeActionContext.Provider>
</NodeActionContext.Provider>
</ActiveNodeContext.Provider>
</NodeSpecsContext.Provider>
);
}