Add turn configuration support for assistants

- Introduce a new `turnConfig` field in `AssistantConfig` and `Assistant` models to manage user interaction settings.
- Implement `TurnConfig`, `BargeInConfig`, `VadConfig`, and `TurnDetectionConfig` schemas to define turn management strategies.
- Update the backend to handle turn configuration in the database and during assistant operations.
- Enhance frontend components with a `TurnConfigEditor` for configuring turn settings, including VAD and barge-in strategies.
- Modify existing pages to integrate turn configuration, improving user experience and interaction capabilities.
This commit is contained in:
Xin Wang
2026-07-12 11:08:19 +08:00
parent 00270a5c01
commit 01c563a3e7
13 changed files with 453 additions and 168 deletions

View File

@@ -7,8 +7,6 @@ import {
Check,
Copy,
Database,
Eye,
EyeOff,
MessageSquareText,
MoreHorizontal,
Pencil,
@@ -41,6 +39,7 @@ import {
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { NetworkQualityIndicator } from "@/components/network-quality-indicator";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
@@ -106,6 +105,7 @@ import {
type KnowledgeBase,
type ModelResource,
type Tool,
type TurnConfig,
} from "@/lib/api";
import {
useVoicePreview,
@@ -139,6 +139,7 @@ type AssistantForm = {
voice: string;
knowledgeBase: string;
enableInterrupt: boolean;
turnConfig: TurnConfig;
visionEnabled: boolean;
visionModelResourceId: string;
toolIds: string[];
@@ -150,6 +151,7 @@ type FastGptForm = {
asr: string;
voice: string;
enableInterrupt: boolean;
turnConfig: TurnConfig;
};
type DifyForm = {
@@ -158,6 +160,7 @@ type DifyForm = {
asr: string;
voice: string;
enableInterrupt: boolean;
turnConfig: TurnConfig;
};
type OpenCodeForm = {
@@ -168,6 +171,7 @@ type OpenCodeForm = {
asr: string;
voice: string;
enableInterrupt: boolean;
turnConfig: TurnConfig;
visionEnabled: boolean;
visionModelResourceId: string;
};
@@ -182,6 +186,24 @@ const assistantTypes: AssistantType[] = [
"OpenCode",
];
function defaultTurnConfig(): TurnConfig {
return {
bargeIn: {
strategy: "vad",
},
vad: {
confidence: 0.7,
startSecs: 0.2,
stopSecs: 0.2,
minVolume: 0.6,
},
turnDetection: {
strategy: "silence",
silenceTimeoutSecs: 0.6,
},
};
}
// 后端 type(英文) ↔ 列表展示标签(中文)
const typeToLabel: Record<ApiAssistantType, AssistantType> = {
prompt: "提示词",
@@ -229,6 +251,7 @@ function blankPromptForm(name: string): AssistantForm {
voice: "",
knowledgeBase: "",
enableInterrupt: true,
turnConfig: defaultTurnConfig(),
visionEnabled: false,
visionModelResourceId: "",
toolIds: [],
@@ -242,6 +265,7 @@ function blankFastGptForm(name: string): FastGptForm {
asr: "",
voice: "",
enableInterrupt: true,
turnConfig: defaultTurnConfig(),
};
}
@@ -252,6 +276,7 @@ function blankDifyForm(name: string): DifyForm {
asr: "",
voice: "",
enableInterrupt: true,
turnConfig: defaultTurnConfig(),
};
}
@@ -264,6 +289,7 @@ function blankOpenCodeForm(name: string): OpenCodeForm {
asr: "",
voice: "",
enableInterrupt: true,
turnConfig: defaultTurnConfig(),
visionEnabled: false,
visionModelResourceId: "",
};
@@ -362,7 +388,6 @@ export function AssistantPage(props: AssistantPageProps) {
// 编辑模式:加载指定 id 助手失败时的错误
const [loadError, setLoadError] = useState<string | null>(null);
// 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥")
const [storedApiKeyMask, setStoredApiKeyMask] = useState("");
// 下拉数据源:模型资源 + 知识库
const [modelResources, setModelResources] = useState<ModelResource[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
@@ -394,6 +419,7 @@ export function AssistantPage(props: AssistantPageProps) {
);
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
allowInterrupt: true,
turnConfig: defaultTurnConfig(),
});
const [debugOpen, setDebugOpen] = useState(false);
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
@@ -505,6 +531,7 @@ export function AssistantPage(props: AssistantPageProps) {
voice: a.modelResourceIds.TTS ?? "",
knowledgeBase: a.knowledgeBaseId ?? "",
enableInterrupt: a.enableInterrupt,
turnConfig: a.turnConfig,
visionEnabled: a.visionEnabled,
visionModelResourceId: a.visionModelResourceId ?? "",
toolIds: a.toolIds ?? [],
@@ -568,6 +595,7 @@ export function AssistantPage(props: AssistantPageProps) {
runtimeMode: "pipeline",
greeting: "",
enableInterrupt: true,
turnConfig: defaultTurnConfig(),
visionEnabled: false,
visionModelResourceId: null,
modelResourceIds: {},
@@ -582,15 +610,13 @@ export function AssistantPage(props: AssistantPageProps) {
};
}
// 保存:停留在编辑页,刷新密钥掩码并把当前表单记为已保存基线(按钮随之置灰)
// 保存停留在编辑页并把当前表单记为已保存基线按钮随之置灰)。
async function save(payload: AssistantUpsert) {
if (!editingId) return;
setSaving(true);
setSaveError(null);
try {
const updated = await assistantsApi.update(editingId, payload);
setStoredApiKeyMask(updated.apiKey ?? "");
// 密钥输入框清空(空值=保留已存密钥),并以清空后的表单为新基线
await assistantsApi.update(editingId, payload);
if (view === "create") {
setSavedSnapshot(JSON.stringify(form));
} else if (view === "create-dify") {
@@ -629,6 +655,7 @@ export function AssistantPage(props: AssistantPageProps) {
runtimeMode: form.runtimeMode,
greeting: form.greeting,
enableInterrupt: form.enableInterrupt,
turnConfig: form.turnConfig,
visionEnabled: form.visionEnabled,
visionModelResourceId: form.visionModelResourceId || null,
modelResourceIds: {
@@ -652,6 +679,7 @@ export function AssistantPage(props: AssistantPageProps) {
asr: a.modelResourceIds.ASR ?? "",
voice: a.modelResourceIds.TTS ?? "",
enableInterrupt: a.enableInterrupt,
turnConfig: a.turnConfig,
};
setDifyForm(next);
return next;
@@ -663,6 +691,7 @@ export function AssistantPage(props: AssistantPageProps) {
name: difyForm.name.trim(),
type: "dify",
enableInterrupt: difyForm.enableInterrupt,
turnConfig: difyForm.turnConfig,
modelResourceIds: {
...(difyForm.agent ? { Agent: difyForm.agent } : {}),
...(difyForm.asr ? { ASR: difyForm.asr } : {}),
@@ -680,6 +709,7 @@ export function AssistantPage(props: AssistantPageProps) {
asr: a.modelResourceIds.ASR ?? "",
voice: a.modelResourceIds.TTS ?? "",
enableInterrupt: a.enableInterrupt,
turnConfig: a.turnConfig,
};
setFastGptForm(next);
return next;
@@ -691,6 +721,7 @@ export function AssistantPage(props: AssistantPageProps) {
name: fastGptForm.name.trim(),
type: "fastgpt",
enableInterrupt: fastGptForm.enableInterrupt,
turnConfig: fastGptForm.turnConfig,
modelResourceIds: {
...(fastGptForm.agent ? { Agent: fastGptForm.agent } : {}),
...(fastGptForm.asr ? { ASR: fastGptForm.asr } : {}),
@@ -710,6 +741,7 @@ export function AssistantPage(props: AssistantPageProps) {
asr: a.modelResourceIds.ASR ?? "",
voice: a.modelResourceIds.TTS ?? "",
enableInterrupt: a.enableInterrupt,
turnConfig: a.turnConfig,
visionEnabled: a.visionEnabled,
visionModelResourceId: a.visionModelResourceId ?? "",
};
@@ -726,7 +758,6 @@ export function AssistantPage(props: AssistantPageProps) {
try {
const assistant = await assistantsApi.get(editingId);
if (cancelled) return;
setStoredApiKeyMask(assistant.apiKey ?? "");
if (assistant.type === "prompt") {
setSavedSnapshot(JSON.stringify(fillPromptForm(assistant)));
} else if (assistant.type === "fastgpt") {
@@ -748,6 +779,7 @@ export function AssistantPage(props: AssistantPageProps) {
asr: assistant.modelResourceIds.ASR,
tts: assistant.modelResourceIds.TTS,
allowInterrupt: assistant.enableInterrupt,
turnConfig: assistant.turnConfig,
};
setWorkflowName(assistant.name);
setWorkflowGraph(graph);
@@ -781,6 +813,7 @@ export function AssistantPage(props: AssistantPageProps) {
name: openCodeForm.name.trim(),
type: "opencode",
enableInterrupt: openCodeForm.enableInterrupt,
turnConfig: openCodeForm.turnConfig,
visionEnabled: openCodeForm.visionEnabled,
visionModelResourceId: openCodeForm.visionModelResourceId || null,
modelResourceIds: {
@@ -800,6 +833,7 @@ export function AssistantPage(props: AssistantPageProps) {
name: workflowName.trim(),
type: "workflow",
enableInterrupt: workflowSettings.allowInterrupt,
turnConfig: workflowSettings.turnConfig,
modelResourceIds: {
...(workflowSettings.llm ? { LLM: workflowSettings.llm } : {}),
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
@@ -1423,13 +1457,11 @@ export function AssistantPage(props: AssistantPageProps) {
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<ToggleRow
title="允许用户打断"
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
checked={difyForm.enableInterrupt}
onChange={(checked) =>
updateDifyForm("enableInterrupt", checked)
}
<TurnConfigEditor
enabled={difyForm.enableInterrupt}
config={difyForm.turnConfig}
onEnabledChange={(checked) => updateDifyForm("enableInterrupt", checked)}
onConfigChange={(config) => updateDifyForm("turnConfig", config)}
/>
</SectionCard>
</div>
@@ -1521,13 +1553,11 @@ export function AssistantPage(props: AssistantPageProps) {
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<ToggleRow
title="允许用户打断"
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
checked={fastGptForm.enableInterrupt}
onChange={(checked) =>
updateFastGptForm("enableInterrupt", checked)
}
<TurnConfigEditor
enabled={fastGptForm.enableInterrupt}
config={fastGptForm.turnConfig}
onEnabledChange={(checked) => updateFastGptForm("enableInterrupt", checked)}
onConfigChange={(config) => updateFastGptForm("turnConfig", config)}
/>
</SectionCard>
</div>
@@ -1660,13 +1690,11 @@ export function AssistantPage(props: AssistantPageProps) {
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<ToggleRow
title="允许用户打断"
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
checked={openCodeForm.enableInterrupt}
onChange={(checked) =>
updateOpenCodeForm("enableInterrupt", checked)
}
<TurnConfigEditor
enabled={openCodeForm.enableInterrupt}
config={openCodeForm.turnConfig}
onEnabledChange={(checked) => updateOpenCodeForm("enableInterrupt", checked)}
onConfigChange={(config) => updateOpenCodeForm("turnConfig", config)}
/>
</SectionCard>
</div>
@@ -1902,12 +1930,18 @@ export function AssistantPage(props: AssistantPageProps) {
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<ToggleRow
title="允许用户打断"
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
checked={form.enableInterrupt}
onChange={(checked) => updateForm("enableInterrupt", checked)}
/>
{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>
@@ -2982,108 +3016,6 @@ function SectionCard({
);
}
function InputField({
label,
hint,
value,
placeholder,
type = "text",
onChange,
}: {
label?: string;
hint?: string;
value: string;
placeholder?: string;
type?: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
{label && (
<div className="mb-2 flex items-center gap-1.5 text-sm font-medium text-foreground">
<span>{label}</span>
{hint && <HelpHint text={hint} />}
</div>
)}
<Input
value={value}
type={type}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
);
}
function SecretInputField({
label,
hint,
value,
placeholder,
storedValueMask,
onChange,
}: {
label?: string;
hint?: string;
value: string;
placeholder?: string;
storedValueMask: string;
onChange: (value: string) => void;
}) {
const [showValue, setShowValue] = useState(false);
const hasStoredValue = Boolean(storedValueMask);
return (
<label className="block">
{label && (
<div className="mb-2 flex items-center gap-1.5 text-sm font-medium text-foreground">
<span>{label}</span>
{hint && <HelpHint text={hint} />}
</div>
)}
{hasStoredValue && (
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<code className="rounded-md bg-surface-strong px-2 py-1 font-mono text-foreground">
{storedValueMask}
</code>
</div>
)}
<div className="relative">
<Input
value={value}
type={showValue ? "text" : "password"}
placeholder={
hasStoredValue ? "已配置,留空则保持不变" : placeholder
}
autoComplete="new-password"
onChange={(event) => {
const nextValue = event.target.value;
if (!nextValue) setShowValue(false);
onChange(nextValue);
}}
className="border-hairline-strong bg-background pr-10 text-foreground placeholder:text-muted-soft"
/>
<button
type="button"
disabled={!value}
onClick={() => setShowValue((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-soft transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
aria-label={showValue ? "隐藏 API Key" : "显示 API Key"}
>
{showValue ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{hasStoredValue && (
<p className="mt-2 text-xs leading-5 text-muted-foreground">
</p>
)}
</label>
);
}
function TextAreaField({
label,
value,

View File

@@ -0,0 +1,204 @@
"use client";
import type { ReactNode } from "react";
import { HelpCircle, Settings2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { TurnConfig } from "@/lib/api";
type Props = {
enabled: boolean;
config: TurnConfig;
onEnabledChange: (enabled: boolean) => void;
onConfigChange: (config: TurnConfig) => void;
};
export function TurnConfigEditor({
enabled,
config,
onEnabledChange,
onConfigChange,
}: Props) {
const updateBargeIn = (patch: Partial<TurnConfig["bargeIn"]>) =>
onConfigChange({
...config,
bargeIn: { ...config.bargeIn, ...patch },
});
const updateVad = (patch: Partial<TurnConfig["vad"]>) =>
onConfigChange({
...config,
vad: { ...config.vad, ...patch },
});
const updateTurnDetection = (
patch: Partial<TurnConfig["turnDetection"]>,
) =>
onConfigChange({
...config,
turnDetection: { ...config.turnDetection, ...patch },
});
return (
<div className="flex items-center justify-between gap-4 rounded-2xl border border-hairline bg-card p-4 shadow-sm">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground">
</span>
<HelpHint text="用户说话时,助手可以停止当前播报并理解新的输入。" />
<Dialog>
<DialogTrigger asChild>
<button
type="button"
aria-label="打开允许用户打断高级配置"
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
>
<Settings2 size={14} />
</button>
</DialogTrigger>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
Pipeline
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 lg:grid-cols-2">
<ConfigSection title="VAD 配置">
<NumberField label="置信度" value={config.vad.confidence} min={0} max={1} step={0.05} onChange={(confidence) => updateVad({ confidence })} />
<NumberField label="最低音量" value={config.vad.minVolume} min={0} max={1} step={0.05} onChange={(minVolume) => updateVad({ minVolume })} />
<NumberField label="开始确认(秒)" value={config.vad.startSecs} min={0.05} max={2} step={0.05} onChange={(startSecs) => updateVad({ startSecs })} />
<NumberField label="停止确认(秒)" value={config.vad.stopSecs} min={0.05} max={3} step={0.05} onChange={(stopSecs) => updateVad({ stopSecs })} />
</ConfigSection>
<div className="space-y-5">
<ConfigSection title="抢话检测">
<label className="block space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={config.bargeIn.strategy}
onValueChange={(strategy: "vad" | "transcription") =>
updateBargeIn({ strategy })
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="vad">VAD </SelectItem>
<SelectItem value="transcription"></SelectItem>
</SelectContent>
</Select>
</label>
</ConfigSection>
<ConfigSection title="轮次检测">
<label className="block space-y-2">
<span className="text-sm font-medium"></span>
<Select
value={config.turnDetection.strategy}
onValueChange={(strategy: "silence" | "smart_turn") =>
updateTurnDetection({ strategy })
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="silence"></SelectItem>
<SelectItem value="smart_turn">Smart Turn</SelectItem>
</SelectContent>
</Select>
</label>
{config.turnDetection.strategy === "silence" && (
<NumberField
label="静音等待(秒)"
value={config.turnDetection.silenceTimeoutSecs}
min={0.2}
max={5}
step={0.1}
onChange={(silenceTimeoutSecs) =>
updateTurnDetection({ silenceTimeoutSecs })
}
/>
)}
</ConfigSection>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<Switch checked={enabled} onCheckedChange={onEnabledChange} />
</div>
);
}
function ConfigSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="rounded-xl border border-hairline bg-surface-strong/20">
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
{title}
</div>
<div className="space-y-4 p-4">
{children}
</div>
</section>
);
}
function HelpHint({ text }: { text: string }) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="查看允许用户打断说明"
onClick={(event) => event.stopPropagation()}
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
>
<HelpCircle size={14} />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-72 text-sm leading-6 text-muted-foreground"
>
{text}
</PopoverContent>
</Popover>
);
}
function NumberField({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) {
return (
<label className="block space-y-2">
<span className="text-sm font-medium">{label}</span>
<Input
type="number"
value={value}
min={min}
max={max}
step={step}
onChange={(event) => {
const next = event.currentTarget.valueAsNumber;
if (Number.isFinite(next)) onChange(next);
}}
/>
</label>
);
}

View File

@@ -47,6 +47,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { TurnConfig } from "@/lib/api";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Textarea } from "@/components/ui/textarea";
import { edgeTypes } from "./ConditionEdge";
@@ -73,6 +75,7 @@ export type WorkflowSettings = {
asr?: string;
tts?: string;
allowInterrupt: boolean;
turnConfig: TurnConfig;
};
export type ModelOption = { value: string; label: string };
@@ -528,22 +531,16 @@ function Canvas({
options={modelOptions.tts}
onChange={(v) => onSettingsChange({ ...settings, tts: v })}
/>
<label className="flex items-center justify-between gap-4 rounded-2xl border border-hairline bg-card p-4 shadow-sm">
<span>
<span className="block text-sm font-medium text-foreground">
</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
</span>
</span>
<Switch
checked={settings.allowInterrupt}
onCheckedChange={(checked) =>
onSettingsChange({ ...settings, allowInterrupt: checked })
}
/>
</label>
<TurnConfigEditor
enabled={settings.allowInterrupt}
config={settings.turnConfig}
onEnabledChange={(allowInterrupt) =>
onSettingsChange({ ...settings, allowInterrupt })
}
onConfigChange={(turnConfig) =>
onSettingsChange({ ...settings, turnConfig })
}
/>
</div>
<DialogFooter className="border-t border-hairline bg-card px-5 py-4">
<Button onClick={() => setSettingsOpen(false)}></Button>

View File

@@ -150,6 +150,21 @@ export type AssistantType =
| "fastgpt"
| "opencode";
export type RuntimeMode = "pipeline" | "realtime";
export type TurnConfig = {
bargeIn: {
strategy: "vad" | "transcription";
};
vad: {
confidence: number;
startSecs: number;
stopSecs: number;
minVolume: number;
};
turnDetection: {
strategy: "silence" | "smart_turn";
silenceTimeoutSecs: number;
};
};
/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */
export type Assistant = {
@@ -159,6 +174,7 @@ export type Assistant = {
runtimeMode: RuntimeMode;
greeting: string;
enableInterrupt: boolean;
turnConfig: TurnConfig;
visionEnabled: boolean;
visionModelResourceId: string | null;
modelResourceIds: Partial<Record<ModelType, string>>;