2026-06-15 10:12:41 +08:00
|
|
|
"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,
|
2026-06-15 15:49:58 +08:00
|
|
|
type NodeChange,
|
2026-06-15 10:12:41 +08:00
|
|
|
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";
|
2026-07-12 11:08:19 +08:00
|
|
|
import type { TurnConfig } from "@/lib/api";
|
|
|
|
|
import { TurnConfigEditor } from "@/components/turn-config-editor";
|
2026-06-15 10:12:41 +08:00
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
|
|
|
|
|
import { edgeTypes } from "./ConditionEdge";
|
|
|
|
|
import {
|
2026-06-15 15:32:10 +08:00
|
|
|
ActiveNodeContext,
|
2026-06-15 10:12:41 +08:00
|
|
|
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;
|
2026-07-12 11:08:19 +08:00
|
|
|
turnConfig: TurnConfig;
|
2026-06-15 10:12:41 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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[] };
|
2026-06-15 15:32:10 +08:00
|
|
|
/** 调试通话中当前激活的节点 id(用于高亮)。 */
|
|
|
|
|
activeNodeId?: string | null;
|
2026-06-15 10:12:41 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let nodeSeq = 0;
|
|
|
|
|
const NONE = "__none__";
|
|
|
|
|
|
2026-07-11 22:26:31 +08:00
|
|
|
function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
|
|
|
|
|
const data: WorkflowNodeData = { name: spec.displayName };
|
|
|
|
|
for (const field of spec.fields) {
|
|
|
|
|
if (field.default !== undefined) data[field.key] = field.default;
|
|
|
|
|
}
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 10:12:41 +08:00
|
|
|
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,
|
2026-06-15 15:57:05 +08:00
|
|
|
data:
|
|
|
|
|
(e.data as {
|
|
|
|
|
condition?: string;
|
|
|
|
|
label?: string;
|
|
|
|
|
transition_speech?: string;
|
|
|
|
|
}) ?? {},
|
2026-06-15 10:12:41 +08:00
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Canvas({
|
|
|
|
|
value,
|
|
|
|
|
onChange,
|
|
|
|
|
settings,
|
|
|
|
|
onSettingsChange,
|
|
|
|
|
modelOptions,
|
2026-06-15 15:32:10 +08:00
|
|
|
activeNodeId,
|
2026-06-15 10:12:41 +08:00
|
|
|
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;
|
2026-07-11 22:26:31 +08:00
|
|
|
const source = nodes.find((n) => n.id === c.source);
|
2026-06-15 10:12:41 +08:00
|
|
|
const target = nodes.find((n) => n.id === c.target);
|
2026-07-11 22:26:31 +08:00
|
|
|
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;
|
2026-06-15 10:12:41 +08:00
|
|
|
},
|
2026-07-11 22:26:31 +08:00
|
|
|
[edges, nodes, specsByType],
|
2026-06-15 10:12:41 +08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
2026-07-11 22:26:31 +08:00
|
|
|
const data = defaultNodeData(spec);
|
2026-06-15 10:12:41 +08:00
|
|
|
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) => {
|
2026-06-15 15:49:58 +08:00
|
|
|
if (nodes.find((node) => node.id === id)?.type === "startCall") return;
|
2026-06-15 10:12:41 +08:00
|
|
|
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));
|
|
|
|
|
},
|
2026-06-15 15:49:58 +08:00
|
|
|
[nodes, setNodes, setEdges],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleNodesChange = useCallback(
|
|
|
|
|
(changes: NodeChange[]) => {
|
|
|
|
|
const startIds = new Set(
|
|
|
|
|
nodes.filter((node) => node.type === "startCall").map((node) => node.id),
|
|
|
|
|
);
|
|
|
|
|
onNodesChange(
|
|
|
|
|
changes.filter(
|
|
|
|
|
(change) => change.type !== "remove" || !startIds.has(change.id),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
[nodes, onNodesChange],
|
2026-06-15 10:12:41 +08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const updateEdgeData = useCallback(
|
2026-06-15 15:57:05 +08:00
|
|
|
(
|
|
|
|
|
id: string,
|
|
|
|
|
patch: {
|
|
|
|
|
condition?: string;
|
|
|
|
|
label?: string;
|
|
|
|
|
transition_speech?: string;
|
|
|
|
|
},
|
|
|
|
|
) => {
|
2026-06-15 10:12:41 +08:00
|
|
|
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);
|
2026-07-11 22:26:31 +08:00
|
|
|
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],
|
|
|
|
|
);
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<NodeSpecsContext.Provider value={specsByType}>
|
2026-06-15 15:32:10 +08:00
|
|
|
<ActiveNodeContext.Provider value={activeNodeId ?? null}>
|
2026-06-15 10:12:41 +08:00
|
|
|
<NodeActionContext.Provider value={nodeActions}>
|
|
|
|
|
<EdgeActionContext.Provider value={edgeActions}>
|
2026-06-15 15:49:58 +08:00
|
|
|
<div className="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%)",
|
|
|
|
|
}}
|
2026-06-15 10:12:41 +08:00
|
|
|
/>
|
2026-06-15 15:49:58 +08:00
|
|
|
<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%)",
|
|
|
|
|
}}
|
2026-06-15 10:12:41 +08:00
|
|
|
/>
|
2026-06-15 15:49:58 +08:00
|
|
|
|
|
|
|
|
<ReactFlow
|
|
|
|
|
nodes={nodes}
|
|
|
|
|
edges={edges}
|
|
|
|
|
nodeTypes={nodeTypes}
|
|
|
|
|
edgeTypes={edgeTypes}
|
|
|
|
|
onNodesChange={handleNodesChange}
|
|
|
|
|
onEdgesChange={onEdgesChange}
|
|
|
|
|
onConnect={onConnect}
|
|
|
|
|
isValidConnection={isValidConnection}
|
|
|
|
|
onPaneClick={() => {
|
|
|
|
|
setEditingId(null);
|
|
|
|
|
setEditingEdgeId(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"
|
|
|
|
|
/>
|
|
|
|
|
<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>
|
|
|
|
|
</section>
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
|
|
|
{/* 添加节点弹窗 */}
|
|
|
|
|
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
2026-06-15 15:49:58 +08:00
|
|
|
<DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
|
|
|
|
|
<DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden
|
|
|
|
|
className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
|
|
|
|
|
style={{
|
|
|
|
|
background:
|
|
|
|
|
"radial-gradient(circle, var(--gradient-sky), transparent 68%)",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div className="relative flex items-start gap-4">
|
|
|
|
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
|
|
|
|
<Plus size={18} />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div className="caption-label text-muted-soft">
|
|
|
|
|
节点目录
|
|
|
|
|
</div>
|
|
|
|
|
<DialogTitle className="font-display display-sm mt-1 text-ink">
|
|
|
|
|
添加节点
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription className="mt-2 leading-6">
|
|
|
|
|
选择节点类型并添加到画布中央,随后可编辑内容并建立连线。
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-15 10:12:41 +08:00
|
|
|
</DialogHeader>
|
2026-06-15 15:49:58 +08:00
|
|
|
<div className="flex max-h-[440px] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
|
|
|
|
|
{addableSpecs.length === 0 ? (
|
|
|
|
|
<p className="rounded-2xl border border-dashed border-hairline-strong bg-card px-4 py-8 text-center text-sm text-muted-soft">
|
|
|
|
|
暂无可添加的节点类型。
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
2026-06-15 10:12:41 +08:00
|
|
|
{addableSpecs.map((spec) => {
|
|
|
|
|
const Icon = spec.icon;
|
2026-07-11 22:26:31 +08:00
|
|
|
const canAdd = canAddSpec(spec);
|
2026-06-15 10:12:41 +08:00
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={spec.type}
|
|
|
|
|
type="button"
|
2026-07-11 22:26:31 +08:00
|
|
|
disabled={!canAdd}
|
|
|
|
|
className="group relative flex items-start gap-4 overflow-hidden rounded-2xl border border-hairline bg-card p-4 text-left shadow-sm transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-hairline-strong hover:shadow-md disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:translate-y-0 disabled:hover:border-hairline disabled:hover:shadow-sm"
|
2026-06-15 10:12:41 +08:00
|
|
|
onClick={() => addNode(spec)}
|
|
|
|
|
>
|
2026-06-15 15:49:58 +08:00
|
|
|
<span
|
|
|
|
|
aria-hidden
|
|
|
|
|
className="absolute left-5 right-5 top-0 h-px"
|
2026-06-15 10:12:41 +08:00
|
|
|
style={{
|
2026-06-15 15:49:58 +08:00
|
|
|
background: `linear-gradient(90deg, transparent, var(${accentVar(spec.accent)}), transparent)`,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-foreground transition-transform group-hover:scale-105"
|
|
|
|
|
style={{
|
|
|
|
|
background: `color-mix(in srgb, var(${accentVar(spec.accent)}) 28%, var(--surface-strong))`,
|
2026-06-15 10:12:41 +08:00
|
|
|
}}
|
|
|
|
|
>
|
2026-06-15 15:49:58 +08:00
|
|
|
<Icon size={17} />
|
2026-06-15 10:12:41 +08:00
|
|
|
</div>
|
2026-06-15 15:49:58 +08:00
|
|
|
<div className="min-w-0 flex-1">
|
2026-07-11 22:26:31 +08:00
|
|
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
|
|
|
<span>{spec.displayName}</span>
|
|
|
|
|
{!canAdd && (
|
|
|
|
|
<span className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] font-normal text-muted-foreground">
|
|
|
|
|
已添加
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-06-15 10:12:41 +08:00
|
|
|
</div>
|
2026-06-15 15:49:58 +08:00
|
|
|
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
2026-06-15 10:12:41 +08:00
|
|
|
{spec.description}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-15 15:49:58 +08:00
|
|
|
<Plus
|
|
|
|
|
size={15}
|
|
|
|
|
className="mt-1 shrink-0 text-muted-soft transition-colors group-hover:text-foreground"
|
|
|
|
|
/>
|
2026-06-15 10:12:41 +08:00
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
{/* 工作流设置弹窗 */}
|
|
|
|
|
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
2026-06-15 15:49:58 +08:00
|
|
|
<DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
|
|
|
|
|
<DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden
|
|
|
|
|
className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
|
|
|
|
|
style={{
|
|
|
|
|
background:
|
|
|
|
|
"radial-gradient(circle, var(--gradient-lavender), transparent 68%)",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div className="relative flex items-start gap-4">
|
|
|
|
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
|
|
|
|
<Settings2 size={18} />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div className="caption-label text-muted-soft">
|
|
|
|
|
全局配置
|
|
|
|
|
</div>
|
|
|
|
|
<DialogTitle className="font-display display-sm mt-1 text-ink">
|
|
|
|
|
工作流设置
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription className="mt-2 leading-6">
|
|
|
|
|
配置整个工作流使用的语音、大模型与交互策略。
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-15 10:12:41 +08:00
|
|
|
</DialogHeader>
|
2026-06-15 15:49:58 +08:00
|
|
|
<div className="flex max-h-[62vh] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
|
2026-06-15 10:12:41 +08:00
|
|
|
<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 })}
|
|
|
|
|
/>
|
2026-07-12 11:08:19 +08:00
|
|
|
<TurnConfigEditor
|
|
|
|
|
enabled={settings.allowInterrupt}
|
|
|
|
|
config={settings.turnConfig}
|
|
|
|
|
onEnabledChange={(allowInterrupt) =>
|
|
|
|
|
onSettingsChange({ ...settings, allowInterrupt })
|
|
|
|
|
}
|
|
|
|
|
onConfigChange={(turnConfig) =>
|
|
|
|
|
onSettingsChange({ ...settings, turnConfig })
|
|
|
|
|
}
|
|
|
|
|
/>
|
2026-06-15 10:12:41 +08:00
|
|
|
</div>
|
2026-06-15 15:49:58 +08:00
|
|
|
<DialogFooter className="border-t border-hairline bg-card px-5 py-4">
|
2026-06-15 10:12:41 +08:00
|
|
|
<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={
|
2026-06-15 15:49:58 +08:00
|
|
|
editingNode.type !== "startCall"
|
2026-06-15 10:12:41 +08:00
|
|
|
? () => 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>
|
2026-06-15 15:32:10 +08:00
|
|
|
</ActiveNodeContext.Provider>
|
2026-06-15 10:12:41 +08:00
|
|
|
</NodeSpecsContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ModelSelect({
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
options,
|
|
|
|
|
onChange,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
value?: string;
|
|
|
|
|
options: ModelOption[];
|
|
|
|
|
onChange: (value: string | undefined) => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
2026-06-15 15:49:58 +08:00
|
|
|
<div className="flex flex-col gap-2 rounded-2xl border border-hairline bg-card p-4 shadow-sm">
|
2026-06-15 10:12:41 +08:00
|
|
|
<label className="text-sm font-medium text-foreground">{label}</label>
|
|
|
|
|
<Select
|
|
|
|
|
value={value ?? NONE}
|
|
|
|
|
onValueChange={(v) => onChange(v === NONE ? undefined : v)}
|
|
|
|
|
>
|
2026-06-15 15:49:58 +08:00
|
|
|
<SelectTrigger className="w-full border-hairline-strong bg-canvas-soft text-foreground">
|
2026-06-15 10:12:41 +08:00
|
|
|
<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 }));
|
2026-07-11 22:26:31 +08:00
|
|
|
const missingRequired = spec.fields.some((field) => {
|
|
|
|
|
if (!field.required) return false;
|
|
|
|
|
const value = draft[field.key];
|
|
|
|
|
return typeof value === "string" ? !value.trim() : value == null;
|
|
|
|
|
});
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
|
|
|
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 />
|
|
|
|
|
)}
|
2026-07-11 22:26:31 +08:00
|
|
|
<Button disabled={missingRequired} onClick={() => onSave(draft)}>
|
|
|
|
|
保存
|
|
|
|
|
</Button>
|
2026-06-15 10:12:41 +08:00
|
|
|
</DialogFooter>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function EdgeForm({
|
|
|
|
|
edge,
|
|
|
|
|
onSave,
|
|
|
|
|
onDelete,
|
|
|
|
|
}: {
|
|
|
|
|
edge: Edge;
|
2026-06-15 15:57:05 +08:00
|
|
|
onSave: (patch: {
|
|
|
|
|
condition: string;
|
|
|
|
|
label: string;
|
|
|
|
|
transition_speech: string;
|
|
|
|
|
}) => void;
|
2026-06-15 10:12:41 +08:00
|
|
|
onDelete: () => void;
|
|
|
|
|
}) {
|
2026-06-15 15:57:05 +08:00
|
|
|
const data = (edge.data ?? {}) as {
|
|
|
|
|
condition?: string;
|
|
|
|
|
label?: string;
|
|
|
|
|
transition_speech?: string;
|
|
|
|
|
};
|
2026-06-15 10:12:41 +08:00
|
|
|
const [label, setLabel] = useState(data.label ?? "");
|
|
|
|
|
const [condition, setCondition] = useState(data.condition ?? "");
|
2026-06-15 15:57:05 +08:00
|
|
|
const [transitionSpeech, setTransitionSpeech] = useState(
|
|
|
|
|
data.transition_speech ?? "",
|
|
|
|
|
);
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
|
|
|
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>
|
2026-06-15 15:57:05 +08:00
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<label className="text-sm font-medium text-foreground">
|
|
|
|
|
过渡语(可选)
|
|
|
|
|
</label>
|
|
|
|
|
<Textarea
|
|
|
|
|
rows={2}
|
|
|
|
|
value={transitionSpeech}
|
|
|
|
|
placeholder="切换到下一节点前先说的一句过渡语,用于掩盖延迟,例如:好的,我帮你转接。"
|
|
|
|
|
onChange={(e) => setTransitionSpeech(e.target.value)}
|
|
|
|
|
className="resize-none border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-xs text-muted-soft">
|
|
|
|
|
命中该条件、切换节点的瞬间播报,不写入对话上下文。
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-06-15 10:12:41 +08:00
|
|
|
</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>
|
2026-06-15 15:57:05 +08:00
|
|
|
<Button
|
|
|
|
|
onClick={() =>
|
|
|
|
|
onSave({ condition, label, transition_speech: transitionSpeech })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
保存条件
|
|
|
|
|
</Button>
|
2026-06-15 10:12:41 +08:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|