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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user