diff --git a/backend/db/models.py b/backend/db/models.py index 0ec206e..9b92029 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -108,6 +108,7 @@ class Assistant(Base): runtime_mode: Mapped[str] = mapped_column(String(16), default="pipeline") greeting: Mapped[str] = mapped_column(String(2048), default="") enable_interrupt: Mapped[bool] = mapped_column(Boolean, default=True) + turn_config: Mapped[dict] = mapped_column(JSON, default=dict) vision_enabled: Mapped[bool] = mapped_column(Boolean, default=False) vision_model_resource_id: Mapped[str | None] = mapped_column( String(40), diff --git a/backend/migrations/versions/20260712_0004_add_turn_config.py b/backend/migrations/versions/20260712_0004_add_turn_config.py new file mode 100644 index 0000000..7180770 --- /dev/null +++ b/backend/migrations/versions/20260712_0004_add_turn_config.py @@ -0,0 +1,25 @@ +"""add assistant turn configuration + +Revision ID: 20260712_0004 +Revises: 20260710_0003 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260712_0004" +down_revision = "20260710_0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "assistants", + sa.Column("turn_config", sa.JSON(), server_default=sa.text("'{}'"), nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("assistants", "turn_config") diff --git a/backend/models.py b/backend/models.py index 5097682..cb23143 100644 --- a/backend/models.py +++ b/backend/models.py @@ -71,6 +71,7 @@ class AssistantConfig(BaseModel): tts_secrets: dict = {} enableInterrupt: bool = True + turnConfig: dict = Field(default_factory=dict) # Prompt assistant reusable tools. Execution remains type-specific in the pipeline. tools: list[RuntimeTool] = Field(default_factory=list) diff --git a/backend/requirements.txt b/backend/requirements.txt index 516d0ee..ca612b3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,7 +2,7 @@ # webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc # silero -> 本地 VAD(判断用户说话起止),语音必备 # openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它) -pipecat-ai[webrtc,websocket,silero,openai]==1.3.0 +pipecat-ai[webrtc,websocket,silero,openai]==1.4.0 Pillow>=11.1.0,<13 # FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话) diff --git a/backend/routes/assistants.py b/backend/routes/assistants.py index e67c2b9..967478a 100644 --- a/backend/routes/assistants.py +++ b/backend/routes/assistants.py @@ -137,6 +137,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut: runtime_mode=assistant.runtime_mode, # type: ignore[arg-type] greeting=assistant.greeting, enable_interrupt=assistant.enable_interrupt, + turn_config=assistant.turn_config or {}, vision_enabled=assistant.vision_enabled, vision_model_resource_id=assistant.vision_model_resource_id, model_resource_ids=await _resource_ids(session, assistant.id), @@ -202,6 +203,7 @@ async def duplicate_assistant( runtime_mode=source.runtime_mode, greeting=source.greeting, enable_interrupt=source.enable_interrupt, + turn_config=dict(source.turn_config or {}), vision_enabled=source.vision_enabled, vision_model_resource_id=source.vision_model_resource_id, knowledge_base_id=source.knowledge_base_id, diff --git a/backend/schemas.py b/backend/schemas.py index 0ed1acf..e87f2b5 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -16,6 +16,7 @@ from pydantic.alias_generators import to_camel RuntimeMode = Literal["pipeline", "realtime"] ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"] AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"] +TurnEndStrategy = Literal["silence", "smart_turn"] ToolType = Literal["end_call", "http"] ToolStatus = Literal["active", "archived", "draft"] ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"] @@ -40,6 +41,27 @@ class CamelModel(BaseModel): ) +class BargeInConfig(CamelModel): + strategy: Literal["vad", "transcription"] = "vad" + + +class VadConfig(CamelModel): + confidence: float = Field(default=0.7, ge=0.0, le=1.0) + start_secs: float = Field(default=0.2, ge=0.05, le=2.0) + stop_secs: float = Field(default=0.2, ge=0.05, le=3.0) + min_volume: float = Field(default=0.6, ge=0.0, le=1.0) + +class TurnDetectionConfig(CamelModel): + strategy: TurnEndStrategy = "silence" + silence_timeout_secs: float = Field(default=0.6, ge=0.2, le=5.0) + + +class TurnConfig(CamelModel): + barge_in: BargeInConfig = Field(default_factory=BargeInConfig) + vad: VadConfig = Field(default_factory=VadConfig) + turn_detection: TurnDetectionConfig = Field(default_factory=TurnDetectionConfig) + + # 各 type 允许的瘦字段(其余字段写入时清零,防止跨类型脏数据) ALLOWED_FIELDS: dict[str, set[str]] = { "prompt": {"prompt"}, @@ -57,6 +79,7 @@ class AssistantUpsert(CamelModel): runtime_mode: RuntimeMode = "pipeline" greeting: str = "" enable_interrupt: bool = True + turn_config: TurnConfig = Field(default_factory=TurnConfig) vision_enabled: bool = False vision_model_resource_id: str | None = None diff --git a/backend/services/config_resolver.py b/backend/services/config_resolver.py index 25e0b83..c4b34cb 100644 --- a/backend/services/config_resolver.py +++ b/backend/services/config_resolver.py @@ -135,6 +135,7 @@ async def resolve_runtime_config( prompt=assistant.prompt or "你是一个有帮助的助手。", runtimeMode=assistant.runtime_mode, # type: ignore[arg-type] enableInterrupt=assistant.enable_interrupt, + turnConfig=assistant.turn_config or {}, tools=await _tools_for(session, assistant), # workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话 graph=(assistant.graph or {}) if assistant.type == "workflow" else {}, diff --git a/backend/services/pipecat/pipeline.py b/backend/services/pipecat/pipeline.py index 63fe50d..03ca866 100644 --- a/backend/services/pipecat/pipeline.py +++ b/backend/services/pipecat/pipeline.py @@ -30,7 +30,6 @@ from services.pipecat.service_factory import ( from pipecat.adapters.schemas.function_schema import FunctionSchema from pipecat.adapters.schemas.tools_schema import ToolsSchema -from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.frames.frames import ( EndFrame, InputTransportMessageFrame, @@ -59,15 +58,14 @@ from pipecat.runner.utils import ( maybe_capture_participant_camera, ) from pipecat.services.llm_service import FunctionCallParams -from pipecat.turns.user_start import ( - TranscriptionUserTurnStartStrategy, - VADUserTurnStartStrategy, -) from pipecat.turns.user_mute.base_user_mute_strategy import BaseUserMuteStrategy from pipecat.turns.user_mute.function_call_user_mute_strategy import ( FunctionCallUserMuteStrategy, ) -from pipecat.turns.user_turn_strategies import UserTurnStrategies +from services.pipecat.turn_config import ( + create_user_turn_strategies, + create_vad_analyzer, +) from pipecat.utils.time import time_now_iso8601 from pipecat.workers.runner import WorkerRunner @@ -448,18 +446,14 @@ async def run_pipeline( user_aggregator = LLMUserAggregator( context, params=LLMUserAggregatorParams( - vad_analyzer=SileroVADAnalyzer(), + vad_analyzer=create_vad_analyzer(cfg.turnConfig), user_mute_strategies=[ FunctionCallUserMuteStrategy(), CallEndingUserMuteStrategy(lambda: call_end.ending), ], - user_turn_strategies=UserTurnStrategies( - start=[ - VADUserTurnStartStrategy(enable_interruptions=cfg.enableInterrupt), - TranscriptionUserTurnStartStrategy( - enable_interruptions=cfg.enableInterrupt - ), - ] + user_turn_strategies=create_user_turn_strategies( + cfg.turnConfig, + enable_interruptions=cfg.enableInterrupt, ), ), ) diff --git a/backend/services/pipecat/turn_config.py b/backend/services/pipecat/turn_config.py new file mode 100644 index 0000000..9dc1d9d --- /dev/null +++ b/backend/services/pipecat/turn_config.py @@ -0,0 +1,89 @@ +"""把稳定的产品配置映射为 Pipecat 用户轮次策略。""" + +from __future__ import annotations + +from typing import Any + +from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.audio.vad.vad_analyzer import VADParams +from pipecat.turns.user_start import ( + TranscriptionUserTurnStartStrategy, + VADUserTurnStartStrategy, +) +from pipecat.turns.user_stop import ( + SpeechTimeoutUserTurnStopStrategy, + TurnAnalyzerUserTurnStopStrategy, +) +from pipecat.turns.user_turn_strategies import UserTurnStrategies + + +DEFAULT_VAD = { + "confidence": 0.7, + "start_secs": 0.2, + "stop_secs": 0.2, + "min_volume": 0.6, +} +DEFAULT_TURN_DETECTION = { + "strategy": "silence", + "silence_timeout_secs": 0.6, +} + + +def _section(config: dict[str, Any], snake: str, camel: str) -> dict[str, Any]: + value = config.get(snake, config.get(camel, {})) + return value if isinstance(value, dict) else {} + + +def _value(config: dict[str, Any], snake: str, camel: str, default: Any) -> Any: + return config.get(snake, config.get(camel, default)) + + +def create_vad_analyzer(turn_config: dict[str, Any]) -> SileroVADAnalyzer: + vad = _section(turn_config, "vad", "vad") + return SileroVADAnalyzer( + params=VADParams( + confidence=float(vad.get("confidence", DEFAULT_VAD["confidence"])), + start_secs=float(_value(vad, "start_secs", "startSecs", 0.2)), + stop_secs=float(_value(vad, "stop_secs", "stopSecs", 0.2)), + min_volume=float(_value(vad, "min_volume", "minVolume", 0.6)), + ) + ) + + +def create_user_turn_strategies( + turn_config: dict[str, Any], *, enable_interruptions: bool +) -> UserTurnStrategies: + barge_in = _section(turn_config, "barge_in", "bargeIn") + start = [] + strategy = barge_in.get("strategy", "vad") + if strategy == "vad": + start.append(VADUserTurnStartStrategy(enable_interruptions=enable_interruptions)) + else: + start.append( + TranscriptionUserTurnStartStrategy(enable_interruptions=enable_interruptions) + ) + + detection = _section(turn_config, "turn_detection", "turnDetection") + if detection.get("strategy", DEFAULT_TURN_DETECTION["strategy"]) == "smart_turn": + stop = [ + TurnAnalyzerUserTurnStopStrategy( + turn_analyzer=LocalSmartTurnAnalyzerV3(), + wait_for_transcript=True, + ) + ] + else: + stop = [ + SpeechTimeoutUserTurnStopStrategy( + user_speech_timeout=float( + _value( + detection, + "silence_timeout_secs", + "silenceTimeoutSecs", + 0.6, + ) + ), + wait_for_transcript=True, + ) + ] + return UserTurnStrategies(start=start, stop=stop) diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx index 9e21b56..41c31a7 100644 --- a/frontend/src/components/pages/AssistantPage.tsx +++ b/frontend/src/components/pages/AssistantPage.tsx @@ -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 = { 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(null); // 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥") - const [storedApiKeyMask, setStoredApiKeyMask] = useState(""); // 下拉数据源:模型资源 + 知识库 const [modelResources, setModelResources] = useState([]); const [knowledgeBases, setKnowledgeBases] = useState([]); @@ -394,6 +419,7 @@ export function AssistantPage(props: AssistantPageProps) { ); const [workflowSettings, setWorkflowSettings] = useState({ allowInterrupt: true, + turnConfig: defaultTurnConfig(), }); const [debugOpen, setDebugOpen] = useState(false); const [activeNodeId, setActiveNodeId] = useState(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="设置实时视频对话时的交互体验" > - - updateDifyForm("enableInterrupt", checked) - } + updateDifyForm("enableInterrupt", checked)} + onConfigChange={(config) => updateDifyForm("turnConfig", config)} /> @@ -1521,13 +1553,11 @@ export function AssistantPage(props: AssistantPageProps) { title="交互策略" description="设置实时视频对话时的交互体验" > - - updateFastGptForm("enableInterrupt", checked) - } + updateFastGptForm("enableInterrupt", checked)} + onConfigChange={(config) => updateFastGptForm("turnConfig", config)} /> @@ -1660,13 +1690,11 @@ export function AssistantPage(props: AssistantPageProps) { title="交互策略" description="设置实时视频对话时的交互体验" > - - updateOpenCodeForm("enableInterrupt", checked) - } + updateOpenCodeForm("enableInterrupt", checked)} + onConfigChange={(config) => updateOpenCodeForm("turnConfig", config)} /> @@ -1902,12 +1930,18 @@ export function AssistantPage(props: AssistantPageProps) { title="交互策略" description="设置实时视频对话时的交互体验" > - updateForm("enableInterrupt", checked)} - /> + {form.runtimeMode === "pipeline" ? ( + updateForm("enableInterrupt", checked)} + onConfigChange={(config) => updateForm("turnConfig", config)} + /> + ) : ( +

+ 高级抢话与轮次检测当前仅支持 Pipeline 模式。 +

+ )} @@ -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 ( - - ); -} - -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 ( - - ); -} - function TextAreaField({ label, value, diff --git a/frontend/src/components/turn-config-editor.tsx b/frontend/src/components/turn-config-editor.tsx new file mode 100644 index 0000000..63d8bcf --- /dev/null +++ b/frontend/src/components/turn-config-editor.tsx @@ -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) => + onConfigChange({ + ...config, + bargeIn: { ...config.bargeIn, ...patch }, + }); + const updateVad = (patch: Partial) => + onConfigChange({ + ...config, + vad: { ...config.vad, ...patch }, + }); + const updateTurnDetection = ( + patch: Partial, + ) => + onConfigChange({ + ...config, + turnDetection: { ...config.turnDetection, ...patch }, + }); + + return ( +
+
+ + 允许用户打断 + + + + + + + + + 高级交互配置 + + 配置 Pipeline 的语音活动检测、用户抢话与对话轮次结束策略。 + + +
+ + updateVad({ confidence })} /> + updateVad({ minVolume })} /> + updateVad({ startSecs })} /> + updateVad({ stopSecs })} /> + + +
+ + + + + + + {config.turnDetection.strategy === "silence" && ( + + updateTurnDetection({ silenceTimeoutSecs }) + } + /> + )} + +
+
+
+
+
+ +
+ ); +} + +function ConfigSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
+ {title} +
+
+ {children} +
+
+ ); +} + +function HelpHint({ text }: { text: string }) { + return ( + + + + + + {text} + + + ); +} + +function NumberField({ label, value, min, max, step, onChange }: { label: string; value: number; min: number; max: number; step: number; onChange: (value: number) => void }) { + return ( + + ); +} diff --git a/frontend/src/components/workflow/WorkflowEditor.tsx b/frontend/src/components/workflow/WorkflowEditor.tsx index f3948dc..75057df 100644 --- a/frontend/src/components/workflow/WorkflowEditor.tsx +++ b/frontend/src/components/workflow/WorkflowEditor.tsx @@ -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 })} /> - + + onSettingsChange({ ...settings, allowInterrupt }) + } + onConfigChange={(turnConfig) => + onSettingsChange({ ...settings, turnConfig }) + } + /> diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index eda4b00..6711036 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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>;