2026-06-15 10:12:41 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 工作流可视化编辑器。基于 @xyflow/react,样式套用 ai-video 设计 token。
|
|
|
|
|
|
*
|
2026-07-14 09:36:28 +08:00
|
|
|
|
* 工具栏:添加节点、工作流设置、动态变量。全局设置、节点、条件边编辑与调试预览
|
2026-07-13 16:13:27 +08:00
|
|
|
|
* 复用画布右半覆盖层。
|
2026-06-15 10:12:41 +08:00
|
|
|
|
* 节点目录来自后端 /api/node-types。数据通过 value/onChange 与外部 graph 同步。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
addEdge,
|
|
|
|
|
|
Background,
|
|
|
|
|
|
BackgroundVariant,
|
|
|
|
|
|
type Connection,
|
|
|
|
|
|
Controls,
|
|
|
|
|
|
type Edge,
|
|
|
|
|
|
type Node,
|
2026-06-15 15:49:58 +08:00
|
|
|
|
type NodeChange,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
type OnConnectEnd,
|
2026-06-15 10:12:41 +08:00
|
|
|
|
Panel,
|
|
|
|
|
|
ReactFlow,
|
|
|
|
|
|
ReactFlowProvider,
|
|
|
|
|
|
useEdgesState,
|
|
|
|
|
|
useNodesState,
|
|
|
|
|
|
useReactFlow,
|
|
|
|
|
|
} from "@xyflow/react";
|
|
|
|
|
|
import "@xyflow/react/dist/style.css";
|
2026-07-14 09:36:28 +08:00
|
|
|
|
import {
|
|
|
|
|
|
AudioLines,
|
|
|
|
|
|
Braces,
|
|
|
|
|
|
Bot,
|
|
|
|
|
|
Brain,
|
|
|
|
|
|
Database,
|
|
|
|
|
|
Flag,
|
|
|
|
|
|
GitBranch,
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
MessageSquareText,
|
|
|
|
|
|
PhoneForwarded,
|
|
|
|
|
|
Play,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Settings2,
|
|
|
|
|
|
Sparkles,
|
|
|
|
|
|
Tag,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
Wrench,
|
|
|
|
|
|
X,
|
|
|
|
|
|
Zap,
|
|
|
|
|
|
} from "lucide-react";
|
2026-07-13 16:13:27 +08:00
|
|
|
|
import {
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useMemo,
|
|
|
|
|
|
useRef,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
type ReactNode,
|
|
|
|
|
|
} from "react";
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
DialogContent,
|
|
|
|
|
|
DialogDescription,
|
|
|
|
|
|
DialogFooter,
|
|
|
|
|
|
DialogHeader,
|
|
|
|
|
|
DialogTitle,
|
|
|
|
|
|
} from "@/components/ui/dialog";
|
2026-07-14 09:36:28 +08:00
|
|
|
|
import { SectionCard } from "@/components/editor/section-card";
|
|
|
|
|
|
import { KnowledgeRetrievalConfigDialog } from "@/components/editor/knowledge-retrieval-config-dialog";
|
2026-06-15 10:12:41 +08:00
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
2026-07-14 09:36:28 +08:00
|
|
|
|
import type { KnowledgeRetrievalConfig, TurnConfig } from "@/lib/api";
|
2026-07-12 11:08:19 +08:00
|
|
|
|
import { TurnConfigEditor } from "@/components/turn-config-editor";
|
2026-07-14 11:08:11 +08:00
|
|
|
|
import { normalizeTurnConfig } from "@/lib/turn-config";
|
2026-06-15 10:12:41 +08:00
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
2026-07-13 16:13:27 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Tooltip,
|
|
|
|
|
|
TooltipContent,
|
|
|
|
|
|
TooltipProvider,
|
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
|
} from "@/components/ui/tooltip";
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
type ExpressionRule,
|
2026-06-15 10:12:41 +08:00
|
|
|
|
type WorkflowGraph,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
type WorkflowEdgeData,
|
2026-06-15 10:12:41 +08:00
|
|
|
|
type WorkflowNodeData,
|
|
|
|
|
|
type WorkflowNodeType,
|
|
|
|
|
|
} from "./specs";
|
|
|
|
|
|
import { useNodeSpecs } from "./useNodeSpecs";
|
|
|
|
|
|
|
|
|
|
|
|
export type WorkflowSettings = {
|
|
|
|
|
|
llm?: string;
|
|
|
|
|
|
asr?: string;
|
|
|
|
|
|
tts?: string;
|
2026-07-14 09:36:28 +08:00
|
|
|
|
toolIds: string[];
|
|
|
|
|
|
knowledgeBaseId: string;
|
|
|
|
|
|
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
|
2026-07-13 16:13:27 +08:00
|
|
|
|
globalPrompt: string;
|
2026-06-15 10:12:41 +08:00
|
|
|
|
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-07-13 16:13:27 +08:00
|
|
|
|
toolOptions?: ModelOption[];
|
|
|
|
|
|
knowledgeOptions?: ModelOption[];
|
|
|
|
|
|
onOpenDynamicVariables: () => void;
|
|
|
|
|
|
editingNodeId: string | null;
|
|
|
|
|
|
onEditingNodeIdChange: (nodeId: string | null) => void;
|
|
|
|
|
|
editingEdgeId: string | null;
|
|
|
|
|
|
onEditingEdgeIdChange: (edgeId: string | null) => void;
|
2026-07-14 09:36:28 +08:00
|
|
|
|
settingsOpen: boolean;
|
|
|
|
|
|
onSettingsOpenChange: (open: boolean) => void;
|
2026-07-13 16:13:27 +08:00
|
|
|
|
debugOpen: boolean;
|
|
|
|
|
|
onDebugOpenChange: (open: boolean) => void;
|
|
|
|
|
|
debugPanel: ReactNode;
|
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 {
|
2026-07-14 09:36:28 +08:00
|
|
|
|
const data: WorkflowNodeData = {
|
|
|
|
|
|
name: spec.displayName,
|
|
|
|
|
|
...(spec.type === "agent"
|
|
|
|
|
|
? {
|
|
|
|
|
|
contextPolicy: "inherit",
|
|
|
|
|
|
inheritGlobalConfig: true,
|
|
|
|
|
|
entryMode: "wait_user",
|
|
|
|
|
|
entrySpeech: "",
|
|
|
|
|
|
}
|
|
|
|
|
|
: {}),
|
|
|
|
|
|
};
|
2026-07-11 22:26:31 +08:00
|
|
|
|
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 {
|
2026-07-13 16:13:27 +08:00
|
|
|
|
specVersion: 3,
|
|
|
|
|
|
settings: {
|
|
|
|
|
|
globalPrompt: "",
|
2026-07-14 09:36:28 +08:00
|
|
|
|
defaultLlmResourceId: "",
|
2026-07-13 16:13:27 +08:00
|
|
|
|
defaultAsrResourceId: "",
|
|
|
|
|
|
defaultTtsResourceId: "",
|
2026-07-14 09:36:28 +08:00
|
|
|
|
toolIds: [],
|
|
|
|
|
|
knowledgeBaseId: "",
|
|
|
|
|
|
knowledgeMode: "automatic",
|
|
|
|
|
|
knowledgeTopN: 5,
|
|
|
|
|
|
knowledgeScoreThreshold: 0,
|
2026-07-14 11:08:11 +08:00
|
|
|
|
enableInterrupt: true,
|
|
|
|
|
|
turnConfig: defaultGraph().settings.turnConfig,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
},
|
2026-06-15 10:12:41 +08:00
|
|
|
|
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-07-13 16:13:27 +08:00
|
|
|
|
data: (e.data ?? {
|
|
|
|
|
|
mode: "always",
|
|
|
|
|
|
priority: 10,
|
|
|
|
|
|
}) as WorkflowGraph["edges"][number]["data"],
|
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-07-13 16:13:27 +08:00
|
|
|
|
onOpenDynamicVariables,
|
|
|
|
|
|
editingNodeId,
|
|
|
|
|
|
onEditingNodeIdChange,
|
|
|
|
|
|
editingEdgeId,
|
|
|
|
|
|
onEditingEdgeIdChange,
|
2026-07-14 09:36:28 +08:00
|
|
|
|
settingsOpen,
|
|
|
|
|
|
onSettingsOpenChange: setSettingsOpen,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
debugOpen,
|
|
|
|
|
|
onDebugOpenChange,
|
|
|
|
|
|
debugPanel,
|
2026-06-15 10:12:41 +08:00
|
|
|
|
specsByType,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
toolOptions = [],
|
|
|
|
|
|
knowledgeOptions = [],
|
2026-06-15 10:12:41 +08:00
|
|
|
|
}: 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);
|
2026-07-13 16:13:27 +08:00
|
|
|
|
const [addSourceId, setAddSourceId] = useState<string | null>(null);
|
|
|
|
|
|
const [addPosition, setAddPosition] = useState<{ x: number; y: number } | null>(null);
|
2026-06-15 10:12:41 +08:00
|
|
|
|
const { screenToFlowPosition } = useReactFlow();
|
|
|
|
|
|
|
|
|
|
|
|
// 回传画布状态给外部(助手 graph)。用 ref 避免把 onChange 放进依赖导致循环。
|
|
|
|
|
|
const onChangeRef = useRef(onChange);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
onChangeRef.current = onChange;
|
|
|
|
|
|
}, [onChange]);
|
|
|
|
|
|
useEffect(() => {
|
2026-07-13 16:13:27 +08:00
|
|
|
|
const graph = fromFlow(nodes, edges);
|
|
|
|
|
|
graph.settings = {
|
|
|
|
|
|
globalPrompt: settings.globalPrompt,
|
2026-07-14 09:36:28 +08:00
|
|
|
|
defaultLlmResourceId: settings.llm ?? "",
|
2026-07-13 16:13:27 +08:00
|
|
|
|
defaultAsrResourceId: settings.asr ?? "",
|
|
|
|
|
|
defaultTtsResourceId: settings.tts ?? "",
|
2026-07-14 09:36:28 +08:00
|
|
|
|
toolIds: settings.toolIds,
|
|
|
|
|
|
knowledgeBaseId: settings.knowledgeBaseId,
|
|
|
|
|
|
knowledgeMode: settings.knowledgeRetrievalConfig.mode,
|
|
|
|
|
|
knowledgeTopN: settings.knowledgeRetrievalConfig.topN,
|
|
|
|
|
|
knowledgeScoreThreshold:
|
|
|
|
|
|
settings.knowledgeRetrievalConfig.scoreThreshold,
|
2026-07-14 11:08:11 +08:00
|
|
|
|
enableInterrupt: settings.allowInterrupt,
|
|
|
|
|
|
turnConfig: settings.turnConfig,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
};
|
|
|
|
|
|
onChangeRef.current?.(graph);
|
2026-07-14 09:36:28 +08:00
|
|
|
|
}, [
|
|
|
|
|
|
nodes,
|
|
|
|
|
|
edges,
|
|
|
|
|
|
settings.globalPrompt,
|
|
|
|
|
|
settings.llm,
|
|
|
|
|
|
settings.asr,
|
|
|
|
|
|
settings.tts,
|
|
|
|
|
|
settings.toolIds,
|
|
|
|
|
|
settings.knowledgeBaseId,
|
|
|
|
|
|
settings.knowledgeRetrievalConfig,
|
2026-07-14 11:08:11 +08:00
|
|
|
|
settings.allowInterrupt,
|
|
|
|
|
|
settings.turnConfig,
|
2026-07-14 09:36:28 +08:00
|
|
|
|
]);
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
|
|
|
|
|
const onConnect = useCallback(
|
|
|
|
|
|
(connection: Connection) => {
|
2026-07-13 16:13:27 +08:00
|
|
|
|
const sourceType = nodes.find((node) => node.id === connection.source)?.type;
|
|
|
|
|
|
const priority =
|
|
|
|
|
|
edges.filter((edge) => edge.source === connection.source).length * 10 + 10;
|
2026-06-15 10:12:41 +08:00
|
|
|
|
setEdges((eds) =>
|
|
|
|
|
|
addEdge(
|
|
|
|
|
|
{
|
|
|
|
|
|
...connection,
|
|
|
|
|
|
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
|
|
|
|
|
|
type: "condition",
|
|
|
|
|
|
animated: true,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
data:
|
|
|
|
|
|
sourceType === "agent"
|
|
|
|
|
|
? {
|
|
|
|
|
|
mode: "llm",
|
|
|
|
|
|
priority,
|
|
|
|
|
|
condition: "当前阶段任务已经完成",
|
|
|
|
|
|
}
|
|
|
|
|
|
: { mode: "always", priority },
|
2026-06-15 10:12:41 +08:00
|
|
|
|
},
|
|
|
|
|
|
eds,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
2026-07-13 16:13:27 +08:00
|
|
|
|
[nodes, edges, setEdges],
|
2026-06-15 10:12:41 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 连线约束:不能连入开始节点(无入边句柄),不能自连。
|
|
|
|
|
|
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}`;
|
2026-07-13 16:13:27 +08:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
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 }]);
|
2026-07-13 16:13:27 +08:00
|
|
|
|
if (source) {
|
|
|
|
|
|
setEdges((currentEdges) => {
|
|
|
|
|
|
const priority =
|
|
|
|
|
|
currentEdges.filter((edge) => edge.source === source.id).length * 10 + 10;
|
|
|
|
|
|
return addEdge(
|
|
|
|
|
|
{
|
|
|
|
|
|
id: `e-${source.id}-${id}-${Date.now()}`,
|
|
|
|
|
|
source: source.id,
|
|
|
|
|
|
target: id,
|
|
|
|
|
|
type: "condition",
|
|
|
|
|
|
animated: true,
|
|
|
|
|
|
data:
|
|
|
|
|
|
source.type === "agent"
|
|
|
|
|
|
? {
|
|
|
|
|
|
mode: "llm",
|
|
|
|
|
|
priority,
|
|
|
|
|
|
condition: "当前阶段任务已经完成",
|
|
|
|
|
|
}
|
|
|
|
|
|
: { mode: "always", priority },
|
|
|
|
|
|
},
|
|
|
|
|
|
currentEdges,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-15 10:12:41 +08:00
|
|
|
|
setAddOpen(false);
|
2026-07-13 16:13:27 +08:00
|
|
|
|
setAddSourceId(null);
|
|
|
|
|
|
setAddPosition(null);
|
2026-07-14 09:36:28 +08:00
|
|
|
|
setSettingsOpen(false);
|
2026-07-13 16:13:27 +08:00
|
|
|
|
onDebugOpenChange(false);
|
2026-07-14 09:36:28 +08:00
|
|
|
|
onEditingEdgeIdChange(null);
|
2026-07-13 16:13:27 +08:00
|
|
|
|
onEditingNodeIdChange(id);
|
2026-06-15 10:12:41 +08:00
|
|
|
|
},
|
2026-07-13 16:13:27 +08:00
|
|
|
|
[
|
|
|
|
|
|
addPosition,
|
|
|
|
|
|
addSourceId,
|
|
|
|
|
|
nodes,
|
|
|
|
|
|
onDebugOpenChange,
|
2026-07-14 09:36:28 +08:00
|
|
|
|
onEditingEdgeIdChange,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
onEditingNodeIdChange,
|
|
|
|
|
|
screenToFlowPosition,
|
|
|
|
|
|
setEdges,
|
|
|
|
|
|
setNodes,
|
2026-07-14 09:36:28 +08:00
|
|
|
|
setSettingsOpen,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
],
|
2026-06-15 10:12:41 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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-07-13 16:13:27 +08:00
|
|
|
|
if (nodes.find((node) => node.id === id)?.type === "start") 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));
|
2026-07-13 16:13:27 +08:00
|
|
|
|
if (editingNodeId === id) onEditingNodeIdChange(null);
|
2026-06-15 10:12:41 +08:00
|
|
|
|
},
|
2026-07-13 16:13:27 +08:00
|
|
|
|
[editingNodeId, nodes, onEditingNodeIdChange, setNodes, setEdges],
|
2026-06-15 15:49:58 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleNodesChange = useCallback(
|
|
|
|
|
|
(changes: NodeChange[]) => {
|
|
|
|
|
|
const startIds = new Set(
|
2026-07-13 16:13:27 +08:00
|
|
|
|
nodes.filter((node) => node.type === "start").map((node) => node.id),
|
2026-06-15 15:49:58 +08:00
|
|
|
|
);
|
|
|
|
|
|
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,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
patch: WorkflowGraph["edges"][number]["data"],
|
2026-06-15 15:57:05 +08:00
|
|
|
|
) => {
|
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));
|
2026-07-13 16:13:27 +08:00
|
|
|
|
if (editingEdgeId === id) onEditingEdgeIdChange(null);
|
2026-06-15 10:12:41 +08:00
|
|
|
|
},
|
2026-07-13 16:13:27 +08:00
|
|
|
|
[editingEdgeId, onEditingEdgeIdChange, setEdges],
|
2026-06-15 10:12:41 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-13 16:13:27 +08:00
|
|
|
|
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 source.type === "agent" || outgoingCount === 0;
|
|
|
|
|
|
},
|
|
|
|
|
|
[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],
|
|
|
|
|
|
);
|
2026-07-14 09:36:28 +08:00
|
|
|
|
const openSettings = useCallback(() => {
|
|
|
|
|
|
onDebugOpenChange(false);
|
|
|
|
|
|
onEditingNodeIdChange(null);
|
|
|
|
|
|
onEditingEdgeIdChange(null);
|
|
|
|
|
|
setSettingsOpen(true);
|
|
|
|
|
|
}, [
|
|
|
|
|
|
onDebugOpenChange,
|
|
|
|
|
|
onEditingEdgeIdChange,
|
|
|
|
|
|
onEditingNodeIdChange,
|
|
|
|
|
|
setSettingsOpen,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-06-15 10:12:41 +08:00
|
|
|
|
const nodeActions = useMemo(
|
2026-07-13 16:13:27 +08:00
|
|
|
|
() => ({
|
|
|
|
|
|
edit: (id: string) => {
|
2026-07-14 09:36:28 +08:00
|
|
|
|
setSettingsOpen(false);
|
2026-07-13 16:13:27 +08:00
|
|
|
|
onDebugOpenChange(false);
|
|
|
|
|
|
onEditingEdgeIdChange(null);
|
|
|
|
|
|
onEditingNodeIdChange(id);
|
|
|
|
|
|
},
|
|
|
|
|
|
remove: deleteNode,
|
|
|
|
|
|
}),
|
|
|
|
|
|
[
|
|
|
|
|
|
deleteNode,
|
|
|
|
|
|
onDebugOpenChange,
|
|
|
|
|
|
onEditingEdgeIdChange,
|
|
|
|
|
|
onEditingNodeIdChange,
|
2026-07-14 09:36:28 +08:00
|
|
|
|
setSettingsOpen,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
],
|
2026-06-15 10:12:41 +08:00
|
|
|
|
);
|
|
|
|
|
|
const edgeActions = useMemo(
|
2026-07-13 16:13:27 +08:00
|
|
|
|
() => ({
|
|
|
|
|
|
edit: (id: string) => {
|
2026-07-14 09:36:28 +08:00
|
|
|
|
setSettingsOpen(false);
|
2026-07-13 16:13:27 +08:00
|
|
|
|
onDebugOpenChange(false);
|
|
|
|
|
|
onEditingNodeIdChange(null);
|
|
|
|
|
|
onEditingEdgeIdChange(id);
|
|
|
|
|
|
},
|
|
|
|
|
|
remove: deleteEdge,
|
|
|
|
|
|
}),
|
|
|
|
|
|
[
|
|
|
|
|
|
deleteEdge,
|
|
|
|
|
|
onDebugOpenChange,
|
|
|
|
|
|
onEditingEdgeIdChange,
|
|
|
|
|
|
onEditingNodeIdChange,
|
2026-07-14 09:36:28 +08:00
|
|
|
|
setSettingsOpen,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
],
|
2026-06-15 10:12:41 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-13 16:13:27 +08:00
|
|
|
|
const editingNode = nodes.find((n) => n.id === editingNodeId);
|
2026-06-15 10:12:41 +08:00
|
|
|
|
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-07-13 16:13:27 +08:00
|
|
|
|
<div className="relative h-full w-full min-h-[560px]">
|
2026-06-15 15:49:58 +08:00
|
|
|
|
<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}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
onConnectEnd={onConnectEnd}
|
2026-06-15 15:49:58 +08:00
|
|
|
|
isValidConnection={isValidConnection}
|
|
|
|
|
|
onPaneClick={() => {
|
2026-07-13 16:13:27 +08:00
|
|
|
|
onEditingEdgeIdChange(null);
|
2026-06-15 15:49:58 +08:00
|
|
|
|
}}
|
|
|
|
|
|
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"
|
|
|
|
|
|
/>
|
2026-07-13 16:13:27 +08:00
|
|
|
|
<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={() => {
|
|
|
|
|
|
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="工作流设置"
|
2026-07-14 09:36:28 +08:00
|
|
|
|
onClick={openSettings}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
>
|
|
|
|
|
|
<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={onOpenDynamicVariables}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Braces size={17} />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent side="right">动态变量</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TooltipProvider>
|
2026-06-15 15:49:58 +08:00
|
|
|
|
</Panel>
|
|
|
|
|
|
</ReactFlow>
|
|
|
|
|
|
</section>
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 添加节点弹窗 */}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
<Dialog
|
|
|
|
|
|
open={addOpen}
|
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
|
setAddOpen(open);
|
|
|
|
|
|
if (!open) {
|
|
|
|
|
|
setAddSourceId(null);
|
|
|
|
|
|
setAddPosition(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
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">
|
2026-07-13 16:13:27 +08:00
|
|
|
|
{addSourceId
|
|
|
|
|
|
? "选择节点类型,将在当前节点下方创建并自动连接。"
|
|
|
|
|
|
: "选择节点类型并添加到画布中央,随后可编辑内容并建立连线。"}
|
2026-06-15 15:49:58 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-07-14 09:36:28 +08:00
|
|
|
|
{(debugOpen ||
|
|
|
|
|
|
settingsOpen ||
|
|
|
|
|
|
(editingNode && editingSpec) ||
|
|
|
|
|
|
editingEdge) && (
|
2026-07-13 16:13:27 +08:00
|
|
|
|
<aside className="absolute inset-y-0 right-0 z-40 flex w-1/2 flex-col overflow-hidden rounded-r-2xl border-l border-hairline bg-card shadow-2xl">
|
|
|
|
|
|
{debugOpen ? (
|
|
|
|
|
|
debugPanel
|
2026-07-14 09:36:28 +08:00
|
|
|
|
) : settingsOpen ||
|
|
|
|
|
|
(editingNode && editingSpec) ||
|
|
|
|
|
|
editingEdge ? (
|
2026-07-13 16:13:27 +08:00
|
|
|
|
<>
|
|
|
|
|
|
<div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-07-14 09:36:28 +08:00
|
|
|
|
aria-label={
|
|
|
|
|
|
settingsOpen
|
|
|
|
|
|
? "关闭工作流设置"
|
|
|
|
|
|
: editingEdge
|
|
|
|
|
|
? "关闭边编辑"
|
|
|
|
|
|
: "关闭节点编辑"
|
|
|
|
|
|
}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
title="关闭"
|
|
|
|
|
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
|
|
|
|
|
|
onClick={() => {
|
2026-07-14 09:36:28 +08:00
|
|
|
|
if (settingsOpen) setSettingsOpen(false);
|
|
|
|
|
|
else if (editingEdge) onEditingEdgeIdChange(null);
|
2026-07-13 16:13:27 +08:00
|
|
|
|
else onEditingNodeIdChange(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X size={16} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
|
2026-07-14 09:36:28 +08:00
|
|
|
|
{settingsOpen
|
|
|
|
|
|
? "工作流设置"
|
|
|
|
|
|
: editingEdge
|
|
|
|
|
|
? "编辑连接条件"
|
|
|
|
|
|
: `编辑${editingSpec?.displayName ?? "节点"}`}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
</h2>
|
|
|
|
|
|
</div>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto bg-canvas-soft px-4 pb-5 pt-4">
|
|
|
|
|
|
{settingsOpen ? (
|
|
|
|
|
|
<SettingsForm
|
|
|
|
|
|
settings={settings}
|
|
|
|
|
|
onSettingsChange={onSettingsChange}
|
|
|
|
|
|
modelOptions={modelOptions}
|
|
|
|
|
|
toolOptions={toolOptions}
|
|
|
|
|
|
knowledgeOptions={knowledgeOptions}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : editingNode && editingSpec ? (
|
2026-07-13 16:13:27 +08:00
|
|
|
|
<NodeForm
|
|
|
|
|
|
key={editingNode.id}
|
|
|
|
|
|
panel
|
|
|
|
|
|
spec={editingSpec}
|
|
|
|
|
|
data={editingNode.data as WorkflowNodeData}
|
|
|
|
|
|
toolOptions={toolOptions}
|
|
|
|
|
|
knowledgeOptions={knowledgeOptions}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
llmOptions={modelOptions.llm}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
asrOptions={modelOptions.asr}
|
|
|
|
|
|
ttsOptions={modelOptions.tts}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
workflowSettings={settings}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
onChange={(patch) =>
|
|
|
|
|
|
updateNodeData(editingNode.id, patch)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : editingEdge ? (
|
|
|
|
|
|
<EdgeForm
|
|
|
|
|
|
key={editingEdge.id}
|
|
|
|
|
|
edge={editingEdge}
|
|
|
|
|
|
sourceType={nodes.find((node) => node.id === editingEdge.source)?.type}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
onChange={(patch) =>
|
|
|
|
|
|
updateEdgeData(editingEdge.id, patch)
|
|
|
|
|
|
}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
)}
|
2026-06-15 10:12:41 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 09:36:28 +08:00
|
|
|
|
function SettingsForm({
|
|
|
|
|
|
settings,
|
|
|
|
|
|
onSettingsChange,
|
|
|
|
|
|
modelOptions,
|
|
|
|
|
|
toolOptions,
|
|
|
|
|
|
knowledgeOptions,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
settings: WorkflowSettings;
|
|
|
|
|
|
onSettingsChange: (settings: WorkflowSettings) => void;
|
|
|
|
|
|
modelOptions: WorkflowEditorProps["modelOptions"];
|
|
|
|
|
|
toolOptions: ModelOption[];
|
|
|
|
|
|
knowledgeOptions: ModelOption[];
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<MessageSquareText size={15} />}
|
|
|
|
|
|
title="全局提示词"
|
|
|
|
|
|
description="与所有选择继承全局配置的 Agent 节点提示词合并"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
value={settings.globalPrompt}
|
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
|
onSettingsChange({
|
|
|
|
|
|
...settings,
|
|
|
|
|
|
globalPrompt: event.target.value,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Brain size={15} />}
|
|
|
|
|
|
title="模型配置"
|
|
|
|
|
|
description="工作流中所有 Agent 共用的大语言模型"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ModelSelect
|
|
|
|
|
|
label="大语言模型"
|
|
|
|
|
|
value={settings.llm}
|
|
|
|
|
|
options={modelOptions.llm}
|
|
|
|
|
|
onChange={(v) => onSettingsChange({ ...settings, llm: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<AudioLines size={15} />}
|
|
|
|
|
|
title="语音配置"
|
|
|
|
|
|
description="Agent 节点未单独选择资源时继承这里的默认值"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ModelSelect
|
|
|
|
|
|
label="语音识别"
|
|
|
|
|
|
value={settings.asr}
|
|
|
|
|
|
options={modelOptions.asr}
|
|
|
|
|
|
onChange={(v) => onSettingsChange({ ...settings, asr: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<ModelSelect
|
|
|
|
|
|
label="语音合成"
|
|
|
|
|
|
value={settings.tts}
|
|
|
|
|
|
options={modelOptions.tts}
|
|
|
|
|
|
onChange={(v) => onSettingsChange({ ...settings, tts: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Database size={15} />}
|
|
|
|
|
|
title="知识库配置"
|
|
|
|
|
|
description="所有继承全局配置的 Agent 都可以使用该知识库"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
|
<span className="text-sm font-medium text-foreground">知识库选择</span>
|
|
|
|
|
|
<KnowledgeRetrievalConfigDialog
|
|
|
|
|
|
disabled={!settings.knowledgeBaseId}
|
|
|
|
|
|
value={settings.knowledgeRetrievalConfig}
|
|
|
|
|
|
onChange={(knowledgeRetrievalConfig) =>
|
|
|
|
|
|
onSettingsChange({ ...settings, knowledgeRetrievalConfig })
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label=""
|
|
|
|
|
|
value={settings.knowledgeBaseId}
|
|
|
|
|
|
options={knowledgeOptions}
|
|
|
|
|
|
onChange={(knowledgeBaseId) =>
|
|
|
|
|
|
onSettingsChange({
|
|
|
|
|
|
...settings,
|
|
|
|
|
|
knowledgeBaseId: knowledgeBaseId || "",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
noneLabel="无"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Wrench size={15} />}
|
|
|
|
|
|
title="工具"
|
|
|
|
|
|
description="所有继承全局配置的 Agent 都可以调用这些工具"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ToolOptionPicker
|
|
|
|
|
|
options={toolOptions}
|
|
|
|
|
|
selectedIds={settings.toolIds}
|
|
|
|
|
|
onChange={(toolIds) => onSettingsChange({ ...settings, toolIds })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Sparkles size={15} />}
|
|
|
|
|
|
title="交互策略"
|
|
|
|
|
|
description="设置实时视频对话时的交互体验"
|
|
|
|
|
|
>
|
|
|
|
|
|
<TurnConfigEditor
|
|
|
|
|
|
enabled={settings.allowInterrupt}
|
|
|
|
|
|
config={settings.turnConfig}
|
|
|
|
|
|
onEnabledChange={(allowInterrupt) =>
|
|
|
|
|
|
onSettingsChange({ ...settings, allowInterrupt })
|
|
|
|
|
|
}
|
|
|
|
|
|
onConfigChange={(turnConfig) =>
|
|
|
|
|
|
onSettingsChange({ ...settings, turnConfig })
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 10:12:41 +08:00
|
|
|
|
function ModelSelect({
|
|
|
|
|
|
label,
|
|
|
|
|
|
value,
|
|
|
|
|
|
options,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
value?: string;
|
|
|
|
|
|
options: ModelOption[];
|
|
|
|
|
|
onChange: (value: string | undefined) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<div className="block">
|
|
|
|
|
|
{label && (
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
|
|
|
|
|
|
)}
|
2026-06-15 10:12:41 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
value={value ?? NONE}
|
|
|
|
|
|
onValueChange={(v) => onChange(v === NONE ? undefined : v)}
|
|
|
|
|
|
>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
|
2026-06-15 10:12:41 +08:00
|
|
|
|
<SelectValue placeholder="选择模型资源" />
|
|
|
|
|
|
</SelectTrigger>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<SelectContent className="border-hairline bg-popover text-popover-foreground">
|
2026-06-15 10:12:41 +08:00
|
|
|
|
<SelectItem value={NONE}>未选择</SelectItem>
|
|
|
|
|
|
{options.map((o) => (
|
|
|
|
|
|
<SelectItem key={o.value} value={o.value}>
|
|
|
|
|
|
{o.label}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function NodeForm({
|
2026-07-13 16:13:27 +08:00
|
|
|
|
panel = false,
|
2026-06-15 10:12:41 +08:00
|
|
|
|
spec,
|
|
|
|
|
|
data,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
toolOptions,
|
|
|
|
|
|
knowledgeOptions,
|
2026-07-14 09:36:28 +08:00
|
|
|
|
llmOptions,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
asrOptions,
|
|
|
|
|
|
ttsOptions,
|
2026-07-14 09:36:28 +08:00
|
|
|
|
workflowSettings,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
onChange,
|
2026-06-15 10:12:41 +08:00
|
|
|
|
}: {
|
2026-07-13 16:13:27 +08:00
|
|
|
|
panel?: boolean;
|
2026-06-15 10:12:41 +08:00
|
|
|
|
spec: RuntimeNodeSpec;
|
|
|
|
|
|
data: WorkflowNodeData;
|
2026-07-13 16:13:27 +08:00
|
|
|
|
toolOptions: ModelOption[];
|
|
|
|
|
|
knowledgeOptions: ModelOption[];
|
2026-07-14 09:36:28 +08:00
|
|
|
|
llmOptions: ModelOption[];
|
2026-07-13 16:13:27 +08:00
|
|
|
|
asrOptions: ModelOption[];
|
|
|
|
|
|
ttsOptions: ModelOption[];
|
2026-07-14 09:36:28 +08:00
|
|
|
|
workflowSettings: WorkflowSettings;
|
2026-07-13 16:13:27 +08:00
|
|
|
|
onChange: (patch: WorkflowNodeData) => void;
|
2026-06-15 10:12:41 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
const [draft, setDraft] = useState<WorkflowNodeData>({ ...data });
|
2026-07-13 16:13:27 +08:00
|
|
|
|
const [argumentsJson, setArgumentsJson] = useState(
|
|
|
|
|
|
JSON.stringify(data.arguments ?? {}, null, 2),
|
|
|
|
|
|
);
|
|
|
|
|
|
const [assignmentsJson, setAssignmentsJson] = useState(
|
|
|
|
|
|
JSON.stringify(data.resultAssignments ?? {}, null, 2),
|
|
|
|
|
|
);
|
|
|
|
|
|
const [jsonError, setJsonError] = useState("");
|
|
|
|
|
|
const commit = (next: WorkflowNodeData) => {
|
|
|
|
|
|
setDraft(next);
|
|
|
|
|
|
onChange(next);
|
|
|
|
|
|
};
|
2026-06-15 10:12:41 +08:00
|
|
|
|
const set = (key: string, val: unknown) =>
|
2026-07-13 16:13:27 +08:00
|
|
|
|
commit({ ...draft, [key]: val });
|
2026-07-14 09:36:28 +08:00
|
|
|
|
const setPatch = (patch: Partial<WorkflowNodeData>) =>
|
|
|
|
|
|
commit({ ...draft, ...patch });
|
2026-07-13 16:13:27 +08:00
|
|
|
|
const commitActionJson = (
|
|
|
|
|
|
nextArgumentsJson: string,
|
|
|
|
|
|
nextAssignmentsJson: string,
|
|
|
|
|
|
) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const args = JSON.parse(nextArgumentsJson) as Record<string, unknown>;
|
|
|
|
|
|
const assignments = JSON.parse(nextAssignmentsJson) as Record<string, string>;
|
|
|
|
|
|
if (Array.isArray(args) || Array.isArray(assignments)) throw new Error();
|
|
|
|
|
|
setJsonError("");
|
|
|
|
|
|
commit({ ...draft, arguments: args, resultAssignments: assignments });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setJsonError("参数和结果映射必须是 JSON 对象");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
2026-07-14 09:36:28 +08:00
|
|
|
|
if (panel) {
|
|
|
|
|
|
if (spec.type !== "agent") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<WorkflowNodePanelForm
|
|
|
|
|
|
spec={spec}
|
|
|
|
|
|
draft={draft}
|
|
|
|
|
|
set={set}
|
|
|
|
|
|
toolOptions={toolOptions}
|
|
|
|
|
|
argumentsJson={argumentsJson}
|
|
|
|
|
|
assignmentsJson={assignmentsJson}
|
|
|
|
|
|
jsonError={jsonError}
|
|
|
|
|
|
setArgumentsJson={setArgumentsJson}
|
|
|
|
|
|
setAssignmentsJson={setAssignmentsJson}
|
|
|
|
|
|
commitActionJson={commitActionJson}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<AgentPanelForm
|
|
|
|
|
|
draft={draft}
|
|
|
|
|
|
set={set}
|
|
|
|
|
|
setPatch={setPatch}
|
|
|
|
|
|
workflowSettings={workflowSettings}
|
|
|
|
|
|
toolOptions={toolOptions}
|
|
|
|
|
|
knowledgeOptions={knowledgeOptions}
|
|
|
|
|
|
llmOptions={llmOptions}
|
|
|
|
|
|
asrOptions={asrOptions}
|
|
|
|
|
|
ttsOptions={ttsOptions}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 10:12:41 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-07-13 16:13:27 +08:00
|
|
|
|
{!panel && (
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle className="font-display text-ink">
|
|
|
|
|
|
编辑{spec.displayName}
|
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
)}
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
|
|
|
|
|
<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)}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
2026-06-15 10:12:41 +08:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
|
|
|
|
|
|
{spec.type === "agent" && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<NodeSelect
|
2026-07-14 09:36:28 +08:00
|
|
|
|
label="进入节点时"
|
|
|
|
|
|
value={(draft.entryMode as string) || "wait_user"}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
options={[
|
2026-07-14 09:36:28 +08:00
|
|
|
|
{ value: "wait_user", label: "等待用户说话(默认)" },
|
|
|
|
|
|
{ value: "generate", label: "立即让 LLM 回复" },
|
|
|
|
|
|
{ value: "fixed_speech", label: "播放固定进入语" },
|
2026-07-13 16:13:27 +08:00
|
|
|
|
]}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
onChange={(value) => set("entryMode", value || "wait_user")}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
allowNone={false}
|
|
|
|
|
|
/>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
{draft.entryMode === "fixed_speech" && (
|
|
|
|
|
|
<div className="block">
|
|
|
|
|
|
<div className="mb-2 text-sm font-medium text-foreground">
|
|
|
|
|
|
固定进入语 <span className="text-destructive">*</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
value={draft.entrySpeech ?? ""}
|
|
|
|
|
|
onChange={(event) => set("entrySpeech", event.target.value)}
|
|
|
|
|
|
placeholder="例如:您好,请告诉我需要处理的问题。"
|
|
|
|
|
|
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="mt-2 block text-xs text-muted-soft">
|
|
|
|
|
|
支持使用 {"{{variable}}"} 动态变量;只播放语音,不调用 LLM。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
|
<label className="text-sm font-medium text-foreground">可用工具</label>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<ToolOptionPicker
|
|
|
|
|
|
options={toolOptions}
|
|
|
|
|
|
selectedIds={draft.toolIds ?? []}
|
|
|
|
|
|
onChange={(toolIds) => set("toolIds", toolIds)}
|
|
|
|
|
|
/>
|
2026-07-13 16:13:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="知识库"
|
|
|
|
|
|
value={(draft.knowledgeBaseId as string) || ""}
|
|
|
|
|
|
options={knowledgeOptions}
|
|
|
|
|
|
onChange={(value) => set("knowledgeBaseId", value || "")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="知识库模式"
|
|
|
|
|
|
value={(draft.knowledgeMode as string) || "disabled"}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "disabled", label: "关闭" },
|
|
|
|
|
|
{ value: "automatic", label: "每轮自动检索" },
|
|
|
|
|
|
{ value: "on_demand", label: "Agent 按需调用" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => set("knowledgeMode", value || "disabled")}
|
|
|
|
|
|
allowNone={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="ASR 资源"
|
|
|
|
|
|
value={(draft.asrResourceId as string) || ""}
|
|
|
|
|
|
options={asrOptions}
|
|
|
|
|
|
onChange={(value) => set("asrResourceId", value || "")}
|
|
|
|
|
|
noneLabel="继承 Workflow 默认值"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="TTS 资源"
|
|
|
|
|
|
value={(draft.ttsResourceId as string) || ""}
|
|
|
|
|
|
options={ttsOptions}
|
|
|
|
|
|
onChange={(value) => set("ttsResourceId", value || "")}
|
|
|
|
|
|
noneLabel="继承 Workflow 默认值"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{spec.type === "action" && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="确定性执行工具"
|
|
|
|
|
|
value={(draft.toolId as string) || ""}
|
|
|
|
|
|
options={toolOptions}
|
|
|
|
|
|
onChange={(value) => set("toolId", value || "")}
|
|
|
|
|
|
noneLabel="请选择工具"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
|
<label className="text-sm font-medium text-foreground">工具参数 JSON</label>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={5}
|
|
|
|
|
|
value={argumentsJson}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const value = event.target.value;
|
|
|
|
|
|
setArgumentsJson(value);
|
|
|
|
|
|
commitActionJson(value, assignmentsJson);
|
|
|
|
|
|
}}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
2026-07-13 16:13:27 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs text-muted-soft">字符串值可使用 {"{{variable}}"} 动态变量。</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
|
<label className="text-sm font-medium text-foreground">结果变量映射 JSON</label>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
value={assignmentsJson}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const value = event.target.value;
|
|
|
|
|
|
setAssignmentsJson(value);
|
|
|
|
|
|
commitActionJson(argumentsJson, value);
|
|
|
|
|
|
}}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
2026-07-13 16:13:27 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs text-muted-soft">格式:变量名 → 响应 JSON Path。</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{jsonError && <span className="text-xs text-destructive">{jsonError}</span>}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{spec.type === "handoff" && (
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="转交类型"
|
|
|
|
|
|
value={(draft.targetType as string) || "human"}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "ai", label: "其他 AI" },
|
|
|
|
|
|
{ value: "human", label: "人工" },
|
|
|
|
|
|
{ value: "queue", label: "队列" },
|
|
|
|
|
|
{ value: "phone", label: "电话" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => set("targetType", value || "human")}
|
|
|
|
|
|
allowNone={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{spec.type === "end" && (
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="结束范围"
|
|
|
|
|
|
value={(draft.scope as string) || "session"}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "flow", label: "仅结束 AI 流程" },
|
|
|
|
|
|
{ value: "session", label: "结束音视频会话" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => set("scope", value || "session")}
|
|
|
|
|
|
allowNone={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-06-15 10:12:41 +08:00
|
|
|
|
</div>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
2026-07-14 09:36:28 +08:00
|
|
|
|
function WorkflowNodePanelForm({
|
|
|
|
|
|
spec,
|
|
|
|
|
|
draft,
|
|
|
|
|
|
set,
|
|
|
|
|
|
toolOptions,
|
|
|
|
|
|
argumentsJson,
|
|
|
|
|
|
assignmentsJson,
|
|
|
|
|
|
jsonError,
|
|
|
|
|
|
setArgumentsJson,
|
|
|
|
|
|
setAssignmentsJson,
|
|
|
|
|
|
commitActionJson,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
spec: RuntimeNodeSpec;
|
|
|
|
|
|
draft: WorkflowNodeData;
|
|
|
|
|
|
set: (key: string, val: unknown) => void;
|
|
|
|
|
|
toolOptions: ModelOption[];
|
|
|
|
|
|
argumentsJson: string;
|
|
|
|
|
|
assignmentsJson: string;
|
|
|
|
|
|
jsonError: string;
|
|
|
|
|
|
setArgumentsJson: (value: string) => void;
|
|
|
|
|
|
setAssignmentsJson: (value: string) => void;
|
|
|
|
|
|
commitActionJson: (argumentsValue: string, assignmentsValue: string) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Tag size={15} />}
|
|
|
|
|
|
title="节点信息"
|
|
|
|
|
|
description="节点在画布上显示的名称"
|
|
|
|
|
|
>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
节点名称
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={draft.name ?? ""}
|
|
|
|
|
|
onChange={(event) => set("name", event.target.value)}
|
|
|
|
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
{spec.type === "start" && (
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Play size={15} />}
|
|
|
|
|
|
title="开场白"
|
|
|
|
|
|
description="会话建立后首先向用户播放的固定内容"
|
|
|
|
|
|
>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
固定开场白
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={5}
|
|
|
|
|
|
value={draft.greeting ?? ""}
|
|
|
|
|
|
onChange={(event) => set("greeting", event.target.value)}
|
|
|
|
|
|
placeholder="例如:你好,我是 AI 视频助手,有什么可以帮你?"
|
|
|
|
|
|
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{spec.type === "action" && (
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Zap size={15} />}
|
|
|
|
|
|
title="工具执行"
|
|
|
|
|
|
description="确定性调用工具,并将响应字段写入会话动态变量"
|
|
|
|
|
|
>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="执行工具"
|
|
|
|
|
|
value={(draft.toolId as string) || ""}
|
|
|
|
|
|
options={toolOptions}
|
|
|
|
|
|
onChange={(value) => set("toolId", value || "")}
|
|
|
|
|
|
noneLabel="请选择工具"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
工具参数 JSON
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={5}
|
|
|
|
|
|
value={argumentsJson}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const value = event.target.value;
|
|
|
|
|
|
setArgumentsJson(value);
|
|
|
|
|
|
commitActionJson(value, assignmentsJson);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background font-mono text-xs text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
|
|
|
|
|
字符串值可使用 {"{{variable}}"} 动态变量。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
结果变量映射 JSON
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
value={assignmentsJson}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const value = event.target.value;
|
|
|
|
|
|
setAssignmentsJson(value);
|
|
|
|
|
|
commitActionJson(argumentsJson, value);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background font-mono text-xs text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
|
|
|
|
|
格式:变量名 → 响应 JSON Path。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{jsonError && (
|
|
|
|
|
|
<p role="alert" className="text-xs text-destructive">
|
|
|
|
|
|
{jsonError}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{spec.type === "handoff" && (
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<PhoneForwarded size={15} />}
|
|
|
|
|
|
title="转交配置"
|
|
|
|
|
|
description="发送转交事件并继续执行工作流"
|
|
|
|
|
|
>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="转交类型"
|
|
|
|
|
|
value={(draft.targetType as string) || "human"}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "ai", label: "其他 AI" },
|
|
|
|
|
|
{ value: "human", label: "人工" },
|
|
|
|
|
|
{ value: "queue", label: "队列" },
|
|
|
|
|
|
{ value: "phone", label: "电话" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => set("targetType", value || "human")}
|
|
|
|
|
|
allowNone={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
转交目标
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={draft.target ?? ""}
|
|
|
|
|
|
onChange={(event) => set("target", event.target.value)}
|
|
|
|
|
|
placeholder="人工、队列、AI 或电话号码"
|
|
|
|
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
转交提示
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
value={draft.message ?? ""}
|
|
|
|
|
|
onChange={(event) => set("message", event.target.value)}
|
|
|
|
|
|
placeholder="例如:好的,正在为你转接人工客服。"
|
|
|
|
|
|
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{spec.type === "end" && (
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Flag size={15} />}
|
|
|
|
|
|
title="结束配置"
|
|
|
|
|
|
description="结束工作流,并可在结束前播放一段固定回复"
|
|
|
|
|
|
>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="结束范围"
|
|
|
|
|
|
value={(draft.scope as string) || "session"}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "flow", label: "仅结束 AI 流程" },
|
|
|
|
|
|
{ value: "session", label: "结束音视频会话" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => set("scope", value || "session")}
|
|
|
|
|
|
allowNone={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
固定结束语
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
value={draft.message ?? ""}
|
|
|
|
|
|
onChange={(event) => set("message", event.target.value)}
|
|
|
|
|
|
placeholder="例如:感谢你的来电,再见。"
|
|
|
|
|
|
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Agent 右栏:与 AssistantPage 共用紧凑 SectionCard。 */
|
|
|
|
|
|
function AgentPanelForm({
|
|
|
|
|
|
draft,
|
|
|
|
|
|
set,
|
|
|
|
|
|
setPatch,
|
|
|
|
|
|
workflowSettings,
|
|
|
|
|
|
toolOptions,
|
|
|
|
|
|
knowledgeOptions,
|
|
|
|
|
|
llmOptions,
|
|
|
|
|
|
asrOptions,
|
|
|
|
|
|
ttsOptions,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
draft: WorkflowNodeData;
|
|
|
|
|
|
set: (key: string, val: unknown) => void;
|
|
|
|
|
|
setPatch: (patch: Partial<WorkflowNodeData>) => void;
|
|
|
|
|
|
workflowSettings: WorkflowSettings;
|
|
|
|
|
|
toolOptions: ModelOption[];
|
|
|
|
|
|
knowledgeOptions: ModelOption[];
|
|
|
|
|
|
llmOptions: ModelOption[];
|
|
|
|
|
|
asrOptions: ModelOption[];
|
|
|
|
|
|
ttsOptions: ModelOption[];
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const inheritsGlobal = draft.inheritGlobalConfig !== false;
|
|
|
|
|
|
const knowledgeConfig: KnowledgeRetrievalConfig = {
|
|
|
|
|
|
mode:
|
|
|
|
|
|
draft.knowledgeMode === "on_demand" ? "on_demand" : "automatic",
|
|
|
|
|
|
topN: Number(draft.knowledgeTopN ?? 5),
|
|
|
|
|
|
scoreThreshold: Number(draft.knowledgeScoreThreshold ?? 0),
|
|
|
|
|
|
};
|
2026-07-14 11:08:11 +08:00
|
|
|
|
const agentTurnConfig = normalizeTurnConfig(
|
|
|
|
|
|
draft.turnConfig ?? workflowSettings.turnConfig,
|
|
|
|
|
|
);
|
2026-07-14 09:36:28 +08:00
|
|
|
|
|
|
|
|
|
|
const setInheritance = (inheritGlobalConfig: boolean) => {
|
|
|
|
|
|
if (inheritGlobalConfig) {
|
|
|
|
|
|
setPatch({ inheritGlobalConfig: true });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setPatch({
|
|
|
|
|
|
inheritGlobalConfig: false,
|
|
|
|
|
|
llmResourceId:
|
|
|
|
|
|
(draft.llmResourceId as string) || workflowSettings.llm || "",
|
|
|
|
|
|
asrResourceId:
|
|
|
|
|
|
(draft.asrResourceId as string) || workflowSettings.asr || "",
|
|
|
|
|
|
ttsResourceId:
|
|
|
|
|
|
(draft.ttsResourceId as string) || workflowSettings.tts || "",
|
|
|
|
|
|
toolIds: draft.toolIds?.length
|
|
|
|
|
|
? draft.toolIds
|
|
|
|
|
|
: workflowSettings.toolIds,
|
|
|
|
|
|
knowledgeBaseId:
|
|
|
|
|
|
(draft.knowledgeBaseId as string) ||
|
|
|
|
|
|
workflowSettings.knowledgeBaseId,
|
|
|
|
|
|
knowledgeMode:
|
|
|
|
|
|
draft.knowledgeMode === "on_demand" ||
|
|
|
|
|
|
draft.knowledgeMode === "automatic"
|
|
|
|
|
|
? draft.knowledgeMode
|
|
|
|
|
|
: workflowSettings.knowledgeRetrievalConfig.mode,
|
|
|
|
|
|
knowledgeTopN:
|
|
|
|
|
|
draft.knowledgeTopN ??
|
|
|
|
|
|
workflowSettings.knowledgeRetrievalConfig.topN,
|
|
|
|
|
|
knowledgeScoreThreshold:
|
|
|
|
|
|
draft.knowledgeScoreThreshold ??
|
|
|
|
|
|
workflowSettings.knowledgeRetrievalConfig.scoreThreshold,
|
2026-07-14 11:08:11 +08:00
|
|
|
|
enableInterrupt:
|
|
|
|
|
|
draft.enableInterrupt ?? workflowSettings.allowInterrupt,
|
|
|
|
|
|
turnConfig: agentTurnConfig,
|
2026-07-14 09:36:28 +08:00
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Tag size={15} />}
|
|
|
|
|
|
title="节点信息"
|
|
|
|
|
|
description="节点在画布上显示的名称"
|
|
|
|
|
|
>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">节点名称</div>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={draft.name ?? ""}
|
|
|
|
|
|
onChange={(event) => set("name", event.target.value)}
|
|
|
|
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Settings2 size={15} />}
|
|
|
|
|
|
title="配置范围"
|
|
|
|
|
|
description="默认复用工作流全局的模型、语音、知识库和工具"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center justify-between gap-4 rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="text-sm font-medium text-foreground">
|
|
|
|
|
|
继承全局配置
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
|
|
|
|
|
{inheritsGlobal
|
|
|
|
|
|
? "全局提示词会与当前节点提示词合并。"
|
|
|
|
|
|
: "当前节点使用独立的完整助手配置。"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Switch checked={inheritsGlobal} onCheckedChange={setInheritance} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<MessageSquareText size={15} />}
|
2026-07-14 11:08:11 +08:00
|
|
|
|
title={inheritsGlobal ? "任务" : "提示词"}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
description={
|
|
|
|
|
|
inheritsGlobal
|
2026-07-14 11:08:11 +08:00
|
|
|
|
? "描述当前阶段要完成的目标;角色、能力和通用规则继承工作流全局配置"
|
2026-07-14 09:36:28 +08:00
|
|
|
|
: "描述当前独立助手的角色、能力和回答要求"
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={8}
|
|
|
|
|
|
value={draft.prompt ?? ""}
|
|
|
|
|
|
onChange={(event) => set("prompt", event.target.value)}
|
2026-07-14 11:08:11 +08:00
|
|
|
|
placeholder={
|
|
|
|
|
|
inheritsGlobal
|
|
|
|
|
|
? "例如:确认用户身份,并收集需要查询的订单编号"
|
|
|
|
|
|
: "请输入提示词,描述助手的角色、能力和回答要求"
|
|
|
|
|
|
}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Bot size={15} />}
|
|
|
|
|
|
title="进入行为"
|
|
|
|
|
|
description="进入该节点时的首轮交互方式"
|
|
|
|
|
|
>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="进入节点时"
|
|
|
|
|
|
value={(draft.entryMode as string) || "wait_user"}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "wait_user", label: "等待用户说话(默认)" },
|
|
|
|
|
|
{ value: "generate", label: "立即让 LLM 回复" },
|
|
|
|
|
|
{ value: "fixed_speech", label: "播放固定进入语" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onChange={(value) => set("entryMode", value || "wait_user")}
|
|
|
|
|
|
allowNone={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{draft.entryMode === "fixed_speech" && (
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
固定进入语 <span className="text-destructive">*</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
value={draft.entrySpeech ?? ""}
|
|
|
|
|
|
onChange={(event) => set("entrySpeech", event.target.value)}
|
|
|
|
|
|
placeholder="例如:您好,请告诉我需要处理的问题。"
|
|
|
|
|
|
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
|
|
|
|
|
支持使用 {"{{variable}}"} 动态变量;只播放语音,不调用 LLM。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
{!inheritsGlobal && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Brain size={15} />}
|
|
|
|
|
|
title="模型与语音"
|
|
|
|
|
|
description="当前 Agent 独立使用的推理、语音识别和语音合成资源"
|
|
|
|
|
|
>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="大语言模型"
|
|
|
|
|
|
value={(draft.llmResourceId as string) || ""}
|
|
|
|
|
|
options={llmOptions}
|
|
|
|
|
|
onChange={(value) => set("llmResourceId", value || "")}
|
|
|
|
|
|
noneLabel="请选择模型"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="语音识别"
|
|
|
|
|
|
value={(draft.asrResourceId as string) || ""}
|
|
|
|
|
|
options={asrOptions}
|
|
|
|
|
|
onChange={(value) => set("asrResourceId", value || "")}
|
|
|
|
|
|
noneLabel="请选择语音识别"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="语音合成"
|
|
|
|
|
|
value={(draft.ttsResourceId as string) || ""}
|
|
|
|
|
|
options={ttsOptions}
|
|
|
|
|
|
onChange={(value) => set("ttsResourceId", value || "")}
|
|
|
|
|
|
noneLabel="请选择语音合成"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Database size={15} />}
|
|
|
|
|
|
title="知识库配置"
|
|
|
|
|
|
description="选择该阶段回答时可检索的业务知识来源"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
|
<span className="text-sm font-medium text-foreground">
|
|
|
|
|
|
知识库选择
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<KnowledgeRetrievalConfigDialog
|
|
|
|
|
|
disabled={!draft.knowledgeBaseId}
|
|
|
|
|
|
value={knowledgeConfig}
|
|
|
|
|
|
onChange={(config) =>
|
|
|
|
|
|
setPatch({
|
|
|
|
|
|
knowledgeMode: config.mode,
|
|
|
|
|
|
knowledgeTopN: config.topN,
|
|
|
|
|
|
knowledgeScoreThreshold: config.scoreThreshold,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label=""
|
|
|
|
|
|
value={(draft.knowledgeBaseId as string) || ""}
|
|
|
|
|
|
options={knowledgeOptions}
|
|
|
|
|
|
onChange={(value) => set("knowledgeBaseId", value || "")}
|
|
|
|
|
|
noneLabel="无"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Wrench size={15} />}
|
|
|
|
|
|
title="工具"
|
|
|
|
|
|
description="配置该阶段可以调用的工具"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ToolOptionPicker
|
|
|
|
|
|
options={toolOptions}
|
|
|
|
|
|
selectedIds={draft.toolIds ?? []}
|
|
|
|
|
|
onChange={(toolIds) => set("toolIds", toolIds)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
2026-07-14 11:08:11 +08:00
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Sparkles size={15} />}
|
|
|
|
|
|
title="交互策略"
|
|
|
|
|
|
description="配置当前 Agent 独立使用的打断和轮次检测策略"
|
|
|
|
|
|
>
|
|
|
|
|
|
<TurnConfigEditor
|
|
|
|
|
|
enabled={
|
|
|
|
|
|
draft.enableInterrupt ?? workflowSettings.allowInterrupt
|
|
|
|
|
|
}
|
|
|
|
|
|
config={agentTurnConfig}
|
|
|
|
|
|
onEnabledChange={(enableInterrupt) =>
|
|
|
|
|
|
set("enableInterrupt", enableInterrupt)
|
|
|
|
|
|
}
|
|
|
|
|
|
onConfigChange={(turnConfig) => set("turnConfig", turnConfig)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SectionCard>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ToolOptionPicker({
|
|
|
|
|
|
options,
|
|
|
|
|
|
selectedIds,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
options: ModelOption[];
|
|
|
|
|
|
selectedIds: string[];
|
|
|
|
|
|
onChange: (ids: string[]) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
const [draftIds, setDraftIds] = useState<string[]>(selectedIds);
|
|
|
|
|
|
const selected = selectedIds
|
|
|
|
|
|
.map((id) => options.find((option) => option.value === id))
|
|
|
|
|
|
.filter((option): option is ModelOption => Boolean(option));
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="flex min-h-9 flex-wrap items-center gap-2">
|
|
|
|
|
|
{selected.map((option) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={option.value}
|
|
|
|
|
|
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Wrench size={14} />
|
|
|
|
|
|
<span className="max-w-48 truncate">{option.label}</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
onChange(selectedIds.filter((id) => id !== option.value))
|
|
|
|
|
|
}
|
|
|
|
|
|
className="text-muted-soft transition-colors hover:text-foreground"
|
|
|
|
|
|
aria-label={`移除工具 ${option.label}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X size={13} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="icon-sm"
|
|
|
|
|
|
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setDraftIds(selectedIds);
|
|
|
|
|
|
setOpen(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
aria-label="添加工具"
|
|
|
|
|
|
title="添加工具"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus size={15} />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
|
|
|
|
<DialogContent className="sm:max-w-xl">
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle>选择工具</DialogTitle>
|
|
|
|
|
|
<DialogDescription>选择已经在工具资源中启用的工具。</DialogDescription>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
{options.length === 0 ? (
|
|
|
|
|
|
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
暂无可用工具
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="max-h-80 divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
|
|
|
|
|
|
{options.map((option) => {
|
|
|
|
|
|
const checked = draftIds.includes(option.value);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<label
|
|
|
|
|
|
key={option.value}
|
|
|
|
|
|
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-strong/40"
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={checked}
|
|
|
|
|
|
onChange={() =>
|
|
|
|
|
|
setDraftIds((current) =>
|
|
|
|
|
|
checked
|
|
|
|
|
|
? current.filter((id) => id !== option.value)
|
|
|
|
|
|
: [...current, option.value],
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
className="size-4 accent-primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="truncate font-medium text-foreground">
|
|
|
|
|
|
{option.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
onChange(draftIds);
|
|
|
|
|
|
setOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
确认
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
2026-06-15 10:12:41 +08:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 16:13:27 +08:00
|
|
|
|
function NodeSelect({
|
|
|
|
|
|
label,
|
|
|
|
|
|
value,
|
|
|
|
|
|
options,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
allowNone = true,
|
|
|
|
|
|
noneLabel = "未选择",
|
|
|
|
|
|
}: {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
options: ModelOption[];
|
|
|
|
|
|
onChange: (value: string | undefined) => void;
|
|
|
|
|
|
allowNone?: boolean;
|
|
|
|
|
|
noneLabel?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<div className="block">
|
|
|
|
|
|
{label && (
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
|
|
|
|
|
|
)}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
value={value || (allowNone ? NONE : options[0]?.value)}
|
|
|
|
|
|
onValueChange={(next) => onChange(next === NONE ? undefined : next)}
|
|
|
|
|
|
>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
|
|
|
|
|
|
<SelectValue placeholder={label ? `请选择${label}` : "请选择"} />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent className="border-hairline bg-popover text-popover-foreground">
|
2026-07-13 16:13:27 +08:00
|
|
|
|
{allowNone && <SelectItem value={NONE}>{noneLabel}</SelectItem>}
|
|
|
|
|
|
{options.map((option) => (
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<SelectItem key={option.value} value={option.value}>
|
|
|
|
|
|
{option.label}
|
|
|
|
|
|
</SelectItem>
|
2026-07-13 16:13:27 +08:00
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 10:12:41 +08:00
|
|
|
|
function EdgeForm({
|
|
|
|
|
|
edge,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
sourceType,
|
2026-07-14 09:36:28 +08:00
|
|
|
|
onChange,
|
2026-06-15 10:12:41 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
edge: Edge;
|
2026-07-13 16:13:27 +08:00
|
|
|
|
sourceType?: string;
|
2026-07-14 09:36:28 +08:00
|
|
|
|
onChange: (patch: WorkflowEdgeData) => void;
|
2026-06-15 10:12:41 +08:00
|
|
|
|
}) {
|
2026-07-13 16:13:27 +08:00
|
|
|
|
const data = (edge.data ?? { mode: "always", priority: 10 }) as WorkflowEdgeData;
|
|
|
|
|
|
const [mode, setMode] = useState(data.mode ?? "always");
|
|
|
|
|
|
const [priority, setPriority] = useState(data.priority ?? 10);
|
2026-06-15 10:12:41 +08:00
|
|
|
|
const [label, setLabel] = useState(data.label ?? "");
|
|
|
|
|
|
const [condition, setCondition] = useState(data.condition ?? "");
|
2026-07-13 16:13:27 +08:00
|
|
|
|
const [transitionSpeech, setTransitionSpeech] = useState(data.transitionSpeech ?? "");
|
|
|
|
|
|
const [combinator, setCombinator] = useState<"and" | "or">(
|
|
|
|
|
|
data.expression?.combinator ?? "and",
|
2026-06-15 15:57:05 +08:00
|
|
|
|
);
|
2026-07-13 16:13:27 +08:00
|
|
|
|
const [rules, setRules] = useState<ExpressionRule[]>(
|
|
|
|
|
|
data.expression?.rules?.length
|
|
|
|
|
|
? data.expression.rules
|
|
|
|
|
|
: [{ variable: "", operator: "eq", value: "" }],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-14 09:36:28 +08:00
|
|
|
|
const publish = ({
|
|
|
|
|
|
nextMode = mode,
|
|
|
|
|
|
nextPriority = priority,
|
|
|
|
|
|
nextLabel = label,
|
|
|
|
|
|
nextCondition = condition,
|
|
|
|
|
|
nextTransitionSpeech = transitionSpeech,
|
|
|
|
|
|
nextCombinator = combinator,
|
|
|
|
|
|
nextRules = rules,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
nextMode?: WorkflowEdgeData["mode"];
|
|
|
|
|
|
nextPriority?: number;
|
|
|
|
|
|
nextLabel?: string;
|
|
|
|
|
|
nextCondition?: string;
|
|
|
|
|
|
nextTransitionSpeech?: string;
|
|
|
|
|
|
nextCombinator?: "and" | "or";
|
|
|
|
|
|
nextRules?: ExpressionRule[];
|
|
|
|
|
|
}) =>
|
|
|
|
|
|
onChange({
|
|
|
|
|
|
mode: nextMode,
|
|
|
|
|
|
priority: nextPriority,
|
|
|
|
|
|
label: nextLabel.trim() ? nextLabel : undefined,
|
|
|
|
|
|
condition: nextMode === "llm" ? nextCondition : undefined,
|
|
|
|
|
|
expression:
|
|
|
|
|
|
nextMode === "expression"
|
|
|
|
|
|
? { combinator: nextCombinator, rules: nextRules }
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
transitionSpeech: nextTransitionSpeech.trim()
|
|
|
|
|
|
? nextTransitionSpeech
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const setRule = (index: number, patch: Partial<ExpressionRule>) => {
|
|
|
|
|
|
const nextRules = rules.map((rule, ruleIndex) =>
|
|
|
|
|
|
ruleIndex === index ? { ...rule, ...patch } : rule,
|
2026-07-13 16:13:27 +08:00
|
|
|
|
);
|
2026-07-14 09:36:28 +08:00
|
|
|
|
setRules(nextRules);
|
|
|
|
|
|
publish({ nextRules });
|
|
|
|
|
|
};
|
2026-07-13 16:13:27 +08:00
|
|
|
|
|
|
|
|
|
|
const parseValue = (value: string): unknown => {
|
|
|
|
|
|
if (value === "true") return true;
|
|
|
|
|
|
if (value === "false") return false;
|
|
|
|
|
|
if (value !== "" && Number.isFinite(Number(value))) return Number(value);
|
|
|
|
|
|
return value;
|
|
|
|
|
|
};
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<GitBranch size={15} />}
|
|
|
|
|
|
title="路由方式"
|
|
|
|
|
|
description="选择由 Agent 判断、动态变量表达式判断,或作为确定性默认路径"
|
|
|
|
|
|
>
|
2026-07-13 16:13:27 +08:00
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="判断方式"
|
|
|
|
|
|
value={mode}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
...(sourceType === "agent" ? [{ value: "llm", label: "LLM 判断" }] : []),
|
|
|
|
|
|
{ value: "expression", label: "动态变量表达式" },
|
|
|
|
|
|
{ value: "always", label: "默认路径" },
|
|
|
|
|
|
]}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
const nextMode =
|
|
|
|
|
|
(value as WorkflowEdgeData["mode"]) || "always";
|
|
|
|
|
|
setMode(nextMode);
|
|
|
|
|
|
publish({ nextMode });
|
|
|
|
|
|
}}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
allowNone={false}
|
|
|
|
|
|
/>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
优先级
|
|
|
|
|
|
</div>
|
2026-07-13 16:13:27 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={priority}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const nextPriority = Number(event.target.value) || 0;
|
|
|
|
|
|
setPriority(nextPriority);
|
|
|
|
|
|
publish({ nextPriority });
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="border-hairline-strong bg-background text-foreground"
|
2026-07-13 16:13:27 +08:00
|
|
|
|
/>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
|
|
|
|
|
同一节点的边按数字从小到大依次判断。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<Braces size={15} />}
|
|
|
|
|
|
title="触发条件"
|
|
|
|
|
|
description="配置画布标签以及这条连接被命中的条件"
|
|
|
|
|
|
>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
条件标签
|
|
|
|
|
|
</div>
|
2026-06-15 10:12:41 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
value={label}
|
|
|
|
|
|
maxLength={64}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
placeholder="例如:用户想转人工"
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const nextLabel = event.target.value;
|
|
|
|
|
|
setLabel(nextLabel);
|
|
|
|
|
|
publish({ nextLabel });
|
|
|
|
|
|
}}
|
2026-06-15 10:12:41 +08:00
|
|
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
|
|
|
|
|
用于画布和日志中识别该路径,{label.length}/64
|
2026-06-15 10:12:41 +08:00
|
|
|
|
</span>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
</label>
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
2026-07-14 09:36:28 +08:00
|
|
|
|
{mode === "llm" && (
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
自然语言条件 <span className="text-destructive">*</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
value={condition}
|
|
|
|
|
|
placeholder="例如:用户已经明确表示需要人工客服。"
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const nextCondition = event.target.value;
|
|
|
|
|
|
setCondition(nextCondition);
|
|
|
|
|
|
publish({ nextCondition });
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{!condition.trim() && (
|
|
|
|
|
|
<span className="mt-1.5 block text-xs text-destructive">
|
|
|
|
|
|
LLM 判断需要填写触发条件。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
)}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
|
|
|
|
|
|
{mode === "expression" && (
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<div className="space-y-3">
|
2026-07-13 16:13:27 +08:00
|
|
|
|
<NodeSelect
|
|
|
|
|
|
label="规则组合"
|
|
|
|
|
|
value={combinator}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "and", label: "全部满足(AND)" },
|
|
|
|
|
|
{ value: "or", label: "任一满足(OR)" },
|
|
|
|
|
|
]}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
const nextCombinator = value === "or" ? "or" : "and";
|
|
|
|
|
|
setCombinator(nextCombinator);
|
|
|
|
|
|
publish({ nextCombinator });
|
|
|
|
|
|
}}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
allowNone={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{rules.map((rule, index) => (
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<div
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
className="space-y-2 rounded-xl border border-hairline bg-canvas-soft p-3"
|
|
|
|
|
|
>
|
2026-07-13 16:13:27 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
value={rule.variable}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
placeholder="动态变量名"
|
2026-07-13 16:13:27 +08:00
|
|
|
|
onChange={(event) => setRule(index, { variable: event.target.value })}
|
2026-07-14 09:36:28 +08:00
|
|
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
2026-07-13 16:13:27 +08:00
|
|
|
|
/>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={rule.operator}
|
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
|
setRule(index, {
|
|
|
|
|
|
operator: value as ExpressionRule["operator"],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="border-hairline-strong bg-background">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{[
|
|
|
|
|
|
"eq",
|
|
|
|
|
|
"neq",
|
|
|
|
|
|
"gt",
|
|
|
|
|
|
"gte",
|
|
|
|
|
|
"lt",
|
|
|
|
|
|
"lte",
|
|
|
|
|
|
"contains",
|
|
|
|
|
|
"in",
|
|
|
|
|
|
"exists",
|
|
|
|
|
|
].map((operator) => (
|
|
|
|
|
|
<SelectItem key={operator} value={operator}>
|
|
|
|
|
|
{operator}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
disabled={rule.operator === "exists"}
|
|
|
|
|
|
value={rule.value == null ? "" : String(rule.value)}
|
|
|
|
|
|
placeholder="比较值"
|
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
|
setRule(index, {
|
|
|
|
|
|
value: parseValue(event.target.value),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
disabled={rules.length === 1}
|
|
|
|
|
|
aria-label={`删除第 ${index + 1} 条规则`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const nextRules = rules.filter(
|
|
|
|
|
|
(_, ruleIndex) => ruleIndex !== index,
|
|
|
|
|
|
);
|
|
|
|
|
|
setRules(nextRules);
|
|
|
|
|
|
publish({ nextRules });
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={14} />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!rule.variable.trim() && (
|
|
|
|
|
|
<span className="text-xs text-destructive">
|
|
|
|
|
|
请输入动态变量名。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
2026-07-14 09:36:28 +08:00
|
|
|
|
className="w-full gap-2 border-hairline-strong"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const nextRules = [
|
|
|
|
|
|
...rules,
|
|
|
|
|
|
{ variable: "", operator: "eq", value: "" } as ExpressionRule,
|
|
|
|
|
|
];
|
|
|
|
|
|
setRules(nextRules);
|
|
|
|
|
|
publish({ nextRules });
|
|
|
|
|
|
}}
|
2026-07-13 16:13:27 +08:00
|
|
|
|
>
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<Plus size={14} />
|
|
|
|
|
|
添加规则
|
2026-07-13 16:13:27 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-06-15 15:57:05 +08:00
|
|
|
|
|
2026-07-14 09:36:28 +08:00
|
|
|
|
{mode === "always" && (
|
|
|
|
|
|
<p className="rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3 text-sm leading-6 text-muted-foreground">
|
|
|
|
|
|
当前节点没有其它条件命中时,将沿这条默认路径继续执行。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SectionCard>
|
2026-06-15 10:12:41 +08:00
|
|
|
|
|
2026-07-14 09:36:28 +08:00
|
|
|
|
<SectionCard
|
|
|
|
|
|
icon={<MessageSquareText size={15} />}
|
|
|
|
|
|
title="过渡语"
|
|
|
|
|
|
description="命中连接后、进入下一节点前播放的固定内容"
|
|
|
|
|
|
>
|
|
|
|
|
|
<label className="block">
|
|
|
|
|
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
|
|
|
|
|
固定过渡语(可选)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
value={transitionSpeech}
|
|
|
|
|
|
placeholder="例如:好的,正在为你转接。"
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
const nextTransitionSpeech = event.target.value;
|
|
|
|
|
|
setTransitionSpeech(nextTransitionSpeech);
|
|
|
|
|
|
publish({ nextTransitionSpeech });
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="field-sizing-fixed min-h-24 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
|
|
|
|
|
会显示在调试与完整聊天记录中,同时使用当前 TTS 播放。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
</div>
|
2026-06-15 10:12:41 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|