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
@@ -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>
);
}
@@ -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">
仅保留相关度达到该值的片段,范围 0–1。
</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>
);
}
@@ -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>
);
}
@@ -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[];
};
@@ -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,
};
}
@@ -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>
);
}