813 lines
26 KiB
TypeScript
813 lines
26 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 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>
|
||
);
|
||
}
|