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:
91
frontend/src/components/workflow/panels/ActionNodePanel.tsx
Normal file
91
frontend/src/components/workflow/panels/ActionNodePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
283
frontend/src/components/workflow/panels/AgentNodePanel.tsx
Normal file
283
frontend/src/components/workflow/panels/AgentNodePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
337
frontend/src/components/workflow/panels/EdgeSettingsPanel.tsx
Normal file
337
frontend/src/components/workflow/panels/EdgeSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
150
frontend/src/components/workflow/panels/GlobalSettingsPanel.tsx
Normal file
150
frontend/src/components/workflow/panels/GlobalSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
458
frontend/src/components/workflow/panels/NodeSettingsPanel.tsx
Normal file
458
frontend/src/components/workflow/panels/NodeSettingsPanel.tsx
Normal 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。 */
|
||||
|
||||
214
frontend/src/components/workflow/panels/controls.tsx
Normal file
214
frontend/src/components/workflow/panels/controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user