Files
ai-video-fullstack/frontend/src/components/workflow/panels/NodeSettingsPanel.tsx

461 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
visionOptions,
workflowSettings,
onChange,
}: {
panel?: boolean;
spec: RuntimeNodeSpec;
data: WorkflowNodeData;
toolOptions: ModelOption[];
knowledgeOptions: ModelOption[];
llmOptions: ModelOption[];
asrOptions: ModelOption[];
ttsOptions: ModelOption[];
visionOptions: 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}
visionOptions={visionOptions}
/>
);
}
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。 */