Files
ai-video-fullstack/frontend/src/components/workflow/WorkflowCanvas.tsx
T

791 lines
25 KiB
TypeScript

"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 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: WorkflowGraph["edges"][number]["data"],
) => {
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 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
? "编辑连接条件"
: 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>
);
}