Files
ai-video-fullstack/frontend/src/components/workflow/WorkflowEditor.tsx
Xin Wang c2a39257ff Add workflow editor and node types support in frontend and backend
- Introduce a new workflow editor component for visualizing and managing workflows, allowing users to add nodes and define connections.
- Implement backend support for node types, including validation and constraints for workflow graphs.
- Add new API endpoints for retrieving node types and their specifications.
- Enhance the AssistantPage to integrate the workflow editor, enabling users to create and edit workflows directly.
- Update frontend components to support new workflow functionalities, including condition edges and generic nodes.
- Refactor existing code to accommodate the new workflow features and improve overall structure.
2026-06-15 10:12:41 +08:00

663 lines
22 KiB
TypeScript

"use client";
/**
* 工作流可视化编辑器。基于 @xyflow/react,样式套用 ai-video 设计 token。
*
* 工具栏:添加节点(弹窗选类型)、工作流设置(ASR/LLM/TTS、是否允许打断)。
* 节点与条件边:悬停/选中出现「编辑 / 删除」按钮,编辑统一用弹出对话框。
* 节点目录来自后端 /api/node-types。数据通过 value/onChange 与外部 graph 同步。
*/
import {
addEdge,
Background,
BackgroundVariant,
type Connection,
Controls,
type Edge,
MiniMap,
type Node,
Panel,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { Loader2, Plus, Settings2, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { edgeTypes } from "./ConditionEdge";
import {
EdgeActionContext,
NodeActionContext,
NodeSpecsContext,
} from "./context";
import { nodeTypes } from "./GenericNode";
import {
accentVar,
defaultGraph,
type NodeSpecMap,
type RuntimeNodeSpec,
type WorkflowGraph,
type WorkflowNodeData,
type WorkflowNodeType,
} from "./specs";
import { useNodeSpecs } from "./useNodeSpecs";
export type WorkflowSettings = {
llm?: string;
asr?: string;
tts?: string;
allowInterrupt: boolean;
};
export type ModelOption = { value: string; label: string };
export type WorkflowEditorProps = {
value?: WorkflowGraph;
onChange?: (graph: WorkflowGraph) => void;
settings: WorkflowSettings;
onSettingsChange: (settings: WorkflowSettings) => void;
modelOptions: { llm: ModelOption[]; asr: ModelOption[]; tts: ModelOption[] };
};
let nodeSeq = 0;
const NONE = "__none__";
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 {
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 as { condition?: string; label?: string }) ?? {},
})),
};
}
function Canvas({
value,
onChange,
settings,
onSettingsChange,
modelOptions,
specsByType,
}: 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 [editingId, setEditingId] = useState<string | null>(null);
const [editingEdgeId, setEditingEdgeId] = useState<string | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const { screenToFlowPosition } = useReactFlow();
// 回传画布状态给外部(助手 graph)。用 ref 避免把 onChange 放进依赖导致循环。
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onChangeRef.current?.(fromFlow(nodes, edges));
}, [nodes, edges]);
const onConnect = useCallback(
(connection: Connection) => {
setEdges((eds) =>
addEdge(
{
...connection,
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
type: "condition",
animated: true,
data: {},
},
eds,
),
);
},
[setEdges],
);
// 连线约束:不能连入开始节点(无入边句柄),不能自连。
const isValidConnection = useCallback(
(c: Connection | Edge) => {
if (c.source === c.target) return false;
const target = nodes.find((n) => n.id === c.target);
if (!target) return false;
const spec = specsByType[target.type as string];
return !!spec?.hasTarget;
},
[nodes, specsByType],
);
const addNode = useCallback(
(spec: RuntimeNodeSpec) => {
nodeSeq += 1;
const id = `${spec.type}-${Date.now()}-${nodeSeq}`;
const position = screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
});
const data: WorkflowNodeData = { name: spec.displayName };
if (spec.type === "agentNode") {
data.allowInterrupt = true;
data.prompt = "";
}
setNodes((ns) => [...ns, { id, type: spec.type, position, data }]);
setAddOpen(false);
setEditingId(id);
},
[screenToFlowPosition, setNodes],
);
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) => {
setNodes((ns) => ns.filter((n) => n.id !== id));
setEdges((es) => es.filter((e) => e.source !== id && e.target !== id));
setEditingId((cur) => (cur === id ? null : cur));
},
[setNodes, setEdges],
);
const updateEdgeData = useCallback(
(id: string, patch: { condition?: string; label?: string }) => {
setEdges((es) =>
es.map((e) =>
e.id === id ? { ...e, data: { ...(e.data ?? {}), ...patch } } : e,
),
);
},
[setEdges],
);
const deleteEdge = useCallback(
(id: string) => {
setEdges((es) => es.filter((e) => e.id !== id));
setEditingEdgeId((cur) => (cur === id ? null : cur));
},
[setEdges],
);
const nodeActions = useMemo(
() => ({ edit: setEditingId, remove: deleteNode }),
[deleteNode],
);
const edgeActions = useMemo(
() => ({ edit: setEditingEdgeId, remove: deleteEdge }),
[deleteEdge],
);
const editingNode = nodes.find((n) => n.id === editingId);
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);
return (
<NodeSpecsContext.Provider value={specsByType}>
<NodeActionContext.Provider value={nodeActions}>
<EdgeActionContext.Provider value={edgeActions}>
<div className="h-full w-full overflow-hidden rounded-2xl border border-hairline bg-canvas-soft">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
onPaneClick={() => {
setEditingId(null);
setEditingEdgeId(null);
}}
fitView
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{ type: "condition", animated: true }}
className="bg-canvas-soft"
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
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" />
<MiniMap
pannable
zoomable
className="!rounded-xl !border !border-hairline !bg-card"
maskColor="color-mix(in srgb, var(--canvas-soft) 70%, transparent)"
nodeColor="var(--surface-strong)"
/>
<Panel position="top-left" className="flex gap-2">
<Button size="sm" className="gap-2" onClick={() => setAddOpen(true)}>
<Plus size={15} />
</Button>
<Button
size="sm"
variant="outline"
className="gap-2 border-hairline-strong bg-card text-foreground hover:bg-surface-strong"
onClick={() => setSettingsOpen(true)}
>
<Settings2 size={15} />
</Button>
</Panel>
</ReactFlow>
{/* 添加节点弹窗 */}
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle className="font-display text-ink">
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 py-2">
{addableSpecs.length === 0 && (
<p className="text-sm text-muted-soft"></p>
)}
{addableSpecs.map((spec) => {
const Icon = spec.icon;
return (
<button
key={spec.type}
type="button"
className="flex items-start gap-3 rounded-xl border border-hairline bg-card p-3 text-left transition-colors hover:border-primary hover:bg-surface-strong/40"
onClick={() => addNode(spec)}
>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-ink"
style={{
backgroundImage: `radial-gradient(circle at 30% 30%, color-mix(in srgb, var(${accentVar(spec.accent)}) 70%, transparent), color-mix(in srgb, var(${accentVar(spec.accent)}) 35%, transparent))`,
}}
>
<Icon size={18} />
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
{spec.displayName}
</div>
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">
{spec.description}
</div>
</div>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
{/* 工作流设置弹窗 */}
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle className="font-display text-ink">
</DialogTitle>
<DialogDescription>
使,
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-5 py-2">
<ModelSelect
label="大模型(LLM)"
value={settings.llm}
options={modelOptions.llm}
onChange={(v) => onSettingsChange({ ...settings, llm: v })}
/>
<ModelSelect
label="语音识别(ASR)"
value={settings.asr}
options={modelOptions.asr}
onChange={(v) => onSettingsChange({ ...settings, asr: v })}
/>
<ModelSelect
label="语音合成(TTS)"
value={settings.tts}
options={modelOptions.tts}
onChange={(v) => onSettingsChange({ ...settings, tts: v })}
/>
<label className="flex items-center justify-between gap-3 pt-1">
<span className="text-sm font-medium text-foreground">
</span>
<Switch
checked={settings.allowInterrupt}
onCheckedChange={(checked) =>
onSettingsChange({ ...settings, allowInterrupt: checked })
}
/>
</label>
</div>
<DialogFooter>
<Button onClick={() => setSettingsOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 节点编辑弹窗 */}
<Dialog
open={!!editingNode}
onOpenChange={(open) => !open && setEditingId(null)}
>
<DialogContent className="sm:max-w-[460px]">
{editingNode && editingSpec && (
<NodeForm
key={editingNode.id}
spec={editingSpec}
data={editingNode.data as WorkflowNodeData}
onSave={(patch) => {
updateNodeData(editingNode.id, patch);
setEditingId(null);
}}
onDelete={
editingSpec.addable
? () => deleteNode(editingNode.id)
: undefined
}
/>
)}
</DialogContent>
</Dialog>
{/* 条件编辑弹窗 */}
<Dialog
open={!!editingEdge}
onOpenChange={(open) => !open && setEditingEdgeId(null)}
>
<DialogContent className="sm:max-w-[460px]">
{editingEdge && (
<EdgeForm
key={editingEdge.id}
edge={editingEdge}
onSave={(patch) => {
updateEdgeData(editingEdge.id, patch);
setEditingEdgeId(null);
}}
onDelete={() => deleteEdge(editingEdge.id)}
/>
)}
</DialogContent>
</Dialog>
</div>
</EdgeActionContext.Provider>
</NodeActionContext.Provider>
</NodeSpecsContext.Provider>
);
}
function ModelSelect({
label,
value,
options,
onChange,
}: {
label: string;
value?: string;
options: ModelOption[];
onChange: (value: string | undefined) => void;
}) {
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground">{label}</label>
<Select
value={value ?? NONE}
onValueChange={(v) => onChange(v === NONE ? undefined : v)}
>
<SelectTrigger className="border-hairline-strong bg-background text-foreground">
<SelectValue placeholder="选择模型资源" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function NodeForm({
spec,
data,
onSave,
onDelete,
}: {
spec: RuntimeNodeSpec;
data: WorkflowNodeData;
onSave: (patch: WorkflowNodeData) => void;
onDelete?: () => void;
}) {
const [draft, setDraft] = useState<WorkflowNodeData>({ ...data });
const set = (key: string, val: unknown) =>
setDraft((d) => ({ ...d, [key]: val }));
return (
<>
<DialogHeader>
<DialogTitle className="font-display text-ink">
{spec.displayName}
</DialogTitle>
<DialogDescription>{spec.description}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-5 py-2">
{spec.fields.map((field) => {
const raw = draft[field.key];
if (field.type === "switch") {
return (
<label
key={field.key}
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium text-foreground">
{field.label}
</span>
<Switch
checked={Boolean(raw)}
onCheckedChange={(checked) => set(field.key, checked)}
/>
</label>
);
}
return (
<div key={field.key} className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground">
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</label>
{field.type === "textarea" ? (
<Textarea
rows={4}
value={(raw as string) ?? ""}
onChange={(e) => set(field.key, e.target.value)}
className="resize-none border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
) : (
<Input
value={(raw as string) ?? ""}
onChange={(e) => set(field.key, e.target.value)}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
)}
</div>
);
})}
</div>
<DialogFooter className="flex-row justify-between sm:justify-between">
{onDelete ? (
<Button
variant="outline"
className="gap-2 border-hairline-strong text-muted-foreground hover:text-destructive"
onClick={onDelete}
>
<Trash2 size={15} />
</Button>
) : (
<span />
)}
<Button onClick={() => onSave(draft)}></Button>
</DialogFooter>
</>
);
}
function EdgeForm({
edge,
onSave,
onDelete,
}: {
edge: Edge;
onSave: (patch: { condition: string; label: string }) => void;
onDelete: () => void;
}) {
const data = (edge.data ?? {}) as { condition?: string; label?: string };
const [label, setLabel] = useState(data.label ?? "");
const [condition, setCondition] = useState(data.condition ?? "");
return (
<>
<DialogHeader>
<DialogTitle className="font-display text-ink"></DialogTitle>
<DialogDescription>
沿
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-5 py-2">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground"></label>
<Input
value={label}
maxLength={64}
placeholder="例如:用户想转人工"
onChange={(e) => setLabel(e.target.value)}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
<span className="text-xs text-muted-soft">
,{label.length}/64
</span>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground"></label>
<Textarea
rows={4}
value={condition}
placeholder="描述触发这条路径的条件,例如:用户明确表示要找人工客服。"
onChange={(e) => setCondition(e.target.value)}
className="resize-none border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</div>
</div>
<DialogFooter className="flex-row justify-between sm:justify-between">
<Button
variant="outline"
className="gap-2 border-hairline-strong text-muted-foreground hover:text-destructive"
onClick={onDelete}
>
<Trash2 size={15} />
线
</Button>
<Button onClick={() => onSave({ condition, label })}></Button>
</DialogFooter>
</>
);
}
export function WorkflowEditor(props: WorkflowEditorProps) {
const { byType, loading, error } = useNodeSpecs();
if (loading) {
return (
<div className="flex h-full w-full items-center justify-center rounded-2xl border border-hairline bg-canvas-soft text-muted-foreground">
<Loader2 className="mr-2 animate-spin" size={18} />
</div>
);
}
if (error) {
return (
<div className="flex h-full w-full items-center justify-center rounded-2xl border border-hairline bg-canvas-soft px-6 text-center text-sm text-destructive">
:{error}
</div>
);
}
return (
<ReactFlowProvider>
<Canvas {...props} specsByType={byType} />
</ReactFlowProvider>
);
}