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:
@@ -108,6 +108,7 @@ class Assistant(Base):
|
|||||||
runtime_mode: Mapped[str] = mapped_column(String(16), default="pipeline")
|
runtime_mode: Mapped[str] = mapped_column(String(16), default="pipeline")
|
||||||
greeting: Mapped[str] = mapped_column(String(2048), default="")
|
greeting: Mapped[str] = mapped_column(String(2048), default="")
|
||||||
enable_interrupt: Mapped[bool] = mapped_column(Boolean, default=True)
|
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_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
vision_model_resource_id: Mapped[str | None] = mapped_column(
|
vision_model_resource_id: Mapped[str | None] = mapped_column(
|
||||||
String(40),
|
String(40),
|
||||||
|
|||||||
25
backend/migrations/versions/20260712_0004_add_turn_config.py
Normal file
25
backend/migrations/versions/20260712_0004_add_turn_config.py
Normal file
@@ -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")
|
||||||
@@ -71,6 +71,7 @@ class AssistantConfig(BaseModel):
|
|||||||
tts_secrets: dict = {}
|
tts_secrets: dict = {}
|
||||||
|
|
||||||
enableInterrupt: bool = True
|
enableInterrupt: bool = True
|
||||||
|
turnConfig: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
# Prompt assistant reusable tools. Execution remains type-specific in the pipeline.
|
# Prompt assistant reusable tools. Execution remains type-specific in the pipeline.
|
||||||
tools: list[RuntimeTool] = Field(default_factory=list)
|
tools: list[RuntimeTool] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
|
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
|
||||||
# silero -> 本地 VAD(判断用户说话起止),语音必备
|
# silero -> 本地 VAD(判断用户说话起止),语音必备
|
||||||
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它)
|
# 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
|
Pillow>=11.1.0,<13
|
||||||
|
|
||||||
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话)
|
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话)
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
|
|||||||
runtime_mode=assistant.runtime_mode, # type: ignore[arg-type]
|
runtime_mode=assistant.runtime_mode, # type: ignore[arg-type]
|
||||||
greeting=assistant.greeting,
|
greeting=assistant.greeting,
|
||||||
enable_interrupt=assistant.enable_interrupt,
|
enable_interrupt=assistant.enable_interrupt,
|
||||||
|
turn_config=assistant.turn_config or {},
|
||||||
vision_enabled=assistant.vision_enabled,
|
vision_enabled=assistant.vision_enabled,
|
||||||
vision_model_resource_id=assistant.vision_model_resource_id,
|
vision_model_resource_id=assistant.vision_model_resource_id,
|
||||||
model_resource_ids=await _resource_ids(session, assistant.id),
|
model_resource_ids=await _resource_ids(session, assistant.id),
|
||||||
@@ -202,6 +203,7 @@ async def duplicate_assistant(
|
|||||||
runtime_mode=source.runtime_mode,
|
runtime_mode=source.runtime_mode,
|
||||||
greeting=source.greeting,
|
greeting=source.greeting,
|
||||||
enable_interrupt=source.enable_interrupt,
|
enable_interrupt=source.enable_interrupt,
|
||||||
|
turn_config=dict(source.turn_config or {}),
|
||||||
vision_enabled=source.vision_enabled,
|
vision_enabled=source.vision_enabled,
|
||||||
vision_model_resource_id=source.vision_model_resource_id,
|
vision_model_resource_id=source.vision_model_resource_id,
|
||||||
knowledge_base_id=source.knowledge_base_id,
|
knowledge_base_id=source.knowledge_base_id,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from pydantic.alias_generators import to_camel
|
|||||||
RuntimeMode = Literal["pipeline", "realtime"]
|
RuntimeMode = Literal["pipeline", "realtime"]
|
||||||
ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"]
|
ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"]
|
||||||
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
|
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
|
||||||
|
TurnEndStrategy = Literal["silence", "smart_turn"]
|
||||||
ToolType = Literal["end_call", "http"]
|
ToolType = Literal["end_call", "http"]
|
||||||
ToolStatus = Literal["active", "archived", "draft"]
|
ToolStatus = Literal["active", "archived", "draft"]
|
||||||
ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"]
|
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 允许的瘦字段(其余字段写入时清零,防止跨类型脏数据)
|
# 各 type 允许的瘦字段(其余字段写入时清零,防止跨类型脏数据)
|
||||||
ALLOWED_FIELDS: dict[str, set[str]] = {
|
ALLOWED_FIELDS: dict[str, set[str]] = {
|
||||||
"prompt": {"prompt"},
|
"prompt": {"prompt"},
|
||||||
@@ -57,6 +79,7 @@ class AssistantUpsert(CamelModel):
|
|||||||
runtime_mode: RuntimeMode = "pipeline"
|
runtime_mode: RuntimeMode = "pipeline"
|
||||||
greeting: str = ""
|
greeting: str = ""
|
||||||
enable_interrupt: bool = True
|
enable_interrupt: bool = True
|
||||||
|
turn_config: TurnConfig = Field(default_factory=TurnConfig)
|
||||||
vision_enabled: bool = False
|
vision_enabled: bool = False
|
||||||
vision_model_resource_id: str | None = None
|
vision_model_resource_id: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ async def resolve_runtime_config(
|
|||||||
prompt=assistant.prompt or "你是一个有帮助的助手。",
|
prompt=assistant.prompt or "你是一个有帮助的助手。",
|
||||||
runtimeMode=assistant.runtime_mode, # type: ignore[arg-type]
|
runtimeMode=assistant.runtime_mode, # type: ignore[arg-type]
|
||||||
enableInterrupt=assistant.enable_interrupt,
|
enableInterrupt=assistant.enable_interrupt,
|
||||||
|
turnConfig=assistant.turn_config or {},
|
||||||
tools=await _tools_for(session, assistant),
|
tools=await _tools_for(session, assistant),
|
||||||
# workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话
|
# workflow 图:仅 workflow 类型非空,引擎据此启用图驱动对话
|
||||||
graph=(assistant.graph or {}) if assistant.type == "workflow" else {},
|
graph=(assistant.graph or {}) if assistant.type == "workflow" else {},
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ from services.pipecat.service_factory import (
|
|||||||
|
|
||||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
EndFrame,
|
EndFrame,
|
||||||
InputTransportMessageFrame,
|
InputTransportMessageFrame,
|
||||||
@@ -59,15 +58,14 @@ from pipecat.runner.utils import (
|
|||||||
maybe_capture_participant_camera,
|
maybe_capture_participant_camera,
|
||||||
)
|
)
|
||||||
from pipecat.services.llm_service import FunctionCallParams
|
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.base_user_mute_strategy import BaseUserMuteStrategy
|
||||||
from pipecat.turns.user_mute.function_call_user_mute_strategy import (
|
from pipecat.turns.user_mute.function_call_user_mute_strategy import (
|
||||||
FunctionCallUserMuteStrategy,
|
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.utils.time import time_now_iso8601
|
||||||
from pipecat.workers.runner import WorkerRunner
|
from pipecat.workers.runner import WorkerRunner
|
||||||
|
|
||||||
@@ -448,18 +446,14 @@ async def run_pipeline(
|
|||||||
user_aggregator = LLMUserAggregator(
|
user_aggregator = LLMUserAggregator(
|
||||||
context,
|
context,
|
||||||
params=LLMUserAggregatorParams(
|
params=LLMUserAggregatorParams(
|
||||||
vad_analyzer=SileroVADAnalyzer(),
|
vad_analyzer=create_vad_analyzer(cfg.turnConfig),
|
||||||
user_mute_strategies=[
|
user_mute_strategies=[
|
||||||
FunctionCallUserMuteStrategy(),
|
FunctionCallUserMuteStrategy(),
|
||||||
CallEndingUserMuteStrategy(lambda: call_end.ending),
|
CallEndingUserMuteStrategy(lambda: call_end.ending),
|
||||||
],
|
],
|
||||||
user_turn_strategies=UserTurnStrategies(
|
user_turn_strategies=create_user_turn_strategies(
|
||||||
start=[
|
cfg.turnConfig,
|
||||||
VADUserTurnStartStrategy(enable_interruptions=cfg.enableInterrupt),
|
enable_interruptions=cfg.enableInterrupt,
|
||||||
TranscriptionUserTurnStartStrategy(
|
|
||||||
enable_interruptions=cfg.enableInterrupt
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
89
backend/services/pipecat/turn_config.py
Normal file
89
backend/services/pipecat/turn_config.py
Normal file
@@ -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)
|
||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
Database,
|
Database,
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -41,6 +39,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { NetworkQualityIndicator } from "@/components/network-quality-indicator";
|
import { NetworkQualityIndicator } from "@/components/network-quality-indicator";
|
||||||
|
import { TurnConfigEditor } from "@/components/turn-config-editor";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -106,6 +105,7 @@ import {
|
|||||||
type KnowledgeBase,
|
type KnowledgeBase,
|
||||||
type ModelResource,
|
type ModelResource,
|
||||||
type Tool,
|
type Tool,
|
||||||
|
type TurnConfig,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
useVoicePreview,
|
useVoicePreview,
|
||||||
@@ -139,6 +139,7 @@ type AssistantForm = {
|
|||||||
voice: string;
|
voice: string;
|
||||||
knowledgeBase: string;
|
knowledgeBase: string;
|
||||||
enableInterrupt: boolean;
|
enableInterrupt: boolean;
|
||||||
|
turnConfig: TurnConfig;
|
||||||
visionEnabled: boolean;
|
visionEnabled: boolean;
|
||||||
visionModelResourceId: string;
|
visionModelResourceId: string;
|
||||||
toolIds: string[];
|
toolIds: string[];
|
||||||
@@ -150,6 +151,7 @@ type FastGptForm = {
|
|||||||
asr: string;
|
asr: string;
|
||||||
voice: string;
|
voice: string;
|
||||||
enableInterrupt: boolean;
|
enableInterrupt: boolean;
|
||||||
|
turnConfig: TurnConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DifyForm = {
|
type DifyForm = {
|
||||||
@@ -158,6 +160,7 @@ type DifyForm = {
|
|||||||
asr: string;
|
asr: string;
|
||||||
voice: string;
|
voice: string;
|
||||||
enableInterrupt: boolean;
|
enableInterrupt: boolean;
|
||||||
|
turnConfig: TurnConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OpenCodeForm = {
|
type OpenCodeForm = {
|
||||||
@@ -168,6 +171,7 @@ type OpenCodeForm = {
|
|||||||
asr: string;
|
asr: string;
|
||||||
voice: string;
|
voice: string;
|
||||||
enableInterrupt: boolean;
|
enableInterrupt: boolean;
|
||||||
|
turnConfig: TurnConfig;
|
||||||
visionEnabled: boolean;
|
visionEnabled: boolean;
|
||||||
visionModelResourceId: string;
|
visionModelResourceId: string;
|
||||||
};
|
};
|
||||||
@@ -182,6 +186,24 @@ const assistantTypes: AssistantType[] = [
|
|||||||
"OpenCode",
|
"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(英文) ↔ 列表展示标签(中文)
|
// 后端 type(英文) ↔ 列表展示标签(中文)
|
||||||
const typeToLabel: Record<ApiAssistantType, AssistantType> = {
|
const typeToLabel: Record<ApiAssistantType, AssistantType> = {
|
||||||
prompt: "提示词",
|
prompt: "提示词",
|
||||||
@@ -229,6 +251,7 @@ function blankPromptForm(name: string): AssistantForm {
|
|||||||
voice: "",
|
voice: "",
|
||||||
knowledgeBase: "",
|
knowledgeBase: "",
|
||||||
enableInterrupt: true,
|
enableInterrupt: true,
|
||||||
|
turnConfig: defaultTurnConfig(),
|
||||||
visionEnabled: false,
|
visionEnabled: false,
|
||||||
visionModelResourceId: "",
|
visionModelResourceId: "",
|
||||||
toolIds: [],
|
toolIds: [],
|
||||||
@@ -242,6 +265,7 @@ function blankFastGptForm(name: string): FastGptForm {
|
|||||||
asr: "",
|
asr: "",
|
||||||
voice: "",
|
voice: "",
|
||||||
enableInterrupt: true,
|
enableInterrupt: true,
|
||||||
|
turnConfig: defaultTurnConfig(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +276,7 @@ function blankDifyForm(name: string): DifyForm {
|
|||||||
asr: "",
|
asr: "",
|
||||||
voice: "",
|
voice: "",
|
||||||
enableInterrupt: true,
|
enableInterrupt: true,
|
||||||
|
turnConfig: defaultTurnConfig(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +289,7 @@ function blankOpenCodeForm(name: string): OpenCodeForm {
|
|||||||
asr: "",
|
asr: "",
|
||||||
voice: "",
|
voice: "",
|
||||||
enableInterrupt: true,
|
enableInterrupt: true,
|
||||||
|
turnConfig: defaultTurnConfig(),
|
||||||
visionEnabled: false,
|
visionEnabled: false,
|
||||||
visionModelResourceId: "",
|
visionModelResourceId: "",
|
||||||
};
|
};
|
||||||
@@ -362,7 +388,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
// 编辑模式:加载指定 id 助手失败时的错误
|
// 编辑模式:加载指定 id 助手失败时的错误
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
// 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥")
|
// 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥")
|
||||||
const [storedApiKeyMask, setStoredApiKeyMask] = useState("");
|
|
||||||
// 下拉数据源:模型资源 + 知识库
|
// 下拉数据源:模型资源 + 知识库
|
||||||
const [modelResources, setModelResources] = useState<ModelResource[]>([]);
|
const [modelResources, setModelResources] = useState<ModelResource[]>([]);
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||||
@@ -394,6 +419,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
);
|
);
|
||||||
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
|
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
|
||||||
allowInterrupt: true,
|
allowInterrupt: true,
|
||||||
|
turnConfig: defaultTurnConfig(),
|
||||||
});
|
});
|
||||||
const [debugOpen, setDebugOpen] = useState(false);
|
const [debugOpen, setDebugOpen] = useState(false);
|
||||||
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
||||||
@@ -505,6 +531,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
voice: a.modelResourceIds.TTS ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
knowledgeBase: a.knowledgeBaseId ?? "",
|
knowledgeBase: a.knowledgeBaseId ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
|
turnConfig: a.turnConfig,
|
||||||
visionEnabled: a.visionEnabled,
|
visionEnabled: a.visionEnabled,
|
||||||
visionModelResourceId: a.visionModelResourceId ?? "",
|
visionModelResourceId: a.visionModelResourceId ?? "",
|
||||||
toolIds: a.toolIds ?? [],
|
toolIds: a.toolIds ?? [],
|
||||||
@@ -568,6 +595,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
runtimeMode: "pipeline",
|
runtimeMode: "pipeline",
|
||||||
greeting: "",
|
greeting: "",
|
||||||
enableInterrupt: true,
|
enableInterrupt: true,
|
||||||
|
turnConfig: defaultTurnConfig(),
|
||||||
visionEnabled: false,
|
visionEnabled: false,
|
||||||
visionModelResourceId: null,
|
visionModelResourceId: null,
|
||||||
modelResourceIds: {},
|
modelResourceIds: {},
|
||||||
@@ -582,15 +610,13 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存:停留在编辑页,刷新密钥掩码并把当前表单记为已保存基线(按钮随之置灰)
|
// 保存后停留在编辑页,并把当前表单记为已保存基线(按钮随之置灰)。
|
||||||
async function save(payload: AssistantUpsert) {
|
async function save(payload: AssistantUpsert) {
|
||||||
if (!editingId) return;
|
if (!editingId) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await assistantsApi.update(editingId, payload);
|
await assistantsApi.update(editingId, payload);
|
||||||
setStoredApiKeyMask(updated.apiKey ?? "");
|
|
||||||
// 密钥输入框清空(空值=保留已存密钥),并以清空后的表单为新基线
|
|
||||||
if (view === "create") {
|
if (view === "create") {
|
||||||
setSavedSnapshot(JSON.stringify(form));
|
setSavedSnapshot(JSON.stringify(form));
|
||||||
} else if (view === "create-dify") {
|
} else if (view === "create-dify") {
|
||||||
@@ -629,6 +655,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
runtimeMode: form.runtimeMode,
|
runtimeMode: form.runtimeMode,
|
||||||
greeting: form.greeting,
|
greeting: form.greeting,
|
||||||
enableInterrupt: form.enableInterrupt,
|
enableInterrupt: form.enableInterrupt,
|
||||||
|
turnConfig: form.turnConfig,
|
||||||
visionEnabled: form.visionEnabled,
|
visionEnabled: form.visionEnabled,
|
||||||
visionModelResourceId: form.visionModelResourceId || null,
|
visionModelResourceId: form.visionModelResourceId || null,
|
||||||
modelResourceIds: {
|
modelResourceIds: {
|
||||||
@@ -652,6 +679,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
asr: a.modelResourceIds.ASR ?? "",
|
asr: a.modelResourceIds.ASR ?? "",
|
||||||
voice: a.modelResourceIds.TTS ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
|
turnConfig: a.turnConfig,
|
||||||
};
|
};
|
||||||
setDifyForm(next);
|
setDifyForm(next);
|
||||||
return next;
|
return next;
|
||||||
@@ -663,6 +691,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: difyForm.name.trim(),
|
name: difyForm.name.trim(),
|
||||||
type: "dify",
|
type: "dify",
|
||||||
enableInterrupt: difyForm.enableInterrupt,
|
enableInterrupt: difyForm.enableInterrupt,
|
||||||
|
turnConfig: difyForm.turnConfig,
|
||||||
modelResourceIds: {
|
modelResourceIds: {
|
||||||
...(difyForm.agent ? { Agent: difyForm.agent } : {}),
|
...(difyForm.agent ? { Agent: difyForm.agent } : {}),
|
||||||
...(difyForm.asr ? { ASR: difyForm.asr } : {}),
|
...(difyForm.asr ? { ASR: difyForm.asr } : {}),
|
||||||
@@ -680,6 +709,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
asr: a.modelResourceIds.ASR ?? "",
|
asr: a.modelResourceIds.ASR ?? "",
|
||||||
voice: a.modelResourceIds.TTS ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
|
turnConfig: a.turnConfig,
|
||||||
};
|
};
|
||||||
setFastGptForm(next);
|
setFastGptForm(next);
|
||||||
return next;
|
return next;
|
||||||
@@ -691,6 +721,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: fastGptForm.name.trim(),
|
name: fastGptForm.name.trim(),
|
||||||
type: "fastgpt",
|
type: "fastgpt",
|
||||||
enableInterrupt: fastGptForm.enableInterrupt,
|
enableInterrupt: fastGptForm.enableInterrupt,
|
||||||
|
turnConfig: fastGptForm.turnConfig,
|
||||||
modelResourceIds: {
|
modelResourceIds: {
|
||||||
...(fastGptForm.agent ? { Agent: fastGptForm.agent } : {}),
|
...(fastGptForm.agent ? { Agent: fastGptForm.agent } : {}),
|
||||||
...(fastGptForm.asr ? { ASR: fastGptForm.asr } : {}),
|
...(fastGptForm.asr ? { ASR: fastGptForm.asr } : {}),
|
||||||
@@ -710,6 +741,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
asr: a.modelResourceIds.ASR ?? "",
|
asr: a.modelResourceIds.ASR ?? "",
|
||||||
voice: a.modelResourceIds.TTS ?? "",
|
voice: a.modelResourceIds.TTS ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
|
turnConfig: a.turnConfig,
|
||||||
visionEnabled: a.visionEnabled,
|
visionEnabled: a.visionEnabled,
|
||||||
visionModelResourceId: a.visionModelResourceId ?? "",
|
visionModelResourceId: a.visionModelResourceId ?? "",
|
||||||
};
|
};
|
||||||
@@ -726,7 +758,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
try {
|
try {
|
||||||
const assistant = await assistantsApi.get(editingId);
|
const assistant = await assistantsApi.get(editingId);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setStoredApiKeyMask(assistant.apiKey ?? "");
|
|
||||||
if (assistant.type === "prompt") {
|
if (assistant.type === "prompt") {
|
||||||
setSavedSnapshot(JSON.stringify(fillPromptForm(assistant)));
|
setSavedSnapshot(JSON.stringify(fillPromptForm(assistant)));
|
||||||
} else if (assistant.type === "fastgpt") {
|
} else if (assistant.type === "fastgpt") {
|
||||||
@@ -748,6 +779,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
asr: assistant.modelResourceIds.ASR,
|
asr: assistant.modelResourceIds.ASR,
|
||||||
tts: assistant.modelResourceIds.TTS,
|
tts: assistant.modelResourceIds.TTS,
|
||||||
allowInterrupt: assistant.enableInterrupt,
|
allowInterrupt: assistant.enableInterrupt,
|
||||||
|
turnConfig: assistant.turnConfig,
|
||||||
};
|
};
|
||||||
setWorkflowName(assistant.name);
|
setWorkflowName(assistant.name);
|
||||||
setWorkflowGraph(graph);
|
setWorkflowGraph(graph);
|
||||||
@@ -781,6 +813,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: openCodeForm.name.trim(),
|
name: openCodeForm.name.trim(),
|
||||||
type: "opencode",
|
type: "opencode",
|
||||||
enableInterrupt: openCodeForm.enableInterrupt,
|
enableInterrupt: openCodeForm.enableInterrupt,
|
||||||
|
turnConfig: openCodeForm.turnConfig,
|
||||||
visionEnabled: openCodeForm.visionEnabled,
|
visionEnabled: openCodeForm.visionEnabled,
|
||||||
visionModelResourceId: openCodeForm.visionModelResourceId || null,
|
visionModelResourceId: openCodeForm.visionModelResourceId || null,
|
||||||
modelResourceIds: {
|
modelResourceIds: {
|
||||||
@@ -800,6 +833,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
name: workflowName.trim(),
|
name: workflowName.trim(),
|
||||||
type: "workflow",
|
type: "workflow",
|
||||||
enableInterrupt: workflowSettings.allowInterrupt,
|
enableInterrupt: workflowSettings.allowInterrupt,
|
||||||
|
turnConfig: workflowSettings.turnConfig,
|
||||||
modelResourceIds: {
|
modelResourceIds: {
|
||||||
...(workflowSettings.llm ? { LLM: workflowSettings.llm } : {}),
|
...(workflowSettings.llm ? { LLM: workflowSettings.llm } : {}),
|
||||||
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
|
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
|
||||||
@@ -1423,13 +1457,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
<ToggleRow
|
<TurnConfigEditor
|
||||||
title="允许用户打断"
|
enabled={difyForm.enableInterrupt}
|
||||||
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
|
config={difyForm.turnConfig}
|
||||||
checked={difyForm.enableInterrupt}
|
onEnabledChange={(checked) => updateDifyForm("enableInterrupt", checked)}
|
||||||
onChange={(checked) =>
|
onConfigChange={(config) => updateDifyForm("turnConfig", config)}
|
||||||
updateDifyForm("enableInterrupt", checked)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -1521,13 +1553,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
<ToggleRow
|
<TurnConfigEditor
|
||||||
title="允许用户打断"
|
enabled={fastGptForm.enableInterrupt}
|
||||||
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
|
config={fastGptForm.turnConfig}
|
||||||
checked={fastGptForm.enableInterrupt}
|
onEnabledChange={(checked) => updateFastGptForm("enableInterrupt", checked)}
|
||||||
onChange={(checked) =>
|
onConfigChange={(config) => updateFastGptForm("turnConfig", config)}
|
||||||
updateFastGptForm("enableInterrupt", checked)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -1660,13 +1690,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
<ToggleRow
|
<TurnConfigEditor
|
||||||
title="允许用户打断"
|
enabled={openCodeForm.enableInterrupt}
|
||||||
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
|
config={openCodeForm.turnConfig}
|
||||||
checked={openCodeForm.enableInterrupt}
|
onEnabledChange={(checked) => updateOpenCodeForm("enableInterrupt", checked)}
|
||||||
onChange={(checked) =>
|
onConfigChange={(config) => updateOpenCodeForm("turnConfig", config)}
|
||||||
updateOpenCodeForm("enableInterrupt", checked)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -1902,12 +1930,18 @@ export function AssistantPage(props: AssistantPageProps) {
|
|||||||
title="交互策略"
|
title="交互策略"
|
||||||
description="设置实时视频对话时的交互体验"
|
description="设置实时视频对话时的交互体验"
|
||||||
>
|
>
|
||||||
<ToggleRow
|
{form.runtimeMode === "pipeline" ? (
|
||||||
title="允许用户打断"
|
<TurnConfigEditor
|
||||||
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
|
enabled={form.enableInterrupt}
|
||||||
checked={form.enableInterrupt}
|
config={form.turnConfig}
|
||||||
onChange={(checked) => updateForm("enableInterrupt", checked)}
|
onEnabledChange={(checked) => updateForm("enableInterrupt", checked)}
|
||||||
/>
|
onConfigChange={(config) => updateForm("turnConfig", config)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
高级抢话与轮次检测当前仅支持 Pipeline 模式。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</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({
|
function TextAreaField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
204
frontend/src/components/turn-config-editor.tsx
Normal file
204
frontend/src/components/turn-config-editor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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 { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
import { edgeTypes } from "./ConditionEdge";
|
import { edgeTypes } from "./ConditionEdge";
|
||||||
@@ -73,6 +75,7 @@ export type WorkflowSettings = {
|
|||||||
asr?: string;
|
asr?: string;
|
||||||
tts?: string;
|
tts?: string;
|
||||||
allowInterrupt: boolean;
|
allowInterrupt: boolean;
|
||||||
|
turnConfig: TurnConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModelOption = { value: string; label: string };
|
export type ModelOption = { value: string; label: string };
|
||||||
@@ -528,22 +531,16 @@ function Canvas({
|
|||||||
options={modelOptions.tts}
|
options={modelOptions.tts}
|
||||||
onChange={(v) => onSettingsChange({ ...settings, tts: v })}
|
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">
|
<TurnConfigEditor
|
||||||
<span>
|
enabled={settings.allowInterrupt}
|
||||||
<span className="block text-sm font-medium text-foreground">
|
config={settings.turnConfig}
|
||||||
允许用户打断
|
onEnabledChange={(allowInterrupt) =>
|
||||||
</span>
|
onSettingsChange({ ...settings, allowInterrupt })
|
||||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
}
|
||||||
用户说话时可中断当前语音回复。
|
onConfigChange={(turnConfig) =>
|
||||||
</span>
|
onSettingsChange({ ...settings, turnConfig })
|
||||||
</span>
|
}
|
||||||
<Switch
|
/>
|
||||||
checked={settings.allowInterrupt}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onSettingsChange({ ...settings, allowInterrupt: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="border-t border-hairline bg-card px-5 py-4">
|
<DialogFooter className="border-t border-hairline bg-card px-5 py-4">
|
||||||
<Button onClick={() => setSettingsOpen(false)}>完成</Button>
|
<Button onClick={() => setSettingsOpen(false)}>完成</Button>
|
||||||
|
|||||||
@@ -150,6 +150,21 @@ export type AssistantType =
|
|||||||
| "fastgpt"
|
| "fastgpt"
|
||||||
| "opencode";
|
| "opencode";
|
||||||
export type RuntimeMode = "pipeline" | "realtime";
|
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 读时打码 */
|
/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */
|
||||||
export type Assistant = {
|
export type Assistant = {
|
||||||
@@ -159,6 +174,7 @@ export type Assistant = {
|
|||||||
runtimeMode: RuntimeMode;
|
runtimeMode: RuntimeMode;
|
||||||
greeting: string;
|
greeting: string;
|
||||||
enableInterrupt: boolean;
|
enableInterrupt: boolean;
|
||||||
|
turnConfig: TurnConfig;
|
||||||
visionEnabled: boolean;
|
visionEnabled: boolean;
|
||||||
visionModelResourceId: string | null;
|
visionModelResourceId: string | null;
|
||||||
modelResourceIds: Partial<Record<ModelType, string>>;
|
modelResourceIds: Partial<Record<ModelType, string>>;
|
||||||
|
|||||||
Reference in New Issue
Block a user