Refactor pipeline and assistant page components for improved structure and performance

- Remove unused imports and classes from pipeline.py to streamline the codebase.
- Consolidate dynamic variable handling and workflow management in AssistantPage, enhancing clarity and maintainability.
- Update WorkflowEditor to utilize a more modular approach, improving the overall architecture and reducing complexity.
- Enhance the import structure across components for better organization and readability.
This commit is contained in:
Xin Wang
2026-07-14 12:59:41 +08:00
parent 2d6ff5b7aa
commit 6e8fc70c5a
21 changed files with 6122 additions and 5439 deletions

View File

@@ -0,0 +1,91 @@
"use client";
import { Zap } from "lucide-react";
import { SectionCard } from "@/components/editor/section-card";
import { Textarea } from "@/components/ui/textarea";
import { NodeSelect } from "./controls";
import type { WorkflowNodeData } from "../specs";
import type { ModelOption } from "../types";
type ActionNodePanelProps = {
draft: WorkflowNodeData;
set: (key: string, value: unknown) => void;
toolOptions: ModelOption[];
argumentsJson: string;
assignmentsJson: string;
jsonError: string;
setArgumentsJson: (value: string) => void;
setAssignmentsJson: (value: string) => void;
commitActionJson: (argumentsValue: string, assignmentsValue: string) => void;
};
export function ActionNodePanel({
draft,
set,
toolOptions,
argumentsJson,
assignmentsJson,
jsonError,
setArgumentsJson,
setAssignmentsJson,
commitActionJson,
}: ActionNodePanelProps) {
return (
<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>
);
}

View File

@@ -0,0 +1,283 @@
"use client";
import {
Bot,
Brain,
Database,
MessageSquareText,
Settings2,
Sparkles,
Tag,
Wrench,
} from "lucide-react";
import { KnowledgeRetrievalConfigDialog } from "@/components/editor/knowledge-retrieval-config-dialog";
import { SectionCard } from "@/components/editor/section-card";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { KnowledgeRetrievalConfig } from "@/lib/api";
import { normalizeTurnConfig } from "@/lib/turn-config";
import { NodeSelect, ToolOptionPicker } from "./controls";
import type { WorkflowNodeData } from "../specs";
import type { ModelOption, WorkflowSettings } from "../types";
export function AgentNodePanel({
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),
};
const agentTurnConfig = normalizeTurnConfig(
draft.turnConfig ?? workflowSettings.turnConfig,
);
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,
enableInterrupt:
draft.enableInterrupt ?? workflowSettings.allowInterrupt,
turnConfig: agentTurnConfig,
});
};
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} />}
title={inheritsGlobal ? "任务" : "提示词"}
description={
inheritsGlobal
? "描述当前阶段要完成的目标;角色、能力和通用规则继承工作流全局配置"
: "描述当前独立助手的角色、能力和回答要求"
}
>
<Textarea
rows={8}
value={draft.prompt ?? ""}
onChange={(event) => set("prompt", event.target.value)}
placeholder={
inheritsGlobal
? "例如:确认用户身份,并收集需要查询的订单编号"
: "请输入提示词,描述助手的角色、能力和回答要求"
}
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>
<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>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,337 @@
"use client";
import type { Edge } from "@xyflow/react";
import {
Braces,
GitBranch,
MessageSquareText,
Plus,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { SectionCard } from "@/components/editor/section-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { NodeSelect } from "./controls";
import type { ExpressionRule, WorkflowEdgeData } from "../specs";
export function EdgeSettingsPanel({
edge,
sourceType,
onChange,
}: {
edge: Edge;
sourceType?: string;
onChange: (patch: WorkflowEdgeData) => void;
}) {
const data = (edge.data ?? { mode: "always", priority: 10 }) as WorkflowEdgeData;
const [mode, setMode] = useState(data.mode ?? "always");
const [priority, setPriority] = useState(data.priority ?? 10);
const [label, setLabel] = useState(data.label ?? "");
const [condition, setCondition] = useState(data.condition ?? "");
const [transitionSpeech, setTransitionSpeech] = useState(data.transitionSpeech ?? "");
const [combinator, setCombinator] = useState<"and" | "or">(
data.expression?.combinator ?? "and",
);
const [rules, setRules] = useState<ExpressionRule[]>(
data.expression?.rules?.length
? data.expression.rules
: [{ variable: "", operator: "eq", value: "" }],
);
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,
);
setRules(nextRules);
publish({ nextRules });
};
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;
};
return (
<div className="space-y-3">
<SectionCard
icon={<GitBranch size={15} />}
title="路由方式"
description="选择由 Agent 判断、动态变量表达式判断,或作为确定性默认路径"
>
<NodeSelect
label="判断方式"
value={mode}
options={[
...(sourceType === "agent" ? [{ value: "llm", label: "LLM 判断" }] : []),
{ value: "expression", label: "动态变量表达式" },
{ value: "always", label: "默认路径" },
]}
onChange={(value) => {
const nextMode =
(value as WorkflowEdgeData["mode"]) || "always";
setMode(nextMode);
publish({ nextMode });
}}
allowNone={false}
/>
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">
</div>
<Input
type="number"
value={priority}
onChange={(event) => {
const nextPriority = Number(event.target.value) || 0;
setPriority(nextPriority);
publish({ nextPriority });
}}
className="border-hairline-strong bg-background text-foreground"
/>
<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>
<Input
value={label}
maxLength={64}
placeholder="例如:用户想转人工"
onChange={(event) => {
const nextLabel = event.target.value;
setLabel(nextLabel);
publish({ nextLabel });
}}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
{label.length}/64
</span>
</label>
{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>
)}
{mode === "expression" && (
<div className="space-y-3">
<NodeSelect
label="规则组合"
value={combinator}
options={[
{ value: "and", label: "全部满足AND" },
{ value: "or", label: "任一满足OR" },
]}
onChange={(value) => {
const nextCombinator = value === "or" ? "or" : "and";
setCombinator(nextCombinator);
publish({ nextCombinator });
}}
allowNone={false}
/>
{rules.map((rule, index) => (
<div
key={index}
className="space-y-2 rounded-xl border border-hairline bg-canvas-soft p-3"
>
<Input
value={rule.variable}
placeholder="动态变量名"
onChange={(event) => setRule(index, { variable: event.target.value })}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
<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>
)}
</div>
))}
<Button
type="button"
variant="outline"
className="w-full gap-2 border-hairline-strong"
onClick={() => {
const nextRules = [
...rules,
{ variable: "", operator: "eq", value: "" } as ExpressionRule,
];
setRules(nextRules);
publish({ nextRules });
}}
>
<Plus size={14} />
</Button>
</div>
)}
{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>
<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>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import {
AudioLines,
Brain,
Database,
MessageSquareText,
Sparkles,
Wrench,
} from "lucide-react";
import { KnowledgeRetrievalConfigDialog } from "@/components/editor/knowledge-retrieval-config-dialog";
import { SectionCard } from "@/components/editor/section-card";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Textarea } from "@/components/ui/textarea";
import { ModelSelect, NodeSelect, ToolOptionPicker } from "./controls";
import type {
ModelOption,
WorkflowEditorProps,
WorkflowSettings,
} from "../types";
export function GlobalSettingsPanel({
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>
);
}

View File

@@ -0,0 +1,458 @@
"use client";
import { Flag, PhoneForwarded, Play, Tag } from "lucide-react";
import { useState } from "react";
import { SectionCard } from "@/components/editor/section-card";
import { DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ActionNodePanel } from "./ActionNodePanel";
import { AgentNodePanel } from "./AgentNodePanel";
import { NodeSelect, ToolOptionPicker } from "./controls";
import type { RuntimeNodeSpec, WorkflowNodeData } from "../specs";
import type { ModelOption, WorkflowSettings } from "../types";
export function NodeSettingsPanel({
panel = false,
spec,
data,
toolOptions,
knowledgeOptions,
llmOptions,
asrOptions,
ttsOptions,
workflowSettings,
onChange,
}: {
panel?: boolean;
spec: RuntimeNodeSpec;
data: WorkflowNodeData;
toolOptions: ModelOption[];
knowledgeOptions: ModelOption[];
llmOptions: ModelOption[];
asrOptions: ModelOption[];
ttsOptions: ModelOption[];
workflowSettings: WorkflowSettings;
onChange: (patch: WorkflowNodeData) => void;
}) {
const [draft, setDraft] = useState<WorkflowNodeData>({ ...data });
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);
};
const set = (key: string, val: unknown) =>
commit({ ...draft, [key]: val });
const setPatch = (patch: Partial<WorkflowNodeData>) =>
commit({ ...draft, ...patch });
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 对象");
}
};
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 (
<AgentNodePanel
draft={draft}
set={set}
setPatch={setPatch}
workflowSettings={workflowSettings}
toolOptions={toolOptions}
knowledgeOptions={knowledgeOptions}
llmOptions={llmOptions}
asrOptions={asrOptions}
ttsOptions={ttsOptions}
/>
);
}
return (
<>
{!panel && (
<DialogHeader>
<DialogTitle className="font-display text-ink">
{spec.displayName}
</DialogTitle>
</DialogHeader>
)}
<div className="flex flex-col gap-5 py-2">
{spec.fields.map((field) => {
const raw = draft[field.key];
if (field.type === "switch") {
return (
<label
key={field.key}
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium text-foreground">
{field.label}
</span>
<Switch
checked={Boolean(raw)}
onCheckedChange={(checked) => set(field.key, checked)}
/>
</label>
);
}
return (
<div key={field.key} className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground">
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</label>
{field.type === "textarea" ? (
<Textarea
rows={4}
value={(raw as string) ?? ""}
onChange={(e) => set(field.key, e.target.value)}
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
) : (
<Input
value={(raw as string) ?? ""}
onChange={(e) => set(field.key, e.target.value)}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
)}
</div>
);
})}
{spec.type === "agent" && (
<>
<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" && (
<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>
)}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground"></label>
<ToolOptionPicker
options={toolOptions}
selectedIds={draft.toolIds ?? []}
onChange={(toolIds) => set("toolIds", toolIds)}
/>
</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);
}}
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
<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);
}}
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
<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}
/>
)}
</div>
</>
);
}
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" && (
<ActionNodePanel
draft={draft}
set={set}
toolOptions={toolOptions}
argumentsJson={argumentsJson}
assignmentsJson={assignmentsJson}
jsonError={jsonError}
setArgumentsJson={setArgumentsJson}
setAssignmentsJson={setAssignmentsJson}
commitActionJson={commitActionJson}
/>
)}
{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。 */

View File

@@ -0,0 +1,214 @@
"use client";
import { Plus, Wrench, X } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ModelOption } from "../types";
const NONE = "__none__";
export function ModelSelect({
label,
value,
options,
onChange,
}: {
label: string;
value?: string;
options: ModelOption[];
onChange: (value: string | undefined) => void;
}) {
return (
<div className="block">
{label && (
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)}
<Select
value={value ?? NONE}
onValueChange={(v) => onChange(v === NONE ? undefined : v)}
>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue placeholder="选择模型资源" />
</SelectTrigger>
<SelectContent className="border-hairline bg-popover text-popover-foreground">
<SelectItem value={NONE}></SelectItem>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export 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>
</>
);
}
export 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 (
<div className="block">
{label && (
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)}
<Select
value={value || (allowNone ? NONE : options[0]?.value)}
onValueChange={(next) => onChange(next === NONE ? undefined : next)}
>
<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">
{allowNone && <SelectItem value={NONE}>{noneLabel}</SelectItem>}
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}