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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,362 @@
"use client";
import { Braces, Check, Copy } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import type { WorkflowGraph } from "@/components/workflow/specs";
import type { DynamicVariableDefinition } from "@/lib/api";
const DYNAMIC_VARIABLE_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_]{0,63})\s*}}/g;
const SYSTEM_DYNAMIC_VARIABLES = [
["system__conversation_id", "会话 ID"],
["system__time", "当前时间"],
["system__timezone", "会话时区"],
["system__agent_turns", "助手轮次"],
["system__conversation_history", "会话历史"],
] as const;
function isPublicDynamicVariableName(name: string): boolean {
return (
/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name) &&
!name.startsWith("system__") &&
!name.startsWith("secret__")
);
}
function extractDynamicVariableNames(...templates: string[]): string[] {
const names = new Set<string>();
for (const template of templates) {
for (const match of template.matchAll(DYNAMIC_VARIABLE_PATTERN)) {
if (isPublicDynamicVariableName(match[1])) names.add(match[1]);
}
}
return [...names];
}
function definitionFor(
name: string,
saved: Record<string, DynamicVariableDefinition>,
): DynamicVariableDefinition {
return (
saved[name] ?? {
type: "string",
required: false,
default: null,
}
);
}
export function activeDynamicVariableDefinitions(
templates: string[],
saved: Record<string, DynamicVariableDefinition>,
): Record<string, DynamicVariableDefinition> {
return Object.fromEntries(
extractDynamicVariableNames(...templates).map((name) => [
name,
definitionFor(name, saved),
]),
);
}
export function activeWorkflowDynamicVariableDefinitions(
graph: WorkflowGraph,
saved: Record<string, DynamicVariableDefinition>,
): Record<string, DynamicVariableDefinition> {
const names = new Set(extractDynamicVariableNames(JSON.stringify(graph)));
for (const edge of graph.edges) {
for (const rule of edge.data.expression?.rules ?? []) {
if (isPublicDynamicVariableName(rule.variable)) names.add(rule.variable);
}
}
for (const node of graph.nodes) {
for (const name of Object.keys(node.data.resultAssignments ?? {})) {
if (isPublicDynamicVariableName(name)) names.add(name);
}
}
return Object.fromEntries(
[...names]
.sort()
.map((name) => [name, definitionFor(name, saved)]),
);
}
export function DynamicVariableEditorHint({
count,
onOpen,
}: {
count: number;
onOpen: () => void;
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-3 py-2 text-xs text-muted-foreground">
<span>
{" "}
<code className="rounded bg-surface-strong px-1 py-0.5 font-mono text-foreground">
{"{{"}
</code>{" "}
</span>
<button
type="button"
onClick={onOpen}
className="shrink-0 font-medium text-foreground underline-offset-4 hover:underline"
>
{count > 0 ? `管理 ${count} 个变量` : "查看变量"}
</button>
</div>
);
}
export function DynamicVariablesDialog({
open,
onOpenChange,
definitions,
onChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
definitions: Record<string, DynamicVariableDefinition>;
onChange: (definitions: Record<string, DynamicVariableDefinition>) => void;
}) {
const entries = Object.entries(definitions);
const [copiedSystemVariable, setCopiedSystemVariable] = useState<string | null>(
null,
);
function updateDefinition(
name: string,
patch: Partial<DynamicVariableDefinition>,
) {
onChange({
...definitions,
[name]: { ...definitions[name], ...patch },
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[78vh] overflow-hidden rounded-2xl border-hairline bg-card p-0 sm:max-w-[520px]">
<DialogHeader className="border-b border-hairline px-6 py-5 text-left">
<DialogTitle className="flex items-center gap-2 text-base font-medium">
<Braces size={17} />
</DialogTitle>
<DialogDescription className="text-xs leading-5">
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="custom" className="min-h-0 px-6 pb-6">
<TabsList className="grid w-full shrink-0 grid-cols-2 bg-surface-strong">
<TabsTrigger value="custom">
<span className="text-[11px] tabular-nums text-muted-foreground">
{entries.length}
</span>
</TabsTrigger>
<TabsTrigger value="system">
<span className="text-[11px] tabular-nums text-muted-foreground">
{SYSTEM_DYNAMIC_VARIABLES.length}
</span>
</TabsTrigger>
</TabsList>
<TabsContent
value="custom"
className="max-h-[calc(78vh-168px)] overflow-y-auto pt-3"
>
{entries.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-5 text-center">
<div className="text-sm font-medium text-foreground">
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{"{{customer_name}}"}
</p>
</div>
) : (
<div className="space-y-3">
{entries.map(([name, definition]) => (
<div
key={name}
className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-3.5"
>
<div className="flex items-center justify-between gap-3">
<code className="truncate font-mono text-sm font-medium text-foreground">
{`{{${name}}}`}
</code>
<Badge
variant="secondary"
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium"
>
</Badge>
</div>
<div className="grid grid-cols-[120px_1fr] gap-2">
<Select
value={definition.type}
onValueChange={(type: DynamicVariableDefinition["type"]) =>
updateDefinition(name, { type, default: null })
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<span className="sr-only"> {name} </span>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="boolean"></SelectItem>
</SelectContent>
</Select>
{definition.type === "boolean" ? (
<Select
value={
definition.default == null
? "unset"
: String(definition.default)
}
onValueChange={(value) =>
updateDefinition(name, {
default:
value === "unset" ? null : value === "true",
})
}
>
<SelectTrigger
aria-label={`变量 ${name} 默认值`}
className="w-full border-hairline-strong bg-background"
>
<SelectValue placeholder="无默认值" />
</SelectTrigger>
<SelectContent>
<SelectItem value="unset"></SelectItem>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type={definition.type === "number" ? "number" : "text"}
value={
typeof definition.default === "boolean"
? String(definition.default)
: definition.default ?? ""
}
onChange={(event) => {
const raw = event.target.value;
updateDefinition(name, {
default:
raw === ""
? null
: definition.type === "number"
? Number(raw)
: raw,
});
}}
placeholder="默认值(可选)"
aria-label={`变量 ${name} 默认值`}
className="border-hairline-strong bg-background"
/>
)}
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">
</span>
<Switch
checked={definition.required}
aria-label={`变量 ${name} 必填`}
onCheckedChange={(required) =>
updateDefinition(name, { required })
}
/>
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent
value="system"
className="max-h-[calc(78vh-168px)] space-y-3 overflow-y-auto pt-3"
>
<p className="text-[11px] leading-5 text-muted-foreground">
</p>
<div className="space-y-1.5">
{SYSTEM_DYNAMIC_VARIABLES.map(([name, label]) => (
<div
key={name}
className="flex items-center justify-between gap-3 rounded-xl border border-hairline bg-background px-3 py-2"
>
<div className="min-w-0">
<div className="text-xs font-medium text-foreground">
{label}
</div>
<code className="block truncate font-mono text-[11px] text-muted-foreground">
{`{{${name}}}`}
</code>
</div>
<button
type="button"
onClick={() => {
void navigator.clipboard.writeText(`{{${name}}}`).then(() => {
setCopiedSystemVariable(name);
window.setTimeout(
() => setCopiedSystemVariable(null),
1400,
);
});
}}
aria-label={
copiedSystemVariable === name
? `${label} 已复制`
: `复制系统变量 ${label}`
}
title="复制引用"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
>
{copiedSystemVariable === name ? (
<Check size={13} />
) : (
<Copy size={13} />
)}
</button>
</div>
))}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,620 @@
"use client";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import {
AudioLines,
Check,
ChevronLeft,
Copy,
Pencil,
PhoneOff,
Plus,
Settings2,
Waypoints,
Wrench,
X,
} from "lucide-react";
import { HelpHint } from "@/components/editor/section-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { KnowledgeRetrievalConfig, Tool } from "@/lib/api";
import type { RuntimeMode } from "./types";
export function EditorBackButton({ onClick }: { onClick: () => void }) {
return (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={onClick}
aria-label="返回助手列表"
>
<ChevronLeft size={20} />
</Button>
);
}
export function AssistantIdentity({ assistantId }: { assistantId: string | null }) {
const [copied, setCopied] = useState(false);
async function copyId() {
if (!assistantId) return;
await navigator.clipboard.writeText(assistantId);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
}
return (
<div className="ml-1 flex shrink-0 items-center rounded-full border border-hairline bg-canvas-soft pl-2.5 text-[11px] text-muted-foreground">
<span className="font-mono">
{assistantId ? `ID · ${assistantId}` : "ID · 保存后生成"}
</span>
{assistantId && (
<button
type="button"
onClick={() => void copyId()}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
aria-label={copied ? "助手 ID 已复制" : "复制助手 ID"}
title={copied ? "已复制" : "复制 ID"}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
)}
{!assistantId && <span className="h-7 w-2" aria-hidden />}
</div>
);
}
export function EditableTitle({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
function startEdit() {
setDraft(value);
setEditing(true);
}
function commit() {
const next = draft.trim();
if (next) {
onChange(next);
}
setEditing(false);
}
if (editing) {
return (
<input
ref={inputRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
} else if (event.key === "Escape") {
event.preventDefault();
setEditing(false);
}
}}
className="font-display display-sm w-[min(60vw,420px)] border-b border-primary bg-transparent text-ink outline-none"
/>
);
}
return (
<button
type="button"
onClick={startEdit}
title="点击修改助手名称"
className="group -mx-2 flex min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-strong"
>
<span className="font-display display-sm truncate text-ink">
{value || "未命名助手"}
</span>
<Pencil
size={16}
className="shrink-0 text-muted-soft opacity-0 transition-opacity group-hover:opacity-100"
/>
</button>
);
}
export function RuntimeModeSelector({
value,
onChange,
}: {
value: RuntimeMode;
onChange: (mode: RuntimeMode) => void;
}) {
const options = [
{
value: "pipeline" as const,
label: "Pipeline 模式",
hint: "通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。",
icon: Waypoints,
},
{
value: "realtime" as const,
label: "Realtime 模式",
hint: "使用原生实时语音模型,模型直接处理音频输入并生成语音回复。",
icon: AudioLines,
},
];
return (
<div className="grid grid-cols-1 gap-3 border-b border-hairline-soft pb-4 md:grid-cols-2">
{options.map((option) => {
const Icon = option.icon;
const selected = value === option.value;
const select = () => onChange(option.value);
return (
<div
key={option.value}
role="button"
tabIndex={0}
onClick={select}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
select();
}
}}
className={[
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
selected
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
].join(" ")}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Icon size={15} />
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground">
{option.label}
</span>
<HelpHint text={option.hint} />
</div>
</div>
{selected && (
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check size={12} />
</span>
)}
</div>
</div>
);
})}
</div>
);
}
export function TextAreaField({
label,
value,
placeholder,
rows = 4,
onChange,
}: {
label?: string;
value: string;
placeholder?: string;
rows?: number;
onChange: (value: string) => void;
}) {
return (
<label className="block">
{label && (
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)}
<Textarea
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
rows={rows}
// Override ui/textarea's field-sizing-content so `rows` sets a real height
// instead of collapsing to min-h-16 when the value is short.
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
</label>
);
}
// Radix Select 不允许空字符串 value,用哨兵表示"未选/无"
const NONE_VALUE = "__none__";
export function ResourceSelectField({
label,
value,
options,
noneLabel,
onChange,
}: {
label?: string;
value: string;
options: { value: string; label: string }[];
/** 提供则在顶部加一个"无/默认"选项,选中映射为空串 */
noneLabel?: string;
onChange: (value: string) => void;
}) {
return (
<div className="block">
{label && (
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)}
<Select
value={value || NONE_VALUE}
onValueChange={(v) => onChange(v === NONE_VALUE ? "" : v)}
>
<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">
{noneLabel && (
<SelectItem value={NONE_VALUE}>{noneLabel}</SelectItem>
)}
{options.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export function KnowledgeRetrievalConfigDialog({
disabled,
value,
onChange,
}: {
disabled: boolean;
value: KnowledgeRetrievalConfig;
onChange: (config: KnowledgeRetrievalConfig) => void;
}) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState<string | null>(null);
function openDialog() {
setDraft(value);
setError(null);
setOpen(true);
}
function saveDraft() {
if (draft.topN === 0 || draft.topN < -1 || !Number.isInteger(draft.topN)) {
setError("Top N 必须为 -1 或大于 0 的整数");
return;
}
if (draft.scoreThreshold < 0 || draft.scoreThreshold > 1) {
setError("最低相关度必须在 0 到 1 之间");
return;
}
onChange(draft);
setOpen(false);
}
return (
<>
<button
type="button"
disabled={disabled}
onClick={openDialog}
aria-label="打开知识库高级配置"
title={
disabled
? "请先选择知识库"
: `${value.mode === "automatic" ? "自动检索" : "模型主动检索"} · Top N ${value.topN === -1 ? "不限" : value.topN} · 最低相关度 ${value.scoreThreshold}`
}
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
>
<Settings2 size={14} />
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground"></div>
<Select
value={draft.mode}
onValueChange={(mode: "automatic" | "on_demand") =>
setDraft({ ...draft, mode })
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="automatic"></SelectItem>
<SelectItem value="on_demand"></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{draft.mode === "automatic"
? "每轮用户提问后自动检索,响应行为更稳定。"
: "由大模型判断是否调用知识库,依赖模型的工具调用能力。"}
</p>
</div>
<label className="block">
<span className="mb-2 block text-sm font-medium text-foreground">
</span>
<Input
type="number"
step="1"
min="-1"
value={draft.topN}
onChange={(event) =>
setDraft({ ...draft, topN: Number(event.target.value) })
}
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
-1
</span>
</label>
<label className="block">
<span className="mb-2 block text-sm font-medium text-foreground">
</span>
<Input
type="number"
step="0.01"
min="0"
max="1"
value={draft.scoreThreshold}
onChange={(event) =>
setDraft({
...draft,
scoreThreshold: Number(event.target.value),
})
}
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
01
</span>
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button type="button" onClick={saveDraft}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export function ToolPicker({
tools,
selectedIds,
onChange,
}: {
tools: Tool[];
selectedIds: string[];
onChange: (ids: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [draftIds, setDraftIds] = useState<string[]>(selectedIds);
const selectedTools = selectedIds
.map((id) => tools.find((tool) => tool.id === id))
.filter((tool): tool is Tool => Boolean(tool));
function openPicker() {
setDraftIds(selectedIds);
setOpen(true);
}
return (
<>
<div className="flex min-h-9 flex-wrap items-center gap-2">
{selectedTools.map((tool) => (
<div
key={tool.id}
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
>
{tool.type === "end_call" ? <PhoneOff size={14} /> : <Wrench size={14} />}
<span className="max-w-48 truncate">{tool.name}</span>
<button
type="button"
onClick={() => onChange(selectedIds.filter((id) => id !== tool.id))}
className="text-muted-soft transition-colors hover:text-foreground"
aria-label={`移除工具 ${tool.name}`}
>
<X size={13} />
</button>
</div>
))}
<Button
type="button"
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={openPicker}
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>
{tools.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">
{tools.map((tool) => {
const checked = draftIds.includes(tool.id);
return (
<label
key={tool.id}
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 !== tool.id)
: [...current, tool.id],
)
}
className="size-4 accent-primary"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-foreground">
{tool.name}
</span>
<Badge variant="secondary">
{tool.type === "end_call" ? "End Call" : "HTTP"}
</Badge>
</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{tool.functionName}
</div>
</div>
</label>
);
})}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button
onClick={() => {
onChange(draftIds);
setOpen(false);
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export function ToggleRow({
icon,
title,
description,
hint,
checked,
onChange,
}: {
icon?: React.ReactNode;
title: string;
description?: string;
hint?: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
const hasIcon = Boolean(icon);
return (
<div
className={[
"flex items-center justify-between border border-hairline bg-canvas-soft",
hasIcon ? "rounded-xl p-3.5" : "rounded-xl px-3.5 py-3",
].join(" ")}
>
<div>
<div
className={[
"flex items-center text-sm font-medium text-foreground",
hasIcon ? "gap-2.5" : "gap-1.5",
].join(" ")}
>
{icon && (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
{icon}
</span>
)}
<span className="flex items-center gap-1.5">
<span>{title}</span>
{hint && <HelpHint text={hint} />}
</span>
</div>
{description && (
<div className="mt-1 text-xs text-muted-foreground">
{description}
</div>
)}
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
}

View File

@@ -0,0 +1,302 @@
"use client";
import {
Bot,
Brain,
Database,
Loader2,
MessageSquareText,
Save,
Sparkles,
Wrench,
} from "lucide-react";
import { DebugDrawer } from "@/components/assistant-editor/debug-preview";
import {
DynamicVariableEditorHint,
DynamicVariablesDialog,
} from "@/components/assistant-editor/dynamic-variables";
import {
AssistantIdentity,
EditableTitle,
EditorBackButton,
KnowledgeRetrievalConfigDialog,
ResourceSelectField,
RuntimeModeSelector,
TextAreaField,
ToggleRow,
ToolPicker,
} from "@/components/assistant-editor/editor-controls";
import type { AssistantForm } from "@/components/assistant-editor/types";
import { SectionCard } from "@/components/editor/section-card";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Button } from "@/components/ui/button";
import type { DynamicVariableDefinition, Tool } from "@/lib/api";
type ResourceOption = { value: string; label: string };
type PromptEditorProps = {
assistantId: string | null;
form: AssistantForm;
dynamicVariablesOpen: boolean;
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
saving: boolean;
dirty: boolean;
saveError: string | null;
llmOptions: ResourceOption[];
asrOptions: ResourceOption[];
ttsOptions: ResourceOption[];
realtimeOptions: ResourceOption[];
visionModelOptions: ResourceOption[];
knowledgeOptions: ResourceOption[];
tools: Tool[];
onBack: () => void;
onSave: () => void;
setDynamicVariablesOpen: (open: boolean) => void;
updateForm: <K extends keyof AssistantForm>(
key: K,
value: AssistantForm[K],
) => void;
handlePromptVisionEnabledChange: (enabled: boolean) => void;
handlePromptModelChange: (value: string) => void;
};
export function PromptEditor({
assistantId,
form,
dynamicVariablesOpen,
dynamicVariableDefinitions,
saving,
dirty,
saveError,
llmOptions,
asrOptions,
ttsOptions,
realtimeOptions,
visionModelOptions,
knowledgeOptions: kbOptions,
tools,
onBack,
onSave,
setDynamicVariablesOpen,
updateForm,
handlePromptVisionEnabledChange,
handlePromptModelChange,
}: PromptEditorProps) {
return (
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={onBack} />
<EditableTitle
value={form.name}
onChange={(value) => updateForm("name", value)}
/>
<AssistantIdentity assistantId={assistantId} />
</div>
<div className="flex shrink-0 gap-2">
{saveError && (
<span className="self-center text-xs text-destructive">
{saveError}
</span>
)}
<Button
className="gap-2"
disabled={saving || !dirty || !form.name.trim()}
onClick={() => onSave()}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
</Button>
</div>
</div>
<DynamicVariablesDialog
open={dynamicVariablesOpen}
onOpenChange={setDynamicVariablesOpen}
definitions={dynamicVariableDefinitions}
onChange={(dynamicVariableDefinitions) =>
updateForm(
"dynamicVariableDefinitions",
dynamicVariableDefinitions,
)
}
/>
<div className="flex min-h-0 flex-1 gap-4">
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
<SectionCard
icon={<MessageSquareText size={15} />}
title="提示词"
description="描述助手的角色、能力和回答要求"
>
<TextAreaField
value={form.prompt}
onChange={(value) => updateForm("prompt", value)}
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
rows={8}
/>
<DynamicVariableEditorHint
count={Object.keys(dynamicVariableDefinitions).length}
onOpen={() => setDynamicVariablesOpen(true)}
/>
</SectionCard>
<SectionCard
icon={<Bot size={15} />}
title="开场白"
description="助手与用户首次对话时的开场语"
>
<TextAreaField
value={form.greeting}
onChange={(value) => updateForm("greeting", value)}
placeholder="请输入助手开场白"
/>
<DynamicVariableEditorHint
count={Object.keys(dynamicVariableDefinitions).length}
onOpen={() => setDynamicVariablesOpen(true)}
/>
</SectionCard>
<SectionCard
icon={<Brain size={15} />}
title="模型配置"
description={
form.runtimeMode === "pipeline"
? "选择运行方式,以及大语言模型、语音识别与语音合成资源"
: "选择运行方式Realtime 模型内置语音识别与语音合成"
}
>
<RuntimeModeSelector
value={form.runtimeMode}
onChange={(runtimeMode) => updateForm("runtimeMode", runtimeMode)}
/>
{form.runtimeMode === "pipeline" ? (
<>
<ToggleRow
title="视觉理解"
hint="开启后,开始对话时会允许助手按需理解当前视频画面。视觉模型选「模型自己」时,大语言模型本身必须支持图片输入。"
checked={form.visionEnabled}
onChange={handlePromptVisionEnabledChange}
/>
{form.visionEnabled && (
<ResourceSelectField
label="视觉模型"
value={form.visionModelResourceId}
onChange={(value) =>
updateForm("visionModelResourceId", value)
}
options={visionModelOptions}
noneLabel="模型自己"
/>
)}
<ResourceSelectField
label="大语言模型"
value={form.model}
onChange={handlePromptModelChange}
options={llmOptions}
noneLabel="无"
/>
<ResourceSelectField
label="语音识别"
value={form.asr}
onChange={(value) => updateForm("asr", value)}
options={asrOptions}
noneLabel="无"
/>
<ResourceSelectField
label="语音合成"
value={form.voice}
onChange={(value) => updateForm("voice", value)}
options={ttsOptions}
noneLabel="无"
/>
</>
) : (
<ResourceSelectField
label="Realtime 模型"
value={form.realtimeModel}
onChange={(value) => updateForm("realtimeModel", value)}
options={realtimeOptions}
noneLabel="无"
/>
)}
</SectionCard>
{form.runtimeMode === "pipeline" && (
<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={!form.knowledgeBase}
value={form.knowledgeRetrievalConfig}
onChange={(config) =>
updateForm("knowledgeRetrievalConfig", config)
}
/>
</div>
<ResourceSelectField
value={form.knowledgeBase}
onChange={(value) => updateForm("knowledgeBase", value)}
options={kbOptions}
noneLabel="无"
/>
</SectionCard>
)}
<SectionCard
icon={<Wrench size={15} />}
title="工具"
description="配置该提示词助手可以调用的工具"
>
<ToolPicker
tools={tools.filter((tool) => tool.status === "active")}
selectedIds={form.toolIds}
onChange={(toolIds) => updateForm("toolIds", toolIds)}
/>
</SectionCard>
<SectionCard
icon={<Sparkles size={15} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
>
{form.runtimeMode === "pipeline" ? (
<TurnConfigEditor
enabled={form.enableInterrupt}
config={form.turnConfig}
onEnabledChange={(checked) => updateForm("enableInterrupt", checked)}
onConfigChange={(config) => updateForm("turnConfig", config)}
/>
) : (
<p className="text-sm text-muted-foreground">
Pipeline
</p>
)}
</SectionCard>
</div>
<DebugDrawer
assistantId={assistantId}
hasUnsavedChanges={dirty}
vision={form.visionEnabled}
dynamicVariablesEnabled
dynamicVariableDefinitions={dynamicVariableDefinitions}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import type {
DynamicVariableDefinition,
KnowledgeRetrievalConfig,
TurnConfig,
} from "@/lib/api";
export type RuntimeMode = "pipeline" | "realtime";
export type AssistantForm = {
name: string;
greeting: string;
prompt: string;
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
runtimeMode: RuntimeMode;
realtimeModel: string;
model: string;
asr: string;
voice: string;
knowledgeBase: string;
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
enableInterrupt: boolean;
turnConfig: TurnConfig;
visionEnabled: boolean;
visionModelResourceId: string;
toolIds: string[];
};

View File

@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import type { WorkflowSettings } from "@/components/workflow/types";
import { defaultGraph, type WorkflowGraph } from "@/components/workflow/specs";
import type { DynamicVariableDefinition } from "@/lib/api";
import { defaultTurnConfig } from "@/lib/turn-config";
function initialWorkflowSettings(): WorkflowSettings {
const graph = defaultGraph();
return {
globalPrompt: graph.settings.globalPrompt,
llm: graph.settings.defaultLlmResourceId,
asr: graph.settings.defaultAsrResourceId,
tts: graph.settings.defaultTtsResourceId,
toolIds: graph.settings.toolIds,
knowledgeBaseId: graph.settings.knowledgeBaseId,
knowledgeRetrievalConfig: {
mode: graph.settings.knowledgeMode,
topN: graph.settings.knowledgeTopN,
scoreThreshold: graph.settings.knowledgeScoreThreshold,
},
allowInterrupt: true,
turnConfig: defaultTurnConfig(),
};
}
/** Owns Workflow editor state; AssistantPage only coordinates loading and saving. */
export function useWorkflowEditorState() {
const [workflowName, setWorkflowName] = useState("");
const [workflowGraph, setWorkflowGraph] = useState<WorkflowGraph>(() =>
defaultGraph(),
);
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>(
initialWorkflowSettings,
);
const [workflowDynamicVariableDefinitions, setWorkflowDynamicVariableDefinitions] =
useState<Record<string, DynamicVariableDefinition>>({});
const [workflowDebugOpen, setWorkflowDebugOpen] = useState(false);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [workflowEditingNodeId, setWorkflowEditingNodeId] = useState<string | null>(
null,
);
const [workflowEditingEdgeId, setWorkflowEditingEdgeId] = useState<string | null>(
null,
);
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
return {
workflowName,
setWorkflowName,
workflowGraph,
setWorkflowGraph,
workflowSettings,
setWorkflowSettings,
workflowDynamicVariableDefinitions,
setWorkflowDynamicVariableDefinitions,
workflowDebugOpen,
setWorkflowDebugOpen,
workflowSettingsOpen,
setWorkflowSettingsOpen,
workflowEditingNodeId,
setWorkflowEditingNodeId,
workflowEditingEdgeId,
setWorkflowEditingEdgeId,
activeNodeId,
setActiveNodeId,
dynamicVariablesOpen,
setDynamicVariablesOpen,
};
}

View File

@@ -0,0 +1,186 @@
"use client";
import { Bug, Loader2, Save } from "lucide-react";
import { DebugDrawer } from "@/components/assistant-editor/debug-preview";
import { DynamicVariablesDialog } from "@/components/assistant-editor/dynamic-variables";
import {
AssistantIdentity,
EditableTitle,
EditorBackButton,
} from "@/components/assistant-editor/editor-controls";
import { Button } from "@/components/ui/button";
import { WorkflowEditor } from "@/components/workflow/WorkflowEditor";
import type { WorkflowSettings } from "@/components/workflow/types";
import type { WorkflowGraph } from "@/components/workflow/specs";
import type { DynamicVariableDefinition } from "@/lib/api";
type ResourceOption = { value: string; label: string };
type WorkflowPageProps = {
assistantId: string | null;
workflowName: string;
workflowGraph: WorkflowGraph;
workflowSettings: WorkflowSettings;
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
workflowDebugOpen: boolean;
workflowSettingsOpen: boolean;
workflowEditingNodeId: string | null;
workflowEditingEdgeId: string | null;
activeNodeId: string | null;
dynamicVariablesOpen: boolean;
saving: boolean;
dirty: boolean;
saveError: string | null;
modelOptions: {
llm: ResourceOption[];
asr: ResourceOption[];
tts: ResourceOption[];
};
toolOptions: ResourceOption[];
knowledgeOptions: ResourceOption[];
onBack: () => void;
onSave: () => void;
setWorkflowName: (name: string) => void;
setWorkflowGraph: (graph: WorkflowGraph) => void;
setWorkflowSettings: (settings: WorkflowSettings) => void;
setWorkflowDynamicVariableDefinitions: (
definitions: Record<string, DynamicVariableDefinition>,
) => void;
setWorkflowDebugOpen: (open: boolean) => void;
setWorkflowSettingsOpen: (open: boolean) => void;
setWorkflowEditingNodeId: (nodeId: string | null) => void;
setWorkflowEditingEdgeId: (edgeId: string | null) => void;
setActiveNodeId: (nodeId: string | null) => void;
setDynamicVariablesOpen: (open: boolean) => void;
};
export function WorkflowPage({
assistantId,
workflowName,
workflowGraph,
workflowSettings,
dynamicVariableDefinitions,
workflowDebugOpen,
workflowSettingsOpen,
workflowEditingNodeId,
workflowEditingEdgeId,
activeNodeId,
dynamicVariablesOpen,
saving,
dirty,
saveError,
modelOptions,
toolOptions,
knowledgeOptions: kbOptions,
onBack,
onSave,
setWorkflowName,
setWorkflowGraph,
setWorkflowSettings,
setWorkflowDynamicVariableDefinitions,
setWorkflowDebugOpen,
setWorkflowSettingsOpen,
setWorkflowEditingNodeId,
setWorkflowEditingEdgeId,
setActiveNodeId,
setDynamicVariablesOpen,
}: WorkflowPageProps) {
return (
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={onBack} />
<EditableTitle value={workflowName} onChange={setWorkflowName} />
<AssistantIdentity assistantId={assistantId} />
</div>
<div className="flex shrink-0 gap-2">
{saveError && (
<span
role="alert"
title={saveError}
className="line-clamp-1 max-w-[min(42vw,560px)] self-center text-right text-sm leading-5 text-destructive"
>
{saveError}
</span>
)}
<Button
variant="outline"
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
disabled={!assistantId}
onClick={() => {
setWorkflowSettingsOpen(false);
setWorkflowEditingNodeId(null);
setWorkflowEditingEdgeId(null);
setWorkflowDebugOpen(true);
}}
>
<Bug size={16} />
</Button>
<Button
className="gap-2"
disabled={saving || !dirty || !workflowName.trim()}
onClick={onSave}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
</Button>
</div>
</div>
<div className="min-h-0 flex-1">
<WorkflowEditor
value={workflowGraph}
onChange={setWorkflowGraph}
settings={workflowSettings}
onSettingsChange={setWorkflowSettings}
onOpenDynamicVariables={() => setDynamicVariablesOpen(true)}
editingNodeId={workflowEditingNodeId}
onEditingNodeIdChange={setWorkflowEditingNodeId}
editingEdgeId={workflowEditingEdgeId}
onEditingEdgeIdChange={setWorkflowEditingEdgeId}
settingsOpen={workflowSettingsOpen}
onSettingsOpenChange={setWorkflowSettingsOpen}
debugOpen={workflowDebugOpen}
onDebugOpenChange={(open) => {
setWorkflowDebugOpen(open);
if (!open) setActiveNodeId(null);
}}
debugPanel={
<DebugDrawer
overlay
assistantId={assistantId}
onClose={() => {
setWorkflowDebugOpen(false);
setActiveNodeId(null);
}}
hasUnsavedChanges={dirty}
onNodeActive={setActiveNodeId}
dynamicVariablesEnabled
dynamicVariableDefinitions={
dynamicVariableDefinitions
}
/>
}
activeNodeId={activeNodeId}
modelOptions={modelOptions}
toolOptions={toolOptions}
knowledgeOptions={kbOptions}
/>
</div>
<DynamicVariablesDialog
open={dynamicVariablesOpen}
onOpenChange={setDynamicVariablesOpen}
definitions={dynamicVariableDefinitions}
onChange={setWorkflowDynamicVariableDefinitions}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,772 @@
"use client";
import {
addEdge,
Background,
BackgroundVariant,
type Connection,
Controls,
type Edge,
type Node,
type NodeChange,
type OnConnectEnd,
Panel,
ReactFlow,
useEdgesState,
useNodesState,
useReactFlow,
} from "@xyflow/react";
import { Braces, Plus, Settings2, X } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { edgeTypes } from "./ConditionEdge";
import {
ActiveNodeContext,
EdgeActionContext,
NodeActionContext,
NodeSpecsContext,
} from "./context";
import { nodeTypes } from "./GenericNode";
import { EdgeSettingsPanel } from "./panels/EdgeSettingsPanel";
import { GlobalSettingsPanel } from "./panels/GlobalSettingsPanel";
import { NodeSettingsPanel } from "./panels/NodeSettingsPanel";
import {
accentVar,
defaultGraph,
type NodeSpecMap,
type RuntimeNodeSpec,
type WorkflowGraph,
type WorkflowNodeData,
type WorkflowNodeType,
} from "./specs";
import type { WorkflowEditorProps } from "./types";
let nodeSeq = 0;
function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
const data: WorkflowNodeData = {
name: spec.displayName,
...(spec.type === "agent"
? {
contextPolicy: "inherit",
inheritGlobalConfig: true,
entryMode: "wait_user",
entrySpeech: "",
}
: {}),
};
for (const field of spec.fields) {
if (field.default !== undefined) data[field.key] = field.default;
}
return data;
}
function toFlow(graph: WorkflowGraph): { nodes: Node[]; edges: Edge[] } {
return {
nodes: graph.nodes.map((n) => ({
id: n.id,
type: n.type,
position: n.position,
data: n.data,
})),
edges: graph.edges.map((e) => ({
id: e.id,
type: "condition",
source: e.source,
target: e.target,
data: e.data ?? {},
})),
};
}
function fromFlow(nodes: Node[], edges: Edge[]): WorkflowGraph {
return {
specVersion: 3,
settings: {
globalPrompt: "",
defaultLlmResourceId: "",
defaultAsrResourceId: "",
defaultTtsResourceId: "",
toolIds: [],
knowledgeBaseId: "",
knowledgeMode: "automatic",
knowledgeTopN: 5,
knowledgeScoreThreshold: 0,
enableInterrupt: true,
turnConfig: defaultGraph().settings.turnConfig,
},
nodes: nodes.map((n) => ({
id: n.id,
type: n.type as WorkflowNodeType,
position: n.position,
data: n.data as WorkflowNodeData,
})),
edges: edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
data: (e.data ?? {
mode: "always",
priority: 10,
}) as WorkflowGraph["edges"][number]["data"],
})),
};
}
export function WorkflowCanvas({
value,
onChange,
settings,
onSettingsChange,
modelOptions,
activeNodeId,
onOpenDynamicVariables,
editingNodeId,
onEditingNodeIdChange,
editingEdgeId,
onEditingEdgeIdChange,
settingsOpen,
onSettingsOpenChange: setSettingsOpen,
debugOpen,
onDebugOpenChange,
debugPanel,
specsByType,
toolOptions = [],
knowledgeOptions = [],
}: WorkflowEditorProps & { specsByType: NodeSpecMap }) {
const initial = useMemo(() => toFlow(value ?? defaultGraph()), [value]);
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
const [addOpen, setAddOpen] = useState(false);
const [addSourceId, setAddSourceId] = useState<string | null>(null);
const [addPosition, setAddPosition] = useState<{ x: number; y: number } | null>(null);
const { screenToFlowPosition } = useReactFlow();
// 回传画布状态给外部(助手 graph)。用 ref 避免把 onChange 放进依赖导致循环。
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
const graph = fromFlow(nodes, edges);
graph.settings = {
globalPrompt: settings.globalPrompt,
defaultLlmResourceId: settings.llm ?? "",
defaultAsrResourceId: settings.asr ?? "",
defaultTtsResourceId: settings.tts ?? "",
toolIds: settings.toolIds,
knowledgeBaseId: settings.knowledgeBaseId,
knowledgeMode: settings.knowledgeRetrievalConfig.mode,
knowledgeTopN: settings.knowledgeRetrievalConfig.topN,
knowledgeScoreThreshold:
settings.knowledgeRetrievalConfig.scoreThreshold,
enableInterrupt: settings.allowInterrupt,
turnConfig: settings.turnConfig,
};
onChangeRef.current?.(graph);
}, [
nodes,
edges,
settings.globalPrompt,
settings.llm,
settings.asr,
settings.tts,
settings.toolIds,
settings.knowledgeBaseId,
settings.knowledgeRetrievalConfig,
settings.allowInterrupt,
settings.turnConfig,
]);
const onConnect = useCallback(
(connection: Connection) => {
const sourceType = nodes.find((node) => node.id === connection.source)?.type;
const priority =
edges.filter((edge) => edge.source === connection.source).length * 10 + 10;
setEdges((eds) =>
addEdge(
{
...connection,
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
type: "condition",
animated: true,
data:
sourceType === "agent"
? {
mode: "llm",
priority,
condition: "当前阶段任务已经完成",
}
: { mode: "always", priority },
},
eds,
),
);
},
[nodes, edges, setEdges],
);
// 连线约束:不能连入开始节点(无入边句柄),不能自连。
const isValidConnection = useCallback(
(c: Connection | Edge) => {
if (c.source === c.target) return false;
const source = nodes.find((n) => n.id === c.source);
const target = nodes.find((n) => n.id === c.target);
if (!source || !target) return false;
const sourceSpec = specsByType[source.type as string];
const targetSpec = specsByType[target.type as string];
if (!sourceSpec?.hasSource || !targetSpec?.hasTarget) return false;
if (edges.some((e) => e.source === c.source && e.target === c.target)) {
return false;
}
const sourceLimit = sourceSpec.constraints.maxOutgoing;
if (
sourceLimit !== undefined &&
edges.filter((e) => e.source === c.source).length >= sourceLimit
) {
return false;
}
const targetLimit = targetSpec.constraints.maxIncoming;
if (
targetLimit !== undefined &&
edges.filter((e) => e.target === c.target).length >= targetLimit
) {
return false;
}
return true;
},
[edges, nodes, specsByType],
);
const addNode = useCallback(
(spec: RuntimeNodeSpec) => {
nodeSeq += 1;
const id = `${spec.type}-${Date.now()}-${nodeSeq}`;
const source = addSourceId
? nodes.find((node) => node.id === addSourceId)
: undefined;
const position = addPosition
? { x: addPosition.x - 125, y: addPosition.y }
: screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
});
const data = defaultNodeData(spec);
setNodes((ns) => [...ns, { id, type: spec.type, position, data }]);
if (source) {
setEdges((currentEdges) => {
const priority =
currentEdges.filter((edge) => edge.source === source.id).length * 10 + 10;
return addEdge(
{
id: `e-${source.id}-${id}-${Date.now()}`,
source: source.id,
target: id,
type: "condition",
animated: true,
data:
source.type === "agent"
? {
mode: "llm",
priority,
condition: "当前阶段任务已经完成",
}
: { mode: "always", priority },
},
currentEdges,
);
});
}
setAddOpen(false);
setAddSourceId(null);
setAddPosition(null);
setSettingsOpen(false);
onDebugOpenChange(false);
onEditingEdgeIdChange(null);
onEditingNodeIdChange(id);
},
[
addPosition,
addSourceId,
nodes,
onDebugOpenChange,
onEditingEdgeIdChange,
onEditingNodeIdChange,
screenToFlowPosition,
setEdges,
setNodes,
setSettingsOpen,
],
);
const updateNodeData = useCallback(
(id: string, patch: Partial<WorkflowNodeData>) => {
setNodes((ns) =>
ns.map((n) =>
n.id === id ? { ...n, data: { ...n.data, ...patch } } : n,
),
);
},
[setNodes],
);
const deleteNode = useCallback(
(id: string) => {
if (nodes.find((node) => node.id === id)?.type === "start") return;
setNodes((ns) => ns.filter((n) => n.id !== id));
setEdges((es) => es.filter((e) => e.source !== id && e.target !== id));
if (editingNodeId === id) onEditingNodeIdChange(null);
},
[editingNodeId, nodes, onEditingNodeIdChange, setNodes, setEdges],
);
const handleNodesChange = useCallback(
(changes: NodeChange[]) => {
const startIds = new Set(
nodes.filter((node) => node.type === "start").map((node) => node.id),
);
onNodesChange(
changes.filter(
(change) => change.type !== "remove" || !startIds.has(change.id),
),
);
},
[nodes, onNodesChange],
);
const updateEdgeData = useCallback(
(
id: string,
patch: WorkflowGraph["edges"][number]["data"],
) => {
setEdges((es) =>
es.map((e) =>
e.id === id ? { ...e, data: { ...(e.data ?? {}), ...patch } } : e,
),
);
},
[setEdges],
);
const deleteEdge = useCallback(
(id: string) => {
setEdges((es) => es.filter((e) => e.id !== id));
if (editingEdgeId === id) onEditingEdgeIdChange(null);
},
[editingEdgeId, onEditingEdgeIdChange, setEdges],
);
const canCreateFromSource = useCallback(
(id: string) => {
const source = nodes.find((node) => node.id === id);
if (!source) return false;
const spec = specsByType[source.type as string];
if (!spec?.hasSource) return false;
const outgoingCount = edges.filter((edge) => edge.source === id).length;
if (
spec.constraints.maxOutgoing !== undefined &&
outgoingCount >= spec.constraints.maxOutgoing
) {
return false;
}
return source.type === "agent" || outgoingCount === 0;
},
[edges, nodes, specsByType],
);
const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => {
if (
connectionState.isValid ||
connectionState.toNode ||
connectionState.fromHandle?.type !== "source" ||
!connectionState.fromNode ||
!canCreateFromSource(connectionState.fromNode.id)
) {
return;
}
const pointer = "changedTouches" in event
? event.changedTouches[0]
: event;
if (!pointer) return;
setAddSourceId(connectionState.fromNode.id);
setAddPosition(
screenToFlowPosition({ x: pointer.clientX, y: pointer.clientY }),
);
setAddOpen(true);
},
[canCreateFromSource, screenToFlowPosition],
);
const openSettings = useCallback(() => {
onDebugOpenChange(false);
onEditingNodeIdChange(null);
onEditingEdgeIdChange(null);
setSettingsOpen(true);
}, [
onDebugOpenChange,
onEditingEdgeIdChange,
onEditingNodeIdChange,
setSettingsOpen,
]);
const nodeActions = useMemo(
() => ({
edit: (id: string) => {
setSettingsOpen(false);
onDebugOpenChange(false);
onEditingEdgeIdChange(null);
onEditingNodeIdChange(id);
},
remove: deleteNode,
}),
[
deleteNode,
onDebugOpenChange,
onEditingEdgeIdChange,
onEditingNodeIdChange,
setSettingsOpen,
],
);
const edgeActions = useMemo(
() => ({
edit: (id: string) => {
setSettingsOpen(false);
onDebugOpenChange(false);
onEditingNodeIdChange(null);
onEditingEdgeIdChange(id);
},
remove: deleteEdge,
}),
[
deleteEdge,
onDebugOpenChange,
onEditingEdgeIdChange,
onEditingNodeIdChange,
setSettingsOpen,
],
);
const editingNode = nodes.find((n) => n.id === editingNodeId);
const editingSpec = editingNode ? specsByType[editingNode.type as string] : null;
const editingEdge = edges.find((e) => e.id === editingEdgeId);
const addableSpecs = Object.values(specsByType).filter((s) => s.addable);
const canAddSpec = useCallback(
(spec: RuntimeNodeSpec) => {
const limit = spec.constraints.maxInstances;
if (limit === undefined) return true;
return nodes.filter((node) => node.type === spec.type).length < limit;
},
[nodes],
);
return (
<NodeSpecsContext.Provider value={specsByType}>
<ActiveNodeContext.Provider value={activeNodeId ?? null}>
<NodeActionContext.Provider value={nodeActions}>
<EdgeActionContext.Provider value={edgeActions}>
<div className="relative h-full w-full min-h-[560px]">
<section className="relative h-full w-full overflow-hidden rounded-2xl border border-hairline bg-canvas-soft shadow-sm">
<div
aria-hidden
className="pointer-events-none absolute -right-24 -top-24 z-0 h-80 w-80 rounded-full opacity-30 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-sky), transparent 68%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-28 left-1/4 z-0 h-72 w-72 rounded-full opacity-25 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-lavender), transparent 68%)",
}}
/>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectEnd={onConnectEnd}
isValidConnection={isValidConnection}
onPaneClick={() => {
onEditingEdgeIdChange(null);
}}
fitView
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{ type: "condition", animated: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={22}
size={1}
color="var(--hairline-strong)"
/>
<Controls
className="!rounded-xl !border !border-hairline !bg-card !shadow-sm [&_button]:!border-hairline [&_button]:!bg-card [&_button]:!text-foreground"
/>
<Panel position="top-left">
<TooltipProvider>
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
className="h-10 w-10 rounded-full shadow-sm"
aria-label="添加节点"
onClick={() => {
setAddSourceId(null);
setAddPosition(null);
setAddOpen(true);
}}
>
<Plus size={17} />
</Button>
</TooltipTrigger>
<TooltipContent side="right"></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-10 w-10 rounded-full border-hairline-strong bg-card text-foreground shadow-sm hover:bg-surface-strong"
aria-label="工作流设置"
onClick={openSettings}
>
<Settings2 size={17} />
</Button>
</TooltipTrigger>
<TooltipContent side="right"></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-10 w-10 rounded-full border-hairline-strong bg-card text-foreground shadow-sm hover:bg-surface-strong"
aria-label="动态变量"
onClick={onOpenDynamicVariables}
>
<Braces size={17} />
</Button>
</TooltipTrigger>
<TooltipContent side="right"></TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
</ReactFlow>
</section>
{/* 添加节点弹窗 */}
<Dialog
open={addOpen}
onOpenChange={(open) => {
setAddOpen(open);
if (!open) {
setAddSourceId(null);
setAddPosition(null);
}
}}
>
<DialogContent className="gap-0 overflow-hidden border border-hairline bg-card p-0 shadow-2xl sm:max-w-[500px]">
<DialogHeader className="relative overflow-hidden border-b border-hairline px-6 py-6 pr-16">
<div
aria-hidden
className="pointer-events-none absolute -right-14 -top-16 h-40 w-40 rounded-full opacity-40 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--gradient-sky), transparent 68%)",
}}
/>
<div className="relative flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Plus size={18} />
</div>
<div>
<div className="caption-label text-muted-soft">
</div>
<DialogTitle className="font-display display-sm mt-1 text-ink">
</DialogTitle>
<DialogDescription className="mt-2 leading-6">
{addSourceId
? "选择节点类型,将在当前节点下方创建并自动连接。"
: "选择节点类型并添加到画布中央,随后可编辑内容并建立连线。"}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="flex max-h-[440px] flex-col gap-3 overflow-y-auto bg-canvas-soft/70 p-4">
{addableSpecs.length === 0 ? (
<p className="rounded-2xl border border-dashed border-hairline-strong bg-card px-4 py-8 text-center text-sm text-muted-soft">
</p>
) : null}
{addableSpecs.map((spec) => {
const Icon = spec.icon;
const canAdd = canAddSpec(spec);
return (
<button
key={spec.type}
type="button"
disabled={!canAdd}
className="group relative flex items-start gap-4 overflow-hidden rounded-2xl border border-hairline bg-card p-4 text-left shadow-sm transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-hairline-strong hover:shadow-md disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:translate-y-0 disabled:hover:border-hairline disabled:hover:shadow-sm"
onClick={() => addNode(spec)}
>
<span
aria-hidden
className="absolute left-5 right-5 top-0 h-px"
style={{
background: `linear-gradient(90deg, transparent, var(${accentVar(spec.accent)}), transparent)`,
}}
/>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-foreground transition-transform group-hover:scale-105"
style={{
background: `color-mix(in srgb, var(${accentVar(spec.accent)}) 28%, var(--surface-strong))`,
}}
>
<Icon size={17} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span>{spec.displayName}</span>
{!canAdd && (
<span className="rounded-full bg-surface-strong px-2 py-0.5 text-[10px] font-normal text-muted-foreground">
</span>
)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{spec.description}
</div>
</div>
<Plus
size={15}
className="mt-1 shrink-0 text-muted-soft transition-colors group-hover:text-foreground"
/>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
{(debugOpen ||
settingsOpen ||
(editingNode && editingSpec) ||
editingEdge) && (
<aside className="absolute inset-y-0 right-0 z-40 flex w-1/2 flex-col overflow-hidden rounded-r-2xl border-l border-hairline bg-card shadow-2xl">
{debugOpen ? (
debugPanel
) : settingsOpen ||
(editingNode && editingSpec) ||
editingEdge ? (
<>
<div className="flex min-h-14 shrink-0 items-center gap-3 border-b border-hairline px-4 py-3">
<button
type="button"
aria-label={
settingsOpen
? "关闭工作流设置"
: editingEdge
? "关闭边编辑"
: "关闭节点编辑"
}
title="关闭"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground"
onClick={() => {
if (settingsOpen) setSettingsOpen(false);
else if (editingEdge) onEditingEdgeIdChange(null);
else onEditingNodeIdChange(null);
}}
>
<X size={16} />
</button>
<h2 className="min-w-0 truncate text-sm font-medium text-foreground">
{settingsOpen
? "工作流设置"
: editingEdge
? "编辑连接条件"
: `编辑${editingSpec?.displayName ?? "节点"}`}
</h2>
</div>
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto bg-canvas-soft px-4 pb-5 pt-4">
{settingsOpen ? (
<GlobalSettingsPanel
settings={settings}
onSettingsChange={onSettingsChange}
modelOptions={modelOptions}
toolOptions={toolOptions}
knowledgeOptions={knowledgeOptions}
/>
) : editingNode && editingSpec ? (
<NodeSettingsPanel
key={editingNode.id}
panel
spec={editingSpec}
data={editingNode.data as WorkflowNodeData}
toolOptions={toolOptions}
knowledgeOptions={knowledgeOptions}
llmOptions={modelOptions.llm}
asrOptions={modelOptions.asr}
ttsOptions={modelOptions.tts}
workflowSettings={settings}
onChange={(patch) =>
updateNodeData(editingNode.id, patch)
}
/>
) : editingEdge ? (
<EdgeSettingsPanel
key={editingEdge.id}
edge={editingEdge}
sourceType={nodes.find((node) => node.id === editingEdge.source)?.type}
onChange={(patch) =>
updateEdgeData(editingEdge.id, patch)
}
/>
) : null}
</div>
</>
) : null}
</aside>
)}
</div>
</EdgeActionContext.Provider>
</NodeActionContext.Provider>
</ActiveNodeContext.Provider>
</NodeSpecsContext.Provider>
);
}

File diff suppressed because it is too large Load Diff

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>
);
}

View File

@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import type { KnowledgeRetrievalConfig, TurnConfig } from "@/lib/api";
import type { WorkflowGraph } from "./specs";
export type WorkflowSettings = {
llm?: string;
asr?: string;
tts?: string;
toolIds: string[];
knowledgeBaseId: string;
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
globalPrompt: string;
allowInterrupt: boolean;
turnConfig: TurnConfig;
};
export type ModelOption = { value: string; label: string };
export type WorkflowEditorProps = {
value?: WorkflowGraph;
onChange?: (graph: WorkflowGraph) => void;
settings: WorkflowSettings;
onSettingsChange: (settings: WorkflowSettings) => void;
modelOptions: { llm: ModelOption[]; asr: ModelOption[]; tts: ModelOption[] };
toolOptions?: ModelOption[];
knowledgeOptions?: ModelOption[];
onOpenDynamicVariables: () => void;
editingNodeId: string | null;
onEditingNodeIdChange: (nodeId: string | null) => void;
editingEdgeId: string | null;
onEditingEdgeIdChange: (edgeId: string | null) => void;
settingsOpen: boolean;
onSettingsOpenChange: (open: boolean) => void;
debugOpen: boolean;
onDebugOpenChange: (open: boolean) => void;
debugPanel: ReactNode;
/** 调试通话中当前激活的节点 id用于高亮。 */
activeNodeId?: string | null;
};