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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user