From 935f2fbd1fcf35785fd43bcb52402bd4705c00a5 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Thu, 5 Mar 2026 21:24:15 +0800 Subject: [PATCH 01/20] Refactor assistant configuration management and update documentation - Removed legacy agent profile settings from the .env.example and README, streamlining the configuration process. - Introduced a new local YAML configuration adapter for assistant settings, allowing for easier management of assistant profiles. - Updated backend integration documentation to clarify the behavior of assistant config sourcing based on backend URL settings. - Adjusted various service implementations to directly utilize API keys from the new configuration structure. - Enhanced test coverage for the new local YAML adapter and its integration with backend services. --- engine/.env.example | 18 +- engine/README.md | 50 +-- engine/app/backend_adapters.py | 312 +++++++++++++- engine/app/config.py | 387 +----------------- engine/app/main.py | 10 +- engine/core/duplex_pipeline.py | 6 +- engine/docs/backend_integration.md | 7 + engine/examples/wav_client.py | 4 +- .../scripts/generate_test_audio/.env.example | 1 + engine/services/dashscope_tts.py | 2 +- engine/services/llm.py | 4 +- engine/services/openai_compatible_asr.py | 2 +- engine/services/openai_compatible_tts.py | 4 +- engine/services/realtime.py | 2 - engine/tests/test_agent_config.py | 300 +------------- engine/tests/test_backend_adapters.py | 214 +++++++++- examples/README.md | 1 + 17 files changed, 585 insertions(+), 739 deletions(-) create mode 100644 engine/scripts/generate_test_audio/.env.example create mode 100644 examples/README.md diff --git a/engine/.env.example b/engine/.env.example index 4007aa0..8a87354 100644 --- a/engine/.env.example +++ b/engine/.env.example @@ -30,21 +30,9 @@ CHUNK_SIZE_MS=20 DEFAULT_CODEC=pcm MAX_AUDIO_BUFFER_SECONDS=30 -# Agent profile selection (optional fallback when CLI args are not used) -# Prefer CLI: -# python -m app.main --agent-config config/agents/default.yaml -# python -m app.main --agent-profile default -# AGENT_CONFIG_PATH=config/agents/default.yaml -# AGENT_PROFILE=default -AGENT_CONFIG_DIR=config/agents - -# Optional: provider credentials referenced from YAML, e.g. ${LLM_API_KEY} -# LLM_API_KEY=your_llm_api_key_here -# LLM_API_URL=https://api.openai.com/v1 -# TTS_API_KEY=your_tts_api_key_here -# TTS_API_URL=https://api.example.com/v1/audio/speech -# ASR_API_KEY=your_asr_api_key_here -# ASR_API_URL=https://api.example.com/v1/audio/transcriptions +# Local assistant/agent YAML directory. In local mode the runtime resolves: +# ASSISTANT_LOCAL_CONFIG_DIR/.yaml +ASSISTANT_LOCAL_CONFIG_DIR=engine/config/agents # Logging LOG_LEVEL=INFO diff --git a/engine/README.md b/engine/README.md index 9d0949b..5018c39 100644 --- a/engine/README.md +++ b/engine/README.md @@ -1,6 +1,6 @@ -# py-active-call-cc +# Realtime Agent Studio Engine -Python Active-Call: real-time audio streaming with WebSocket and WebRTC. +This repo contains a Python 3.11+ codebase for building low-latency realtime human-agent interaction pipelines (capture, stream, and process audio) using WebSockets or WebRTC. This repo contains a Python 3.11+ codebase for building low-latency voice pipelines (capture, stream, and process audio) using WebRTC and WebSockets. @@ -14,35 +14,11 @@ It is currently in an early, experimental stage. uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ``` -使用 agent profile(推荐) - -``` -python -m app.main --agent-profile default -``` - -使用指定 YAML - -``` -python -m app.main --agent-config config/agents/default.yaml -``` - -Agent 配置路径优先级 -1. `--agent-config` -2. `--agent-profile`(映射到 `config/agents/.yaml`) -3. `AGENT_CONFIG_PATH` -4. `AGENT_PROFILE` -5. `config/agents/default.yaml`(若存在) - 说明 -- Agent 相关配置是严格模式:YAML 缺少必须项会直接报错,不会回退到 `.env` 或代码默认值。 -- 如果要引用环境变量,请在 YAML 显式写 `${ENV_VAR}`。 -- `siliconflow` 独立 section 已移除;请在 `agent.llm / agent.tts / agent.asr` 内通过 `provider`、`api_key`、`api_url`、`model` 配置。 -- `agent.tts.provider` 现支持 `dashscope`(Realtime 协议,非 OpenAI-compatible);默认 URL 为 `wss://dashscope.aliyuncs.com/api-ws/v1/realtime`,默认模型为 `qwen3-tts-flash-realtime`。 -- `agent.tts.dashscope_mode`(兼容旧写法 `agent.tts.mode`)支持 `commit | server_commit`,且仅在 `provider=dashscope` 时生效: - - `commit`:Engine 先按句切分,再逐句提交给 DashScope。 - - `server_commit`:Engine 不再逐句切分,由 DashScope 对整段文本自行切分。 -- 现在支持在 Agent YAML 中配置 `agent.tools`(列表),用于声明运行时可调用工具。 -- 工具配置示例见 `config/agents/tools.yaml`。 +- 启动阶段不再通过参数加载 Agent YAML。 +- 会话阶段统一按 `assistant_id` 拉取运行时配置: + - 有 `BACKEND_URL`:从 backend API 获取。 + - 无 `BACKEND_URL`(或 `BACKEND_MODE=disabled`):从 `ASSISTANT_LOCAL_CONFIG_DIR/.yaml` 获取。 ## Backend Integration @@ -50,6 +26,7 @@ Engine runtime now supports adapter-based backend integration: - `BACKEND_MODE=auto|http|disabled` - `BACKEND_URL` + `BACKEND_TIMEOUT_SEC` +- `ASSISTANT_LOCAL_CONFIG_DIR` (default `engine/config/agents`) - `HISTORY_ENABLED=true|false` Behavior: @@ -58,6 +35,16 @@ Behavior: - `http`: force HTTP backend; falls back to engine-only mode when URL is missing. - `disabled`: force engine-only mode (no backend calls). +Assistant config source behavior: + +- If `BACKEND_URL` is configured and backend mode is enabled, assistant config is loaded from backend API. +- If `BACKEND_URL` is empty (or backend mode is disabled), assistant config is loaded from local YAML. + +Local assistant YAML example: + +- File path: `engine/config/agents/.yaml` +- Runtime still requires WebSocket query param `assistant_id`; it must match the local file name. + History write path is now asynchronous and buffered per session: - `HISTORY_QUEUE_MAX_SIZE` @@ -84,3 +71,6 @@ python mic_client.py `/ws` uses a strict `v1` JSON control protocol with binary PCM audio frames. See `docs/ws_v1_schema.md`. + +# Reference +* [active-call](https://github.com/restsend/active-call) diff --git a/engine/app/backend_adapters.py b/engine/app/backend_adapters.py index 6ff2716..087f744 100644 --- a/engine/app/backend_adapters.py +++ b/engine/app/backend_adapters.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re +from pathlib import Path from typing import Any, Dict, List, Optional import aiohttp @@ -9,6 +11,18 @@ from loguru import logger from app.config import settings +try: + import yaml +except ImportError: # pragma: no cover - validated when local YAML source is enabled + yaml = None + + +_ASSISTANT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") + + +def _assistant_error(code: str, assistant_id: str) -> Dict[str, Any]: + return {"__error_code": code, "assistantId": str(assistant_id or "")} + class NullBackendAdapter: """No-op adapter for engine-only runtime without backend dependencies.""" @@ -128,6 +142,283 @@ class HistoryDisabledBackendAdapter: return await self._delegate.fetch_tool_resource(tool_id) +class LocalYamlAssistantConfigAdapter(NullBackendAdapter): + """Load assistant runtime config from local YAML files.""" + + def __init__(self, config_dir: str): + self._config_dir = self._resolve_base_dir(config_dir) + + @staticmethod + def _resolve_base_dir(config_dir: str) -> Path: + raw = Path(str(config_dir or "").strip() or "engine/config/agents") + if raw.is_absolute(): + return raw.resolve() + + cwd_candidate = (Path.cwd() / raw).resolve() + if cwd_candidate.exists(): + return cwd_candidate + + engine_dir = Path(__file__).resolve().parent.parent + engine_candidate = (engine_dir / raw).resolve() + if engine_candidate.exists(): + return engine_candidate + + parts = raw.parts + if parts and parts[0] == "engine" and len(parts) > 1: + trimmed_candidate = (engine_dir / Path(*parts[1:])).resolve() + if trimmed_candidate.exists(): + return trimmed_candidate + + return cwd_candidate + + def _resolve_config_file(self, assistant_id: str) -> Optional[Path]: + normalized = str(assistant_id or "").strip() + if not _ASSISTANT_ID_PATTERN.match(normalized): + return None + + yaml_path = self._config_dir / f"{normalized}.yaml" + yml_path = self._config_dir / f"{normalized}.yml" + if yaml_path.exists(): + return yaml_path + if yml_path.exists(): + return yml_path + return None + + @staticmethod + def _as_str(value: Any) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + return text or None + + @classmethod + def _translate_agent_schema(cls, assistant_id: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Translate legacy `agent:` YAML schema into runtime assistant metadata.""" + agent = payload.get("agent") + if not isinstance(agent, dict): + return None + + runtime: Dict[str, Any] = { + "assistantId": str(assistant_id), + "services": {}, + } + + llm = agent.get("llm") + if isinstance(llm, dict): + llm_runtime: Dict[str, Any] = {} + if cls._as_str(llm.get("provider")): + llm_runtime["provider"] = cls._as_str(llm.get("provider")) + if cls._as_str(llm.get("model")): + llm_runtime["model"] = cls._as_str(llm.get("model")) + if cls._as_str(llm.get("api_key")): + llm_runtime["apiKey"] = cls._as_str(llm.get("api_key")) + if cls._as_str(llm.get("api_url")): + llm_runtime["baseUrl"] = cls._as_str(llm.get("api_url")) + if llm_runtime: + runtime["services"]["llm"] = llm_runtime + + tts = agent.get("tts") + if isinstance(tts, dict): + tts_runtime: Dict[str, Any] = {} + if cls._as_str(tts.get("provider")): + tts_runtime["provider"] = cls._as_str(tts.get("provider")) + if cls._as_str(tts.get("model")): + tts_runtime["model"] = cls._as_str(tts.get("model")) + if cls._as_str(tts.get("api_key")): + tts_runtime["apiKey"] = cls._as_str(tts.get("api_key")) + if cls._as_str(tts.get("api_url")): + tts_runtime["baseUrl"] = cls._as_str(tts.get("api_url")) + if cls._as_str(tts.get("voice")): + tts_runtime["voice"] = cls._as_str(tts.get("voice")) + if tts.get("speed") is not None: + tts_runtime["speed"] = tts.get("speed") + dashscope_mode = cls._as_str(tts.get("dashscope_mode")) or cls._as_str(tts.get("mode")) + if dashscope_mode: + tts_runtime["mode"] = dashscope_mode + if tts_runtime: + runtime["services"]["tts"] = tts_runtime + + asr = agent.get("asr") + if isinstance(asr, dict): + asr_runtime: Dict[str, Any] = {} + if cls._as_str(asr.get("provider")): + asr_runtime["provider"] = cls._as_str(asr.get("provider")) + if cls._as_str(asr.get("model")): + asr_runtime["model"] = cls._as_str(asr.get("model")) + if cls._as_str(asr.get("api_key")): + asr_runtime["apiKey"] = cls._as_str(asr.get("api_key")) + if cls._as_str(asr.get("api_url")): + asr_runtime["baseUrl"] = cls._as_str(asr.get("api_url")) + if asr.get("interim_interval_ms") is not None: + asr_runtime["interimIntervalMs"] = asr.get("interim_interval_ms") + if asr.get("min_audio_ms") is not None: + asr_runtime["minAudioMs"] = asr.get("min_audio_ms") + if asr_runtime: + runtime["services"]["asr"] = asr_runtime + + duplex = agent.get("duplex") + if isinstance(duplex, dict): + if cls._as_str(duplex.get("system_prompt")): + runtime["systemPrompt"] = cls._as_str(duplex.get("system_prompt")) + if duplex.get("greeting") is not None: + runtime["greeting"] = duplex.get("greeting") + + barge_in = agent.get("barge_in") + if isinstance(barge_in, dict): + runtime["bargeIn"] = {} + if barge_in.get("min_duration_ms") is not None: + runtime["bargeIn"]["minDurationMs"] = barge_in.get("min_duration_ms") + if barge_in.get("silence_tolerance_ms") is not None: + runtime["bargeIn"]["silenceToleranceMs"] = barge_in.get("silence_tolerance_ms") + if not runtime["bargeIn"]: + runtime.pop("bargeIn", None) + + if isinstance(agent.get("tools"), list): + runtime["tools"] = agent.get("tools") + + if not runtime.get("services"): + runtime.pop("services", None) + return runtime + + async def fetch_assistant_config(self, assistant_id: str) -> Optional[Dict[str, Any]]: + config_file = self._resolve_config_file(assistant_id) + if config_file is None: + return _assistant_error("assistant.not_found", assistant_id) + + if yaml is None: + logger.warning( + "Local assistant config requested but PyYAML is unavailable (assistant_id={})", + assistant_id, + ) + return _assistant_error("assistant.config_unavailable", assistant_id) + + try: + with config_file.open("r", encoding="utf-8") as handle: + payload = yaml.safe_load(handle) or {} + except Exception as exc: + logger.warning( + "Failed to read local assistant config {} (assistant_id={}): {}", + config_file, + assistant_id, + exc, + ) + return _assistant_error("assistant.config_unavailable", assistant_id) + + if not isinstance(payload, dict): + logger.warning( + "Local assistant config is not an object (assistant_id={}, file={})", + assistant_id, + config_file, + ) + return _assistant_error("assistant.config_unavailable", assistant_id) + + translated = self._translate_agent_schema(assistant_id, payload) + if translated is not None: + payload = translated + + # Accept either backend-like payload shape or a direct assistant metadata object. + if isinstance(payload.get("assistant"), dict) or isinstance(payload.get("sessionStartMetadata"), dict): + normalized_payload = dict(payload) + else: + normalized_payload = {"assistant": dict(payload)} + + assistant_obj = normalized_payload.get("assistant") + if isinstance(assistant_obj, dict): + resolved_assistant_id = assistant_obj.get("assistantId") or assistant_obj.get("id") or assistant_id + assistant_obj["assistantId"] = str(resolved_assistant_id) + else: + normalized_payload["assistant"] = {"assistantId": str(assistant_id)} + + normalized_payload.setdefault("assistantId", str(assistant_id)) + normalized_payload.setdefault("configVersionId", f"local:{config_file.name}") + return normalized_payload + + +class AssistantConfigSourceAdapter: + """Route assistant config reads by backend availability without changing other APIs.""" + + def __init__( + self, + *, + delegate: HttpBackendAdapter | NullBackendAdapter | HistoryDisabledBackendAdapter, + local_delegate: LocalYamlAssistantConfigAdapter, + use_backend_assistant_config: bool, + ): + self._delegate = delegate + self._local_delegate = local_delegate + self._use_backend_assistant_config = bool(use_backend_assistant_config) + + async def fetch_assistant_config(self, assistant_id: str) -> Optional[Dict[str, Any]]: + if self._use_backend_assistant_config: + return await self._delegate.fetch_assistant_config(assistant_id) + return await self._local_delegate.fetch_assistant_config(assistant_id) + + async def create_call_record( + self, + *, + user_id: int, + assistant_id: Optional[str], + source: str = "debug", + ) -> Optional[str]: + return await self._delegate.create_call_record( + user_id=user_id, + assistant_id=assistant_id, + source=source, + ) + + async def add_transcript( + self, + *, + call_id: str, + turn_index: int, + speaker: str, + content: str, + start_ms: int, + end_ms: int, + confidence: Optional[float] = None, + duration_ms: Optional[int] = None, + ) -> bool: + return await self._delegate.add_transcript( + call_id=call_id, + turn_index=turn_index, + speaker=speaker, + content=content, + start_ms=start_ms, + end_ms=end_ms, + confidence=confidence, + duration_ms=duration_ms, + ) + + async def finalize_call_record( + self, + *, + call_id: str, + status: str, + duration_seconds: int, + ) -> bool: + return await self._delegate.finalize_call_record( + call_id=call_id, + status=status, + duration_seconds=duration_seconds, + ) + + async def search_knowledge_context( + self, + *, + kb_id: str, + query: str, + n_results: int = 5, + ) -> List[Dict[str, Any]]: + return await self._delegate.search_knowledge_context( + kb_id=kb_id, + query=query, + n_results=n_results, + ) + + async def fetch_tool_resource(self, tool_id: str) -> Optional[Dict[str, Any]]: + return await self._delegate.fetch_tool_resource(tool_id) + + class HttpBackendAdapter: """HTTP implementation of backend integration ports.""" @@ -322,36 +613,49 @@ def build_backend_adapter( backend_mode: str = "auto", history_enabled: bool = True, timeout_sec: int = 10, -) -> HttpBackendAdapter | NullBackendAdapter | HistoryDisabledBackendAdapter: + assistant_local_config_dir: str = "engine/config/agents", +) -> AssistantConfigSourceAdapter: """Create backend adapter implementation based on runtime settings.""" mode = str(backend_mode or "auto").strip().lower() has_url = bool(str(backend_url or "").strip()) base_adapter: HttpBackendAdapter | NullBackendAdapter + using_http_backend = False if mode in {"disabled", "off", "none", "null", "engine_only", "engine-only"}: base_adapter = NullBackendAdapter() elif mode == "http": if has_url: base_adapter = HttpBackendAdapter(backend_url=str(backend_url), timeout_sec=timeout_sec) + using_http_backend = True else: logger.warning("BACKEND_MODE=http but BACKEND_URL is empty; falling back to NullBackendAdapter") base_adapter = NullBackendAdapter() else: if has_url: base_adapter = HttpBackendAdapter(backend_url=str(backend_url), timeout_sec=timeout_sec) + using_http_backend = True else: base_adapter = NullBackendAdapter() + runtime_adapter: HttpBackendAdapter | NullBackendAdapter | HistoryDisabledBackendAdapter if not history_enabled: - return HistoryDisabledBackendAdapter(base_adapter) - return base_adapter + runtime_adapter = HistoryDisabledBackendAdapter(base_adapter) + else: + runtime_adapter = base_adapter + + return AssistantConfigSourceAdapter( + delegate=runtime_adapter, + local_delegate=LocalYamlAssistantConfigAdapter(assistant_local_config_dir), + use_backend_assistant_config=using_http_backend, + ) -def build_backend_adapter_from_settings() -> HttpBackendAdapter | NullBackendAdapter | HistoryDisabledBackendAdapter: +def build_backend_adapter_from_settings() -> AssistantConfigSourceAdapter: """Create backend adapter using current app settings.""" return build_backend_adapter( backend_url=settings.backend_url, backend_mode=settings.backend_mode, history_enabled=settings.history_enabled, timeout_sec=settings.backend_timeout_sec, + assistant_local_config_dir=settings.assistant_local_config_dir, ) diff --git a/engine/app/config.py b/engine/app/config.py index e81b852..d1ac72f 100644 --- a/engine/app/config.py +++ b/engine/app/config.py @@ -1,371 +1,31 @@ -"""Configuration management using Pydantic settings and agent YAML profiles.""" +"""Configuration management using Pydantic settings.""" import json import os -import re -import sys -from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, List, Optional from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict try: - import yaml -except ImportError: # pragma: no cover - validated when agent YAML is used - yaml = None + from dotenv import load_dotenv +except ImportError: # pragma: no cover - optional dependency in some runtimes + load_dotenv = None + +def _prime_process_env_from_dotenv() -> None: + """Load .env into process env early.""" + if load_dotenv is None: + return + + cwd_env = Path.cwd() / ".env" + engine_env = Path(__file__).resolve().parent.parent / ".env" + load_dotenv(dotenv_path=cwd_env, override=False) + if engine_env != cwd_env: + load_dotenv(dotenv_path=engine_env, override=False) -_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}") -_DEFAULT_AGENT_CONFIG_DIR = "config/agents" -_DEFAULT_AGENT_CONFIG_FILE = "default.yaml" -_AGENT_SECTION_KEY_MAP: Dict[str, Dict[str, str]] = { - "vad": { - "type": "vad_type", - "model_path": "vad_model_path", - "threshold": "vad_threshold", - "min_speech_duration_ms": "vad_min_speech_duration_ms", - "eou_threshold_ms": "vad_eou_threshold_ms", - }, - "llm": { - "provider": "llm_provider", - "model": "llm_model", - "temperature": "llm_temperature", - "api_key": "llm_api_key", - "api_url": "llm_api_url", - }, - "tts": { - "provider": "tts_provider", - "api_key": "tts_api_key", - "api_url": "tts_api_url", - "model": "tts_model", - "voice": "tts_voice", - "dashscope_mode": "tts_mode", - "mode": "tts_mode", - "speed": "tts_speed", - }, - "asr": { - "provider": "asr_provider", - "api_key": "asr_api_key", - "api_url": "asr_api_url", - "model": "asr_model", - "interim_interval_ms": "asr_interim_interval_ms", - "min_audio_ms": "asr_min_audio_ms", - "start_min_speech_ms": "asr_start_min_speech_ms", - "pre_speech_ms": "asr_pre_speech_ms", - "final_tail_ms": "asr_final_tail_ms", - }, - "duplex": { - "enabled": "duplex_enabled", - "greeting": "duplex_greeting", - "system_prompt": "duplex_system_prompt", - "opener_audio_file": "duplex_opener_audio_file", - }, - "barge_in": { - "min_duration_ms": "barge_in_min_duration_ms", - "silence_tolerance_ms": "barge_in_silence_tolerance_ms", - }, -} -_AGENT_SETTING_KEYS = { - "vad_type", - "vad_model_path", - "vad_threshold", - "vad_min_speech_duration_ms", - "vad_eou_threshold_ms", - "llm_provider", - "llm_api_key", - "llm_api_url", - "llm_model", - "llm_temperature", - "tts_provider", - "tts_api_key", - "tts_api_url", - "tts_model", - "tts_voice", - "tts_mode", - "tts_speed", - "asr_provider", - "asr_api_key", - "asr_api_url", - "asr_model", - "asr_interim_interval_ms", - "asr_min_audio_ms", - "asr_start_min_speech_ms", - "asr_pre_speech_ms", - "asr_final_tail_ms", - "duplex_enabled", - "duplex_greeting", - "duplex_system_prompt", - "duplex_opener_audio_file", - "barge_in_min_duration_ms", - "barge_in_silence_tolerance_ms", - "tools", -} -_BASE_REQUIRED_AGENT_SETTING_KEYS = { - "vad_type", - "vad_model_path", - "vad_threshold", - "vad_min_speech_duration_ms", - "vad_eou_threshold_ms", - "llm_provider", - "llm_model", - "llm_temperature", - "tts_provider", - "tts_voice", - "tts_speed", - "asr_provider", - "asr_interim_interval_ms", - "asr_min_audio_ms", - "asr_start_min_speech_ms", - "asr_pre_speech_ms", - "asr_final_tail_ms", - "duplex_enabled", - "duplex_system_prompt", - "barge_in_min_duration_ms", - "barge_in_silence_tolerance_ms", -} -_OPENAI_COMPATIBLE_LLM_PROVIDERS = {"openai_compatible", "openai-compatible", "siliconflow"} -_OPENAI_COMPATIBLE_TTS_PROVIDERS = {"openai_compatible", "openai-compatible", "siliconflow"} -_DASHSCOPE_TTS_PROVIDERS = {"dashscope"} -_OPENAI_COMPATIBLE_ASR_PROVIDERS = {"openai_compatible", "openai-compatible", "siliconflow"} - - -def _normalized_provider(overrides: Dict[str, Any], key: str, default: str) -> str: - return str(overrides.get(key) or default).strip().lower() - - -def _is_blank(value: Any) -> bool: - return value is None or (isinstance(value, str) and not value.strip()) - - -@dataclass(frozen=True) -class AgentConfigSelection: - """Resolved agent config location and how it was selected.""" - - path: Optional[Path] - source: str - - -def _parse_cli_agent_args(argv: List[str]) -> Tuple[Optional[str], Optional[str]]: - """Parse only agent-related CLI flags from argv.""" - config_path: Optional[str] = None - profile: Optional[str] = None - i = 0 - while i < len(argv): - arg = argv[i] - if arg.startswith("--agent-config="): - config_path = arg.split("=", 1)[1].strip() or None - elif arg == "--agent-config" and i + 1 < len(argv): - config_path = argv[i + 1].strip() or None - i += 1 - elif arg.startswith("--agent-profile="): - profile = arg.split("=", 1)[1].strip() or None - elif arg == "--agent-profile" and i + 1 < len(argv): - profile = argv[i + 1].strip() or None - i += 1 - i += 1 - return config_path, profile - - -def _agent_config_dir() -> Path: - base_dir = Path(os.getenv("AGENT_CONFIG_DIR", _DEFAULT_AGENT_CONFIG_DIR)) - if not base_dir.is_absolute(): - base_dir = Path.cwd() / base_dir - return base_dir.resolve() - - -def _resolve_agent_selection( - agent_config_path: Optional[str] = None, - agent_profile: Optional[str] = None, - argv: Optional[List[str]] = None, -) -> AgentConfigSelection: - cli_path, cli_profile = _parse_cli_agent_args(list(argv if argv is not None else sys.argv[1:])) - path_value = agent_config_path or cli_path or os.getenv("AGENT_CONFIG_PATH") - profile_value = agent_profile or cli_profile or os.getenv("AGENT_PROFILE") - source = "none" - candidate: Optional[Path] = None - - if path_value: - source = "cli_path" if (agent_config_path or cli_path) else "env_path" - candidate = Path(path_value) - elif profile_value: - source = "cli_profile" if (agent_profile or cli_profile) else "env_profile" - candidate = _agent_config_dir() / f"{profile_value}.yaml" - else: - fallback = _agent_config_dir() / _DEFAULT_AGENT_CONFIG_FILE - if fallback.exists(): - source = "default" - candidate = fallback - - if candidate is None: - raise ValueError( - "Agent YAML config is required. Provide --agent-config/--agent-profile " - "or create config/agents/default.yaml." - ) - - if not candidate.is_absolute(): - candidate = (Path.cwd() / candidate).resolve() - else: - candidate = candidate.resolve() - - if not candidate.exists(): - raise ValueError(f"Agent config file not found ({source}): {candidate}") - if not candidate.is_file(): - raise ValueError(f"Agent config path is not a file: {candidate}") - return AgentConfigSelection(path=candidate, source=source) - - -def _resolve_env_refs(value: Any) -> Any: - """Resolve ${ENV_VAR} / ${ENV_VAR:default} placeholders recursively.""" - if isinstance(value, dict): - return {k: _resolve_env_refs(v) for k, v in value.items()} - if isinstance(value, list): - return [_resolve_env_refs(item) for item in value] - if not isinstance(value, str) or "${" not in value: - return value - - def _replace(match: re.Match[str]) -> str: - env_key = match.group(1) - default_value = match.group(2) - env_value = os.getenv(env_key) - if env_value is None: - if default_value is None: - raise ValueError(f"Missing environment variable referenced in agent YAML: {env_key}") - return default_value - return env_value - - return _ENV_REF_PATTERN.sub(_replace, value) - - -def _normalize_agent_overrides(raw: Dict[str, Any]) -> Dict[str, Any]: - """Normalize YAML into flat Settings fields.""" - normalized: Dict[str, Any] = {} - - for key, value in raw.items(): - if key == "siliconflow": - raise ValueError( - "Section 'siliconflow' is no longer supported. " - "Move provider-specific fields into agent.llm / agent.asr / agent.tts." - ) - if key == "tools": - if not isinstance(value, list): - raise ValueError("Agent config key 'tools' must be a list") - normalized["tools"] = value - continue - section_map = _AGENT_SECTION_KEY_MAP.get(key) - if section_map is None: - normalized[key] = value - continue - - if not isinstance(value, dict): - raise ValueError(f"Agent config section '{key}' must be a mapping") - - for nested_key, nested_value in value.items(): - mapped_key = section_map.get(nested_key) - if mapped_key is None: - raise ValueError(f"Unknown key in '{key}' section: '{nested_key}'") - normalized[mapped_key] = nested_value - - unknown_keys = sorted(set(normalized) - _AGENT_SETTING_KEYS) - if unknown_keys: - raise ValueError( - "Unknown agent config keys in YAML: " - + ", ".join(unknown_keys) - ) - return normalized - - -def _missing_required_keys(overrides: Dict[str, Any]) -> List[str]: - missing = set(_BASE_REQUIRED_AGENT_SETTING_KEYS - set(overrides)) - string_required = { - "vad_type", - "vad_model_path", - "llm_provider", - "llm_model", - "tts_provider", - "tts_voice", - "asr_provider", - "duplex_system_prompt", - } - for key in string_required: - if key in overrides and _is_blank(overrides.get(key)): - missing.add(key) - - llm_provider = _normalized_provider(overrides, "llm_provider", "openai") - if llm_provider in _OPENAI_COMPATIBLE_LLM_PROVIDERS or llm_provider == "openai": - if "llm_api_key" not in overrides or _is_blank(overrides.get("llm_api_key")): - missing.add("llm_api_key") - - tts_provider = _normalized_provider(overrides, "tts_provider", "openai_compatible") - if tts_provider in _OPENAI_COMPATIBLE_TTS_PROVIDERS: - if "tts_api_key" not in overrides or _is_blank(overrides.get("tts_api_key")): - missing.add("tts_api_key") - if "tts_api_url" not in overrides or _is_blank(overrides.get("tts_api_url")): - missing.add("tts_api_url") - if "tts_model" not in overrides or _is_blank(overrides.get("tts_model")): - missing.add("tts_model") - elif tts_provider in _DASHSCOPE_TTS_PROVIDERS: - if "tts_api_key" not in overrides or _is_blank(overrides.get("tts_api_key")): - missing.add("tts_api_key") - - asr_provider = _normalized_provider(overrides, "asr_provider", "openai_compatible") - if asr_provider in _OPENAI_COMPATIBLE_ASR_PROVIDERS: - if "asr_api_key" not in overrides or _is_blank(overrides.get("asr_api_key")): - missing.add("asr_api_key") - if "asr_api_url" not in overrides or _is_blank(overrides.get("asr_api_url")): - missing.add("asr_api_url") - if "asr_model" not in overrides or _is_blank(overrides.get("asr_model")): - missing.add("asr_model") - - return sorted(missing) - - -def _load_agent_overrides(selection: AgentConfigSelection) -> Dict[str, Any]: - if yaml is None: - raise RuntimeError( - "PyYAML is required for agent YAML configuration. Install with: pip install pyyaml" - ) - - with selection.path.open("r", encoding="utf-8") as file: - raw = yaml.safe_load(file) or {} - - if not isinstance(raw, dict): - raise ValueError(f"Agent config must be a YAML mapping: {selection.path}") - - if "agent" in raw: - agent_value = raw["agent"] - if not isinstance(agent_value, dict): - raise ValueError("The 'agent' key in YAML must be a mapping") - raw = agent_value - - resolved = _resolve_env_refs(raw) - overrides = _normalize_agent_overrides(resolved) - missing_required = _missing_required_keys(overrides) - if missing_required: - raise ValueError( - f"Missing required agent settings in YAML ({selection.path}): " - + ", ".join(missing_required) - ) - - overrides["agent_config_path"] = str(selection.path) - overrides["agent_config_source"] = selection.source - return overrides - - -def load_settings( - agent_config_path: Optional[str] = None, - agent_profile: Optional[str] = None, - argv: Optional[List[str]] = None, -) -> "Settings": - """Load settings from .env and optional agent YAML.""" - selection = _resolve_agent_selection( - agent_config_path=agent_config_path, - agent_profile=agent_profile, - argv=argv, - ) - agent_overrides = _load_agent_overrides(selection) - return Settings(**agent_overrides) +_prime_process_env_from_dotenv() class Settings(BaseSettings): @@ -404,7 +64,6 @@ class Settings(BaseSettings): default="openai", description="LLM provider (openai, openai_compatible, siliconflow)" ) - llm_api_key: Optional[str] = Field(default=None, description="LLM provider API key") llm_api_url: Optional[str] = Field(default=None, description="LLM provider API base URL") llm_model: str = Field(default="gpt-4o-mini", description="LLM model name") llm_temperature: float = Field(default=0.7, description="LLM temperature for response generation") @@ -414,7 +73,6 @@ class Settings(BaseSettings): default="openai_compatible", description="TTS provider (edge, openai_compatible, siliconflow, dashscope)" ) - tts_api_key: Optional[str] = Field(default=None, description="TTS provider API key") tts_api_url: Optional[str] = Field(default=None, description="TTS provider API URL") tts_model: Optional[str] = Field(default=None, description="TTS model name") tts_voice: str = Field(default="anna", description="TTS voice name") @@ -429,7 +87,6 @@ class Settings(BaseSettings): default="openai_compatible", description="ASR provider (openai_compatible, buffered, siliconflow)" ) - asr_api_key: Optional[str] = Field(default=None, description="ASR provider API key") asr_api_url: Optional[str] = Field(default=None, description="ASR provider API URL") asr_model: Optional[str] = Field(default=None, description="ASR model name") asr_interim_interval_ms: int = Field(default=500, description="Interval for interim ASR results in ms") @@ -505,6 +162,10 @@ class Settings(BaseSettings): ) backend_url: Optional[str] = Field(default=None, description="Backend API base URL (e.g. http://localhost:8787)") backend_timeout_sec: int = Field(default=10, description="Backend API request timeout in seconds") + assistant_local_config_dir: str = Field( + default="engine/config/agents", + description="Directory containing local assistant runtime YAML files" + ) history_enabled: bool = Field(default=True, description="Enable history write bridge") history_default_user_id: int = Field(default=1, description="Fallback user_id for history records") history_queue_max_size: int = Field(default=256, description="Max buffered transcript writes per session") @@ -515,10 +176,6 @@ class Settings(BaseSettings): description="Max wait before finalizing history when queue is still draining" ) - # Agent YAML metadata - agent_config_path: Optional[str] = Field(default=None, description="Resolved agent YAML path") - agent_config_source: str = Field(default="none", description="How the agent YAML was selected") - @property def chunk_size_bytes(self) -> int: """Calculate chunk size in bytes based on sample rate and duration.""" @@ -543,7 +200,7 @@ class Settings(BaseSettings): # Global settings instance -settings = load_settings() +settings = Settings() def get_settings() -> Settings: diff --git a/engine/app/main.py b/engine/app/main.py index b8a39bb..b4c5c05 100644 --- a/engine/app/main.py +++ b/engine/app/main.py @@ -371,12 +371,10 @@ async def startup_event(): logger.info(f"Server: {settings.host}:{settings.port}") logger.info(f"Sample rate: {settings.sample_rate} Hz") logger.info(f"VAD model: {settings.vad_model_path}") - if settings.agent_config_path: - logger.info( - f"Agent config loaded ({settings.agent_config_source}): {settings.agent_config_path}" - ) - else: - logger.info("Agent config: none (using .env/default agent values)") + logger.info( + "Assistant runtime config source: backend when BACKEND_URL is set, " + "otherwise local YAML by assistant_id from ASSISTANT_LOCAL_CONFIG_DIR" + ) @app.on_event("shutdown") diff --git a/engine/core/duplex_pipeline.py b/engine/core/duplex_pipeline.py index 13f1852..d6c81ee 100644 --- a/engine/core/duplex_pipeline.py +++ b/engine/core/duplex_pipeline.py @@ -893,7 +893,7 @@ class DuplexPipeline: # Connect LLM service if not self.llm_service: llm_provider = (self._runtime_llm.get("provider") or settings.llm_provider).lower() - llm_api_key = self._runtime_llm.get("apiKey") or settings.llm_api_key + llm_api_key = self._runtime_llm.get("apiKey") llm_base_url = ( self._runtime_llm.get("baseUrl") or settings.llm_api_url @@ -926,7 +926,7 @@ class DuplexPipeline: if tts_output_enabled: if not self.tts_service: tts_provider = (self._runtime_tts.get("provider") or settings.tts_provider).lower() - tts_api_key = self._runtime_tts.get("apiKey") or settings.tts_api_key + tts_api_key = self._runtime_tts.get("apiKey") tts_api_url = self._runtime_tts.get("baseUrl") or settings.tts_api_url tts_voice = self._runtime_tts.get("voice") or settings.tts_voice tts_model = self._runtime_tts.get("model") or settings.tts_model @@ -982,7 +982,7 @@ class DuplexPipeline: # Connect ASR service if not self.asr_service: asr_provider = (self._runtime_asr.get("provider") or settings.asr_provider).lower() - asr_api_key = self._runtime_asr.get("apiKey") or settings.asr_api_key + asr_api_key = self._runtime_asr.get("apiKey") asr_api_url = self._runtime_asr.get("baseUrl") or settings.asr_api_url asr_model = self._runtime_asr.get("model") or settings.asr_model asr_interim_interval = int(self._runtime_asr.get("interimIntervalMs") or settings.asr_interim_interval_ms) diff --git a/engine/docs/backend_integration.md b/engine/docs/backend_integration.md index 1f5d14d..e8165fd 100644 --- a/engine/docs/backend_integration.md +++ b/engine/docs/backend_integration.md @@ -10,6 +10,7 @@ Configure with environment variables: - `BACKEND_MODE=auto|http|disabled` - `BACKEND_URL` - `BACKEND_TIMEOUT_SEC` +- `ASSISTANT_LOCAL_CONFIG_DIR` (default: `engine/config/agents`) - `HISTORY_ENABLED=true|false` Mode behavior: @@ -18,6 +19,12 @@ Mode behavior: - `http`: force HTTP backend adapter (falls back to null adapter when URL is missing). - `disabled`: force null adapter and run engine-only. +Assistant config source behavior: + +- If `BACKEND_URL` exists and backend mode is enabled, fetch assistant config from backend. +- If `BACKEND_URL` is missing (or backend mode is disabled), load assistant config from local YAML. +- `assistant_id` query parameter is still required and maps to `engine/config/agents/.yaml` when local YAML source is active. + ## Architecture - Ports: `core/ports/backend.py` diff --git a/engine/examples/wav_client.py b/engine/examples/wav_client.py index 1e4a50d..7e4aef1 100644 --- a/engine/examples/wav_client.py +++ b/engine/examples/wav_client.py @@ -58,7 +58,7 @@ class WavFileClient: url: str, input_file: str, output_file: str, - assistant_id: str = "assistant_demo", + assistant_id: str = "default", channel: str = "wav_client", sample_rate: int = 16000, chunk_duration_ms: int = 20, @@ -520,7 +520,7 @@ async def main(): ) parser.add_argument( "--assistant-id", - default="assistant_demo", + default="default", help="Assistant identifier used in websocket query parameter" ) parser.add_argument( diff --git a/engine/scripts/generate_test_audio/.env.example b/engine/scripts/generate_test_audio/.env.example new file mode 100644 index 0000000..72b7707 --- /dev/null +++ b/engine/scripts/generate_test_audio/.env.example @@ -0,0 +1 @@ +SILICONFLOW_API_KEY=sk-4163471a164f40769590b72863711781 \ No newline at end of file diff --git a/engine/services/dashscope_tts.py b/engine/services/dashscope_tts.py index 6d89221..1ddcbff 100644 --- a/engine/services/dashscope_tts.py +++ b/engine/services/dashscope_tts.py @@ -89,7 +89,7 @@ class DashScopeTTSService(BaseTTSService): speed: float = 1.0, ): super().__init__(voice=voice, sample_rate=sample_rate, speed=speed) - self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY") or os.getenv("TTS_API_KEY") + self.api_key = api_key self.api_url = ( api_url or os.getenv("DASHSCOPE_TTS_API_URL") diff --git a/engine/services/llm.py b/engine/services/llm.py index eb7f89c..c4d539e 100644 --- a/engine/services/llm.py +++ b/engine/services/llm.py @@ -44,13 +44,13 @@ class OpenAILLMService(BaseLLMService): Args: model: Model name (e.g., "gpt-4o-mini", "gpt-4o") - api_key: Provider API key (defaults to LLM_API_KEY/OPENAI_API_KEY env vars) + api_key: Provider API key base_url: Custom API base URL (for Azure or compatible APIs) system_prompt: Default system prompt for conversations """ super().__init__(model=model) - self.api_key = api_key or os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY") + self.api_key = api_key self.base_url = base_url or os.getenv("LLM_API_URL") or os.getenv("OPENAI_API_URL") self.system_prompt = system_prompt or ( "You are a helpful, friendly voice assistant. " diff --git a/engine/services/openai_compatible_asr.py b/engine/services/openai_compatible_asr.py index 7972189..182d7a0 100644 --- a/engine/services/openai_compatible_asr.py +++ b/engine/services/openai_compatible_asr.py @@ -75,7 +75,7 @@ class OpenAICompatibleASRService(BaseASRService): if not AIOHTTP_AVAILABLE: raise RuntimeError("aiohttp is required for OpenAICompatibleASRService") - self.api_key = api_key or os.getenv("ASR_API_KEY") or os.getenv("SILICONFLOW_API_KEY") + self.api_key = api_key raw_api_url = api_url or os.getenv("ASR_API_URL") or self.API_URL self.api_url = self._resolve_transcriptions_endpoint(raw_api_url) self.model = self.MODELS.get(model.lower(), model) diff --git a/engine/services/openai_compatible_tts.py b/engine/services/openai_compatible_tts.py index b2dc30d..41e3e45 100644 --- a/engine/services/openai_compatible_tts.py +++ b/engine/services/openai_compatible_tts.py @@ -49,7 +49,7 @@ class OpenAICompatibleTTSService(BaseTTSService): Initialize OpenAI-compatible TTS service. Args: - api_key: Provider API key (defaults to TTS_API_KEY/SILICONFLOW_API_KEY env vars) + api_key: Provider API key api_url: Provider API URL (defaults to SiliconFlow endpoint) voice: Voice name (alex, anna, bella, benjamin, charles, claire, david, diana) model: Model name @@ -73,7 +73,7 @@ class OpenAICompatibleTTSService(BaseTTSService): super().__init__(voice=full_voice, sample_rate=sample_rate, speed=speed) - self.api_key = api_key or os.getenv("TTS_API_KEY") or os.getenv("SILICONFLOW_API_KEY") + self.api_key = api_key self.model = model raw_api_url = api_url or os.getenv("TTS_API_URL") or "https://api.siliconflow.cn/v1/audio/speech" self.api_url = self._resolve_speech_endpoint(raw_api_url) diff --git a/engine/services/realtime.py b/engine/services/realtime.py index 3fd95c1..142f018 100644 --- a/engine/services/realtime.py +++ b/engine/services/realtime.py @@ -13,7 +13,6 @@ The Realtime API provides: - Barge-in/interruption handling """ -import os import asyncio import json import base64 @@ -98,7 +97,6 @@ class RealtimeService: config: Realtime configuration (uses defaults if not provided) """ self.config = config or RealtimeConfig() - self.config.api_key = self.config.api_key or os.getenv("OPENAI_API_KEY") self.state = RealtimeState.DISCONNECTED self._ws = None diff --git a/engine/tests/test_agent_config.py b/engine/tests/test_agent_config.py index 6432581..90bb277 100644 --- a/engine/tests/test_agent_config.py +++ b/engine/tests/test_agent_config.py @@ -1,293 +1,21 @@ -import os -from pathlib import Path +import importlib -import pytest -os.environ.setdefault("LLM_API_KEY", "test-openai-key") -os.environ.setdefault("TTS_API_KEY", "test-tts-key") -os.environ.setdefault("ASR_API_KEY", "test-asr-key") +def test_settings_load_from_environment(monkeypatch): + monkeypatch.setenv("HOST", "127.0.0.1") + monkeypatch.setenv("PORT", "8123") -from app.config import load_settings + import app.config as config_module + importlib.reload(config_module) + settings = config_module.get_settings() + assert settings.host == "127.0.0.1" + assert settings.port == 8123 -def _write_yaml(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") +def test_assistant_local_config_dir_default_present(): + import app.config as config_module -def _full_agent_yaml(llm_model: str = "gpt-4o-mini", llm_key: str = "test-openai-key") -> str: - return f""" -agent: - vad: - type: silero - model_path: data/vad/silero_vad.onnx - threshold: 0.63 - min_speech_duration_ms: 100 - eou_threshold_ms: 800 - - llm: - provider: openai_compatible - model: {llm_model} - temperature: 0.2 - api_key: {llm_key} - api_url: https://example-llm.invalid/v1 - - tts: - provider: openai_compatible - api_key: test-tts-key - api_url: https://example-tts.invalid/v1/audio/speech - model: FunAudioLLM/CosyVoice2-0.5B - voice: anna - speed: 1.0 - - asr: - provider: openai_compatible - api_key: test-asr-key - api_url: https://example-asr.invalid/v1/audio/transcriptions - model: FunAudioLLM/SenseVoiceSmall - interim_interval_ms: 500 - min_audio_ms: 300 - start_min_speech_ms: 160 - pre_speech_ms: 240 - final_tail_ms: 120 - - duplex: - enabled: true - system_prompt: You are a strict test assistant. - - barge_in: - min_duration_ms: 200 - silence_tolerance_ms: 60 -""".strip() - - -def _dashscope_tts_yaml() -> str: - return _full_agent_yaml().replace( - """ tts: - provider: openai_compatible - api_key: test-tts-key - api_url: https://example-tts.invalid/v1/audio/speech - model: FunAudioLLM/CosyVoice2-0.5B - voice: anna - speed: 1.0 -""", - """ tts: - provider: dashscope - api_key: test-dashscope-key - voice: Cherry - speed: 1.0 -""", - ) - - -def test_cli_profile_loads_agent_yaml(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - config_dir = tmp_path / "config" / "agents" - _write_yaml( - config_dir / "support.yaml", - _full_agent_yaml(llm_model="gpt-4.1-mini"), - ) - - settings = load_settings( - argv=["--agent-profile", "support"], - ) - - assert settings.llm_model == "gpt-4.1-mini" - assert settings.llm_temperature == 0.2 - assert settings.vad_threshold == 0.63 - assert settings.agent_config_source == "cli_profile" - assert settings.agent_config_path == str((config_dir / "support.yaml").resolve()) - - -def test_cli_path_has_higher_priority_than_env(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - env_file = tmp_path / "config" / "agents" / "env.yaml" - cli_file = tmp_path / "config" / "agents" / "cli.yaml" - - _write_yaml(env_file, _full_agent_yaml(llm_model="env-model")) - _write_yaml(cli_file, _full_agent_yaml(llm_model="cli-model")) - - monkeypatch.setenv("AGENT_CONFIG_PATH", str(env_file)) - - settings = load_settings(argv=["--agent-config", str(cli_file)]) - - assert settings.llm_model == "cli-model" - assert settings.agent_config_source == "cli_path" - assert settings.agent_config_path == str(cli_file.resolve()) - - -def test_default_yaml_is_loaded_without_args_or_env(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - default_file = tmp_path / "config" / "agents" / "default.yaml" - _write_yaml(default_file, _full_agent_yaml(llm_model="from-default")) - - monkeypatch.delenv("AGENT_CONFIG_PATH", raising=False) - monkeypatch.delenv("AGENT_PROFILE", raising=False) - - settings = load_settings(argv=[]) - - assert settings.llm_model == "from-default" - assert settings.agent_config_source == "default" - assert settings.agent_config_path == str(default_file.resolve()) - - -def test_missing_required_agent_settings_fail(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - file_path = tmp_path / "missing-required.yaml" - _write_yaml( - file_path, - """ -agent: - llm: - model: gpt-4o-mini -""".strip(), - ) - - with pytest.raises(ValueError, match="Missing required agent settings in YAML"): - load_settings(argv=["--agent-config", str(file_path)]) - - -def test_blank_required_provider_key_fails(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - file_path = tmp_path / "blank-key.yaml" - _write_yaml(file_path, _full_agent_yaml(llm_key="")) - - with pytest.raises(ValueError, match="Missing required agent settings in YAML"): - load_settings(argv=["--agent-config", str(file_path)]) - - -def test_missing_tts_api_url_fails(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - file_path = tmp_path / "missing-tts-url.yaml" - _write_yaml( - file_path, - _full_agent_yaml().replace( - " api_url: https://example-tts.invalid/v1/audio/speech\n", - "", - ), - ) - - with pytest.raises(ValueError, match="Missing required agent settings in YAML"): - load_settings(argv=["--agent-config", str(file_path)]) - - -def test_dashscope_tts_allows_default_url_and_model(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - file_path = tmp_path / "dashscope-tts.yaml" - _write_yaml(file_path, _dashscope_tts_yaml()) - - settings = load_settings(argv=["--agent-config", str(file_path)]) - - assert settings.tts_provider == "dashscope" - assert settings.tts_api_key == "test-dashscope-key" - assert settings.tts_api_url is None - assert settings.tts_model is None - - -def test_dashscope_tts_requires_api_key(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - file_path = tmp_path / "dashscope-tts-missing-key.yaml" - _write_yaml(file_path, _dashscope_tts_yaml().replace(" api_key: test-dashscope-key\n", "")) - - with pytest.raises(ValueError, match="Missing required agent settings in YAML"): - load_settings(argv=["--agent-config", str(file_path)]) - - -def test_missing_asr_api_url_fails(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - file_path = tmp_path / "missing-asr-url.yaml" - _write_yaml( - file_path, - _full_agent_yaml().replace( - " api_url: https://example-asr.invalid/v1/audio/transcriptions\n", - "", - ), - ) - - with pytest.raises(ValueError, match="Missing required agent settings in YAML"): - load_settings(argv=["--agent-config", str(file_path)]) - - -def test_agent_yaml_unknown_key_fails(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - file_path = tmp_path / "bad-agent.yaml" - _write_yaml(file_path, _full_agent_yaml() + "\n unknown_option: true") - - with pytest.raises(ValueError, match="Unknown agent config keys"): - load_settings(argv=["--agent-config", str(file_path)]) - - -def test_legacy_siliconflow_section_fails(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - file_path = tmp_path / "legacy-siliconflow.yaml" - _write_yaml( - file_path, - """ -agent: - siliconflow: - api_key: x -""".strip(), - ) - - with pytest.raises(ValueError, match="Section 'siliconflow' is no longer supported"): - load_settings(argv=["--agent-config", str(file_path)]) - - -def test_agent_yaml_missing_env_reference_fails(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - file_path = tmp_path / "bad-ref.yaml" - _write_yaml( - file_path, - _full_agent_yaml(llm_key="${UNSET_LLM_API_KEY}"), - ) - - with pytest.raises(ValueError, match="Missing environment variable"): - load_settings(argv=["--agent-config", str(file_path)]) - - -def test_agent_yaml_tools_list_is_loaded(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - file_path = tmp_path / "tools-agent.yaml" - _write_yaml( - file_path, - _full_agent_yaml() - + """ - - tools: - - current_time - - name: weather - description: Get weather by city. - parameters: - type: object - properties: - city: - type: string - required: [city] - executor: server -""", - ) - - settings = load_settings(argv=["--agent-config", str(file_path)]) - - assert isinstance(settings.tools, list) - assert settings.tools[0] == "current_time" - assert settings.tools[1]["name"] == "weather" - assert settings.tools[1]["executor"] == "server" - - -def test_agent_yaml_tools_must_be_list(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - file_path = tmp_path / "bad-tools-agent.yaml" - _write_yaml( - file_path, - _full_agent_yaml() - + """ - - tools: - weather: - executor: server -""", - ) - - with pytest.raises(ValueError, match="Agent config key 'tools' must be a list"): - load_settings(argv=["--agent-config", str(file_path)]) + settings = config_module.get_settings() + assert isinstance(settings.assistant_local_config_dir, str) + assert settings.assistant_local_config_dir diff --git a/engine/tests/test_backend_adapters.py b/engine/tests/test_backend_adapters.py index d55f5e2..347df45 100644 --- a/engine/tests/test_backend_adapters.py +++ b/engine/tests/test_backend_adapters.py @@ -2,24 +2,42 @@ import aiohttp import pytest from app.backend_adapters import ( - HistoryDisabledBackendAdapter, - HttpBackendAdapter, - NullBackendAdapter, + AssistantConfigSourceAdapter, + LocalYamlAssistantConfigAdapter, build_backend_adapter, ) @pytest.mark.asyncio -async def test_build_backend_adapter_without_url_returns_null_adapter(): +async def test_without_backend_url_uses_local_yaml_for_assistant_config(tmp_path): + config_dir = tmp_path / "assistants" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "dev_local.yaml").write_text( + "\n".join( + [ + "assistant:", + " assistantId: dev_local", + " systemPrompt: local prompt", + " greeting: local greeting", + ] + ), + encoding="utf-8", + ) + adapter = build_backend_adapter( backend_url=None, backend_mode="auto", history_enabled=True, timeout_sec=3, + assistant_local_config_dir=str(config_dir), ) - assert isinstance(adapter, NullBackendAdapter) + assert isinstance(adapter, AssistantConfigSourceAdapter) - assert await adapter.fetch_assistant_config("assistant_1") is None + payload = await adapter.fetch_assistant_config("dev_local") + assert isinstance(payload, dict) + assert payload.get("__error_code") in (None, "") + assert payload["assistant"]["assistantId"] == "dev_local" + assert payload["assistant"]["systemPrompt"] == "local prompt" assert ( await adapter.create_call_record( user_id=1, @@ -54,7 +72,7 @@ async def test_build_backend_adapter_without_url_returns_null_adapter(): @pytest.mark.asyncio -async def test_http_backend_adapter_create_call_record_posts_expected_payload(monkeypatch): +async def test_http_backend_adapter_create_call_record_posts_expected_payload(monkeypatch, tmp_path): captured = {} class _FakeResponse: @@ -90,15 +108,31 @@ async def test_http_backend_adapter_create_call_record_posts_expected_payload(mo captured["json"] = json return _FakeResponse(status=200, payload={"id": "call_123"}) + def get(self, url): + _ = url + return _FakeResponse( + status=200, + payload={ + "assistant": { + "assistantId": "assistant_9", + "systemPrompt": "backend prompt", + } + }, + ) + monkeypatch.setattr("app.backend_adapters.aiohttp.ClientSession", _FakeClientSession) + config_dir = tmp_path / "assistants" + config_dir.mkdir(parents=True, exist_ok=True) + adapter = build_backend_adapter( backend_url="http://localhost:8100", backend_mode="auto", history_enabled=True, timeout_sec=7, + assistant_local_config_dir=str(config_dir), ) - assert isinstance(adapter, HttpBackendAdapter) + assert isinstance(adapter, AssistantConfigSourceAdapter) call_id = await adapter.create_call_record( user_id=99, @@ -119,25 +153,115 @@ async def test_http_backend_adapter_create_call_record_posts_expected_payload(mo @pytest.mark.asyncio -async def test_backend_mode_disabled_forces_null_even_with_url(): +async def test_with_backend_url_uses_backend_for_assistant_config(monkeypatch, tmp_path): + class _FakeResponse: + def __init__(self, status=200, payload=None): + self.status = status + self._payload = payload if payload is not None else {} + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def json(self): + return self._payload + + def raise_for_status(self): + if self.status >= 400: + raise RuntimeError("http_error") + + class _FakeClientSession: + def __init__(self, timeout=None): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + def get(self, url): + _ = url + return _FakeResponse( + status=200, + payload={ + "assistant": { + "assistantId": "dev_http", + "systemPrompt": "backend prompt", + } + }, + ) + + def post(self, url, json=None): + _ = (url, json) + return _FakeResponse(status=200, payload={"id": "call_1"}) + + monkeypatch.setattr("app.backend_adapters.aiohttp.ClientSession", _FakeClientSession) + + config_dir = tmp_path / "assistants" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "dev_http.yaml").write_text( + "\n".join( + [ + "assistant:", + " assistantId: dev_http", + " systemPrompt: local prompt", + ] + ), + encoding="utf-8", + ) + + adapter = build_backend_adapter( + backend_url="http://localhost:8100", + backend_mode="auto", + history_enabled=True, + timeout_sec=3, + assistant_local_config_dir=str(config_dir), + ) + assert isinstance(adapter, AssistantConfigSourceAdapter) + + payload = await adapter.fetch_assistant_config("dev_http") + assert payload["assistant"]["assistantId"] == "dev_http" + assert payload["assistant"]["systemPrompt"] == "backend prompt" + + +@pytest.mark.asyncio +async def test_backend_mode_disabled_uses_local_assistant_config_even_with_url(monkeypatch, tmp_path): + class _FailIfCalledClientSession: + def __init__(self, timeout=None): + _ = timeout + raise AssertionError("HTTP client should not be created when backend_mode=disabled") + + monkeypatch.setattr("app.backend_adapters.aiohttp.ClientSession", _FailIfCalledClientSession) + + config_dir = tmp_path / "assistants" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "dev_disabled.yaml").write_text( + "\n".join( + [ + "assistant:", + " assistantId: dev_disabled", + " systemPrompt: local disabled prompt", + ] + ), + encoding="utf-8", + ) + adapter = build_backend_adapter( backend_url="http://localhost:8100", backend_mode="disabled", history_enabled=True, - timeout_sec=7, + timeout_sec=3, + assistant_local_config_dir=str(config_dir), ) - assert isinstance(adapter, NullBackendAdapter) + assert isinstance(adapter, AssistantConfigSourceAdapter) + payload = await adapter.fetch_assistant_config("dev_disabled") + assert payload["assistant"]["assistantId"] == "dev_disabled" + assert payload["assistant"]["systemPrompt"] == "local disabled prompt" -@pytest.mark.asyncio -async def test_history_disabled_wraps_backend_adapter(): - adapter = build_backend_adapter( - backend_url="http://localhost:8100", - backend_mode="auto", - history_enabled=False, - timeout_sec=7, - ) - assert isinstance(adapter, HistoryDisabledBackendAdapter) assert await adapter.create_call_record(user_id=1, assistant_id="a1", source="debug") is None assert await adapter.add_transcript( call_id="c1", @@ -148,3 +272,53 @@ async def test_history_disabled_wraps_backend_adapter(): end_ms=10, duration_ms=10, ) is False + + +@pytest.mark.asyncio +async def test_local_yaml_adapter_rejects_path_traversal_like_assistant_id(tmp_path): + adapter = LocalYamlAssistantConfigAdapter(str(tmp_path)) + payload = await adapter.fetch_assistant_config("../etc/passwd") + assert payload == {"__error_code": "assistant.not_found", "assistantId": "../etc/passwd"} + + +@pytest.mark.asyncio +async def test_local_yaml_translates_agent_schema_to_runtime_services(tmp_path): + config_dir = tmp_path / "assistants" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "default.yaml").write_text( + "\n".join( + [ + "agent:", + " llm:", + " provider: openai", + " model: gpt-4o-mini", + " api_key: sk-llm", + " api_url: https://api.example.com/v1", + " tts:", + " provider: openai_compatible", + " model: tts-model", + " api_key: sk-tts", + " api_url: https://tts.example.com/v1/audio/speech", + " voice: anna", + " asr:", + " provider: openai_compatible", + " model: asr-model", + " api_key: sk-asr", + " api_url: https://asr.example.com/v1/audio/transcriptions", + " duplex:", + " system_prompt: You are test assistant", + ] + ), + encoding="utf-8", + ) + + adapter = LocalYamlAssistantConfigAdapter(str(config_dir)) + payload = await adapter.fetch_assistant_config("default") + + assert isinstance(payload, dict) + assistant = payload.get("assistant", {}) + services = assistant.get("services", {}) + assert services.get("llm", {}).get("apiKey") == "sk-llm" + assert services.get("tts", {}).get("apiKey") == "sk-tts" + assert services.get("asr", {}).get("apiKey") == "sk-asr" + assert assistant.get("systemPrompt") == "You are test assistant" diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..d7d3b64 --- /dev/null +++ b/examples/README.md @@ -0,0 +1 @@ +# Example Application using RAS \ No newline at end of file From 1cecbaa172104d54d709f999f0d9dd166a24e496 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Thu, 5 Mar 2026 21:28:17 +0800 Subject: [PATCH 02/20] Update .gitignore and add audio example file - Removed duplicate entry for Thumbs.db in .gitignore to streamline ignored files. - Added a new audio example file: three_utterances_simple.wav to the audio_examples directory. --- .gitignore | 5 +---- .../audio_examples/three_utterances_simple.wav | Bin 0 -> 753712 bytes 2 files changed, 1 insertion(+), 4 deletions(-) create mode 100644 engine/data/audio_examples/three_utterances_simple.wav diff --git a/.gitignore b/.gitignore index a9bcc58..cee9c76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ # OS artifacts .DS_Store -Thumbs.db - -# Workspace runtime data -data/ +Thumbs.db \ No newline at end of file diff --git a/engine/data/audio_examples/three_utterances_simple.wav b/engine/data/audio_examples/three_utterances_simple.wav new file mode 100644 index 0000000000000000000000000000000000000000..40cd6493767b32359e244071b38218cc38196f46 GIT binary patch literal 753712 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%0DRs z)vKEQdYb42{d?^rbvxFZE{{e5Dhu5*8>plk!7TNaYECV;{wI52NuwL@p38`$-n*K8 zJ+E3<`BNUiok@l#b=`kL)x|9LlCy_1coe#7D`MBJ@0e!$nVHV4z>f3E%n=)FmnKv4 zE}a}GJ>i`mR6j-0bTZRv-!*O#FPg1&QkU=?6vb*UMc(f`xrCNjw-wcLJTJ$&GbM<+ z{Xkr46xy=A)Kz4(QYl@y>NRwE?DJkQK`Wy(0}a(Cu^U!ibf}-1z0DNnF0%rf$hTve z*>R%o3V%LzqczDUGc`y{sh`$-Ev-IUdz%RIIigi<(FD7q4}n3ZFeoTUh8a+{VW~IwG)|Uyv9l!+YtNO zuJ06an|p1^4-J&Qm9vr&n~TMva~?L)vS~H3*VZv9_bC2IM`ANwi1)WsZRG);;AHA= z>S3k+ONB;fe6F0#C?8FiqeMQEP9&m@o6$r+PQLlMv)5@xj%X$`AHTF`phTWz_hLnjnJ%Ui8OlcM?YoGSvfqPKI$XlKqH9=^~D>k`Z@v^#abJ>`QY)HG zenk#u0Tp7!r1SXfjp!dTQCTg`$vz#e)B4n-okUS;1d-t{-FDaut;z8pabLRg`OI-Y zb1;=&eTi?Cq2??bdEG)pYkpCt5&Kw0h248pz@+0-cEHQ^u=?M|{y#yr)c`7>;?vT0 zk_)UuRcCxA`8Raj<;ClsKviOZvh8m41aG=G+&{?xj;FG4ITb*AnOt4Q`+|y*UjANs z{D1 z^deLwE0u+s&~5a{^+9)Cqn2X}?3?=8{F_9l?oxdh*A;$<8f*^MVl#gMYNay$gmdW< zjbcYOqgP=mJ#xvJd|96S`(X47$FQCbprf>ls`T;l5USPE$T`rVI!}kw0-_RKc;^z- z?Bev()o_t6nmNQuJ|RNXkvh=4#9=Q{ftyI3+G8SA6{%LvL4RI+e&QZHIg5U+O4P}g zpuV&+K6F<)s)|yhQIS5^%6JTMwVvVl*j1^MeV^Sg1@9D4mvxQms-48{ex@?x>Fa*w zlk}PTSeXI6cc~yz=d}+%p3ifZYS63HdnZEyu`m@c5%kY)l6CIGNs$39zgc+0H}Ne! zro&F4SMF1MwtFZ7*CmfW2LGa&-;rp23jY5ibjTk23ZCZ}Z1HGN@!RSKl|WUHnS zD@Rq+Y+@o^P_bJKu|-6nW>bwZ5pAzJ{9Q3(k+-O0TuD7raq6_EQYYRP73Kr1zX2#f zmEfG%h@ViG^;(rVes6gibravw4&UFO@Bc;5#7|U&93o5kC%O7Bu?p%d8MWT(SNPPw zvnyW2wr@yuYZt%Usno}Q$e|7+yBwviU^Dl0BE96)al(xFb2-_gN4dqlTizXiYtV;ouB^nK6k;I5n4gvxW%jqVLnsw?VHe1YjnbV= zUuxdPCNDjP3cbd32b7=&uLcTt$;tGz$6vWaB`@>TsGeLy4agJ6vrnNT8lRQh-yCT$p} z(wFQMpRgbNhEHab)!#!jxDDON$?#_ivI@@;K{|_Gcs(jHek2<3AeK5d2MzoL>ko4d z*|i~-Z!NPQITh*2IqRpBTE5QAb=A6(A6A&x_ct>h%k$0$^_BPz`SfDC$4PXX{r)5# z;dvqh>)C-k&ciS1w|$FBi2Znyy8jpbGru#N^fVUiW<0$%vBTEe)>w0h`6*}JaNa$; zRT`aI)t&7Pp{_C;naTocL#)(+#PO>d*+a*S<;EzZiSeFM-N$jk9$+MO zR#V90e2x~_NuI;X)PB^Vs%Z!s+J;Z(63WASs9ip5FW}Q2wdzn(Ff z^nrE7p6q<<_9b5S0sTe_ao_Arq902Jb1E|cIxvm%u(6OS48=mp@E}$a+4z7={RlGP zYpFl@oYh{1I-i16HJ#&GE{RVO*VEfW1pRwz#?CUc{yqCB^Sg&yKbWVZA@d~@>u*Lg znFY{>uV!y{dNYHyQP7N?>>Fj0b{Nm_ZR3$~%qWj8{Y9g7=t-zTsD?4bSY)iz?`ywl zm9=(66owJ~N}~*-hoS=A0{zHB)aQJ2h*^Bg2{WA4)g67@j!q$`q8(+Wy360FGao{~-vatkJE4Q;(^t}i2x|kcBzc)CPHj6Acl)%p z!mMEyvWl23qE)PLY@SupZceTDZMPU*L8GKCvZI(xUWn-_3=J*jJ)w6)?}vU1Rm9f5 zW>hhL(GTbus4M>+cgq^#cZme5Wub(5cmds1%c7%Nv zy!GZ*JM*CVr8VEI7~L8D&gv7J6+0DMZ4YxLB4;P*ZGO!3g;(lP?VR4683a>}-l3hL z@u3DxLP*DT95oE=>#AgkztbiXvF)X<aZ&$wxdT^E!>mfuou9 zG0bg>mD`(n*coHJ%$erDW)Jf~REpM%UNwJ41KYNW+uI!1eds;&OG}N3B==Rn)hdw% zPiLGmI-_pBK2(SGcZgXK=g@1uuaDDL9vX>vBWc-}&!A`_UbD`L|hV|c_ zuKOlrqiodGe`D>Q+_j?P%7db@;o@Z?bpP ze@2f>dgX7fVyd=CA8#Zw`{Rqy(9qb>@1cz0zM-+k4&xA$P=@o|_rSh9%0%-Qtc4Op z*p{%vjY9!)4R@nIxr2A;2)RXv)FgJCBko}LmNU+Nh4yxHtB(1?Y;T@UDioa`ElVxP zam8;*tMhCU0u zLYI6KO2@0T+^may>atm@TSE*--F?+M1pA&uGR_+MrBfDj6 zgf+@+Y0fh9MVBP@Od1<)5gi)65{;N6t+muiRd*h^i_pN$Le}ID_TIYs9sP*0H}r4F z2rmmqBBdf)B$9lVdG1TBQ`CCou*#Y1k}f3Oihj;jl`uayJH_@8BR}EfK-IJ^ z9Yb^JD(<4T)NX1i49ob@xM`T7bYTsT^meFhXkX|lYqd2`&m5{Ku4}!tO6qzdNh9&< zkCI#1Kvz^wxjuD}{i)5nOO5piETgqNtqyg(`|!x>aS~0l-;ZUnI$1rfKh3+*#?j(t z73&vszPZH86Dw>_afpPwk3Ag=11)QL2DKhPYMXRZ&&iyaZcm4{TCnH5$8e5LK#;>4VRQ1$l=I;|b2L8{})Q zVNE`!V|_fm57n6cyO93p{{9OTW*?yd-hk;f_hN@*&0;OB&E_#PWM$(^wQ5@L#-7Bc z+jX4ZoJP!zkH=7xp;(y=59zv^UmK$t>|-794c}s-P+6m?(ZD#M*J2{ZMJg21P^I#M zEZh+MmtT02GGc*$K)=AR?5_$M%QaX{H>9kbHy=}%Fce$2IhAMKuq)s4CwQNEtKGiN zCcC_SIyNQt(AsW(Nxei5YlFGN%u1!lessHA*(dEv?n(TrP5!yymb6Yjs|=#r<%m{G z@1kckHgd{jr!uA$@ePyusL_S}7VHhHKud_-4ADstbM=nt8KJ$0I# z(KDiM)$zTqVej?g35?JS_ZN2V5qeV&c%Ql_iLTXTN=Y3oju)H&^{C<5V}5R(wEnVg zSzpC^pxl1IF{sGgjOOeiI@JfsA1YPVwdw=nS*i6JdRO9$qx8@9X6VN!ks0}%r|mCw zD?Vrvb?k%aDrt=E^OE}4&2j?!^I7sL=j38U)U$F$4X9{Wn^U-&(kjzeFWfvY@WvV-y!D}jdi#wz|^x$`9XRl1J z+EcpM_M@fP2EXJ1tFR%Sz!my+cB4+K(${J;B_}05*9uP9Wd1ZP=!*U>D!R8*Oqq?i2~THScd-Q%9b`P9_|${esPW#GcN4Ri#17t99zh4hJ?aj6VAEEhYjiGss1kLq z+sV09Vvfmfthc@1Y!tAEqAt7;pXD$5pEB_`mFWbzKt**xzi@k2^dIl z>x2BE5FbZ>;sQR`HY(vyu{uNC)i&%<?sMl?XrylpRJt;FDM`!%YY53Gz*e?zc zf%%R4>CHqZRxx*=5oc@z_T~^3y6f=+mvDkqqAntV4%&Of2j+682J&f}(?65}E5N6+ z?G>KG&;Dxi$P4-Rd-|eU`yC)Tl9+8(KEW3}*Asb~M)9}X$T2?0(oauZqZkAib8qIe z7M`JDmO#v*Jkg8p*rK0PiFQyrF8xIG;R>CtdD+k67Ex2;DZ|LY)TAn5Go2|ru^fk> z%5s+Y%35~E&SYUH@jT{~AJN|)*IND(D?x(oMXtRKYamQV`$bN%=`d`;T5aan;AzZ{ zjn{;$Zpm8ig5|k`zQKdgd`5kLeL4baQN{8#d(?7P!fYZ4>xrA72*7_g^M60HqT~9s zXNdu&V-*x*4U9l@`Y(1n54E9A#4m=E75@vh@Z0h=dORM|VR%;F0r^E-@rT&pA+GoU zox30MoNMHlwn4fMJyU7PAz$ZuX7FrHWtRPLYDnUAN5gc^YhCCyM2GHi7Xq{s(gek+ zn`p=VX~VNTlN$LYJa=p8AHGg(?jWAdbJE~xU>$*WT{?+7P!}~6eStse12`<*CwfO4veqIn8imP-F;Z=&VKO_=`+X|!j zu3i%LfDoDX!`Q9+=#-e|e~n7_bUwp$*2V-XBLDJFkcWRjH^F;6`E5~=8qG7Xp9to2 zB2qH3xbo-mZsQ;0bdWe|NBPz+06QRn>GesXi zQ)oWI-f4zKQHN{I!dDvEq}z5K{aPd0&pyU>jL}hjp1ZdRRz29MJ|_1Pk9)q${+5{@ z^g`s`Ghlls<4?+B$-T#E`YzYnp1txze5H@s2WGQl#b>22rf)m0BK;Rp+vn^CsnN$u zhsHv2>Jj5Q*iEs!K4&i-#@A5x)(PmxO~#g;g=YBF*R_|EsQ4Mr|Bc~f9D-;5CH<`N zd`vrHF|E1Y8tf7k;ZcZ_G(MYKXD7eS^`E3~@lUv~B6hfl`#p~xE3W!HncvOf*YCNu z_*(s$f8+braTL9d(+3vUF}_XreOzZTu8!!T?-8E>or+jf4%E5cV~xc1nM=QZ-}of< zV)!Ce*po|g_Pv=RTaZtv@`R=0n$mGaaivz37-L5MG_J#(mggZ0zs93FamC`e@^Nae z{f#^vdX`GwS1duW@b3 zxTa-XmmmV!xXNW*u~9`g;U2$_Ybz_Szu$VT0U1{=jw_MIW4v#}J4{VjTnjeN+u&N_ zimdUkIJfu}VqoyDaRtse&$#YqT>UbxbR1Xj)%Z28V;k3nP0oMgn$Yq4rg2qqWze`n z=zqGMPkEL2or&u~$JK%3s-1CNSC99IzgApNGd|rpu4NflxQ)Lu6@#M|7`rT#=k%QY5$v&{okzY z_|?QUY2(-MztV*Eb7xtni(_l8~k*Z=#^e|J0nXa5oYkNp3*|EFU6-}n6YH~(MX`QP7( z{|W+vfFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Ko zf`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`b zAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_` z0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qD zARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U z2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg# zfFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|( z2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Ko zf`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`b zAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_` z0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qD zARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U z2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg# zfFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|( z2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Ko zf`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`b zAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_` z0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qD zARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U z2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg# zfFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|( z2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Ko zf`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`b zAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_` z0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qD zARq_`0)oK*p9GSEC&BgLRbcZK|NVpDa&R@67BrQ<2zvS%rAE?qshToaxhQv3epd1; zqm=$iW4WpHq10Hq6fBU=25$%TrA@)(ptj@&&7{Y{)1Z=+C3s&dBW(`SNGYW!K_jWW zTvjS5Rg^bN2c?zL5NVBcE|?vB5!4Bm2haT+elLGcP(H{IJoEQ?A9^{0qy9(!L%*3{ z%{P5rnjU-}tPHOCTl}ws3BiPug&Ib>J!(2g$;Ok&(P(<1oGz+o@*@7y; zSHaO>Tu?Zu6?6&K@_ny@xqQtJx(4ThDF1ge*chaf<^{#1VO(im>1B{vDlgTRdP#SK ze*-r-9-Is=1^4-NYS1!hADBUEse2#=m4m$y+6t?^!I>Zk1_V2#$5IAqPw=tyrBqCs zEe)3%NK>V+r0u~v$&e#bS*fPnjMX1rSM;Im+HkPmjn zf|`&o9Lx&_1v!IvgEhR%gp^E6RDbzxI>XQSWF>G~PB^7#|z&8Bg>|`h4x0nnA6uw3HW0 z{R7AA=xub{It%Rd_C2eymBLzJb~V2;Z#U3?QOB8R#S6$^xve8 zN!601B>Po3acp#k`M$Z%TxM;xv-#tKy1|#y3uV8S+PDxJ$1AmsaUD=>NG)5Yg!>JOpf)mwwg|KFkk7S`;&S{wCc6~*EpjljEwUogHJmo|qY>3tXy2$+luOdu zV3=RPOX@u;!u@AiwJS{z?zRE=P z2fbFPNa#=~l+YxhYNTPp*ktVz3PxH+mP96pD}`1W-|EdYRa>u2ly(J){>R=&?qKI- zEN0!Y%2|og_0eO|3emnv<)S;IYEp%0A^W~n$vkdfbsKm`g1u5}NmuXb*Ypxb>+r&G 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

G z8cqka3oG5D=(_s5lE^Ejqj-uDqd+XufP(e57rH?hm73}td9S_2`pYimz80tDuI@1> zN;D^%yl$6t4~g+Gt$mzwZd2!leO1Xut~XP}rtC6oc1=+jHBC{tzincmTi$6by>K3h za?&d?+8t}>f%*QEyMN&paehSClT-QxSDjHR3^(`)E^m>O(!bk|HMZQ`&UvepbXXn_ zPxp>^T#(hg2y&GFKmKg3TNnhSJ{rGsqAQpI)T1)HU9g#{n0K*<=Tq1`WybZC;hT*z)dcplFg0^ zXgGYz6R^}%Fsl=ZO_$JY??xe)68WRi$~2VvILnDq~DQ6j|S_V!Z{iw zouF%OIu1Ews@a}Ku92iIb^pm~XE^bGxqts%* z*!S!$c2QIZSGa0H@&mLiBDp-G{S!85@V^bfucehRJ#c_YIOkJZUpC}ofzEB&>n zdQUp}PDhQ5o);a6o)i5dsv~`vE!_&4 zwajY9(MUD8nD4`rLZgE(0;2+}0(pY9gWE&ZjS`m6DU2O9Qrc;Tp4vOaH!|u<)W)a{ zeEv1xeeV^|0=Z|pYU+AG9M2_|Y-uoDOx4G!k z_R7cQoJtvGx12>DD8|57yt7+ce&c>*O8Bc#>R__pNN%6}Rr0FjY5q>ZzL9}e(3vJ; z)#-X$Z_qo%mo_TpDtBOFUg;OLJ#d+A=y(j_q5I(FchbY!K%^&N^#M)KN98z~E`!|i zDg5s^%>1^sP_v zwW3z8u4mTDs!f#~^0#mjSz+rxpu_e{zY0H!!$HbU$Ca%u2-AWLAE|98LX&mVjoaw6NU*6t3GPwhk(*h9ML4DWn;rJm@EwKcGBTjaCw z@ngwxo|Dbyr8?5ZXd%EW^C*?U;wvkz+*s}-W`V=Bbqd+ptt2{D3r8x2hlO?pw+6Zf zwggTD76$(aUp3O(2hqZAQ6K4_Jxjf9eK~#2yoWsx^)1>sc#t}BW?`eF@nBcy$;i*5 z7Hlb^K-$MDH{r5pDA(Z`N{M`U>^x3K`wvU9(wnhHfruPFMyGY9Kn|`}BPfT*83F5v zdqu9TRr2)kX7*XWE50G#Upz+v?1#w2@XgR@UZ+Am!lxteP2q&xR`M7%Mz7@gpXVw)dt3DiT2ZyM5-S%% zHQJsmxH#&%EmCeVlq${x@?Dh*F1H*@jXM=oI}dl%ia7Q?og;ajiFUlz(b{FcHe!ty zkzL_RVG$k@?hqMi2rJq4NuR`AB_AE$3-pb8E&V%ftU5|*CeNb!><^#PjA&9tECa1Q zAeU8!v1T=3LQcwIG#NpvgN0N$ubt_1zLs=~IXRq{_AYyhozDJ_?xT34Gu?u7BcCD* zj8*1mYoJq3x-3k&s><_dm(dHj$_LqnpV)#gUrqjB1tn2skonzm8`!7*%0^|R@)C~c zAh-eQa5_G2PyyF)np5R1a^~?`>vVPQ*^TVImSWX5XB*Y%E#G5QGy7Vr?d?vqL@g~B zQSK`7$_?19yYLi?;X+!H>E)rmnT)PAtGt?bY*jughH^}4t9*v(`X2t>hH1D?E!mdq z<$`Tp23j$T%Ca;%j>L=31NV$4(t0vMZA0Wgri-atlUg6I!+@ zWQkd*7JilTild^1d_(SlT4NHtRuA4ag0&pLJ(Z(7q8nPpQ_dQ&gzI!0ZRPs~`n?_# zr);a1b;ulJb~H=j@!y-rt)K1YsIRu7i;sx0@)>z6e1Zjc)k-`gqF*9=DofvP5qXCk zMnf`IS*@%ANA%EB_KiFju3-jQ;3VRHz`0M2{tp@FN9TjHjee~{D6P`iKUtT}K4xz7 zsgcT@V@A{CbR5O^buy3%qP)DARht58@Ept{E4sEv1XS9AX3zn^Ua9s9=ha z=kI}czbvxAr37KZ9*NnaG_UfoTIJ{{dnroG)8JEbDPJk+SJQNWRCg}e`Rv}-6?2?f-t@r`#+wtYqF_9R^9nrHl(vdj;t)v0 zW$=>p6cI1U(yx;H6ot9f;F2E6C6r=HVI?0|91GL)5N<4uI7XdZj0#{6Joqjqr8Fhu zsDPgH6}D3iX31v{uu54;=0bCcnU~JBOV%*E8_3aAa`da{eVXDY*3pHvQ1rqorZZ8X z5BK1azlNQ;D`!#ifJvr>`y30el>kaoRfIt$dg8g$qEI|TRec79)h|^1wcTW{>2*fh zN9c;XN*8Dw^Jh9iM}xcnZG$6Gd)G##TnnXBGraxJ?Bi1@go^4W5gCgVWwEIYoWt?T zk8rMKl!vH&a>{pLs%)~|@tpn=R8wdFQ!)>Pv+Cgr=6tkfE=h{Q$ZO3ri?Oz2=*TQ( zU9o<(Yl2ISLu0iY+sq=mfHMw8-}gQBPq%~si7Ygcy5_q! z@~WGlOfUY+c&fqw=YS0?fQBrqT3pSqR)+JRq?8AJUII@k(e=;;2HDF*jRn#Wu33>x z{RQgb@7yG(uJgOC*lDdy=5b?@;iXS{HJE5UGnZ8b4Deexons&nDH`Z`uquO3wrh-SK7@J3D!D&>Kt;mf6sD00h<6;eaQXk#<2|QO}l%|Ey z$rgbz*hY_e6?3Q2&nRGgprd!aamQ#*=cgByT~0?raWNj;r#!wa1+}S01Q{>=jf&q9 zjo}(sqnt75@hz*~SEi#f$V#kVB~ON(txpWgA!nCm^!95xZ>>>1{K@HkO{TDsi3P3g z(biORuhGe{BZGMTz<9z@jh1Tl^q@3m#ilenRwY57*R{9KJPm$al_Yv?=N7@V6op zB7a0`M&iS3BHhstOfpwn4PnUtLZvr^YVKcbF$FLBi~@f=)rT!zpqh`9S1K?V>R$Dt zxG1bH|Brg;*3u4_-Xel&kDo%8Y_46{CdNCBKs>Lp=5?qOYT(&5(Ex6_h^#706-^zj`qhS7F^xQ3 zU5Z}v7Jrjqd z@RiWOa4}{poHHJoQ>`EAza0j`-GF?nGa8?QV3T{O^rnhdC^b4L7nB)1_a*wOb)I_; zdmgKXm8;56#G;`@BwKzeC&9rtCU(`9zld4rOAmn%O>{MSLT1?eEfZa2ZKG-AaCl5O zb@)?gLAX_9Wu&KZiB9gK_EUQ~QL8kS?-P0w`=e8@NK7|GDI)L) z=KWNLm+@uWP}&qDi_XPKi=e={fr_IvJHDFg{~a}JIyx@;x!0Vxc71!3wa}bm%!u?3 zA7q+PAEwNVVDiZR$bO@O6>AT-8#zAI@cXH`D^WG20?(fyzLFP`KX;{Lyr|lotm*~q z$2l~H3)OE`gD&!==vH&{Evyb!r>pnW zXxO_$%(3XC7K1&UNLADl5bzE*e-X{f2==`nJ0C!q@FQ`x35vfNbXY&NyV;km3+83x zPUJxNY3P^GkD*SXo1xv|S&?syGG-0*yTf68J~`dV6g#2Uu7ytAOE$9+-mW9LVl8#P zx)=>std^vnRew_lsSVU5lvI6`I*O0H=Z*Xv+keBE8wStuk;rOL4-`gSRTs^=4Wn|( z+G={t4u*>s*3ZnVC800O5}FgqVZ@k0bE`GluIK#ibi(TTqLpq*mVZge_|z|CAKw$V zim7ANU)0Im#cyhBHJ($r6unY<&MX(KB)j72_$UC6x-d4O?N|_}3U~*0);N-(dSG-37~I@~;x*;vc`jWSktPX9Pq!<^`?W5`i%lMD7jN%FT` zn2h#Mre9=HyW{hkFq1Bsq%6TchQMmpM%VU&TH+i^m=ffL%rYYT&&E`r zqfSw$iam^pJvGgO#=^*p@N3rpP3SRSf8uwm8=s6l<~Sx(l>v|Vj@%_TJ9tTSBEvm} z-)T;M>nLsUH~(P&H<@nG58bMdujkA_xuYCc4l8R>w^d-eN^Y|Jck)YiF7JOGUX$r* z`p(^sEYGF*OKi+b}Lx(Mes!(MFE~< z1OBuVC%u$95^o=)<<>580uG=`&dN&EfX6PXe!&a9MQc}&8uu8gwp^@60VXH;IR{^% zN_YfIv>Yrko1F{4f1mpLd{_?0hcbj;FrBF!GmTb65+V(a($pID>?QVEhh8tT?nzkS z5bUd%GFXXKSEJH?r{>lQXwlkxb%WXstg4S%g3RWza+IBDpnT%J*--%hisxU$1dP|5 z;TXJs2KL~T(~jK#2%KwQb1PO5AO3<5Xha0K6B-xp7}-TF*UM;PR%hDA0{b9#Se>kY zCH!4IRB#QIU5dc^Of??_v8$%lBI;pvf;tqn?kG@^IUp8`$mzC_$;P8l>MS>vGt0NI z!>_4-2GX(B6YR6JGsS+1=6My0@GvvPnuRNeXNL!H4xWY=MxIAX7!QmsW+$sLy&h4- z{UIQ}{lsa}7XQ+Z$w7&zh7*}7aZ-H-R(FpGG*10qZL5}3-?RUd(8`x!_rKr^R+4=z zAogAt=g0~FByac(CVvb&TxbW;dM*4t@KVSDCW zlme~X?#u-j-64&l3QUmGD&3V=%3$?8S3bxDmw4=Ol-e7#@{l@EErLhPs2-&9%!itB zGN*Aa%Cwmeb9o2sc#m3rjU-zx|4vnS+xvPC_Skw`y4&WAScU#k2G zN^!M|+L-%l%&v4IH&{p>Iv8a_DbbP+%7q}1E9@9+JNf*Y$Tje-C&4$t%)v>46~SYn z0pY4fl6eHBc3L|2YRh?0y_MB`+I%&&T0#Awbdc9@61Iv$Ae{Ztl&lwFNTw;E8D+7B ztlCbkul~DUT;HUP(kg+6Z6`8pLFJ)=>93@2%ESbbbX133K^9IMD-AiaHuP7>paVh= zR|eUe9Ik0xHzrzZ-0SEh{uMQp6Xdm9i8(vi-7Lyc>cLo^`-IpA3gtstm?V!RE6Jy| z(;cm~)=eMb$?ch=d-b~79Z>7G@)&f{^yNE8>|H1>+gYEDc3dIl_{6XB*X*bVZTD&|3Hz!5WjBk6oI~Cx-h;vaD3*f+oL4HN zuB)Zr&=)f&^0a56r>JMD-c0MRu2cHR13(lDxo^RYu5spnFyoBc#Q$E#45M-+OK2@q z2@3@lCm#t^3jZ7&9r!i8!>DX|=({^6jhD;fX|igy^$z+#ZHQ73HSk4n4NdGrU9*sR z1@pvCc_2HvfO&x?;oBAdR_g#T2u*%CDLa4sIoDYak>4@ zu3^nEYMAA%hsHDNo2{X?f!N@9f2yR9{_jG)0&@fN!kdjht$b1oyk|xAA&A{dcC({r zg4ROWB%cy9skyqjt;mlS!B%dS?#s3^M!g3*TF!gi_jA-(-*D!(jb?gUYOR}65-W{G z)7I8rZQTdid2f|7%c3qlk50Ok(K!+yS{=+2xSsSpxjZ@WX@BcrD`SlH#OW#e$U~Iu z+CeRY-oP``Q$p{o4wAp3Qe8>q)7q^ErmIl(7M9;Djp=Y1sc-g7_U(--8THb8%afO@ zzt$S6zstYjZwI-*ISrggc58B)L!j|z%-&Y46)?Vp%ZE;by}nLvkT@@?e=r;@5||x) z8Ts0J=Hw;MDMIJZNTxKk@Qm<`(YvdEF*Qd89gN0>Bfjq*Wkgxs-y)8 zO_PHDZGI>DOK@VuFlM+r$lsc4UwaDaiTYS(e^t|VtDofqWRXL7_S?=+R0i~8OIKt= z?VvaIOl9>K`i6n)AM)MyoYfj=50&vKllMu&m0{w}Fo$6sRS7q(s-4I&dmMFY2!jwRMhxD&T5zL9^2f4#p&__Mjg87q&_0(wsGXYVj?I|K|f z_2%jfCUqo(Gq1PfsZ3Kb)p3TL3AB8^_aC}gM)_X&Dl-+fpx3KsQH3%~G-JJ9QFY9w z%W)XJx2wrPj!SD@k5dn2=ohnp#9~J5gXD=x^Af{fn#Q$B*zBL^FBE8PbaHHWoTBQz zJjcCde0Mz8=-{}eu9Z`Xj_wT*z1;R}$A>bdfIOWZkAVKb*DC6SuQpoTK3?5h)00Y@ zq|8IlsG{3xNW4u2JGfaqLC^l9yq8I`^T9b<*`16~sC=k-;8s$Pq+&@c;{9>cz8s9t zpIkOnHd5O9>`HPEwG2E;e^kb0y*c%R=sN3)1@y9>0X4m5SEC#Ep}Za6*xdVT)b^;M z(ecsMqFOTj(XYoc`LUx?2*1+~#l|r5mp&-M#={=|$!w;Ta(?mF`IkIAgYh_2C0IIe zHu)in-}+w$G5&c~;{8N9SkT&RS9R;C={)tlC!$hC5A%kIDNU5Ctm!c4J1d4hi@$80 z$t_t_70yqNnjHN{Oq@z14Zcm{ z=<-ZuqMA%J-9fgHRaKPL;(%M;jy8Km4u;&|;^10(k~;XWC2vcU+60AsdZ9U$(Fmze$G6#@y4NWM|!w^3aY{Sfy4es z{%8L9^5pDf z&6U(Npk*7C{UE@N70JkIl(Sq>H}j zd+uqaFH^_JRj7(b)0G{R+o*ljpS0Y>%S&)2qrvEE%H70Xu*J$u&K+odG*%gHjbOM^ z=yD+0pVz-4d1Z2T|4{!uzwlR08t4BUzHQ!j$7wUYg`-?wBkuzJmC^$hXiMjlm7h7c z?W{2{@L$PQv=iR1qh?1vfEiyzw_SfdPOVO*vO&&3*Zv>qaT}_in75l*FQ^q!7cjf> z9vv)6VBV!UMW^lUs6pFX1tiJRh)Qb23vnZ#UT%Xi#2!`sx;O^?xTgO$dU zqkjcj>4!c0qORjLkQ^d4bK6>iyge6lix#bD>y+vJuI={?7fI^ zm7MNYAtOU%cz8%CU$9f)slO>56FQTCqk}6$V?z^yZGv^eUztbjE@GI5O4>KmTSu>? z4iL9t6a#i4)KMO(pZHoM!1?_(nMLcIfZ{Z?{GIt_ma77XtX&kOs*dBQUT zjz0^N!dDm@or=m0t(2#L_mMtc?WOpcHaVYZB8#0p%pDs=w@!kbN1MoLALV=FtLgjT z3224Y%jiEkqc-r%rnO2SK9|(g-|iys}SuN1s%A zc@fp80u~Za_j7GgQ~KLkO;>M2Gn?@Y?CYygj^LU=Fwi|XE%-5bFO(xZAyg!k2>*V^ zzl<5@X_zRrO`p%K;nco99h8Qknk)Hj)os`xqQoAN0R({zC>TDn;eqOh;>Cmqs~p5cNz+CP|+Q zP7Qtzjt%`2x*JLxt{*-W+8C@GY#aE-KP*r)(%Swct<`2l1)^6(&G7Z~q*g97dtjfv z$zJD96wl-hYCvm6H~I@aa8FMe&m{U#=_RIW*+{q83aVDGDr+aGk=B9Ov{s6dp{@{d zqMSS!WMiXzM2usC*+M(k8gI0T>d4xbEl zXL9@t<^j(KHG0C_=#QZ)p`>6>riteW{t*7oJZ>jS6SeD}QM{&ka%un2b@n@|v!Gi^ zbS47Y$}sg;H9b>E=W1>Bvz|M=Zm>{M=zW>c3@*MW^IEc`f#I zoL{5mekkg8IW6s%a2I7Gle46<{8Sn?) zr7NPMlAvr>M``D^-K&QLuuLz< zJ^jw=1=X1#AYIiw>SCCqMTBK$}IMJFLh-O_kZ?Qn1M3p zKgKy@ow32#Zj7Nu?`tT=naC3KGP}b4vAunvGNG5jzk+>&_kxYWnZanXyQ-W|9ikP} z2kYN!H|UMu%3ReMXjg_YEBcGPN!h3dh-ZDY?M!i)M+f0v>_4@Z2yXcv%yuHYK|1jB z>#(KA>F~G*r`AxJ%l%CNLA@Y*jU?sD-cN-_TzYazb<4R;m zqzWkFlkm~-L=cT}{Ob^BgS&%i!v99*nMIx1VyALlO{b60`(s5zb_#LqR}SF!^Gb7S`4V>YPFVXav!DXjrs!|wGV!M73bozTpb^K11u^de?Laq z%WnKCRi~S!xZN48!FGDH6XB%?T4Su-%uwiTo?`y=(nyy`-pG~kh47_t`|z94f=~^- z?by(aa7&|`waY0jDk!Ve(r|gl*spKouH2&!wlP8K4Eme}pZ+yi_gehvRydzce4nL` z1%dmXnYz#6m>Ve9=n*aszj+r-s4e(^Ik4zbObMw%Z+b(j;k+VRy1^=@Lr3$YRhsF{ zy_m2cO&|AUv=P4>m5m2dAWrQV>Z02F>Pv^F@TmGz^ z0{uP*qy0=?3!-)m=6a&EORRzQ+Rr*ar@y@fXJRv5T}g69@W+C%GpCq|o|P{1Ep+By zrpH{x4j;**lyY!c7r+sRf>gZ&rM=AT&wY-;^yB9A?R7<~x!s=2beFTJasFn4$PQzs zF^jJgMmFY%m*UR%8tIM3kwxH^dbme;X{3^w!*1?&6seWeYJYOe=gK~?gCHE@JLYn| z1~aM$i@1TA;UCC)D=NwKqYvS<3Qq4ln2pK*@j5T)pP$6fNlf?KL)Xw5aasOHS<6xHc15Dn;fP>CtW$mp+IRN{CPI%?yseWJk#2D|Y`orUa!GpT)m;?B5j= z?(Y~{shptu7vY2E$fxBOoR&MBnRVcwcf}&n0+hWs>Y0ghAx_p*^|AUIY`U!4o4k4~ zY9Kg86yRmx0ZX8bE#OpeRA;eW6i%)@(-7L5nar2QKgL5N(TFoH8*7Z(#s%!wVa|T@ zhyb}dYxPA1K8MP`8?09@Wsh7~E-4100w|0+<&yN9=!q&Qg^ACJ?rRyjM;umJfLB>M z*5`0`KFA)T$w@lfd%#QNhLgX{y)ISCU{f-t*m5JaxWQ{wj3J+eE6X-BK z>ms?t2Qt==ax%X4Fx{a~;j|932AyG~E2CJN%Smf$H|E5ZMHTj$e{IM$7nz0m7lJAq zg^faV2lk+%duc=r-PD=AUep|dK5Ylaj0I`OZPeBq?6#3r5_e#6JVqS3`{ zV%9KQVk^hZveqZ-ntju`MX$nK(H|D$`G0dCR@0?d96io9^qV>1j0ba`im<{%&?dG< z73*gYeu2R$0l!dME=6_P2^^^p_B4uLf8f`Y*J|$g6kog1x)hfG2ivy@{Ze0aXg@jW zo&9LgyD+Olw>A5nwZj^3wXup5ZBu4J{c5&g-bZJ1xY^z8!2j(s$1*AEe`ZmuwRIRw z;g!=F#Ys1ii}L@cz4Hu{qUzdph3@I;=>Y^KNl*bnkQ^0|ASg&y1VKQO1PPLZpahAE z0um%A0m&Hz5s(ZLC1=S&$xL@or>Z*lo_^=^d(U+ov#!0yQa&XtL7jiiT`V@(>w9D4#)q^{VKsc4PAZzS-Ju56#NzGdD`qBk6;F2y!7nP;PV z-UYpcWcU+%qRXPYKy?kjZH=zww-p@EQ<>s)Y__$<9t`s;{p86{HP9mjhewy^l1|8E!HAyK4Wl_HHv;c%Bq3RLo9YOHZIl^ zWv;I|=EgRn@Gt{SjN0()YjX6AJ&O&pyl^td5$(K9o_1F5?08nu`LwAN`|>+*1YI!n zY7`!PbRkx|(Wwp~rwi-q(I`W>kagt z?BpA9MK9}0OSL{ep*pAyE+nV(5sG)~tmVucoQOKIzj^&*UoIrw)MYt8ih^v;f8zKLE@m6(~A(RhP9ToE>5D_#rJ z=Wns{-ecbF4IAtU%;h04MRTC}5`+DC9lpt7R95DpCO8IW*muPIcJgd(ntJ40u&pzZ zu`(LoaZPeE50G;*f(+4I#I$YezI7A53=KYFPTRmweb+k0wXA0L{>9paI`2+?Tg47? zsx{GCZ0$uWE;Et16LtwWey@_@^cc3sQg~z);Et>$(tDh%*ba}OztJ5PlxSM*IYpdh!#9%`4hLsrnbz!?49nr3yO$MoX&p7fxZJU?k&Cw_3=N+3IU9*-e1 zV2hG*ddbdCw&z@whw{QQ9t~?W1+G9VeG#$#J$g2nM`~u9OV2+>1b993+jsPy8jRwn zjK%x-GuCoUWxZ~s7uQWJlsn)*KTU_e6zl18c zV&{O(av6<>+txvA4>97SFasZ1CikK;T*=SuFUXBmnapkNigtVZXIQ^`;WUkd*LVfx zU6iTGb3Ly`$gKA0RpE!$MF+1J9MK;5H>4|=TB>S_KG)O6rSMX}C(r31-&e>JJjko-LAwjgi;`r_=79h8$S#liVjZ%b zbAg-EKP>=1z(jkH^aYML~dmW|4NOP+~oF6C$}z4uAJg$uH$MS!>K9@FQ^-RcQHPZ z17s5HMnh)@(fOXtAuT|o9CMq}L2S>wH&I`stWW0bO)!T0+b_N#q1+=^F+V?=r$NOueBszmEN0SWL zdgND4M)!CL{ckc}>Myy|T~J>fNWRyPuz-$fXNh#D!g9UAeLutaT1fuL=loxB^6pNO zjXajjwQ}&TBld0kx_uhm(*tB=Z{k=E7IQhKaMyn3`2iT{iR5aur{!BvSj|a}bsaK! zdXt4UgfaXBXPnLT&Ee-%o{wNe_obhFN@nt>)Gz8x&;5>f&Bdqx8;Ym<;Z*-gM#gWn zb0MuC%e&iO4%KplvZ;aE5@1s)qW6<0-R%%9&R8!<*L5c)qjBy0*zOhRnx%9`mlpX*ucY z?!{-05Lf8uJHmGghsp_m$frK#v+wf#gx?>hZJLH9N8f8jLrU*vr%XIu3cJq_bj&U710ZgBt3lL2~^ySbgQvVj?9 z9%FABJ#iwd(G+6&Gw7G|$bMYTec!~plzy_x+rI$154ckry)PqJWlzg8$wrH^@GL8B zQfR8|_3|9$cq|G>KxMD1Y?SP2@6QAN1!-#;`d(ES?u}8y`i${5i1GFXqr5$t(8amx zJM{huFi&fe?~{|bwU5l=Tv&tUh#E8@Z>2lY+~LgTlUemvFn0HoM|Pk4;^S;Z$Rn!E z9QqFVS2gM9)nWUpL(Sq1VR?VZSsM_utxRt$0GgT8?s^8UA_rIS8n2e)6H0R>h537K z`d}hR+T2%_ZLhxBiqk*Tm%8UFpPWq!zp4JK?wZPaS1JoxIbKLZNBy3S&(FiECU0wVd+4iJ8Pk+D4gSXOpIgZ2sd5rh$ zxXyw48RSqu38khz`swZdv_rkBehZ|HfRwZm_4q$O)#GDEgUaiF%%R5GQ$|nv|N8k~ z^?B;Q{b_Te`is(5P-7`2?UnRTj=!eft=bSjcl^An1?pARW_76Rh>vkK&ef~x6Vuxl zKUe%yNX+}sp+>pV6HwnupIzw*s6+Jt^%Z}nURQrn?@_P(i+EfWKq(8vkN98WSmT2H z59Vs#GWbs5j;T3DeXBz~`&Vy)bl3km)T^qFjk?R~QOzImzpCf)*VOa)tLmqESNy-? zvzYpS^&S5iKNp1~(^?d7nfg86Le)Mqtu;#XLA^`;jK8XSQT&zor=_1i{)zGTrGJj< zb?RI7H1(~X#rtCVtLl^0=f=(;TmSc$8k6xa^{6x+6!r?~xN<{W!{OimQ14ZL zSFilvpY`9bq>m5Pg8zNQUstVDt4;ctiC;_nc^onI7j;k7WBhe>P3r0t`sz`k_3yKP z(Nyn_Uws_4`0w$b>ht~`yYauLpD+Hmcw5!Ew-e11uvuj2EIdX_#PsMS0rZC;A2kEoyO zc+A=zw-4eoSNaU5o+)cUt?cSGHK(P&mi|imtLkrY3nTuSYVM2Afa>Re|MtJ%r|bsx ztGP|)c4c=e`z${5{yYCE6xE?{ zjL)6wF?}|S{pYv%y7=#1NT2QEkLoVP52YZZo~ipBzpLu5$L%)NQ{#OuKIf@^rjCDS zsDFE0dY}96N7cXLeJp*P#pltu8jk9<@p&AgKZtERt0 zJ*xkUkCOB;quQZfQD;x@pGuELt)B`lg`C1jtr4o1rsEO+J&uI>8DBxvyW{^8|8LcU zit;3SJ<6jPg4|9|F3$u zvWu01)FVz*0MBD)G(TpL?^S|bUL$P9Hazwusx~%d zfA+z}I75_*G=-L>SH`7gHZse#hJIU2mJ(lx*2x# zXzb~?*~zxxJw?IwX&Mg4(7jR$RJ*X6Kj-t0a)w1zn7V)($~R<5Ph@}aEBURe{@X9S zcQt!}<5<;;@b!ERc30U4e2>Oh2mUsWUF!QN1O3fTus@!s{3xvK;vMJUSJt4q$pO^B zws9Rjd9@stZxlV1`(XYeKQB=)q#Jt9N?qz*vM`D=Zdwqboq%=RAFr8?MgBf}>Xqye z%ChVJjyw&b)hH#m1_J+KL~* z&fUxIqdE8CH219o@vewHne4qkwDZ)k+`;bU9d;|H=`YL3t~$ovrYJpdI-h?HUovVr zs0ZcOE>d~z7Crqn+C^PW+LR1}#r2GEaNEP>MNdM%pj}1%ZU()wBFLVwOKPo*>#%*# z!Svfr{&O#_0G~b!@5xcTvje$LMfEbAu>e}D&*+1ziRctY1G)^};7{=8-PAIn68wWx z!rr5Ne%cd#Gb#ubQRn+iZ=f}fHM3je3*7-b0WY5CC-Si#hP-456<;B{H1-RHi3jVA7PG?Yp?m-NnBCaR51)Lya2*>jw0 zZGQ;k?I3-yojqQgpjW1E)pdr#9Xz4`X3W>05lMJJ4LMlo#1zLFchgpv=j=nVjQU?j zF2+Q5s*^8dAEjsI;PHLtxm}Ez$98WjUH8(qn~VZ$6{B*EaaucPjV6!o zPpVP1a$4!RVTp#A)4DOkWYn)YTTuMc^zPJ0oMO4D1Jw|};6r<|iu5v4_v4o=NJLY1ivNZs$t5>zvW?%cf^)@~C1F`^C z5$%7KsE|QL)7NRo5~2(yGyh||uTzJ_k#Y7lZX0|le7l*NNPxF#K+!n^4mF>wJO7lD(8%` z6UgCfO7=(vG`PNkZHd>2_{$h)AyL9_(N(Ktt!D zgS27BMQy2-nT(ZGqo#uzi1w@f2GtT1^sPj5n>quGe0pE|ArXSSa4&0N*@2hXa>&xBUgJDQ$=B9J;#;pf$;Pj|cfMnhS-l01@D{tXmIpqvK@HKXRvn{)UWAeR znKhNq*ru1jtDYJBO|q@-YkkOETtyX=an^j|Dc@;p%+gvdYdEWQHP)Ns%s|*%`de5! z{fOFZWCUc;TiQ9O5p>>~<&-xL>RXr-zsJwMmweRHjNobH;*HWCSWE12<{9!ci=bQn zJKnm>dLC_K>{Ticm13mk!%o;jBfMMY9wYM9M4x3(L|J2xeIM?eZdJ86Y6Xqco;+y@r%LB>1EWm$0A+oi>ZfA6K3frcB#J=cw z&7WDTQ*BNA-5A5HTu)oCpVb!H7o1*r_Qs=w_kfI_U&vuU?NHeY9?cpfNq;xiL%XTB zu+K9GouEEu4b97p&=*|)U?hG)CE{m9mm=0)yR`9@cAJ`|Ifw~$qk=+ByE(JVM&cWe ze$LKn4WoubDeC<;b$+#bz&!rN{xGI$7ZhRU^4kNgZH`S2>~XD)^|Jjw(W|?R;|KO! z?KQJ3*YFDUfA<@WG?zU~Z^HSPW1sE9{@ta2t$l{R&d*MNDz4VGzckM2uf~cxT?`A4 z?r5sAj?lWGRn;GU{6?aR9(^#idAI5ltT}dovGuueJNB)81vmRGB0@W@LQX&OI9h|! zVpj0l==IIwx@%(rH^wf_!qxkn9_A9Qqg7Q~s{8Dz&Sh=4^R2#}%=5+0b$y$&)+(lN zHzqm1#n3M`0^HF}s0@CnZ$w|X4Js^Cu}V@|A@&$QSvBpy7@=ddd)9X6UE=^Y@lSRR z__}kM1?O>f>$Rx0ggwAF`XA0WmT6pN#=eP8^+mEq>tR{8b+S`C=x*#Pm0mB|*L4;1 zU4kz>N~PD+C~NrikWtE+XqB}aQIYJdy&v0UHT9EwQ7bbyk=DBATB_Mjw0^ffH>w*C z9ltfoPNaT9N9RL3BeU*X=xiUihg*L!Q$D~JE`=td8!NSwHo%^XMcP#lFuNSlHrubW zOBrI@M6!mnq7Jim65E?)HPQ=m1$SUc^o^CL{?<-sILeVnh;2U6+Zul7$5I_c)h(evP>5QP#>l`fVp>-$|>X?I-i#oHbnA zt$*Nbw0e>|T%474yS3fEM>h6SYELh4x?78=W070?%=*|VZ*F8gH?_-FBAIhV?W@jH zy+1mn?`p-V)?Hnj>Gaiak@;7b9FIq6f(=J8G-&&X8UN+C9Sg8I{d$oRy8!P%P3}+uC!=vqbL`RVh7$GJWJ1iMH(h4U z8AR>&J?sfT!>92NQE5YK$2E5+CN!N@AwM-aP~y@XFiY;lqK=^(`y5fULi&7^fO5e* zc!S!>Wl&nJ&1!1$d6%&#W>JyX$GghX$3|co1g*#Pj%?a_cA#6Gg?JzQ?D>Ae60QKp z=rJ|D3pw@Z!TGc|^b2UlKBI3}!i$p$h10y$Do=uUF`M_n-k=)K3g@^sO>gRahhpDo zVt_sk{b8(}v#fT%JJ(T}uTGE2$liCN9kti6w_ZXItw^2dbL=_>z|zGNh9=M~=KiMC z7X69+$^$$I^O-;VZlmY20KKM-jHEmEeCI20(3!1PaE;HQ zMv%>Zh-K6TeZlYSc2-7MHv6pY=%v)M=EBfE_Sq!RALjajn_RmCIcP z4WRz6gVb!Q55uvt*$IwPKiH#rjnib06~g){u2;f4^(X9tBF+vl%}F-)TQN^;AK7)y zBQ?V>p-=j5IDj5crEtwiN~8gKpE>L&)&{#26;vBhGiyI=#06#+vIUo#FT3))Q_;PN zq65>?^~Bt7);IgYGc9w>#%$#K_x zm*vXsu7!?S2bb;&!<8ILj!_%v+PZu>GIr7zcHVIb75qu_@vAL=-*9=E_ zrLeUcg~K|L_2C~;mpDi*vpV4cp;Mv3kuRe!#r}-twW_lpwNaj_tuI9BV?7M*8Rpxr zHtr_qO#O=1Rg$|2EZy?(aQ`Nwr<}19M$ALblZcjC9sL14<2)93kp04K7$lSFYsaZQ zk=yA`M$S^Jc&u%7F1jxz!zV+>!u2A{$?PN+Lj8h6*5VeSQ}IXp#iqaez#@4)=a3P1cZ`f|5Xw_UI4uy%H{`~Q@P`Yb$bJ+Ux1 zW5vJc9Afm`#;Y;;wcm<8mUknwkZmBZ=kuGm#aRC@2(p&(j7QD z?eWNErRH^R=PoL&joG&>hK1ZMIxR9F{CU_-u4r2-P1Xw+MNP9E%11xOrds9f8Fn2i z#c1%RPQoe8j2dWdYLOm9g`qAv5l3KYH*~#A515R?O(WDVi%{!kBYd_kta9aO#h-W+ zf8c%`Ly&8wYp0Hu{fm$G&Hbm7w>16CM|S7~U5y9-bKPgW}E3==-t1 zV>hgwX!-qu@=rB#=@yWe<%WrS7R{d#u;YKD1>I26E9{DxE8v$8PE#zbg3i`aD%frT zi4V9V_h6^3q5pMbx8EET)?jI*up$qzGuTJq_&tk#8jD4(=+Dt}0@pE48dv1VHXt-RK?*dfk4 zB{q!uY?aY&92Wa2b~lz_)wh>Z^CPHSQS#4Bdfy%--h?>P1G0;q5In$ z-q=Ofm-pe(?LqVL1$f4FV8eFh++UN0Fp#4ocr;<&Di3=!kvxmPVcyLE&9=;6F?<}e zvE7ToIo!Y%Hb_Hf8NS6D+^gHHMk~oi=?xyOtPkMJcLf*4W8G)@t-N+!YO-uY?eulL z^uJ=Cm!tn5&|EMp2ayTt=&zyN+6g3n7d ziC6sxY~1QFO#Jvs7ooV=3{TSYRAu?oUJ0Xb0F2I>zs!Y`bTO}_)H;|~5(i+Mad=Pj%k zL-(}|Y*4kbs)c;;WhR}>&#aD*PY2p`}Oyw9!iIo4yX zuZb#aNqj8jVQ#+ZRAnzyD^07lH7Xc=z;sL+lW7^zggvmct`dv#6N$ek4H1;8*rmnryJsV286gUG3vb9_ zVmQBIKQ4w{sJMsU<4Yd{U-BDg6yM`uTutSefv0{cxUPXO`x~QkFZSXoY{}bj$WqwH zC)({uXv&S-kD9az)u-G9?rB zvAIUY@h zY4LrATc!PU#oiktM`!e5(0eK_s`ws6@@%;yBvGgEqiZj6wr{@~uOt9?4 zauoCHWg;0w1?VYxI9}xZIlw}lT~(HGgQ~s%?MM zy8HaRO}lQSwePRAcB$5?S8nrn#et01Nl^?26{StD45QAYxEt{}x?)-;(FVmB`M3R+VK}G!wVc|`M!wle+kqsF*X!u zRB=b|(f0fNkBYe}Mx|mKsB2Z9n3~qkcr;u^j8%+YwM#MLGNs*V^;;HN!`_-hu{2dw zTSck;wB&JGTU2XQ?H^SqUfna*4%MOyv?kuF^E{s8)l2;SD(_Nv_&Wdf4=qxkl8W`G z?nnaXQMG~MY}Oa)V|lonis7nww~D>@0_{=gd1!4sUajt3oTc`F&r{#`xEr^*@*A}1 zum7~^9OpmBxI96>K1uIC&ML1sw&!T2;@RHexKCSDOX8Jd6!%wgEpsye<>$ypPcFzR z`a0gZB56HeG33?pDt%48oo+>uKnFxHqb&s8%WV?@RRZmpJl*POh{g2dz~-Jr|h8`@DKx@kSM! zRk24i(>hfXM^y##f_OaoALEWHrmSMAsQVG`{c%o;irTA@rg#T&?v`R*s7gwi_@s<{ zqT*jFUZfh~iia70%$D|AHlC?7$Nw5E#+tRvPuC`l9+u=l3Z- zgknG_4s)D?q57bDCC>a)?^N$sJz23J;#?+Oy_D8VR9}g+uGDKf?~C$TaR!na4{`2c zoNtuQFG?RLiqoi2c|^}rb&*s(DaA5YxTybBV_ls`o!eym#79sT?t{X?$6wT)&r8oK z21ho=nlL~+#lE2}+U4`KjQDErJL9n7FLEuN*fZBe8~G6G_GkFd?&S8AAlf^a)n*s< zcy?e34r3LVL`K1SdgN{AORS%r9P3y~7wDIb1ih^DqOsJS*}X__PGl|Fxn%8*HKNYU zaCX;2Q1I^%`z6{c)&?82F_}3N$!K|-J=!Q@sYCJ1rDDV9!ymGMm2E1yvtK!fwL$tm zb~l>7jrv1lS<8l#0hFDUe=`>TPOCKWb+^4Ww$$oCR%{aXP&L-wKe4>~VEa~N<$IHr zv@^$W{QWlL@p0Oxxak!-=lYV>t1xRvSJu@@SOs&4HXdZHEl2MtOy91;-y(QRyr@<8 zCU@~??4iYWVJa~FV3)Q>*{$vQ)(Em;F0sQOM7~ZDa={#HJoVz{;Tc&%EdLZLRE@|+ zQ&s0*aJO*JaG!BEpo(`GRfyy6s_tIofDSfiqJQqwUm(MNt-Xq@^RlroqlY7TB5T8+ zgpZ@oHzw38G&W?0-wBTo7m7?_*S;*eH+nUyqg>mH6#X+seYD$Jcw(LgiEB9;Cl*f} zkk~CTg3iJ&PexBU6vceh{WwBy`*5NvBk*zuxNm2%PjWa5>~7do53%50#$(gQK1yAH z5K8j(Q3mQqcH{g=t;qgxb#}1XQ47#RzlSojd)#0xb}s8*nwMN968=mm;&D715_5RJ z@}Bhm?LFnKiGo#?r^-$_qJ`7K=HdbN|@tgsj*&I*3IofSoyuN zE&rsaXzbVr#d5?JqnJD`a+KWgS3^xwccR*NI`w__jt8UEOrZMJQ*)TRTf!JmtHgyWKsF!)iYHzvdpOd2ZB30S1&I+waeH?5Od>j}P9G6-&v?tsbm7^ZoDdU9eql7Kg z2Dy;dZBa?dhi1$-k&4!4C#nx}O-sm| zSj2nU`xcsWA@3&C&%%ja6R&xyd-^2wcYjJ9k9+tcSpDoe_CMr?-v`(7*vEHm9o9|- zMx#dL<0ImvJ*+#iZm~^NU?>);9WILk)x6*?rjtB@O2H1PJwqEJN3GAa)kYC_A5T$l z$om;JcYa7Z?;VROSxJ-zt9xcsfxeY{GCrwKjZu0FYLEQM9srLpBjSMFgBj^9Z0xG?gODixsHxR0Subl4ngOuz#|T!(6m0qZc@^at3hzR1Yc_1f6& zQ}IIjsHK(LsuUX%U5euE$nc`jmDHN4O@f~Uj{0Z$iw5ol{K3ZI3bAwc9({|ejpuvs zzN9ZuQ~Sa5mi`Lpa9a{o!fl@FM#gqYwkt;@(*nfT$?gn=@Kg!nP5%Xb3N2r zqig@3z6G|@SmKJ=Vsj(Csc5_+)ss3Zm=c&C=o9D>SQ+RLT%4MRG7w9N>TfWd?pjE$`b{!=8?j$p$DZhKR=Y{?g;wCBJV8~{`OHR} z(Jn$ESU=Ay79%q`b8J>5d-%)L(x{%)@$X1kl(H@*um6L<=3uT+jmST-@lHWwiYw7G z-kZsH%=caL;$V~X)SQV_HKIvqhlqxb2EvM zgis{^nib+6d!H4k!z_+2LzQYmFb`T#^HK_?e3MeeUo~(&I4iU@vdwy3`_lN%9rGOY z4)%SSd^`E?IZdwGyVxD& z*c)DfbGOR5ZGUe?qO~KvL#NPaE#YsH^7z@aXB$$^`%4A8g`QEDiVBtHSa-y)%1v99Ns+i)GlKLmG)vutL6d69Y;XIOt#Ori)i4<6cCs2K7s&zKWo z7e2;I)51OzdoEfpygD^|aD;zM%4--zJyWLpvj%gc9Oc6gf6u7u9^pCT_4pS0iX?}9 znS2kugVFyP<^Bh+*kj%1vsS|koK7rY7kP$rVbXPES9BIzAvaj&WA|1a3tBzhg!%!Emak)-a)4U$_VKcDVm+@~>!Lft z7gE~?>jWzMm!@pxY&8N$gQr4SqeHDy+99Kvdy1#FcW2U8YHT<3ok(iuUFRvCu+mlE ztZWpbU;4SGFr%#xcd##gXAdj-D=@5Q?8~gqh{5u)a;Q*v5wB)`dwESPhs!=q}#sM$$641`Vx~SC(ch;MCGM^a8{PF zYyXfvY6G%WyW+V>L67_lzP|Q)c_QT7@cGWcuE?(~Cz@~@b(1b&StxV`C96^XGb!6r zj;1uEwph#5&%?)}OY8;uSFW6%e%{W$G07>(PIA8FrAbtlO0ZoYnFIAY*kzx?=BPl< zb6&hiImw?+q&8u5G&#nwzn+O_aUK2g0K0z|v9hnNYO#@#1EFH62LjvtDJf%84y3&5 zzw6%}yn~kKyH;!Mb@P%tZ(_-$Y{`d`S0;~2ZsXg9N@!2_AoHPq-#JBQWM_Ph%i;Ju z)iR)p(GqV}6Cw!5@gTm0SM>(>?KnKPtFRLmS_5NGBRRusQ;PEiwY?a6+uxTmdTe4SOC-r8PjWmGb! zqe?x3%=s+F`{)qP#hPnJ)GM>oj(L*;9MOGf!Z!+)N-Y!26`1G0>dzfm8+Zj>;+La) ztpeIfh|J2=}2)Wrs3t?i?FWvV?C#im;D==$S1&Vu&xDsr%E zQk}CVzPKUq=tgj5OHt$)6m1u|8ETrkBA5_7>96ZwN1~5Io)nDhHEiv+;yFZ$v4K<%1psmnQ zPXWi*U|syf=zbKQ7pjz+IaoKa%m0;shTjty9{4l3D>NbUW9(yxYbXakC{cr6Q={HA7>)4{C`W>gW=cATPZhe@x z$k}B7X1xvesXYm28tQtjlv846|m zA;WIp9ae&7Zo_;|?+g2^KKa9!?ODWhztKOyU)dN%^NA>i4#Brvin*;f6`IbYLK?Hn zSwBaAh(tq+Q~wHH4wMaC^Vjiz?El@L>aP_X7MdH$XSLTZo9}woQ7ye>h7uXeWZ2_t zn^Zc{?|xve#!`v0?`eqNskK%fyf&j{wE)$MTPU))=_RTv*dp``E)cz{WOqY(ac_8R z=xpjQ!2-eN!C8Ta{z`%U{(b(8!7`~O!?j~>=Z2BVGt=w$ITGbUE(+{8BOKBH0!&{=6wfU|jPjPQYU(@7i$r55H8I4~cg)^RDbyxtq4sU3^hxQE za{bxPXFvO=QS1G;NF_U$vCCa4ab41;SJ8t$~J2NpqYr7M{Tkw2TjJhBl;#88|6dMqo6V4nul{z3;-#;KFW6HH>(^LBRzruF?DLUNw z+3f88(o;IAv2Ukujju~mKkvZAoe8Vm3tbP5qqJQ++b3_h^NT)l?EGj*4A< zQ`Jd%Kn$V-43hIG#4m|{8QzlGGWdPq0yQiDOu3cf@edE23wBC97w&0|*G`+A6NV*j zPrB;+-Pg$XO48QEuxCTU+wKgm3FxvvBIb65z5FWpXWKy!%~$h~`969~kI}rzW30xD zT^43~BYRA2HpNX*2}Du9aU zF7JWFDW1RBYwR@pUlvz{6Op7a-a+m}pdekjsrq;2t~|o9x!s_UIiH z{$B}o489sD>5n|~rJPSG?T?^$zC6;^nxKs_@435ps(BwLt?*?`Ug;Z@RNs5mlRKf9 ztDvzH&+&S66pE0E+#gR(KH@wLQFT~v6eT*@&b*1XLpwd_d|@v@Ev0TWZ=`Q$ZG_MfrWqSL|+Lz{!`1Izt2{1yGD z{l1`{x+S%1xK}i*b=i55imxAeZYJ*a=1F=!X%=-@pG$1z`68i*yPT`9;nFL!Q(MD* z%1jiXgO;1vPj&Ved$3J&W7pJyKT$y|hT?W=EPreu#S~BfKE8D%!-VrnNUm zxR)jj^0Z|CJIq_pdlolWqNi)Ze0ND#b0b+_kJ`#7oa-mn*)PbnIn4g^b=JXp*oSrT z&8Zm8MQT~>Cr6-DYy{eZGpWJw0IkT0Xz{mAts80--Vn)P&2w((O^N$9cMWl0N!afx znz-6?9ktOK3CH1hoWw$!frbrLAy|!@gJ5amRBuuz51)Wy)UdzK4X0-V^Y2=_C-qss zjn+d8>Wk?2(XEl+P+t5tbR)Dqycdk@|c{Q(s;-Aa=j^3k~~GF%8RkzW9wq| zP}hDn+A>ll(j6~RyJ*AMzF0@AEit@;*q4L#1I8J&sE;yNZ$%-o0lJ`@-G5LoqJ&w` z7=VvvwN{RJ+z0wl_#?xyN*_AsP_G-wetRf;`9@^Y1n{_7)N-s&we0S(2&#j{isshQ5){SVSesa(JS$QWbME zw(UDay0Vc~vPk=e)nywjMirH;=IpjE*z<^_-LtcxyqX8KnS=OouE!!)d&bQos;R+M zwI{*Bn6JG}#O?(=dDHPge9Vp^gxbw|?DPQh(h4G-sW8>Iq5+*BEpJuLbca?MwW?WE z%U01sNAr+n@fUl-lh!%sAC#1yC+FiM`#e0YH()Uk3VSmO( zFS4~hVRc*S6tITIrrIry@6jgQWG6&RSgV|=ROA@1S+PgfKutGNwRQH0SQ+aJc;+3< zyG~Z?o7hu3CsoRt8rii*))?EXU(@oTgEk26<5IghJpEi+S^coH6Ru(+`6J`W8p#8X zCbN0Rx)RO6o~gOB4F##p#Gy~)ld58`aSG~1%s$Q)>v>pu``{ZEhd=)|H7~2dOd|4S z?bfc518|?1uv_cJ-Q1?V02_9Vbpr0~Kyt^%aGgcf5$9vGXQ>sHy=m6+e?;J%}`bcS1Yq@Deqxzx2v+iRn(unRDfUHc+uiid=+w7_nO6-?;k&T#Kc#;r zT2u8ZW(9kZWjLqxt;T-1hY7}5r-t2=xpW0w*v4c89&mET?&(9#{P=czlBm@_a^Wm!ik%`w{IRs8gHXc~O68tg|NAFX@?`$=U<5Zw`^^ z7$)ECL+<|pvQsu{?QNHHo1D1OFd9$WFX~gA#q8#X;xnHD4`DG?E%KQSoNkN|pElMw z3D+>mtZR?7CL1%kLTYnb)LCUd+@}|C>c%QN2lcVqW9*Ig)@S7NWYsgGLO1}nksIE? zKjfHn(@w(!p9cpdXt#wmPuv_eytQC@6`$<;=4``MQ3^O3{q)ubNW^dkz>ygR+r5iD zMe9u7)RAzr0IaPpflH(h4tB;Uhaq4kCR-Bq)gB?iMl-$tg%TPUP- zb?Vceht?Eoi$8B98S|}vWY_dV?d)SGfvUPU$?cd3q8F`OtlM3k z%q|(suI!;k^Pe9Q8-2mdt^XLiZI9DFw!832o3vB0YGe?#trzX*vGw!AW^Cguwcf{~eC8ZsN0W;@&|S_J@PiX> z4YvKP99W=K_Ie9D?pK&IE%b=JGB#cxYMis0kP|_!h*kvl@Xyv8%mDMP?usF4?Q#m4 zIf&-L8?i?+yHz*t!FqmA_mT-{kOOgn1`V-;RtFLPQN5A96!lBV1v0Y^U9mi@T^I0;8F=Onu#;Lvj?+Hf ztzEGGu(#@&h|~s{-^Rm}PSoofJ*k;=iE7$4m|0zV6|KIVPcKhq$wt;k#na8l71cB9 zIUb^GJ?wrcZGJ@-ztUaBJD;Mo$Vnz6uUe%22V z2YrJajm~8DOfws@TltYZ(pT}_NAZO1W<1#XuU2_1@%i{yzIQ&;tmrH!o9i%nRUeSO zP?E^*5q1IX?Zd42qp7np3JaiwUWk3bWM_}DUdzrZUV!tqV{X`s7x`=Ji8DUifV(H7k`J_S=k7XCwq-5DC6}IysP8n7fj=vGhl0#BM0Iz)>L8q%kx-gVni!$ zlN;KC{?OYR<(z_<+<~l`%Ff4zj^}-YeZXp~oBF5PVXBOu(R^^)Z(@gO+EcqT8Cx@~ z$MEE5*$bgNJdU2X=b2r}@2F)ywo6!_(UW41vJ7`ylUe-=GY7uONV#JzwDanj4dV3J zNGo84$Fy>GZkRcptXX<(b2)izZS4A51@kB?e|e|Aov2SV{;{XB?vn##x5YcyFE-Yy zLt77$r(K&&$%)jiFGMy?c|2ep^_AG%Jsp!fkV_k(|E2wH9f51{Gu6xT(c1TkzG_-- zGMC$Pul|NPUzL6K1+1Ia;8TtHaSML3Zm_^dkqu7;n{b>1(aB&Tm>~=N=65 zveiqs_7(W<$3P)Kbo2;!evs~AfBQaM z&4v2w>g`G@!X=#H;AjAL{o7ck@{I!7Nv=9oN^-chO+qa z=1_J1Ro2)RIKFBp3ajwBpJ*1TY{qM;VNYE;? zXZ#0t&UoVC^WmRP!0XzbjF4OQDE4@>;cwhymwAWX#LMJuwzStd-*U%i;N?C>gwBuZ z_lv}S8tBJhsPsqmpd~w-E_!W{dW+BL2rFO-yMVvhf4qxFx)t}THSD>?SeVIV8ZRI= zRE?chm|5?TJ(9@Z9jmb2h_RXr4(el8^3FubH&`D-I62F+nbuV*lAp9*x0Bd$`?ay` zET>Vsb)h-R)!O|9Ub!de3V&>lgT)?0iS;k|yR)g`cav&{k5HDKN%XIb{xkO?gYyxq z+3#qPULjNA5(?5&qf4WYsogg@vNGI&8Ui<{Xz(62R9_5D4UG@?qFK@3Ew6>asTd`P1S_1MjO_YRMf7|o1eRlE2$FT(-uZ+rkVx{|rZcp1g**WlLn(8(fn?R4cxSm(*^ZSL*qeU8}qNpFI8gl9G$ zkxfPecrLG#x3mau^CEo=>r0TiZ=EyG?rWWi+Tkvt7gO^D4+c&IZU%-2i&E)kK&X6Z zL+TFd7rKM1gB$QA{Tlnj9;M|p-XkX@FV!DAx;C5Lji&l&?1v0!hm6;^nIZQNo+;jz zNn4Uy5<%$YU7J|ZQ{R06FI*Y&kqe^Bnu7`xK{LPWYqK8}gzr(ev5>uwYN^>H2SZz_ zQ}Ql&=MOInzYqyfzoA8_bL#iOK7s81UMYc;UI9C`YxJr8oN?NE}O6 z^kj0Nb2vXafyG>RJRf@}C3-!BT@{QbSSxQ@k7BuL$rERiUe%S!)7<-w?@DsU40DnX zC-qOf<=$dWLi>JzenbBWP5=3>mDEKV>{;xo<*AmC#r=+1Lf=gd#8qhJ{%CEs>cdF> z%9e~|h|;Q7EN|26;Ez<0r8se3~`!5jU0xtqBQyE2(`i1d|Yt@Fa~U594DcGu~I8HsNtRrA&MMZHB6S0qe#twbMsx&9nn z=~;-^Y%wFQ8t#(rIqt#k)~=d(pATwt*!i4h-MWHbt+um{oR{CwUw;SGB9T#%2jRkD z7o%n}^-X7lMuujF)`zB1fxKd<1GO(Zrrt~46wXSWgNAxj*M|vS&!dEcZp$oaG-Mr` ziWNVFx!W{)Q){zh!hKJF?=F}wO}r^EO=h|>m>b!H=SEBWqOsW=K*fu0?jNah@iNSe z;l!VQXRUt?j$S=1f;aTccptuHj<0W*x7x+VM~g(OFuL8b8jSS5)U5g<+CB0onK_v# zq~19g3sz6P6ucFjn%bB8xOXFN>mM>h1Ll6$CU-{j0UaeuTGRwNc0Q zfxAXRSY29|M z3R>tV$pN_r(w~!i_9u!A&8ZdZvOeUF--Gd95Ur8!*gd1I1F@{J=Fvfst#FbSg-YNb z-<4XHN*u3KeWeWb4;Dr|(RtCXc=W#2-!zk4r-;hk(yNnS*VP(q4Yj8`DDJy|1B52fVO?x z$WT<{XH%85Z=^a^cD@aNNVSfi!}X%=t)b3edInbycMW$IS3{#DyPg5mO3>jfWp=z& z?YQh(=x&p+D4|5cFn0>x!YpPG&(3?ydhhBeJsabwL-D@rsoBNcWvpN)pA|c195zQS z>=Ej?P}BKS>IqyXSFMy@l@+xq9RB+DEW5EYo*e4Z%%C}pOIWZ~s9oDU&0exz?JBV z!SoxbDAT;BGk0MJOk%AbfaQ>dy-^NgZM)fn_QkJp z9*f{76t_FtRhchl;GtUz=kN}Ey}smBoQ$=gCdt>awJ_c4NA^&GqHW{{Y7Ug3CQYA6 z*GSH2%h(5YLH$)~xuhmcNl0;RA@_cJG;8GB@b_@DzSVA-+o%zk;2n}UG-00kHy)sU zRuM8Zx;h`~dCg9)m)*Ij8YmvRqe#`D{b&vIn9*h`~M&aJ99doERe*)LY@Psq5S`q&=Mwf1Ve1Ni?gwn_y~K{Lz;;=iP}FlaVI_0tGGjX4;tN_Qy%JfE z-%~53y7?U|S0~opxn%!cWzSTKb)Csoz61e5KoAfF1OY)n5D)|e0YN|z5CjAPK|l}? z1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAP zK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e z0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n z5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF z1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#= zKoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}? z1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAP zK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e z0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n z5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF z1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#= zKoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}? z1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAP zK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e z0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n z5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF z1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#= zKoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}? z1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAP zK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e i0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5cvN>;Qs)l-sjc; literal 0 HcmV?d00001 From 6b589a1b7c3b0ab3146d75dfdf959eea3dd625b1 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Thu, 5 Mar 2026 21:44:23 +0800 Subject: [PATCH 03/20] Enhance session management and logging configuration - Updated .env.example to clarify audio frame size validation and default codec settings. - Refactored logging setup in main.py to support JSON serialization based on log format configuration. - Improved session.py to dynamically compute audio frame bytes and include protocol version in session events. - Added tests to validate session start events and audio frame handling based on chunk size settings. --- engine/.env.example | 7 +++- engine/app/main.py | 41 +++++++++++++------ engine/core/session.py | 36 ++++++++++++++-- .../tests/test_ws_protocol_session_start.py | 38 +++++++++++++++++ 4 files changed, 105 insertions(+), 17 deletions(-) diff --git a/engine/.env.example b/engine/.env.example index 8a87354..7f09de7 100644 --- a/engine/.env.example +++ b/engine/.env.example @@ -26,22 +26,27 @@ HISTORY_FINALIZE_DRAIN_TIMEOUT_SEC=1.5 SAMPLE_RATE=16000 # 20ms is recommended for VAD stability and latency. # 100ms works but usually worsens start-of-speech accuracy. +# WS binary audio frame size validation is derived from SAMPLE_RATE + CHUNK_SIZE_MS. +# Client frame payloads must be a multiple of: SAMPLE_RATE * 2 * (CHUNK_SIZE_MS / 1000). CHUNK_SIZE_MS=20 +# Public default output codec exposed in config.resolved (overridable by runtime metadata). DEFAULT_CODEC=pcm MAX_AUDIO_BUFFER_SECONDS=30 # Local assistant/agent YAML directory. In local mode the runtime resolves: # ASSISTANT_LOCAL_CONFIG_DIR/.yaml -ASSISTANT_LOCAL_CONFIG_DIR=engine/config/agents +ASSISTANT_LOCAL_CONFIG_DIR=config/agents # Logging LOG_LEVEL=INFO # json is better for production/observability; text is easier locally. +# Controls both console and file log serialization/format. LOG_FORMAT=json # WebSocket behavior INACTIVITY_TIMEOUT_SEC=60 HEARTBEAT_INTERVAL_SEC=50 +# Public protocol label emitted in session.started/config.resolved payloads. WS_PROTOCOL_VERSION=v1 # CORS / ICE (JSON strings) diff --git a/engine/app/main.py b/engine/app/main.py index b4c5c05..bd513f6 100644 --- a/engine/app/main.py +++ b/engine/app/main.py @@ -80,18 +80,35 @@ backend_gateway = build_backend_adapter_from_settings() # Configure logging logger.remove() -logger.add( - "./logs/active_call_{time}.log", - rotation="1 day", - retention="7 days", - level=settings.log_level, - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}" -) -logger.add( - lambda msg: print(msg, end=""), - level=settings.log_level, - format="{time:HH:mm:ss} | {level: <8} | {message}" -) +_log_format = str(settings.log_format or "text").strip().lower() +if _log_format == "json": + logger.add( + "./logs/active_call_{time}.log", + rotation="1 day", + retention="7 days", + level=settings.log_level, + serialize=True, + format="{message}", + ) + logger.add( + lambda msg: print(msg, end=""), + level=settings.log_level, + serialize=True, + format="{message}", + ) +else: + logger.add( + "./logs/active_call_{time}.log", + rotation="1 day", + retention="7 days", + level=settings.log_level, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + ) + logger.add( + lambda msg: print(msg, end=""), + level=settings.log_level, + format="{time:HH:mm:ss} | {level: <8} | {message}", + ) @app.get("/health") diff --git a/engine/core/session.py b/engine/core/session.py index de00855..bc8bab8 100644 --- a/engine/core/session.py +++ b/engine/core/session.py @@ -54,7 +54,7 @@ class Session: TRACK_AUDIO_IN = "audio_in" TRACK_AUDIO_OUT = "audio_out" TRACK_CONTROL = "control" - AUDIO_FRAME_BYTES = 640 # 16k mono pcm_s16le, 20ms + AUDIO_FRAME_BYTES = 640 # Legacy fallback: 16k mono pcm_s16le, 20ms _METADATA_ALLOWED_TOP_LEVEL_KEYS = { "overrides", "dynamicVariables", @@ -111,6 +111,7 @@ class Session: self.id = session_id self.transport = transport self.use_duplex = use_duplex if use_duplex is not None else settings.duplex_enabled + self.audio_frame_bytes = self._compute_audio_frame_bytes() self._assistant_id = str(assistant_id or "").strip() or None self._backend_gateway = backend_gateway or build_backend_adapter_from_settings() self._history_bridge = SessionHistoryBridge( @@ -210,11 +211,14 @@ class Session: ) return - frame_bytes = self.AUDIO_FRAME_BYTES + frame_bytes = getattr(self, "audio_frame_bytes", self._compute_audio_frame_bytes()) if len(audio_bytes) % frame_bytes != 0: await self._send_error( "client", - f"Audio frame size must be a multiple of {frame_bytes} bytes (20ms PCM)", + ( + f"Audio frame size must be a multiple of {frame_bytes} bytes " + f"({settings.chunk_size_ms}ms PCM @ {settings.sample_rate}Hz)" + ), "audio.frame_size_mismatch", stage="audio", retryable=False, @@ -384,6 +388,7 @@ class Session: ev( "session.started", trackId=self.current_track_id, + protocolVersion=self._public_ws_protocol_version(), tracks={ "audio_in": self.TRACK_AUDIO_IN, "audio_out": self.TRACK_AUDIO_OUT, @@ -1137,6 +1142,7 @@ class Session: output_mode = str(runtime_output.get("mode") or "").strip().lower() if isinstance(runtime_output, dict) else "" if output_mode not in {"audio", "text"}: output_mode = "audio" + output_codec = str(runtime_output.get("codec") or settings.default_codec or "pcm").strip().lower() or "pcm" tools_allowlist: List[str] = [] runtime_tools = runtime.get("tools", {}) if isinstance(runtime, dict) else {} @@ -1146,7 +1152,11 @@ class Session: tools_allowlist = [str(item) for item in allowlist if item is not None and str(item).strip()] resolved: Dict[str, Any] = { - "output": {"mode": output_mode}, + "protocolVersion": self._public_ws_protocol_version(), + "output": { + "mode": output_mode, + "codec": output_codec, + }, "tools": { "enabled": bool(tools_allowlist), "count": len(tools_allowlist), @@ -1162,6 +1172,24 @@ class Session: return resolved + @staticmethod + def _compute_audio_frame_bytes() -> int: + """Compute expected PCM frame bytes from SAMPLE_RATE and CHUNK_SIZE_MS.""" + sample_rate = max(1, int(getattr(settings, "sample_rate", 16000))) + chunk_ms = max(1, int(getattr(settings, "chunk_size_ms", 20))) + bytes_per_frame = int(round(sample_rate * 2 * (chunk_ms / 1000.0))) + if bytes_per_frame < 2: + bytes_per_frame = 2 + if bytes_per_frame % 2 != 0: + bytes_per_frame += 1 + return bytes_per_frame + + @staticmethod + def _public_ws_protocol_version() -> str: + """Return public protocol version label announced to clients.""" + version = str(getattr(settings, "ws_protocol_version", "v1") or "v1").strip() + return version or "v1" + def _extract_json_obj(self, text: str) -> Optional[Dict[str, Any]]: """Best-effort extraction of a JSON object from freeform text.""" try: diff --git a/engine/tests/test_ws_protocol_session_start.py b/engine/tests/test_ws_protocol_session_start.py index 90ac179..ac15b16 100644 --- a/engine/tests/test_ws_protocol_session_start.py +++ b/engine/tests/test_ws_protocol_session_start.py @@ -290,6 +290,8 @@ async def test_handle_session_start_applies_whitelisted_overrides_and_ignores_wo @pytest.mark.asyncio async def test_handle_session_start_emits_config_resolved_when_enabled(monkeypatch): monkeypatch.setattr("core.session.settings.ws_emit_config_resolved", True) + monkeypatch.setattr("core.session.settings.ws_protocol_version", "v1-custom") + monkeypatch.setattr("core.session.settings.default_codec", "pcmu") session = Session.__new__(Session) session.id = "sess_start_emit_config" @@ -368,10 +370,46 @@ async def test_handle_session_start_emits_config_resolved_when_enabled(monkeypat ) config_event = next(item for item in events if item.get("type") == "config.resolved") + session_started_event = next(item for item in events if item.get("type") == "session.started") + assert session_started_event["protocolVersion"] == "v1-custom" assert "appId" not in config_event["config"] assert "configVersionId" not in config_event["config"] assert "services" not in config_event["config"] + assert config_event["config"]["protocolVersion"] == "v1-custom" assert config_event["config"]["channel"] == "web_debug" assert config_event["config"]["output"]["mode"] == "text" + assert config_event["config"]["output"]["codec"] == "pcmu" assert config_event["config"]["tools"]["enabled"] is True assert config_event["config"]["tools"]["count"] == 1 + + +@pytest.mark.asyncio +async def test_handle_audio_uses_chunk_size_for_frame_validation(monkeypatch): + monkeypatch.setattr("core.session.settings.sample_rate", 16000) + monkeypatch.setattr("core.session.settings.chunk_size_ms", 10) + + session = Session.__new__(Session) + session.id = "sess_chunk_frame" + session.ws_state = WsSessionState.ACTIVE + + class _Pipeline: + def __init__(self): + self.frames = [] + + async def process_audio(self, frame: bytes): + self.frames.append(frame) + + session.pipeline = _Pipeline() + errors = [] + + async def _send_error(sender, message, code, **kwargs): + _ = (sender, kwargs) + errors.append((code, message)) + + session._send_error = _send_error + payload = b"\x00\x01" * 320 # 640 bytes = 2 frames when chunk_size_ms=10 + await session.handle_audio(payload) + + assert errors == [] + assert len(session.pipeline.frames) == 2 + assert all(len(frame) == 320 for frame in session.pipeline.frames) From 4e2450e8004d99c6e23c88e4ffc85ff5dba55c05 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 6 Mar 2026 09:00:43 +0800 Subject: [PATCH 04/20] Refactor backend integration and service architecture - Removed the backend client compatibility wrapper and associated methods to streamline backend integration. - Updated session management to utilize control plane gateways and runtime configuration providers. - Adjusted TTS service implementations to remove the EdgeTTS service and simplify service dependencies. - Enhanced documentation to reflect changes in backend integration and service architecture. - Updated configuration files to remove deprecated TTS provider options and clarify available settings. --- engine/app/backend_client.py | 87 ------ engine/app/config.py | 2 +- engine/app/main.py | 6 +- engine/app/service_factory.py | 112 ++++++++ engine/config/agents/example.yaml | 2 +- engine/config/agents/tools.yaml | 2 +- engine/core/duplex_pipeline.py | 129 ++++----- engine/core/history_bridge.py | 6 +- engine/core/ports/__init__.py | 37 ++- engine/core/ports/asr.py | 64 +++++ .../ports/{backend.py => control_plane.py} | 27 +- engine/core/ports/llm.py | 67 +++++ engine/core/ports/service_factory.py | 22 ++ engine/core/ports/tts.py | 41 +++ engine/core/session.py | 36 ++- engine/docs/backend_integration.md | 3 +- engine/docs/extension_ports.md | 47 ++++ engine/docs/high_level_architecture.md | 129 +++++++++ engine/requirements.txt | 4 - engine/services/__init__.py | 3 +- engine/services/tts.py | 254 ++---------------- .../tests/test_ws_protocol_session_start.py | 4 +- 22 files changed, 632 insertions(+), 452 deletions(-) delete mode 100644 engine/app/backend_client.py create mode 100644 engine/app/service_factory.py create mode 100644 engine/core/ports/asr.py rename engine/core/ports/{backend.py => control_plane.py} (75%) create mode 100644 engine/core/ports/llm.py create mode 100644 engine/core/ports/service_factory.py create mode 100644 engine/core/ports/tts.py create mode 100644 engine/docs/extension_ports.md create mode 100644 engine/docs/high_level_architecture.md diff --git a/engine/app/backend_client.py b/engine/app/backend_client.py deleted file mode 100644 index 93ea183..0000000 --- a/engine/app/backend_client.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Compatibility wrappers around backend adapter implementations.""" - -from __future__ import annotations - -from typing import Any, Dict, List, Optional - -from app.backend_adapters import build_backend_adapter_from_settings - - -def _adapter(): - return build_backend_adapter_from_settings() - - -async def fetch_assistant_config(assistant_id: str) -> Optional[Dict[str, Any]]: - """Fetch assistant config payload from backend adapter.""" - return await _adapter().fetch_assistant_config(assistant_id) - - -async def create_history_call_record( - *, - user_id: int, - assistant_id: Optional[str], - source: str = "debug", -) -> Optional[str]: - """Create a call record via backend history API and return call_id.""" - return await _adapter().create_call_record( - user_id=user_id, - assistant_id=assistant_id, - source=source, - ) - - -async def add_history_transcript( - *, - call_id: str, - turn_index: int, - speaker: str, - content: str, - start_ms: int, - end_ms: int, - confidence: Optional[float] = None, - duration_ms: Optional[int] = None, -) -> bool: - """Append a transcript segment to backend history.""" - return await _adapter().add_transcript( - call_id=call_id, - turn_index=turn_index, - speaker=speaker, - content=content, - start_ms=start_ms, - end_ms=end_ms, - confidence=confidence, - duration_ms=duration_ms, - ) - - -async def finalize_history_call_record( - *, - call_id: str, - status: str, - duration_seconds: int, -) -> bool: - """Finalize a call record with status and duration.""" - return await _adapter().finalize_call_record( - call_id=call_id, - status=status, - duration_seconds=duration_seconds, - ) - - -async def search_knowledge_context( - *, - kb_id: str, - query: str, - n_results: int = 5, -) -> List[Dict[str, Any]]: - """Search backend knowledge base and return retrieval results.""" - return await _adapter().search_knowledge_context( - kb_id=kb_id, - query=query, - n_results=n_results, - ) - - -async def fetch_tool_resource(tool_id: str) -> Optional[Dict[str, Any]]: - """Fetch tool resource configuration from backend API.""" - return await _adapter().fetch_tool_resource(tool_id) diff --git a/engine/app/config.py b/engine/app/config.py index d1ac72f..233ba75 100644 --- a/engine/app/config.py +++ b/engine/app/config.py @@ -71,7 +71,7 @@ class Settings(BaseSettings): # TTS Configuration tts_provider: str = Field( default="openai_compatible", - description="TTS provider (edge, openai_compatible, siliconflow, dashscope)" + description="TTS provider (openai_compatible, siliconflow, dashscope)" ) tts_api_url: Optional[str] = Field(default=None, description="TTS provider API URL") tts_model: Optional[str] = Field(default=None, description="TTS model name") diff --git a/engine/app/main.py b/engine/app/main.py index bd513f6..5625061 100644 --- a/engine/app/main.py +++ b/engine/app/main.py @@ -76,7 +76,7 @@ app.add_middleware( # Active sessions storage active_sessions: Dict[str, Session] = {} -backend_gateway = build_backend_adapter_from_settings() +control_plane_gateway = build_backend_adapter_from_settings() # Configure logging logger.remove() @@ -187,7 +187,7 @@ async def websocket_endpoint(websocket: WebSocket): session = Session( session_id, transport, - backend_gateway=backend_gateway, + control_plane_gateway=control_plane_gateway, assistant_id=assistant_id, ) active_sessions[session_id] = session @@ -272,7 +272,7 @@ async def webrtc_endpoint(websocket: WebSocket): session = Session( session_id, transport, - backend_gateway=backend_gateway, + control_plane_gateway=control_plane_gateway, assistant_id=assistant_id, ) active_sessions[session_id] = session diff --git a/engine/app/service_factory.py b/engine/app/service_factory.py new file mode 100644 index 0000000..6bdb64c --- /dev/null +++ b/engine/app/service_factory.py @@ -0,0 +1,112 @@ +"""Default runtime service factory implementing core extension ports.""" + +from __future__ import annotations + +from typing import Any + +from loguru import logger + +from core.ports import ( + ASRPort, + ASRServiceSpec, + LLMPort, + LLMServiceSpec, + RealtimeServiceFactory, + TTSPort, + TTSServiceSpec, +) +from services.asr import BufferedASRService +from services.dashscope_tts import DashScopeTTSService +from services.llm import MockLLMService, OpenAILLMService +from services.openai_compatible_asr import OpenAICompatibleASRService +from services.openai_compatible_tts import OpenAICompatibleTTSService +from services.tts import MockTTSService + +_OPENAI_COMPATIBLE_PROVIDERS = {"openai_compatible", "openai-compatible", "siliconflow"} +_SUPPORTED_LLM_PROVIDERS = {"openai", *_OPENAI_COMPATIBLE_PROVIDERS} + + +class DefaultRealtimeServiceFactory(RealtimeServiceFactory): + """Build concrete runtime services from normalized specs.""" + + _DEFAULT_DASHSCOPE_TTS_REALTIME_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" + _DEFAULT_DASHSCOPE_TTS_MODEL = "qwen3-tts-flash-realtime" + _DEFAULT_OPENAI_COMPATIBLE_TTS_MODEL = "FunAudioLLM/CosyVoice2-0.5B" + _DEFAULT_OPENAI_COMPATIBLE_ASR_MODEL = "FunAudioLLM/SenseVoiceSmall" + + @staticmethod + def _normalize_provider(provider: Any) -> str: + return str(provider or "").strip().lower() + + @staticmethod + def _resolve_dashscope_mode(raw_mode: Any) -> str: + mode = str(raw_mode or "commit").strip().lower() + if mode in {"commit", "server_commit"}: + return mode + return "commit" + + def create_llm_service(self, spec: LLMServiceSpec) -> LLMPort: + provider = self._normalize_provider(spec.provider) + if provider in _SUPPORTED_LLM_PROVIDERS and spec.api_key: + return OpenAILLMService( + api_key=spec.api_key, + base_url=spec.base_url, + model=spec.model, + system_prompt=spec.system_prompt, + knowledge_config=spec.knowledge_config, + knowledge_searcher=spec.knowledge_searcher, + ) + + logger.warning( + "LLM provider unsupported or API key missing (provider={}); using mock LLM", + provider or "-", + ) + return MockLLMService() + + def create_tts_service(self, spec: TTSServiceSpec) -> TTSPort: + provider = self._normalize_provider(spec.provider) + + if provider == "dashscope" and spec.api_key: + return DashScopeTTSService( + api_key=spec.api_key, + api_url=spec.api_url or self._DEFAULT_DASHSCOPE_TTS_REALTIME_URL, + voice=spec.voice, + model=spec.model or self._DEFAULT_DASHSCOPE_TTS_MODEL, + mode=self._resolve_dashscope_mode(spec.mode), + sample_rate=spec.sample_rate, + speed=spec.speed, + ) + + if provider in _OPENAI_COMPATIBLE_PROVIDERS and spec.api_key: + return OpenAICompatibleTTSService( + api_key=spec.api_key, + api_url=spec.api_url, + voice=spec.voice, + model=spec.model or self._DEFAULT_OPENAI_COMPATIBLE_TTS_MODEL, + sample_rate=spec.sample_rate, + speed=spec.speed, + ) + + logger.warning( + "TTS provider unsupported or API key missing (provider={}); using mock TTS", + provider or "-", + ) + return MockTTSService(sample_rate=spec.sample_rate) + + def create_asr_service(self, spec: ASRServiceSpec) -> ASRPort: + provider = self._normalize_provider(spec.provider) + + if provider in _OPENAI_COMPATIBLE_PROVIDERS and spec.api_key: + return OpenAICompatibleASRService( + api_key=spec.api_key, + api_url=spec.api_url, + model=spec.model or self._DEFAULT_OPENAI_COMPATIBLE_ASR_MODEL, + sample_rate=spec.sample_rate, + language=spec.language, + interim_interval_ms=spec.interim_interval_ms, + min_audio_for_interim_ms=spec.min_audio_for_interim_ms, + on_transcript=spec.on_transcript, + ) + + logger.info("Using buffered ASR service (provider={})", provider or "-") + return BufferedASRService(sample_rate=spec.sample_rate, language=spec.language) diff --git a/engine/config/agents/example.yaml b/engine/config/agents/example.yaml index dd0e927..70f4933 100644 --- a/engine/config/agents/example.yaml +++ b/engine/config/agents/example.yaml @@ -21,7 +21,7 @@ agent: api_url: https://api.qnaigc.com/v1 tts: - # provider: edge | openai_compatible | siliconflow | dashscope + # provider: openai_compatible | siliconflow | dashscope # dashscope defaults (if omitted): # api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime # model: qwen3-tts-flash-realtime diff --git a/engine/config/agents/tools.yaml b/engine/config/agents/tools.yaml index 4d8bd72..e2968bb 100644 --- a/engine/config/agents/tools.yaml +++ b/engine/config/agents/tools.yaml @@ -18,7 +18,7 @@ agent: api_url: https://api.qnaigc.com/v1 tts: - # provider: edge | openai_compatible | siliconflow | dashscope + # provider: openai_compatible | siliconflow | dashscope # dashscope defaults (if omitted): # api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime # model: qwen3-tts-flash-realtime diff --git a/engine/core/duplex_pipeline.py b/engine/core/duplex_pipeline.py index d6c81ee..bbf3d47 100644 --- a/engine/core/duplex_pipeline.py +++ b/engine/core/duplex_pipeline.py @@ -26,21 +26,25 @@ import aiohttp from loguru import logger from app.config import settings +from app.service_factory import DefaultRealtimeServiceFactory from core.conversation import ConversationManager, ConversationState from core.events import get_event_bus +from core.ports import ( + ASRPort, + ASRServiceSpec, + LLMPort, + LLMServiceSpec, + RealtimeServiceFactory, + TTSPort, + TTSServiceSpec, +) from core.tool_executor import execute_server_tool from core.transports import BaseTransport from models.ws_v1 import ev from processors.eou import EouDetector from processors.vad import SileroVAD, VADProcessor -from services.asr import BufferedASRService -from services.base import BaseASRService, BaseLLMService, BaseTTSService, LLMMessage, LLMStreamEvent -from services.dashscope_tts import DashScopeTTSService -from services.llm import MockLLMService, OpenAILLMService -from services.openai_compatible_asr import OpenAICompatibleASRService -from services.openai_compatible_tts import OpenAICompatibleTTSService +from services.base import LLMMessage, LLMStreamEvent from services.streaming_text import extract_tts_sentence, has_spoken_content -from services.tts import EdgeTTSService, MockTTSService class DuplexPipeline: @@ -258,9 +262,9 @@ class DuplexPipeline: self, transport: BaseTransport, session_id: str, - llm_service: Optional[BaseLLMService] = None, - tts_service: Optional[BaseTTSService] = None, - asr_service: Optional[BaseASRService] = None, + llm_service: Optional[LLMPort] = None, + tts_service: Optional[TTSPort] = None, + asr_service: Optional[ASRPort] = None, system_prompt: Optional[str] = None, greeting: Optional[str] = None, knowledge_searcher: Optional[ @@ -272,6 +276,7 @@ class DuplexPipeline: server_tool_executor: Optional[ Callable[[Dict[str, Any]], Awaitable[Dict[str, Any]]] ] = None, + service_factory: Optional[RealtimeServiceFactory] = None, ): """ Initialize duplex pipeline. @@ -279,8 +284,8 @@ class DuplexPipeline: Args: transport: Transport for sending audio/events session_id: Session identifier - llm_service: LLM service (defaults to OpenAI) - tts_service: TTS service (defaults to EdgeTTS) + llm_service: Optional injected LLM port implementation + tts_service: Optional injected TTS port implementation asr_service: ASR service (optional) system_prompt: System prompt for LLM greeting: Optional greeting to speak on start @@ -312,6 +317,7 @@ class DuplexPipeline: self.llm_service = llm_service self.tts_service = tts_service self.asr_service = asr_service # Will be initialized in start() + self._service_factory = service_factory or DefaultRealtimeServiceFactory() self._knowledge_searcher = knowledge_searcher self._tool_resource_resolver = tool_resource_resolver self._server_tool_executor = server_tool_executor @@ -776,21 +782,11 @@ class DuplexPipeline: return False return None - @staticmethod - def _is_openai_compatible_provider(provider: Any) -> bool: - normalized = str(provider or "").strip().lower() - return normalized in {"openai_compatible", "openai-compatible", "siliconflow"} - @staticmethod def _is_dashscope_tts_provider(provider: Any) -> bool: normalized = str(provider or "").strip().lower() return normalized == "dashscope" - @staticmethod - def _is_llm_provider_supported(provider: Any) -> bool: - normalized = str(provider or "").strip().lower() - return normalized in {"openai", "openai_compatible", "openai-compatible", "siliconflow"} - @staticmethod def _default_llm_base_url(provider: Any) -> Optional[str]: normalized = str(provider or "").strip().lower() @@ -798,10 +794,6 @@ class DuplexPipeline: return "https://api.siliconflow.cn/v1" return None - @staticmethod - def _default_dashscope_tts_realtime_url() -> str: - return "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" - @staticmethod def _default_dashscope_tts_model() -> str: return "qwen3-tts-flash-realtime" @@ -900,18 +892,18 @@ class DuplexPipeline: or self._default_llm_base_url(llm_provider) ) llm_model = self._runtime_llm.get("model") or settings.llm_model - - if self._is_llm_provider_supported(llm_provider) and llm_api_key: - self.llm_service = OpenAILLMService( - api_key=llm_api_key, - base_url=llm_base_url, - model=llm_model, + self.llm_service = self._service_factory.create_llm_service( + LLMServiceSpec( + provider=llm_provider, + model=str(llm_model), + api_key=str(llm_api_key).strip() if llm_api_key else None, + base_url=str(llm_base_url).strip() if llm_base_url else None, + system_prompt=self.conversation.system_prompt, + temperature=settings.llm_temperature, knowledge_config=self._resolved_knowledge_config(), knowledge_searcher=self._knowledge_searcher, ) - else: - logger.warning("LLM provider unsupported or API key missing - using mock LLM") - self.llm_service = MockLLMService() + ) if hasattr(self.llm_service, "set_knowledge_config"): self.llm_service.set_knowledge_config(self._resolved_knowledge_config()) @@ -938,41 +930,29 @@ class DuplexPipeline: "services.tts.mode is DashScope-only and will be ignored " f"for provider={tts_provider}" ) - - if self._is_dashscope_tts_provider(tts_provider) and tts_api_key: - self.tts_service = DashScopeTTSService( - api_key=tts_api_key, - api_url=tts_api_url or self._default_dashscope_tts_realtime_url(), - voice=tts_voice, - model=tts_model or self._default_dashscope_tts_model(), + self.tts_service = self._service_factory.create_tts_service( + TTSServiceSpec( + provider=tts_provider, + api_key=str(tts_api_key).strip() if tts_api_key else None, + api_url=str(tts_api_url).strip() if tts_api_url else None, + voice=str(tts_voice), + model=str(tts_model).strip() if tts_model else None, + sample_rate=settings.sample_rate, + speed=tts_speed, mode=str(tts_mode), - sample_rate=settings.sample_rate, - speed=tts_speed ) - logger.info("Using DashScope realtime TTS service") - elif self._is_openai_compatible_provider(tts_provider) and tts_api_key: - self.tts_service = OpenAICompatibleTTSService( - api_key=tts_api_key, - api_url=tts_api_url, - voice=tts_voice, - model=tts_model or "FunAudioLLM/CosyVoice2-0.5B", - sample_rate=settings.sample_rate, - speed=tts_speed - ) - logger.info(f"Using OpenAI-compatible TTS service (provider={tts_provider})") - else: - self.tts_service = EdgeTTSService( - voice=tts_voice, - sample_rate=settings.sample_rate - ) - logger.info("Using Edge TTS service") + ) try: await self.tts_service.connect() except Exception as e: - logger.warning(f"TTS backend unavailable ({e}); falling back to MockTTS") - self.tts_service = MockTTSService( - sample_rate=settings.sample_rate + logger.warning(f"TTS backend unavailable ({e}); falling back to default TTS adapter") + self.tts_service = self._service_factory.create_tts_service( + TTSServiceSpec( + provider="mock", + voice="mock", + sample_rate=settings.sample_rate, + ) ) await self.tts_service.connect() else: @@ -988,22 +968,19 @@ class DuplexPipeline: asr_interim_interval = int(self._runtime_asr.get("interimIntervalMs") or settings.asr_interim_interval_ms) asr_min_audio_ms = int(self._runtime_asr.get("minAudioMs") or settings.asr_min_audio_ms) - if self._is_openai_compatible_provider(asr_provider) and asr_api_key: - self.asr_service = OpenAICompatibleASRService( - api_key=asr_api_key, - api_url=asr_api_url, - model=asr_model or "FunAudioLLM/SenseVoiceSmall", + self.asr_service = self._service_factory.create_asr_service( + ASRServiceSpec( + provider=asr_provider, sample_rate=settings.sample_rate, + language="auto", + api_key=str(asr_api_key).strip() if asr_api_key else None, + api_url=str(asr_api_url).strip() if asr_api_url else None, + model=str(asr_model).strip() if asr_model else None, interim_interval_ms=asr_interim_interval, min_audio_for_interim_ms=asr_min_audio_ms, - on_transcript=self._on_transcript_callback + on_transcript=self._on_transcript_callback, ) - logger.info(f"Using OpenAI-compatible ASR service (provider={asr_provider})") - else: - self.asr_service = BufferedASRService( - sample_rate=settings.sample_rate - ) - logger.info("Using Buffered ASR service (no real transcription)") + ) await self.asr_service.connect() diff --git a/engine/core/history_bridge.py b/engine/core/history_bridge.py index ead9a3b..70a681b 100644 --- a/engine/core/history_bridge.py +++ b/engine/core/history_bridge.py @@ -5,10 +5,12 @@ from __future__ import annotations import asyncio import time from dataclasses import dataclass -from typing import Any, Optional +from typing import Optional from loguru import logger +from core.ports import ConversationHistoryStore + @dataclass class _HistoryTranscriptJob: @@ -29,7 +31,7 @@ class SessionHistoryBridge: def __init__( self, *, - history_writer: Any, + history_writer: ConversationHistoryStore | None, enabled: bool, queue_max_size: int, retry_max_attempts: int, diff --git a/engine/core/ports/__init__.py b/engine/core/ports/__init__.py index 7d7c9dd..2ae96c4 100644 --- a/engine/core/ports/__init__.py +++ b/engine/core/ports/__init__.py @@ -1,17 +1,32 @@ """Port interfaces for engine-side integration boundaries.""" -from core.ports.backend import ( - AssistantConfigProvider, - BackendGateway, - HistoryWriter, - KnowledgeSearcher, - ToolResourceResolver, +from core.ports.asr import ASRBufferControl, ASRInterimControl, ASRPort, ASRServiceSpec +from core.ports.control_plane import ( + AssistantRuntimeConfigProvider, + ControlPlaneGateway, + ConversationHistoryStore, + KnowledgeRetriever, + ToolCatalog, ) +from core.ports.llm import LLMCancellable, LLMPort, LLMRuntimeConfigurable, LLMServiceSpec +from core.ports.service_factory import RealtimeServiceFactory +from core.ports.tts import TTSPort, TTSServiceSpec __all__ = [ - "AssistantConfigProvider", - "BackendGateway", - "HistoryWriter", - "KnowledgeSearcher", - "ToolResourceResolver", + "ASRPort", + "ASRServiceSpec", + "ASRInterimControl", + "ASRBufferControl", + "AssistantRuntimeConfigProvider", + "ControlPlaneGateway", + "ConversationHistoryStore", + "KnowledgeRetriever", + "ToolCatalog", + "LLMCancellable", + "LLMPort", + "LLMRuntimeConfigurable", + "LLMServiceSpec", + "RealtimeServiceFactory", + "TTSPort", + "TTSServiceSpec", ] diff --git a/engine/core/ports/asr.py b/engine/core/ports/asr.py new file mode 100644 index 0000000..fa302cd --- /dev/null +++ b/engine/core/ports/asr.py @@ -0,0 +1,64 @@ +"""ASR extension port contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import AsyncIterator, Awaitable, Callable, Optional, Protocol + +from services.base import ASRResult + +TranscriptCallback = Callable[[str, bool], Awaitable[None]] + + +@dataclass(frozen=True) +class ASRServiceSpec: + """Resolved runtime configuration for ASR service creation.""" + + provider: str + sample_rate: int + language: str = "auto" + api_key: Optional[str] = None + api_url: Optional[str] = None + model: Optional[str] = None + interim_interval_ms: int = 500 + min_audio_for_interim_ms: int = 300 + on_transcript: Optional[TranscriptCallback] = None + + +class ASRPort(Protocol): + """Port for speech recognition providers.""" + + async def connect(self) -> None: + """Establish connection to ASR provider.""" + + async def disconnect(self) -> None: + """Release ASR resources.""" + + async def send_audio(self, audio: bytes) -> None: + """Push one PCM audio chunk for recognition.""" + + async def receive_transcripts(self) -> AsyncIterator[ASRResult]: + """Stream partial/final recognition results.""" + + +class ASRInterimControl(Protocol): + """Optional extension for explicit interim transcription control.""" + + async def start_interim_transcription(self) -> None: + """Start interim transcription loop if supported.""" + + async def stop_interim_transcription(self) -> None: + """Stop interim transcription loop if supported.""" + + +class ASRBufferControl(Protocol): + """Optional extension for explicit ASR buffer lifecycle control.""" + + def clear_buffer(self) -> None: + """Clear provider-side ASR buffer.""" + + async def get_final_transcription(self) -> str: + """Return final transcription for the current utterance.""" + + def get_and_clear_text(self) -> str: + """Return buffered text and clear internal state.""" diff --git a/engine/core/ports/backend.py b/engine/core/ports/control_plane.py similarity index 75% rename from engine/core/ports/backend.py rename to engine/core/ports/control_plane.py index 227c743..c50d642 100644 --- a/engine/core/ports/backend.py +++ b/engine/core/ports/control_plane.py @@ -1,7 +1,7 @@ -"""Backend integration ports. +"""Control-plane integration ports. These interfaces define the boundary between engine runtime logic and -backend-side capabilities (config lookup, history persistence, retrieval, +control-plane capabilities (config lookup, history persistence, retrieval, and tool resource discovery). """ @@ -10,14 +10,14 @@ from __future__ import annotations from typing import Any, Dict, List, Optional, Protocol -class AssistantConfigProvider(Protocol): +class AssistantRuntimeConfigProvider(Protocol): """Port for loading trusted assistant runtime configuration.""" async def fetch_assistant_config(self, assistant_id: str) -> Optional[Dict[str, Any]]: """Fetch assistant configuration payload.""" -class HistoryWriter(Protocol): +class ConversationHistoryStore(Protocol): """Port for persisting call and transcript history.""" async def create_call_record( @@ -27,7 +27,7 @@ class HistoryWriter(Protocol): assistant_id: Optional[str], source: str = "debug", ) -> Optional[str]: - """Create a call record and return backend call ID.""" + """Create a call record and return control-plane call ID.""" async def add_transcript( self, @@ -53,7 +53,7 @@ class HistoryWriter(Protocol): """Finalize a call record.""" -class KnowledgeSearcher(Protocol): +class KnowledgeRetriever(Protocol): """Port for RAG / knowledge retrieval operations.""" async def search_knowledge_context( @@ -66,19 +66,18 @@ class KnowledgeSearcher(Protocol): """Search a knowledge source and return ranked snippets.""" -class ToolResourceResolver(Protocol): +class ToolCatalog(Protocol): """Port for resolving tool metadata/configuration.""" async def fetch_tool_resource(self, tool_id: str) -> Optional[Dict[str, Any]]: """Fetch tool resource configuration.""" -class BackendGateway( - AssistantConfigProvider, - HistoryWriter, - KnowledgeSearcher, - ToolResourceResolver, +class ControlPlaneGateway( + AssistantRuntimeConfigProvider, + ConversationHistoryStore, + KnowledgeRetriever, + ToolCatalog, Protocol, ): - """Composite backend gateway interface used by engine services.""" - + """Composite control-plane gateway used by engine services.""" diff --git a/engine/core/ports/llm.py b/engine/core/ports/llm.py new file mode 100644 index 0000000..ca515ac --- /dev/null +++ b/engine/core/ports/llm.py @@ -0,0 +1,67 @@ +"""LLM extension port contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Awaitable, Callable, Dict, List, Optional, Protocol + +from services.base import LLMMessage, LLMStreamEvent + +KnowledgeRetrieverFn = Callable[..., Awaitable[List[Dict[str, Any]]]] + + +@dataclass(frozen=True) +class LLMServiceSpec: + """Resolved runtime configuration for LLM service creation.""" + + provider: str + model: str + api_key: Optional[str] = None + base_url: Optional[str] = None + system_prompt: Optional[str] = None + temperature: float = 0.7 + knowledge_config: Dict[str, Any] = field(default_factory=dict) + knowledge_searcher: Optional[KnowledgeRetrieverFn] = None + + +class LLMPort(Protocol): + """Port for LLM providers.""" + + async def connect(self) -> None: + """Establish connection to LLM provider.""" + + async def disconnect(self) -> None: + """Release LLM resources.""" + + async def generate( + self, + messages: List[LLMMessage], + temperature: float = 0.7, + max_tokens: Optional[int] = None, + ) -> str: + """Generate a complete assistant response.""" + + async def generate_stream( + self, + messages: List[LLMMessage], + temperature: float = 0.7, + max_tokens: Optional[int] = None, + ) -> AsyncIterator[LLMStreamEvent]: + """Generate streaming assistant response events.""" + + +class LLMCancellable(Protocol): + """Optional extension for interrupting in-flight LLM generation.""" + + def cancel(self) -> None: + """Cancel an in-flight generation request.""" + + +class LLMRuntimeConfigurable(Protocol): + """Optional extension for runtime config updates.""" + + def set_knowledge_config(self, config: Optional[Dict[str, Any]]) -> None: + """Apply runtime knowledge retrieval settings.""" + + def set_tool_schemas(self, schemas: Optional[List[Dict[str, Any]]]) -> None: + """Apply runtime tool schemas used for tool calling.""" diff --git a/engine/core/ports/service_factory.py b/engine/core/ports/service_factory.py new file mode 100644 index 0000000..d1d8476 --- /dev/null +++ b/engine/core/ports/service_factory.py @@ -0,0 +1,22 @@ +"""Factory port for creating runtime ASR/LLM/TTS services.""" + +from __future__ import annotations + +from typing import Protocol + +from core.ports.asr import ASRPort, ASRServiceSpec +from core.ports.llm import LLMPort, LLMServiceSpec +from core.ports.tts import TTSPort, TTSServiceSpec + + +class RealtimeServiceFactory(Protocol): + """Port for provider-specific service construction.""" + + def create_llm_service(self, spec: LLMServiceSpec) -> LLMPort: + """Create an LLM service instance from a resolved spec.""" + + def create_tts_service(self, spec: TTSServiceSpec) -> TTSPort: + """Create a TTS service instance from a resolved spec.""" + + def create_asr_service(self, spec: ASRServiceSpec) -> ASRPort: + """Create an ASR service instance from a resolved spec.""" diff --git a/engine/core/ports/tts.py b/engine/core/ports/tts.py new file mode 100644 index 0000000..0693cdb --- /dev/null +++ b/engine/core/ports/tts.py @@ -0,0 +1,41 @@ +"""TTS extension port contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import AsyncIterator, Optional, Protocol + +from services.base import TTSChunk + + +@dataclass(frozen=True) +class TTSServiceSpec: + """Resolved runtime configuration for TTS service creation.""" + + provider: str + voice: str + sample_rate: int + speed: float = 1.0 + api_key: Optional[str] = None + api_url: Optional[str] = None + model: Optional[str] = None + mode: str = "commit" + + +class TTSPort(Protocol): + """Port for speech synthesis providers.""" + + async def connect(self) -> None: + """Establish connection to TTS provider.""" + + async def disconnect(self) -> None: + """Release TTS resources.""" + + async def synthesize(self, text: str) -> bytes: + """Synthesize complete PCM payload for text.""" + + async def synthesize_stream(self, text: str) -> AsyncIterator[TTSChunk]: + """Stream synthesized PCM chunks for text.""" + + async def cancel(self) -> None: + """Cancel an in-flight synthesis request.""" diff --git a/engine/core/session.py b/engine/core/session.py index bc8bab8..eff77a6 100644 --- a/engine/core/session.py +++ b/engine/core/session.py @@ -11,6 +11,13 @@ from loguru import logger from app.backend_adapters import build_backend_adapter_from_settings from core.transports import BaseTransport +from core.ports import ( + AssistantRuntimeConfigProvider, + ControlPlaneGateway, + ConversationHistoryStore, + KnowledgeRetriever, + ToolCatalog, +) from core.duplex_pipeline import DuplexPipeline from core.conversation import ConversationTurn from core.history_bridge import SessionHistoryBridge @@ -97,7 +104,11 @@ class Session: session_id: str, transport: BaseTransport, use_duplex: bool = None, - backend_gateway: Optional[Any] = None, + control_plane_gateway: Optional[ControlPlaneGateway] = None, + runtime_config_provider: Optional[AssistantRuntimeConfigProvider] = None, + history_store: Optional[ConversationHistoryStore] = None, + knowledge_retriever: Optional[KnowledgeRetriever] = None, + tool_catalog: Optional[ToolCatalog] = None, assistant_id: Optional[str] = None, ): """ @@ -107,15 +118,24 @@ class Session: session_id: Unique session identifier transport: Transport instance for communication use_duplex: Whether to use duplex pipeline (defaults to settings.duplex_enabled) + control_plane_gateway: Optional composite control-plane dependency + runtime_config_provider: Optional assistant runtime config provider + history_store: Optional conversation history store + knowledge_retriever: Optional knowledge retrieval dependency + tool_catalog: Optional tool resource catalog """ self.id = session_id self.transport = transport self.use_duplex = use_duplex if use_duplex is not None else settings.duplex_enabled self.audio_frame_bytes = self._compute_audio_frame_bytes() self._assistant_id = str(assistant_id or "").strip() or None - self._backend_gateway = backend_gateway or build_backend_adapter_from_settings() + self._control_plane_gateway = control_plane_gateway or build_backend_adapter_from_settings() + self._runtime_config_provider = runtime_config_provider or self._control_plane_gateway + self._history_store = history_store or self._control_plane_gateway + self._knowledge_retriever = knowledge_retriever or self._control_plane_gateway + self._tool_catalog = tool_catalog or self._control_plane_gateway self._history_bridge = SessionHistoryBridge( - history_writer=self._backend_gateway, + history_writer=self._history_store, enabled=settings.history_enabled, queue_max_size=settings.history_queue_max_size, retry_max_attempts=settings.history_retry_max_attempts, @@ -128,8 +148,8 @@ class Session: session_id=session_id, system_prompt=settings.duplex_system_prompt, greeting=settings.duplex_greeting, - knowledge_searcher=getattr(self._backend_gateway, "search_knowledge_context", None), - tool_resource_resolver=getattr(self._backend_gateway, "fetch_tool_resource", None), + knowledge_searcher=getattr(self._knowledge_retriever, "search_knowledge_context", None), + tool_resource_resolver=getattr(self._tool_catalog, "fetch_tool_resource", None), ) # Session state @@ -935,18 +955,18 @@ class Session: self, assistant_id: str, ) -> tuple[Dict[str, Any], Optional[Dict[str, str]]]: - """Load trusted runtime metadata from backend assistant config.""" + """Load trusted runtime metadata from control-plane assistant config.""" if not assistant_id: return {}, { "code": "protocol.assistant_id_required", "message": "Missing required query parameter assistant_id", } - provider = getattr(self._backend_gateway, "fetch_assistant_config", None) + provider = getattr(self._runtime_config_provider, "fetch_assistant_config", None) if not callable(provider): return {}, { "code": "assistant.config_unavailable", - "message": "Assistant config backend unavailable", + "message": "Assistant config control plane unavailable", } payload = await provider(str(assistant_id).strip()) diff --git a/engine/docs/backend_integration.md b/engine/docs/backend_integration.md index e8165fd..bcd44fc 100644 --- a/engine/docs/backend_integration.md +++ b/engine/docs/backend_integration.md @@ -27,9 +27,8 @@ Assistant config source behavior: ## Architecture -- Ports: `core/ports/backend.py` +- Ports: `core/ports/control_plane.py` - Adapters: `app/backend_adapters.py` -- Compatibility wrappers: `app/backend_client.py` `Session` and `DuplexPipeline` receive backend capabilities via injected adapter methods instead of hard-coding backend client imports. diff --git a/engine/docs/extension_ports.md b/engine/docs/extension_ports.md new file mode 100644 index 0000000..47f596a --- /dev/null +++ b/engine/docs/extension_ports.md @@ -0,0 +1,47 @@ +# Engine Extension Ports (Draft) + +This document defines the draft port set used to keep core runtime extensible. + +## Port Modules + +- `core/ports/control_plane.py` + - `AssistantRuntimeConfigProvider` + - `ConversationHistoryStore` + - `KnowledgeRetriever` + - `ToolCatalog` + - `ControlPlaneGateway` +- `core/ports/llm.py` + - `LLMServiceSpec` + - `LLMPort` + - optional extensions: `LLMCancellable`, `LLMRuntimeConfigurable` +- `core/ports/tts.py` + - `TTSServiceSpec` + - `TTSPort` +- `core/ports/asr.py` + - `ASRServiceSpec` + - `ASRPort` + - optional extensions: `ASRInterimControl`, `ASRBufferControl` +- `core/ports/service_factory.py` + - `RealtimeServiceFactory` + +## Adapter Layer + +- `app/service_factory.py` provides `DefaultRealtimeServiceFactory`. +- It maps resolved provider specs to concrete adapters. +- Core orchestration (`core/duplex_pipeline.py`) depends on the factory port/specs, not concrete provider classes. + +## Provider Behavior (Current) + +- LLM: + - supported providers: `openai`, `openai_compatible`, `openai-compatible`, `siliconflow` + - fallback: `MockLLMService` +- TTS: + - supported providers: `dashscope`, `openai_compatible`, `openai-compatible`, `siliconflow` + - fallback: `MockTTSService` +- ASR: + - supported providers: `openai_compatible`, `openai-compatible`, `siliconflow` + - fallback: `BufferedASRService` + +## Notes + +- This is a draft contract set; follow-up work can add explicit capability negotiation and contract-version fields. diff --git a/engine/docs/high_level_architecture.md b/engine/docs/high_level_architecture.md new file mode 100644 index 0000000..91e6845 --- /dev/null +++ b/engine/docs/high_level_architecture.md @@ -0,0 +1,129 @@ +# Engine High-Level Architecture + +This document describes the runtime architecture of `engine` for realtime voice/text assistant interactions. + +## Goals + +- Low-latency duplex interaction (user speaks while assistant can respond) +- Clear separation between transport, orchestration, and model/service integrations +- Backend-optional runtime (works with or without external backend) +- Protocol-first interoperability through strict WS v1 control messages + +## Top-Level Components + +```mermaid +flowchart LR + C[Client\nWeb / Mobile / Device] <-- WS v1 + PCM --> A[FastAPI App\napp/main.py] + A --> S[Session\ncore/session.py] + S --> D[Duplex Pipeline\ncore/duplex_pipeline.py] + + D --> P[Processors\nVAD / EOU / Tracks] + D --> R[Workflow Runner\ncore/workflow_runner.py] + D --> E[Event Bus + Models\ncore/events.py + models/*] + + R --> SV[Service Layer\nservices/asr.py\nservices/llm.py\nservices/tts.py] + R --> TE[Tool Executor\ncore/tool_executor.py] + + S --> HB[History Bridge\ncore/history_bridge.py] + S --> BA[Control Plane Port\ncore/ports/control_plane.py] + BA --> AD[Adapters\napp/backend_adapters.py] + + AD --> B[(External Backend API\noptional)] + SV --> M[(ASR/LLM/TTS Providers)] +``` + +## Request Lifecycle (Simplified) + +1. Client connects to `/ws?assistant_id=` and sends `session.start`. +2. App creates a `Session` with resolved assistant config (backend or local YAML). +3. Binary PCM frames enter the duplex pipeline. +4. `VAD`/`EOU` processors detect speech segments and trigger ASR finalization. +5. ASR text is routed into workflow + LLM generation. +6. Optional tool calls are executed (server-side or client-side result return). +7. LLM output streams as text deltas; TTS produces audio chunks for playback. +8. Session emits structured events (`transcript.*`, `assistant.*`, `output.audio.*`, `error`). +9. History bridge persists conversation data asynchronously. +10. On `session.stop` (or disconnect), session finalizes and drains pending writes. + +## Layering and Responsibilities + +### 1) Transport / API Layer + +- Entry point: `app/main.py` +- Responsibilities: + - WebSocket lifecycle management + - WS v1 message validation and order guarantees + - Session creation and teardown + - Converting raw WS frames into internal events + +### 2) Session + Orchestration Layer + +- Core: `core/session.py`, `core/duplex_pipeline.py`, `core/conversation.py` +- Responsibilities: + - Per-session state machine + - Turn boundaries and interruption/cancel handling + - Event sequencing (`seq`) and envelope consistency + - Bridging input/output tracks (`audio_in`, `audio_out`, `control`) + +### 3) Processing Layer + +- Modules: `processors/vad.py`, `processors/eou.py`, `processors/tracks.py` +- Responsibilities: + - Speech activity detection + - End-of-utterance decisioning + - Track-oriented routing and timing-sensitive pre/post processing + +### 4) Workflow + Tooling Layer + +- Modules: `core/workflow_runner.py`, `core/tool_executor.py` +- Responsibilities: + - Assistant workflow execution + - Tool call planning/execution and timeout handling + - Tool result normalization into protocol events + +### 5) Service Integration Layer + +- Modules: `services/*` +- Responsibilities: + - Abstracting ASR/LLM/TTS provider differences + - Streaming token/audio adaptation + - Provider-specific adapters (OpenAI-compatible, DashScope, SiliconFlow, etc.) + +### 6) Backend Integration Layer (Optional) + +- Port: `core/ports/control_plane.py` +- Adapters: `app/backend_adapters.py` +- Responsibilities: + - Fetching assistant runtime config + - Persisting call/session metadata and history + - Supporting `BACKEND_MODE=auto|http|disabled` + +### 7) Persistence / Reliability Layer + +- Module: `core/history_bridge.py` +- Responsibilities: + - Non-blocking queue-based history writes + - Retry with backoff on backend failures + - Best-effort drain on session finalize + +## Key Design Principles + +- Dependency inversion for backend: session/pipeline depend on port interfaces, not concrete clients. +- Streaming-first: text/audio are emitted incrementally to minimize perceived latency. +- Fail-soft behavior: backend/history failures should not block realtime interaction paths. +- Protocol strictness: WS v1 rejects malformed/out-of-order control traffic early. +- Explicit event model: all client-observable state changes are represented as typed events. + +## Configuration Boundaries + +- Runtime environment settings live in `app/config.py`. +- Assistant-specific behavior is loaded by `assistant_id`: + - backend mode: from backend API + - engine-only mode: local `engine/config/agents/.yaml` +- Client-provided `metadata.overrides` and `dynamicVariables` can alter runtime behavior within protocol constraints. + +## Related Docs + +- WS protocol: `engine/docs/ws_v1_schema.md` +- Backend integration details: `engine/docs/backend_integration.md` +- Duplex interaction diagram: `engine/docs/duplex_interaction.svg` diff --git a/engine/requirements.txt b/engine/requirements.txt index a32b7d2..2818030 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -29,10 +29,6 @@ aiohttp>=3.9.1 openai>=1.0.0 dashscope>=1.25.11 -# AI Services - TTS -edge-tts>=6.1.0 -pydub>=0.25.0 # For audio format conversion - # Microphone client dependencies sounddevice>=0.4.6 soundfile>=0.12.1 diff --git a/engine/services/__init__.py b/engine/services/__init__.py index 0e46834..f64ef05 100644 --- a/engine/services/__init__.py +++ b/engine/services/__init__.py @@ -14,7 +14,7 @@ from services.base import ( ) from services.llm import OpenAILLMService, MockLLMService from services.dashscope_tts import DashScopeTTSService -from services.tts import EdgeTTSService, MockTTSService +from services.tts import MockTTSService from services.asr import BufferedASRService, MockASRService from services.openai_compatible_asr import OpenAICompatibleASRService, SiliconFlowASRService from services.openai_compatible_tts import OpenAICompatibleTTSService, SiliconFlowTTSService @@ -35,7 +35,6 @@ __all__ = [ "MockLLMService", # TTS "DashScopeTTSService", - "EdgeTTSService", "MockTTSService", # ASR "BufferedASRService", diff --git a/engine/services/tts.py b/engine/services/tts.py index e838f08..0ed629d 100644 --- a/engine/services/tts.py +++ b/engine/services/tts.py @@ -1,271 +1,49 @@ -"""TTS (Text-to-Speech) Service implementations. +"""TTS service implementations used by the engine runtime.""" -Provides multiple TTS backend options including edge-tts (free) -and placeholder for cloud services. -""" - -import os -import io import asyncio -import struct -from typing import AsyncIterator, Optional +from typing import AsyncIterator + from loguru import logger from services.base import BaseTTSService, TTSChunk, ServiceState -# Try to import edge-tts -try: - import edge_tts - EDGE_TTS_AVAILABLE = True -except ImportError: - EDGE_TTS_AVAILABLE = False - logger.warning("edge-tts not available - EdgeTTS service will be disabled") - - -class EdgeTTSService(BaseTTSService): - """ - Microsoft Edge TTS service. - - Uses edge-tts library for free, high-quality speech synthesis. - Supports streaming for low-latency playback. - """ - - # Voice mapping for common languages - VOICE_MAP = { - "en": "en-US-JennyNeural", - "en-US": "en-US-JennyNeural", - "en-GB": "en-GB-SoniaNeural", - "zh": "zh-CN-XiaoxiaoNeural", - "zh-CN": "zh-CN-XiaoxiaoNeural", - "zh-TW": "zh-TW-HsiaoChenNeural", - "ja": "ja-JP-NanamiNeural", - "ko": "ko-KR-SunHiNeural", - "fr": "fr-FR-DeniseNeural", - "de": "de-DE-KatjaNeural", - "es": "es-ES-ElviraNeural", - } - - def __init__( - self, - voice: str = "en-US-JennyNeural", - sample_rate: int = 16000, - speed: float = 1.0 - ): - """ - Initialize Edge TTS service. - - Args: - voice: Voice name (e.g., "en-US-JennyNeural") or language code (e.g., "en") - sample_rate: Target sample rate (will be resampled) - speed: Speech speed multiplier - """ - # Resolve voice from language code if needed - if voice in self.VOICE_MAP: - voice = self.VOICE_MAP[voice] - - super().__init__(voice=voice, sample_rate=sample_rate, speed=speed) - self._cancel_event = asyncio.Event() - - async def connect(self) -> None: - """Edge TTS doesn't require explicit connection.""" - if not EDGE_TTS_AVAILABLE: - raise RuntimeError("edge-tts package not installed") - self.state = ServiceState.CONNECTED - logger.info(f"Edge TTS service ready: voice={self.voice}") - - async def disconnect(self) -> None: - """Edge TTS doesn't require explicit disconnection.""" - self.state = ServiceState.DISCONNECTED - logger.info("Edge TTS service disconnected") - - def _get_rate_string(self) -> str: - """Convert speed to rate string for edge-tts.""" - # edge-tts uses percentage format: "+0%", "-10%", "+20%" - percentage = int((self.speed - 1.0) * 100) - if percentage >= 0: - return f"+{percentage}%" - return f"{percentage}%" - - async def synthesize(self, text: str) -> bytes: - """ - Synthesize complete audio for text. - - Args: - text: Text to synthesize - - Returns: - PCM audio data (16-bit, mono, 16kHz) - """ - if not EDGE_TTS_AVAILABLE: - raise RuntimeError("edge-tts not available") - - # Collect all chunks - audio_data = b"" - async for chunk in self.synthesize_stream(text): - audio_data += chunk.audio - - return audio_data - - async def synthesize_stream(self, text: str) -> AsyncIterator[TTSChunk]: - """ - Synthesize audio in streaming mode. - - Args: - text: Text to synthesize - - Yields: - TTSChunk objects with PCM audio - """ - if not EDGE_TTS_AVAILABLE: - raise RuntimeError("edge-tts not available") - - self._cancel_event.clear() - - try: - communicate = edge_tts.Communicate( - text, - voice=self.voice, - rate=self._get_rate_string() - ) - - # edge-tts outputs MP3, we need to decode to PCM - # For now, collect MP3 chunks and yield after conversion - mp3_data = b"" - - async for chunk in communicate.stream(): - # Check for cancellation - if self._cancel_event.is_set(): - logger.info("TTS synthesis cancelled") - return - - if chunk["type"] == "audio": - mp3_data += chunk["data"] - - # Convert MP3 to PCM - if mp3_data: - pcm_data = await self._convert_mp3_to_pcm(mp3_data) - if pcm_data: - # Yield in chunks for streaming playback - chunk_size = self.sample_rate * 2 // 10 # 100ms chunks - for i in range(0, len(pcm_data), chunk_size): - if self._cancel_event.is_set(): - return - - chunk_data = pcm_data[i:i + chunk_size] - yield TTSChunk( - audio=chunk_data, - sample_rate=self.sample_rate, - is_final=(i + chunk_size >= len(pcm_data)) - ) - - except asyncio.CancelledError: - logger.info("TTS synthesis cancelled via asyncio") - raise - except Exception as e: - logger.error(f"TTS synthesis error: {e}") - raise - - async def _convert_mp3_to_pcm(self, mp3_data: bytes) -> bytes: - """ - Convert MP3 audio to PCM. - - Uses pydub or ffmpeg for conversion. - """ - try: - # Try using pydub (requires ffmpeg) - from pydub import AudioSegment - - # Load MP3 from bytes - audio = AudioSegment.from_mp3(io.BytesIO(mp3_data)) - - # Convert to target format - audio = audio.set_frame_rate(self.sample_rate) - audio = audio.set_channels(1) - audio = audio.set_sample_width(2) # 16-bit - - # Export as raw PCM - return audio.raw_data - - except ImportError: - logger.warning("pydub not available, trying fallback") - # Fallback: Use subprocess to call ffmpeg directly - return await self._ffmpeg_convert(mp3_data) - except Exception as e: - logger.error(f"Audio conversion error: {e}") - return b"" - - async def _ffmpeg_convert(self, mp3_data: bytes) -> bytes: - """Convert MP3 to PCM using ffmpeg subprocess.""" - try: - process = await asyncio.create_subprocess_exec( - "ffmpeg", - "-i", "pipe:0", - "-f", "s16le", - "-acodec", "pcm_s16le", - "-ar", str(self.sample_rate), - "-ac", "1", - "pipe:1", - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL - ) - - stdout, _ = await process.communicate(input=mp3_data) - return stdout - - except Exception as e: - logger.error(f"ffmpeg conversion error: {e}") - return b"" - - async def cancel(self) -> None: - """Cancel ongoing synthesis.""" - self._cancel_event.set() - class MockTTSService(BaseTTSService): - """ - Mock TTS service for testing without actual synthesis. - - Generates silence or simple tones. - """ - + """Mock TTS service for tests and no-provider fallback.""" + def __init__( self, voice: str = "mock", sample_rate: int = 16000, - speed: float = 1.0 + speed: float = 1.0, ): super().__init__(voice=voice, sample_rate=sample_rate, speed=speed) - + async def connect(self) -> None: self.state = ServiceState.CONNECTED logger.info("Mock TTS service connected") - + async def disconnect(self) -> None: self.state = ServiceState.DISCONNECTED logger.info("Mock TTS service disconnected") - + async def synthesize(self, text: str) -> bytes: """Generate silence based on text length.""" - # Approximate: 100ms per word word_count = len(text.split()) duration_ms = word_count * 100 samples = int(self.sample_rate * duration_ms / 1000) - - # Generate silence (zeros) - return bytes(samples * 2) # 16-bit = 2 bytes per sample - + return bytes(samples * 2) + async def synthesize_stream(self, text: str) -> AsyncIterator[TTSChunk]: - """Generate silence chunks.""" + """Generate silence chunks to emulate streaming synthesis.""" audio = await self.synthesize(text) - - # Yield in 100ms chunks + chunk_size = self.sample_rate * 2 // 10 for i in range(0, len(audio), chunk_size): - chunk_data = audio[i:i + chunk_size] + chunk_data = audio[i : i + chunk_size] yield TTSChunk( audio=chunk_data, sample_rate=self.sample_rate, - is_final=(i + chunk_size >= len(audio)) + is_final=(i + chunk_size >= len(audio)), ) - await asyncio.sleep(0.05) # Simulate processing time + await asyncio.sleep(0.05) diff --git a/engine/tests/test_ws_protocol_session_start.py b/engine/tests/test_ws_protocol_session_start.py index ac15b16..ad68674 100644 --- a/engine/tests/test_ws_protocol_session_start.py +++ b/engine/tests/test_ws_protocol_session_start.py @@ -139,7 +139,7 @@ async def test_load_server_runtime_metadata_returns_not_found_error(): _ = assistant_id return {"__error_code": "assistant.not_found"} - session._backend_gateway = _Gateway() + session._runtime_config_provider = _Gateway() runtime, error = await session._load_server_runtime_metadata("assistant_demo") assert runtime == {} assert error is not None @@ -155,7 +155,7 @@ async def test_load_server_runtime_metadata_returns_config_unavailable_error(): _ = assistant_id return None - session._backend_gateway = _Gateway() + session._runtime_config_provider = _Gateway() runtime, error = await session._load_server_runtime_metadata("assistant_demo") assert runtime == {} assert error is not None From 7e0b777923cbfd7efe02bf038b8778b83cbe4d79 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 6 Mar 2026 09:51:56 +0800 Subject: [PATCH 05/20] Refactor project structure and enhance backend integration - Expanded package inclusion in `pyproject.toml` to support new modules. - Introduced new `adapters` and `protocol` packages for better organization. - Added backend adapter implementations for control plane integration. - Updated main application imports to reflect new package structure. - Removed deprecated core components and adjusted documentation accordingly. - Enhanced architecture documentation to clarify the new runtime and integration layers. --- engine/adapters/__init__.py | 1 + engine/adapters/control_plane/__init__.py | 1 + .../control_plane/backend.py} | 0 engine/app/main.py | 8 +- engine/core/__init__.py | 20 -- engine/docs/backend_integration.md | 6 +- engine/docs/extension_ports.md | 14 +- engine/docs/high_level_architecture.md | 30 +-- engine/docs/import_migration.md | 21 ++ engine/docs/ws_v1_schema_zh.md | 6 +- engine/models/__init__.py | 1 - engine/models/commands.py | 143 ----------- engine/models/config.py | 126 ---------- engine/models/events.py | 231 ------------------ engine/protocol/__init__.py | 1 + engine/protocol/ws_v1/__init__.py | 1 + .../ws_v1.py => protocol/ws_v1/schema.py} | 0 engine/providers/__init__.py | 1 + engine/providers/asr/__init__.py | 1 + .../asr.py => providers/asr/buffered.py} | 2 +- .../asr/openai_compatible.py} | 2 +- .../asr/siliconflow.py} | 2 +- engine/providers/common/__init__.py | 1 + engine/{services => providers/common}/base.py | 0 .../common}/streaming_text.py | 0 engine/providers/factory/__init__.py | 1 + .../factory/default.py} | 14 +- engine/providers/llm/__init__.py | 1 + .../llm.py => providers/llm/openai.py} | 4 +- engine/providers/realtime/__init__.py | 1 + .../realtime/service.py} | 0 engine/providers/tts/__init__.py | 1 + .../tts/dashscope.py} | 2 +- .../tts.py => providers/tts/mock.py} | 2 +- .../tts/openai_compatible.py} | 4 +- .../tts/siliconflow.py} | 2 +- .../tts/streaming_adapter.py} | 4 +- engine/pyproject.toml | 12 +- engine/runtime/__init__.py | 1 + engine/{core => runtime}/conversation.py | 2 +- engine/{core => runtime}/events.py | 0 engine/runtime/history/__init__.py | 1 + .../history/bridge.py} | 2 +- engine/runtime/pipeline/__init__.py | 1 + engine/runtime/pipeline/asr_flow.py | 13 + engine/runtime/pipeline/constants.py | 6 + .../pipeline/duplex.py} | 18 +- engine/runtime/pipeline/events_out.py | 12 + engine/runtime/pipeline/interrupts.py | 8 + engine/runtime/pipeline/llm_flow.py | 13 + engine/runtime/pipeline/tooling.py | 13 + engine/runtime/pipeline/tts_flow.py | 15 ++ engine/{core => runtime}/ports/__init__.py | 12 +- engine/{core => runtime}/ports/asr.py | 2 +- .../{core => runtime}/ports/control_plane.py | 0 engine/{core => runtime}/ports/llm.py | 2 +- .../ports/service_factory.py | 6 +- engine/{core => runtime}/ports/tts.py | 2 +- engine/runtime/session/__init__.py | 1 + engine/runtime/session/lifecycle.py | 10 + .../session.py => runtime/session/manager.py} | 18 +- engine/runtime/session/metadata.py | 9 + engine/runtime/session/workflow_bridge.py | 12 + engine/{core => runtime}/transports.py | 0 engine/services/__init__.py | 52 ---- engine/tests/test_backend_adapters.py | 8 +- engine/tests/test_dynamic_variables.py | 2 +- engine/tests/test_history_bridge.py | 2 +- engine/tests/test_tool_call_flow.py | 28 +-- engine/tests/test_tool_executor.py | 4 +- .../tests/test_ws_protocol_session_start.py | 16 +- engine/tools/__init__.py | 1 + .../tool_executor.py => tools/executor.py} | 2 +- engine/workflow/__init__.py | 1 + .../workflow_runner.py => workflow/runner.py} | 0 75 files changed, 274 insertions(+), 688 deletions(-) create mode 100644 engine/adapters/__init__.py create mode 100644 engine/adapters/control_plane/__init__.py rename engine/{app/backend_adapters.py => adapters/control_plane/backend.py} (100%) delete mode 100644 engine/core/__init__.py create mode 100644 engine/docs/import_migration.md delete mode 100644 engine/models/__init__.py delete mode 100644 engine/models/commands.py delete mode 100644 engine/models/config.py delete mode 100644 engine/models/events.py create mode 100644 engine/protocol/__init__.py create mode 100644 engine/protocol/ws_v1/__init__.py rename engine/{models/ws_v1.py => protocol/ws_v1/schema.py} (100%) create mode 100644 engine/providers/__init__.py create mode 100644 engine/providers/asr/__init__.py rename engine/{services/asr.py => providers/asr/buffered.py} (98%) rename engine/{services/openai_compatible_asr.py => providers/asr/openai_compatible.py} (99%) rename engine/{services/siliconflow_asr.py => providers/asr/siliconflow.py} (75%) create mode 100644 engine/providers/common/__init__.py rename engine/{services => providers/common}/base.py (100%) rename engine/{services => providers/common}/streaming_text.py (100%) create mode 100644 engine/providers/factory/__init__.py rename engine/{app/service_factory.py => providers/factory/default.py} (91%) create mode 100644 engine/providers/llm/__init__.py rename engine/{services/llm.py => providers/llm/openai.py} (98%) create mode 100644 engine/providers/realtime/__init__.py rename engine/{services/realtime.py => providers/realtime/service.py} (100%) create mode 100644 engine/providers/tts/__init__.py rename engine/{services/dashscope_tts.py => providers/tts/dashscope.py} (99%) rename engine/{services/tts.py => providers/tts/mock.py} (95%) rename engine/{services/openai_compatible_tts.py => providers/tts/openai_compatible.py} (98%) rename engine/{services/siliconflow_tts.py => providers/tts/siliconflow.py} (72%) rename engine/{services/streaming_tts_adapter.py => providers/tts/streaming_adapter.py} (95%) create mode 100644 engine/runtime/__init__.py rename engine/{core => runtime}/conversation.py (99%) rename engine/{core => runtime}/events.py (100%) create mode 100644 engine/runtime/history/__init__.py rename engine/{core/history_bridge.py => runtime/history/bridge.py} (99%) create mode 100644 engine/runtime/pipeline/__init__.py create mode 100644 engine/runtime/pipeline/asr_flow.py create mode 100644 engine/runtime/pipeline/constants.py rename engine/{core/duplex_pipeline.py => runtime/pipeline/duplex.py} (99%) create mode 100644 engine/runtime/pipeline/events_out.py create mode 100644 engine/runtime/pipeline/interrupts.py create mode 100644 engine/runtime/pipeline/llm_flow.py create mode 100644 engine/runtime/pipeline/tooling.py create mode 100644 engine/runtime/pipeline/tts_flow.py rename engine/{core => runtime}/ports/__init__.py (56%) rename engine/{core => runtime}/ports/asr.py (97%) rename engine/{core => runtime}/ports/control_plane.py (100%) rename engine/{core => runtime}/ports/llm.py (96%) rename engine/{core => runtime}/ports/service_factory.py (79%) rename engine/{core => runtime}/ports/tts.py (96%) create mode 100644 engine/runtime/session/__init__.py create mode 100644 engine/runtime/session/lifecycle.py rename engine/{core/session.py => runtime/session/manager.py} (98%) create mode 100644 engine/runtime/session/metadata.py create mode 100644 engine/runtime/session/workflow_bridge.py rename engine/{core => runtime}/transports.py (100%) delete mode 100644 engine/services/__init__.py create mode 100644 engine/tools/__init__.py rename engine/{core/tool_executor.py => tools/executor.py} (99%) create mode 100644 engine/workflow/__init__.py rename engine/{core/workflow_runner.py => workflow/runner.py} (100%) diff --git a/engine/adapters/__init__.py b/engine/adapters/__init__.py new file mode 100644 index 0000000..6a94df0 --- /dev/null +++ b/engine/adapters/__init__.py @@ -0,0 +1 @@ +"""Adapters package.""" diff --git a/engine/adapters/control_plane/__init__.py b/engine/adapters/control_plane/__init__.py new file mode 100644 index 0000000..06f5004 --- /dev/null +++ b/engine/adapters/control_plane/__init__.py @@ -0,0 +1 @@ +"""Control-plane adapters package.""" diff --git a/engine/app/backend_adapters.py b/engine/adapters/control_plane/backend.py similarity index 100% rename from engine/app/backend_adapters.py rename to engine/adapters/control_plane/backend.py diff --git a/engine/app/main.py b/engine/app/main.py index 5625061..09ffa1d 100644 --- a/engine/app/main.py +++ b/engine/app/main.py @@ -20,11 +20,11 @@ except ImportError: logger.warning("aiortc not available - WebRTC endpoint will be disabled") from app.config import settings -from app.backend_adapters import build_backend_adapter_from_settings -from core.transports import SocketTransport, WebRtcTransport, BaseTransport -from core.session import Session +from adapters.control_plane.backend import build_backend_adapter_from_settings +from runtime.transports import SocketTransport, WebRtcTransport, BaseTransport +from runtime.session.manager import Session from processors.tracks import Resampled16kTrack -from core.events import get_event_bus, reset_event_bus +from runtime.events import get_event_bus, reset_event_bus # Check interval for heartbeat/timeout (seconds) _HEARTBEAT_CHECK_INTERVAL_SEC = 5 diff --git a/engine/core/__init__.py b/engine/core/__init__.py deleted file mode 100644 index 0110686..0000000 --- a/engine/core/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Core Components Package""" - -from core.events import EventBus, get_event_bus -from core.transports import BaseTransport, SocketTransport, WebRtcTransport -from core.session import Session -from core.conversation import ConversationManager, ConversationState, ConversationTurn -from core.duplex_pipeline import DuplexPipeline - -__all__ = [ - "EventBus", - "get_event_bus", - "BaseTransport", - "SocketTransport", - "WebRtcTransport", - "Session", - "ConversationManager", - "ConversationState", - "ConversationTurn", - "DuplexPipeline", -] diff --git a/engine/docs/backend_integration.md b/engine/docs/backend_integration.md index bcd44fc..22fa09e 100644 --- a/engine/docs/backend_integration.md +++ b/engine/docs/backend_integration.md @@ -27,15 +27,15 @@ Assistant config source behavior: ## Architecture -- Ports: `core/ports/control_plane.py` -- Adapters: `app/backend_adapters.py` +- Ports: `runtime/ports/control_plane.py` +- Adapters: `adapters/control_plane/backend.py` `Session` and `DuplexPipeline` receive backend capabilities via injected adapter methods instead of hard-coding backend client imports. ## Async History Writes -Session history persistence is handled by `core/history_bridge.py`. +Session history persistence is handled by `runtime/history/bridge.py`. Design: diff --git a/engine/docs/extension_ports.md b/engine/docs/extension_ports.md index 47f596a..8566194 100644 --- a/engine/docs/extension_ports.md +++ b/engine/docs/extension_ports.md @@ -4,31 +4,31 @@ This document defines the draft port set used to keep core runtime extensible. ## Port Modules -- `core/ports/control_plane.py` +- `runtime/ports/control_plane.py` - `AssistantRuntimeConfigProvider` - `ConversationHistoryStore` - `KnowledgeRetriever` - `ToolCatalog` - `ControlPlaneGateway` -- `core/ports/llm.py` +- `runtime/ports/llm.py` - `LLMServiceSpec` - `LLMPort` - optional extensions: `LLMCancellable`, `LLMRuntimeConfigurable` -- `core/ports/tts.py` +- `runtime/ports/tts.py` - `TTSServiceSpec` - `TTSPort` -- `core/ports/asr.py` +- `runtime/ports/asr.py` - `ASRServiceSpec` - `ASRPort` - optional extensions: `ASRInterimControl`, `ASRBufferControl` -- `core/ports/service_factory.py` +- `runtime/ports/service_factory.py` - `RealtimeServiceFactory` ## Adapter Layer -- `app/service_factory.py` provides `DefaultRealtimeServiceFactory`. +- `providers/factory/default.py` provides `DefaultRealtimeServiceFactory`. - It maps resolved provider specs to concrete adapters. -- Core orchestration (`core/duplex_pipeline.py`) depends on the factory port/specs, not concrete provider classes. +- Runtime orchestration (`runtime/pipeline/duplex.py`) depends on the factory port/specs, not concrete provider classes. ## Provider Behavior (Current) diff --git a/engine/docs/high_level_architecture.md b/engine/docs/high_level_architecture.md index 91e6845..bdae564 100644 --- a/engine/docs/high_level_architecture.md +++ b/engine/docs/high_level_architecture.md @@ -14,19 +14,19 @@ This document describes the runtime architecture of `engine` for realtime voice/ ```mermaid flowchart LR C[Client\nWeb / Mobile / Device] <-- WS v1 + PCM --> A[FastAPI App\napp/main.py] - A --> S[Session\ncore/session.py] - S --> D[Duplex Pipeline\ncore/duplex_pipeline.py] + A --> S[Session\nruntime/session/manager.py] + S --> D[Duplex Pipeline\nruntime/pipeline/duplex.py] D --> P[Processors\nVAD / EOU / Tracks] - D --> R[Workflow Runner\ncore/workflow_runner.py] - D --> E[Event Bus + Models\ncore/events.py + models/*] + D --> R[Workflow Runner\nworkflow/runner.py] + D --> E[Event Bus + Models\nruntime/events.py + protocol/ws_v1/*] - R --> SV[Service Layer\nservices/asr.py\nservices/llm.py\nservices/tts.py] - R --> TE[Tool Executor\ncore/tool_executor.py] + R --> SV[Service Layer\nproviders/asr/*\nproviders/llm/*\nproviders/tts/*] + R --> TE[Tool Executor\ntools/executor.py] - S --> HB[History Bridge\ncore/history_bridge.py] - S --> BA[Control Plane Port\ncore/ports/control_plane.py] - BA --> AD[Adapters\napp/backend_adapters.py] + S --> HB[History Bridge\nruntime/history/bridge.py] + S --> BA[Control Plane Port\nruntime/ports/control_plane.py] + BA --> AD[Adapters\nadapters/control_plane/backend.py] AD --> B[(External Backend API\noptional)] SV --> M[(ASR/LLM/TTS Providers)] @@ -58,7 +58,7 @@ flowchart LR ### 2) Session + Orchestration Layer -- Core: `core/session.py`, `core/duplex_pipeline.py`, `core/conversation.py` +- Core: `runtime/session/manager.py`, `runtime/pipeline/duplex.py`, `runtime/conversation.py` - Responsibilities: - Per-session state machine - Turn boundaries and interruption/cancel handling @@ -75,7 +75,7 @@ flowchart LR ### 4) Workflow + Tooling Layer -- Modules: `core/workflow_runner.py`, `core/tool_executor.py` +- Modules: `workflow/runner.py`, `tools/executor.py` - Responsibilities: - Assistant workflow execution - Tool call planning/execution and timeout handling @@ -83,7 +83,7 @@ flowchart LR ### 5) Service Integration Layer -- Modules: `services/*` +- Modules: `providers/*` - Responsibilities: - Abstracting ASR/LLM/TTS provider differences - Streaming token/audio adaptation @@ -91,8 +91,8 @@ flowchart LR ### 6) Backend Integration Layer (Optional) -- Port: `core/ports/control_plane.py` -- Adapters: `app/backend_adapters.py` +- Port: `runtime/ports/control_plane.py` +- Adapters: `adapters/control_plane/backend.py` - Responsibilities: - Fetching assistant runtime config - Persisting call/session metadata and history @@ -100,7 +100,7 @@ flowchart LR ### 7) Persistence / Reliability Layer -- Module: `core/history_bridge.py` +- Module: `runtime/history/bridge.py` - Responsibilities: - Non-blocking queue-based history writes - Retry with backoff on backend failures diff --git a/engine/docs/import_migration.md b/engine/docs/import_migration.md new file mode 100644 index 0000000..eaeba1c --- /dev/null +++ b/engine/docs/import_migration.md @@ -0,0 +1,21 @@ +# Canonical Module Layout + +This MVP uses a single canonical module layout without legacy import shims. + +## Runtime and protocol + +- `protocol.ws_v1.schema` +- `runtime.session.manager` +- `runtime.pipeline.duplex` +- `runtime.history.bridge` +- `runtime.events` +- `runtime.transports` +- `runtime.conversation` +- `runtime.ports.*` + +## Integrations and orchestration + +- `providers.*` +- `adapters.control_plane.backend` +- `workflow.runner` +- `tools.executor` diff --git a/engine/docs/ws_v1_schema_zh.md b/engine/docs/ws_v1_schema_zh.md index ce5f175..1681c23 100644 --- a/engine/docs/ws_v1_schema_zh.md +++ b/engine/docs/ws_v1_schema_zh.md @@ -7,9 +7,9 @@ - 握手顺序、状态机、错误语义与实现细节。 实现对照来源: -- `models/ws_v1.py` -- `core/session.py` -- `core/duplex_pipeline.py` +- `protocol/ws_v1/schema.py` +- `runtime/session/manager.py` +- `runtime/pipeline/duplex.py` - `app/main.py` --- diff --git a/engine/models/__init__.py b/engine/models/__init__.py deleted file mode 100644 index 924d5fd..0000000 --- a/engine/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Data Models Package""" diff --git a/engine/models/commands.py b/engine/models/commands.py deleted file mode 100644 index 5bcf47e..0000000 --- a/engine/models/commands.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Protocol command models matching the original active-call API.""" - -from typing import Optional, Dict, Any -from pydantic import BaseModel, Field - - -class InviteCommand(BaseModel): - """Invite command to initiate a call.""" - - command: str = Field(default="invite", description="Command type") - option: Optional[Dict[str, Any]] = Field(default=None, description="Call configuration options") - - -class AcceptCommand(BaseModel): - """Accept command to accept an incoming call.""" - - command: str = Field(default="accept", description="Command type") - option: Optional[Dict[str, Any]] = Field(default=None, description="Call configuration options") - - -class RejectCommand(BaseModel): - """Reject command to reject an incoming call.""" - - command: str = Field(default="reject", description="Command type") - reason: str = Field(default="", description="Reason for rejection") - code: Optional[int] = Field(default=None, description="SIP response code") - - -class RingingCommand(BaseModel): - """Ringing command to send ringing response.""" - - command: str = Field(default="ringing", description="Command type") - recorder: Optional[Dict[str, Any]] = Field(default=None, description="Call recording configuration") - early_media: bool = Field(default=False, description="Enable early media") - ringtone: Optional[str] = Field(default=None, description="Custom ringtone URL") - - -class TTSCommand(BaseModel): - """TTS command to convert text to speech.""" - - command: str = Field(default="tts", description="Command type") - text: str = Field(..., description="Text to synthesize") - speaker: Optional[str] = Field(default=None, description="Speaker voice name") - play_id: Optional[str] = Field(default=None, description="Unique identifier for this TTS session") - auto_hangup: bool = Field(default=False, description="Auto hangup after TTS completion") - streaming: bool = Field(default=False, description="Streaming text input") - end_of_stream: bool = Field(default=False, description="End of streaming input") - wait_input_timeout: Optional[int] = Field(default=None, description="Max time to wait for input (seconds)") - option: Optional[Dict[str, Any]] = Field(default=None, description="TTS provider specific options") - - -class PlayCommand(BaseModel): - """Play command to play audio from URL.""" - - command: str = Field(default="play", description="Command type") - url: str = Field(..., description="URL of audio file to play") - auto_hangup: bool = Field(default=False, description="Auto hangup after playback") - wait_input_timeout: Optional[int] = Field(default=None, description="Max time to wait for input (seconds)") - - -class InterruptCommand(BaseModel): - """Interrupt command to interrupt current playback.""" - - command: str = Field(default="interrupt", description="Command type") - graceful: bool = Field(default=False, description="Wait for current TTS to complete") - - -class PauseCommand(BaseModel): - """Pause command to pause current playback.""" - - command: str = Field(default="pause", description="Command type") - - -class ResumeCommand(BaseModel): - """Resume command to resume paused playback.""" - - command: str = Field(default="resume", description="Command type") - - -class HangupCommand(BaseModel): - """Hangup command to end the call.""" - - command: str = Field(default="hangup", description="Command type") - reason: Optional[str] = Field(default=None, description="Reason for hangup") - initiator: Optional[str] = Field(default=None, description="Who initiated the hangup") - - -class HistoryCommand(BaseModel): - """History command to add conversation history.""" - - command: str = Field(default="history", description="Command type") - speaker: str = Field(..., description="Speaker identifier") - text: str = Field(..., description="Conversation text") - - -class ChatCommand(BaseModel): - """Chat command for text-based conversation.""" - - command: str = Field(default="chat", description="Command type") - text: str = Field(..., description="Chat text message") - - -# Command type mapping -COMMAND_TYPES = { - "invite": InviteCommand, - "accept": AcceptCommand, - "reject": RejectCommand, - "ringing": RingingCommand, - "tts": TTSCommand, - "play": PlayCommand, - "interrupt": InterruptCommand, - "pause": PauseCommand, - "resume": ResumeCommand, - "hangup": HangupCommand, - "history": HistoryCommand, - "chat": ChatCommand, -} - - -def parse_command(data: Dict[str, Any]) -> BaseModel: - """ - Parse a command from JSON data. - - Args: - data: JSON data as dictionary - - Returns: - Parsed command model - - Raises: - ValueError: If command type is unknown - """ - command_type = data.get("command") - - if not command_type: - raise ValueError("Missing 'command' field") - - command_class = COMMAND_TYPES.get(command_type) - - if not command_class: - raise ValueError(f"Unknown command type: {command_type}") - - return command_class(**data) diff --git a/engine/models/config.py b/engine/models/config.py deleted file mode 100644 index 009411e..0000000 --- a/engine/models/config.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Configuration models for call options.""" - -from typing import Optional, Dict, Any, List -from pydantic import BaseModel, Field - - -class VADOption(BaseModel): - """Voice Activity Detection configuration.""" - - type: str = Field(default="silero", description="VAD algorithm type (silero, webrtc)") - samplerate: int = Field(default=16000, description="Audio sample rate for VAD") - speech_padding: int = Field(default=250, description="Speech padding in milliseconds") - silence_padding: int = Field(default=100, description="Silence padding in milliseconds") - ratio: float = Field(default=0.5, description="Voice detection ratio threshold") - voice_threshold: float = Field(default=0.5, description="Voice energy threshold") - max_buffer_duration_secs: int = Field(default=50, description="Maximum buffer duration in seconds") - silence_timeout: Optional[int] = Field(default=None, description="Silence timeout in milliseconds") - endpoint: Optional[str] = Field(default=None, description="Custom VAD service endpoint") - secret_key: Optional[str] = Field(default=None, description="VAD service secret key") - secret_id: Optional[str] = Field(default=None, description="VAD service secret ID") - - -class ASROption(BaseModel): - """Automatic Speech Recognition configuration.""" - - provider: str = Field(..., description="ASR provider (tencent, aliyun, openai, etc.)") - language: Optional[str] = Field(default=None, description="Language code (zh-CN, en-US)") - app_id: Optional[str] = Field(default=None, description="Application ID") - secret_id: Optional[str] = Field(default=None, description="Secret ID for authentication") - secret_key: Optional[str] = Field(default=None, description="Secret key for authentication") - model_type: Optional[str] = Field(default=None, description="ASR model type (16k_zh, 8k_en)") - buffer_size: Optional[int] = Field(default=None, description="Audio buffer size in bytes") - samplerate: Optional[int] = Field(default=None, description="Audio sample rate") - endpoint: Optional[str] = Field(default=None, description="Custom ASR service endpoint") - extra: Optional[Dict[str, Any]] = Field(default=None, description="Additional parameters") - start_when_answer: bool = Field(default=False, description="Start ASR when call is answered") - - -class TTSOption(BaseModel): - """Text-to-Speech configuration.""" - - samplerate: Optional[int] = Field(default=None, description="TTS output sample rate") - provider: str = Field(default="msedge", description="TTS provider (tencent, aliyun, deepgram, msedge)") - speed: float = Field(default=1.0, description="Speech speed multiplier") - app_id: Optional[str] = Field(default=None, description="Application ID") - secret_id: Optional[str] = Field(default=None, description="Secret ID for authentication") - secret_key: Optional[str] = Field(default=None, description="Secret key for authentication") - volume: Optional[int] = Field(default=None, description="Speech volume level (1-10)") - speaker: Optional[str] = Field(default=None, description="Voice speaker name") - codec: Optional[str] = Field(default=None, description="Audio codec") - subtitle: bool = Field(default=False, description="Enable subtitle generation") - emotion: Optional[str] = Field(default=None, description="Speech emotion") - endpoint: Optional[str] = Field(default=None, description="Custom TTS service endpoint") - extra: Optional[Dict[str, Any]] = Field(default=None, description="Additional parameters") - max_concurrent_tasks: Optional[int] = Field(default=None, description="Max concurrent tasks") - - -class RecorderOption(BaseModel): - """Call recording configuration.""" - - recorder_file: str = Field(..., description="Path to recording file") - samplerate: int = Field(default=16000, description="Recording sample rate") - ptime: int = Field(default=200, description="Packet time in milliseconds") - - -class MediaPassOption(BaseModel): - """Media pass-through configuration for external audio processing.""" - - url: str = Field(..., description="WebSocket URL for media streaming") - input_sample_rate: int = Field(default=16000, description="Sample rate of audio received from WebSocket") - output_sample_rate: int = Field(default=16000, description="Sample rate of audio sent to WebSocket") - packet_size: int = Field(default=2560, description="Packet size in bytes") - ptime: Optional[int] = Field(default=None, description="Buffered playback period in milliseconds") - - -class SipOption(BaseModel): - """SIP protocol configuration.""" - - username: Optional[str] = Field(default=None, description="SIP username") - password: Optional[str] = Field(default=None, description="SIP password") - realm: Optional[str] = Field(default=None, description="SIP realm/domain") - headers: Optional[Dict[str, str]] = Field(default=None, description="Additional SIP headers") - - -class HandlerRule(BaseModel): - """Handler routing rule.""" - - caller: Optional[str] = Field(default=None, description="Caller pattern (regex)") - callee: Optional[str] = Field(default=None, description="Callee pattern (regex)") - playbook: Optional[str] = Field(default=None, description="Playbook file path") - webhook: Optional[str] = Field(default=None, description="Webhook URL") - - -class CallOption(BaseModel): - """Comprehensive call configuration options.""" - - # Basic options - denoise: bool = Field(default=False, description="Enable noise reduction") - offer: Optional[str] = Field(default=None, description="SDP offer string") - callee: Optional[str] = Field(default=None, description="Callee SIP URI or phone number") - caller: Optional[str] = Field(default=None, description="Caller SIP URI or phone number") - - # Audio codec - codec: str = Field(default="pcm", description="Audio codec (pcm, pcma, pcmu, g722)") - - # Component configurations - recorder: Optional[RecorderOption] = Field(default=None, description="Call recording config") - asr: Optional[ASROption] = Field(default=None, description="ASR configuration") - vad: Optional[VADOption] = Field(default=None, description="VAD configuration") - tts: Optional[TTSOption] = Field(default=None, description="TTS configuration") - media_pass: Optional[MediaPassOption] = Field(default=None, description="Media pass-through config") - sip: Optional[SipOption] = Field(default=None, description="SIP configuration") - - # Timeouts and networking - handshake_timeout: Optional[int] = Field(default=None, description="Handshake timeout in seconds") - enable_ipv6: bool = Field(default=False, description="Enable IPv6 support") - inactivity_timeout: Optional[int] = Field(default=None, description="Inactivity timeout in seconds") - - # EOU configuration - eou: Optional[Dict[str, Any]] = Field(default=None, description="End of utterance detection config") - - # Extra parameters - extra: Optional[Dict[str, Any]] = Field(default=None, description="Additional custom parameters") - - class Config: - populate_by_name = True diff --git a/engine/models/events.py b/engine/models/events.py deleted file mode 100644 index 031b8be..0000000 --- a/engine/models/events.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Protocol event models matching the original active-call API.""" - -from typing import Optional, Dict, Any -from pydantic import BaseModel, Field -from datetime import datetime - - -def current_timestamp_ms() -> int: - """Get current timestamp in milliseconds.""" - return int(datetime.now().timestamp() * 1000) - - -# Base Event Model -class BaseEvent(BaseModel): - """Base event model.""" - - event: str = Field(..., description="Event type") - track_id: str = Field(..., description="Unique track identifier") - timestamp: int = Field(default_factory=current_timestamp_ms, description="Event timestamp in milliseconds") - - -# Lifecycle Events -class IncomingEvent(BaseEvent): - """Incoming call event (SIP only).""" - - event: str = Field(default="incoming", description="Event type") - caller: Optional[str] = Field(default=None, description="Caller's SIP URI") - callee: Optional[str] = Field(default=None, description="Callee's SIP URI") - sdp: Optional[str] = Field(default=None, description="SDP offer from caller") - - -class AnswerEvent(BaseEvent): - """Call answered event.""" - - event: str = Field(default="answer", description="Event type") - sdp: Optional[str] = Field(default=None, description="SDP answer from server") - - -class RejectEvent(BaseEvent): - """Call rejected event.""" - - event: str = Field(default="reject", description="Event type") - reason: Optional[str] = Field(default=None, description="Rejection reason") - code: Optional[int] = Field(default=None, description="SIP response code") - - -class RingingEvent(BaseEvent): - """Call ringing event.""" - - event: str = Field(default="ringing", description="Event type") - early_media: bool = Field(default=False, description="Early media available") - - -class HangupEvent(BaseModel): - """Call hangup event.""" - - event: str = Field(default="hangup", description="Event type") - timestamp: int = Field(default_factory=current_timestamp_ms, description="Event timestamp") - reason: Optional[str] = Field(default=None, description="Hangup reason") - initiator: Optional[str] = Field(default=None, description="Who initiated hangup") - start_time: Optional[str] = Field(default=None, description="Call start time (ISO 8601)") - hangup_time: Optional[str] = Field(default=None, description="Hangup time (ISO 8601)") - answer_time: Optional[str] = Field(default=None, description="Answer time (ISO 8601)") - ringing_time: Optional[str] = Field(default=None, description="Ringing time (ISO 8601)") - from_: Optional[Dict[str, Any]] = Field(default=None, alias="from", description="Caller info") - to: Optional[Dict[str, Any]] = Field(default=None, description="Callee info") - extra: Optional[Dict[str, Any]] = Field(default=None, description="Additional metadata") - - class Config: - populate_by_name = True - - -# VAD Events -class SpeakingEvent(BaseEvent): - """Speech detected event.""" - - event: str = Field(default="speaking", description="Event type") - start_time: int = Field(default_factory=current_timestamp_ms, description="Speech start time") - - -class SilenceEvent(BaseEvent): - """Silence detected event.""" - - event: str = Field(default="silence", description="Event type") - start_time: int = Field(default_factory=current_timestamp_ms, description="Silence start time") - duration: int = Field(default=0, description="Silence duration in milliseconds") - - -# AI/ASR Events -class AsrFinalEvent(BaseEvent): - """ASR final transcription event.""" - - event: str = Field(default="asrFinal", description="Event type") - index: int = Field(..., description="ASR result sequence number") - start_time: Optional[int] = Field(default=None, description="Speech start time") - end_time: Optional[int] = Field(default=None, description="Speech end time") - text: str = Field(..., description="Transcribed text") - - -class AsrDeltaEvent(BaseEvent): - """ASR partial transcription event (streaming).""" - - event: str = Field(default="asrDelta", description="Event type") - index: int = Field(..., description="ASR result sequence number") - start_time: Optional[int] = Field(default=None, description="Speech start time") - end_time: Optional[int] = Field(default=None, description="Speech end time") - text: str = Field(..., description="Partial transcribed text") - - -class EouEvent(BaseEvent): - """End of utterance detection event.""" - - event: str = Field(default="eou", description="Event type") - completed: bool = Field(default=True, description="Whether utterance was completed") - - -# Audio Track Events -class TrackStartEvent(BaseEvent): - """Audio track start event.""" - - event: str = Field(default="trackStart", description="Event type") - play_id: Optional[str] = Field(default=None, description="Play ID from TTS/Play command") - - -class TrackEndEvent(BaseEvent): - """Audio track end event.""" - - event: str = Field(default="trackEnd", description="Event type") - duration: int = Field(..., description="Track duration in milliseconds") - ssrc: int = Field(..., description="RTP SSRC identifier") - play_id: Optional[str] = Field(default=None, description="Play ID from TTS/Play command") - - -class InterruptionEvent(BaseEvent): - """Playback interruption event.""" - - event: str = Field(default="interruption", description="Event type") - play_id: Optional[str] = Field(default=None, description="Play ID that was interrupted") - subtitle: Optional[str] = Field(default=None, description="TTS text being played") - position: Optional[int] = Field(default=None, description="Word index position") - total_duration: Optional[int] = Field(default=None, description="Total TTS duration") - current: Optional[int] = Field(default=None, description="Elapsed time when interrupted") - - -# System Events -class ErrorEvent(BaseEvent): - """Error event.""" - - event: str = Field(default="error", description="Event type") - sender: str = Field(..., description="Component that generated the error") - error: str = Field(..., description="Error message") - code: Optional[int] = Field(default=None, description="Error code") - - -class MetricsEvent(BaseModel): - """Performance metrics event.""" - - event: str = Field(default="metrics", description="Event type") - timestamp: int = Field(default_factory=current_timestamp_ms, description="Event timestamp") - key: str = Field(..., description="Metric key") - duration: int = Field(..., description="Duration in milliseconds") - data: Optional[Dict[str, Any]] = Field(default=None, description="Additional metric data") - - -class AddHistoryEvent(BaseModel): - """Conversation history entry added event.""" - - event: str = Field(default="addHistory", description="Event type") - timestamp: int = Field(default_factory=current_timestamp_ms, description="Event timestamp") - sender: Optional[str] = Field(default=None, description="Component that added history") - speaker: str = Field(..., description="Speaker identifier") - text: str = Field(..., description="Conversation text") - - -class DTMFEvent(BaseEvent): - """DTMF tone detected event.""" - - event: str = Field(default="dtmf", description="Event type") - digit: str = Field(..., description="DTMF digit (0-9, *, #, A-D)") - - -class HeartBeatEvent(BaseModel): - """Server-to-client heartbeat to keep connection alive.""" - - event: str = Field(default="heartBeat", description="Event type") - timestamp: int = Field(default_factory=current_timestamp_ms, description="Event timestamp in milliseconds") - - -# Event type mapping -EVENT_TYPES = { - "incoming": IncomingEvent, - "answer": AnswerEvent, - "reject": RejectEvent, - "ringing": RingingEvent, - "hangup": HangupEvent, - "speaking": SpeakingEvent, - "silence": SilenceEvent, - "asrFinal": AsrFinalEvent, - "asrDelta": AsrDeltaEvent, - "eou": EouEvent, - "trackStart": TrackStartEvent, - "trackEnd": TrackEndEvent, - "interruption": InterruptionEvent, - "error": ErrorEvent, - "metrics": MetricsEvent, - "addHistory": AddHistoryEvent, - "dtmf": DTMFEvent, - "heartBeat": HeartBeatEvent, -} - - -def create_event(event_type: str, **kwargs) -> BaseModel: - """ - Create an event model. - - Args: - event_type: Type of event to create - **kwargs: Event fields - - Returns: - Event model instance - - Raises: - ValueError: If event type is unknown - """ - event_class = EVENT_TYPES.get(event_type) - - if not event_class: - raise ValueError(f"Unknown event type: {event_type}") - - return event_class(event=event_type, **kwargs) diff --git a/engine/protocol/__init__.py b/engine/protocol/__init__.py new file mode 100644 index 0000000..311e510 --- /dev/null +++ b/engine/protocol/__init__.py @@ -0,0 +1 @@ +"""Protocol package.""" diff --git a/engine/protocol/ws_v1/__init__.py b/engine/protocol/ws_v1/__init__.py new file mode 100644 index 0000000..6b76589 --- /dev/null +++ b/engine/protocol/ws_v1/__init__.py @@ -0,0 +1 @@ +"""WS v1 protocol package.""" diff --git a/engine/models/ws_v1.py b/engine/protocol/ws_v1/schema.py similarity index 100% rename from engine/models/ws_v1.py rename to engine/protocol/ws_v1/schema.py diff --git a/engine/providers/__init__.py b/engine/providers/__init__.py new file mode 100644 index 0000000..2209974 --- /dev/null +++ b/engine/providers/__init__.py @@ -0,0 +1 @@ +"""Providers package.""" diff --git a/engine/providers/asr/__init__.py b/engine/providers/asr/__init__.py new file mode 100644 index 0000000..2efe6a9 --- /dev/null +++ b/engine/providers/asr/__init__.py @@ -0,0 +1 @@ +"""ASR providers.""" diff --git a/engine/services/asr.py b/engine/providers/asr/buffered.py similarity index 98% rename from engine/services/asr.py rename to engine/providers/asr/buffered.py index 51ab584..ce1a248 100644 --- a/engine/services/asr.py +++ b/engine/providers/asr/buffered.py @@ -9,7 +9,7 @@ import json from typing import AsyncIterator, Optional from loguru import logger -from services.base import BaseASRService, ASRResult, ServiceState +from providers.common.base import BaseASRService, ASRResult, ServiceState # Try to import websockets for streaming ASR try: diff --git a/engine/services/openai_compatible_asr.py b/engine/providers/asr/openai_compatible.py similarity index 99% rename from engine/services/openai_compatible_asr.py rename to engine/providers/asr/openai_compatible.py index 182d7a0..1a2083b 100644 --- a/engine/services/openai_compatible_asr.py +++ b/engine/providers/asr/openai_compatible.py @@ -19,7 +19,7 @@ except ImportError: AIOHTTP_AVAILABLE = False logger.warning("aiohttp not available - OpenAICompatibleASRService will not work") -from services.base import BaseASRService, ASRResult, ServiceState +from providers.common.base import BaseASRService, ASRResult, ServiceState class OpenAICompatibleASRService(BaseASRService): diff --git a/engine/services/siliconflow_asr.py b/engine/providers/asr/siliconflow.py similarity index 75% rename from engine/services/siliconflow_asr.py rename to engine/providers/asr/siliconflow.py index 2cb95dc..d0aeb50 100644 --- a/engine/services/siliconflow_asr.py +++ b/engine/providers/asr/siliconflow.py @@ -1,6 +1,6 @@ """Backward-compatible imports for legacy siliconflow_asr module.""" -from services.openai_compatible_asr import OpenAICompatibleASRService +from providers.asr.openai_compatible import OpenAICompatibleASRService # Backward-compatible alias SiliconFlowASRService = OpenAICompatibleASRService diff --git a/engine/providers/common/__init__.py b/engine/providers/common/__init__.py new file mode 100644 index 0000000..8550c10 --- /dev/null +++ b/engine/providers/common/__init__.py @@ -0,0 +1 @@ +"""Common provider types.""" diff --git a/engine/services/base.py b/engine/providers/common/base.py similarity index 100% rename from engine/services/base.py rename to engine/providers/common/base.py diff --git a/engine/services/streaming_text.py b/engine/providers/common/streaming_text.py similarity index 100% rename from engine/services/streaming_text.py rename to engine/providers/common/streaming_text.py diff --git a/engine/providers/factory/__init__.py b/engine/providers/factory/__init__.py new file mode 100644 index 0000000..9be8bc5 --- /dev/null +++ b/engine/providers/factory/__init__.py @@ -0,0 +1 @@ +"""Provider factories.""" diff --git a/engine/app/service_factory.py b/engine/providers/factory/default.py similarity index 91% rename from engine/app/service_factory.py rename to engine/providers/factory/default.py index 6bdb64c..4294d3c 100644 --- a/engine/app/service_factory.py +++ b/engine/providers/factory/default.py @@ -6,7 +6,7 @@ from typing import Any from loguru import logger -from core.ports import ( +from runtime.ports import ( ASRPort, ASRServiceSpec, LLMPort, @@ -15,12 +15,12 @@ from core.ports import ( TTSPort, TTSServiceSpec, ) -from services.asr import BufferedASRService -from services.dashscope_tts import DashScopeTTSService -from services.llm import MockLLMService, OpenAILLMService -from services.openai_compatible_asr import OpenAICompatibleASRService -from services.openai_compatible_tts import OpenAICompatibleTTSService -from services.tts import MockTTSService +from providers.asr.buffered import BufferedASRService +from providers.tts.dashscope import DashScopeTTSService +from providers.llm.openai import MockLLMService, OpenAILLMService +from providers.asr.openai_compatible import OpenAICompatibleASRService +from providers.tts.openai_compatible import OpenAICompatibleTTSService +from providers.tts.mock import MockTTSService _OPENAI_COMPATIBLE_PROVIDERS = {"openai_compatible", "openai-compatible", "siliconflow"} _SUPPORTED_LLM_PROVIDERS = {"openai", *_OPENAI_COMPATIBLE_PROVIDERS} diff --git a/engine/providers/llm/__init__.py b/engine/providers/llm/__init__.py new file mode 100644 index 0000000..3258a10 --- /dev/null +++ b/engine/providers/llm/__init__.py @@ -0,0 +1 @@ +"""LLM providers.""" diff --git a/engine/services/llm.py b/engine/providers/llm/openai.py similarity index 98% rename from engine/services/llm.py rename to engine/providers/llm/openai.py index c4d539e..02735fe 100644 --- a/engine/services/llm.py +++ b/engine/providers/llm/openai.py @@ -10,8 +10,8 @@ import uuid from typing import AsyncIterator, Optional, List, Dict, Any, Callable, Awaitable from loguru import logger -from app.backend_adapters import build_backend_adapter_from_settings -from services.base import BaseLLMService, LLMMessage, LLMStreamEvent, ServiceState +from adapters.control_plane.backend import build_backend_adapter_from_settings +from providers.common.base import BaseLLMService, LLMMessage, LLMStreamEvent, ServiceState # Try to import openai try: diff --git a/engine/providers/realtime/__init__.py b/engine/providers/realtime/__init__.py new file mode 100644 index 0000000..0d4cb46 --- /dev/null +++ b/engine/providers/realtime/__init__.py @@ -0,0 +1 @@ +"""Realtime providers.""" diff --git a/engine/services/realtime.py b/engine/providers/realtime/service.py similarity index 100% rename from engine/services/realtime.py rename to engine/providers/realtime/service.py diff --git a/engine/providers/tts/__init__.py b/engine/providers/tts/__init__.py new file mode 100644 index 0000000..531ecfa --- /dev/null +++ b/engine/providers/tts/__init__.py @@ -0,0 +1 @@ +"""TTS providers.""" diff --git a/engine/services/dashscope_tts.py b/engine/providers/tts/dashscope.py similarity index 99% rename from engine/services/dashscope_tts.py rename to engine/providers/tts/dashscope.py index 1ddcbff..c0b3fdb 100644 --- a/engine/services/dashscope_tts.py +++ b/engine/providers/tts/dashscope.py @@ -12,7 +12,7 @@ from typing import Any, AsyncIterator, Dict, Optional, Tuple from loguru import logger -from services.base import BaseTTSService, ServiceState, TTSChunk +from providers.common.base import BaseTTSService, ServiceState, TTSChunk try: import dashscope diff --git a/engine/services/tts.py b/engine/providers/tts/mock.py similarity index 95% rename from engine/services/tts.py rename to engine/providers/tts/mock.py index 0ed629d..1d1e143 100644 --- a/engine/services/tts.py +++ b/engine/providers/tts/mock.py @@ -5,7 +5,7 @@ from typing import AsyncIterator from loguru import logger -from services.base import BaseTTSService, TTSChunk, ServiceState +from providers.common.base import BaseTTSService, TTSChunk, ServiceState class MockTTSService(BaseTTSService): diff --git a/engine/services/openai_compatible_tts.py b/engine/providers/tts/openai_compatible.py similarity index 98% rename from engine/services/openai_compatible_tts.py rename to engine/providers/tts/openai_compatible.py index 41e3e45..767ad12 100644 --- a/engine/services/openai_compatible_tts.py +++ b/engine/providers/tts/openai_compatible.py @@ -13,8 +13,8 @@ from typing import AsyncIterator, Optional from urllib.parse import urlparse, urlunparse from loguru import logger -from services.base import BaseTTSService, TTSChunk, ServiceState -from services.streaming_tts_adapter import StreamingTTSAdapter # backward-compatible re-export +from providers.common.base import BaseTTSService, TTSChunk, ServiceState +from providers.tts.streaming_adapter import StreamingTTSAdapter # backward-compatible re-export class OpenAICompatibleTTSService(BaseTTSService): diff --git a/engine/services/siliconflow_tts.py b/engine/providers/tts/siliconflow.py similarity index 72% rename from engine/services/siliconflow_tts.py rename to engine/providers/tts/siliconflow.py index 3cdf32a..3b894d9 100644 --- a/engine/services/siliconflow_tts.py +++ b/engine/providers/tts/siliconflow.py @@ -1,6 +1,6 @@ """Backward-compatible imports for legacy siliconflow_tts module.""" -from services.openai_compatible_tts import OpenAICompatibleTTSService, StreamingTTSAdapter +from providers.tts.openai_compatible import OpenAICompatibleTTSService, StreamingTTSAdapter # Backward-compatible alias SiliconFlowTTSService = OpenAICompatibleTTSService diff --git a/engine/services/streaming_tts_adapter.py b/engine/providers/tts/streaming_adapter.py similarity index 95% rename from engine/services/streaming_tts_adapter.py rename to engine/providers/tts/streaming_adapter.py index d4cb745..853e7ab 100644 --- a/engine/services/streaming_tts_adapter.py +++ b/engine/providers/tts/streaming_adapter.py @@ -4,8 +4,8 @@ import asyncio from loguru import logger -from services.base import BaseTTSService -from services.streaming_text import extract_tts_sentence, has_spoken_content +from providers.common.base import BaseTTSService +from providers.common.streaming_text import extract_tts_sentence, has_spoken_content class StreamingTTSAdapter: diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 8786905..c0031f0 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -31,7 +31,17 @@ Issues = "https://github.com/yourusername/py-active-call-cc/issues" [tool.setuptools.packages.find] where = ["."] -include = ["app*"] +include = [ + "app*", + "adapters*", + "protocol*", + "providers*", + "processors*", + "runtime*", + "tools*", + "utils*", + "workflow*", +] exclude = ["tests*", "scripts*", "reference*"] [tool.black] diff --git a/engine/runtime/__init__.py b/engine/runtime/__init__.py new file mode 100644 index 0000000..1364082 --- /dev/null +++ b/engine/runtime/__init__.py @@ -0,0 +1 @@ +"""Runtime package.""" diff --git a/engine/core/conversation.py b/engine/runtime/conversation.py similarity index 99% rename from engine/core/conversation.py rename to engine/runtime/conversation.py index 08b23c6..fe21c01 100644 --- a/engine/core/conversation.py +++ b/engine/runtime/conversation.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from enum import Enum from loguru import logger -from services.base import LLMMessage +from providers.common.base import LLMMessage class ConversationState(Enum): diff --git a/engine/core/events.py b/engine/runtime/events.py similarity index 100% rename from engine/core/events.py rename to engine/runtime/events.py diff --git a/engine/runtime/history/__init__.py b/engine/runtime/history/__init__.py new file mode 100644 index 0000000..44329ff --- /dev/null +++ b/engine/runtime/history/__init__.py @@ -0,0 +1 @@ +"""Runtime history package.""" diff --git a/engine/core/history_bridge.py b/engine/runtime/history/bridge.py similarity index 99% rename from engine/core/history_bridge.py rename to engine/runtime/history/bridge.py index 70a681b..bacd682 100644 --- a/engine/core/history_bridge.py +++ b/engine/runtime/history/bridge.py @@ -9,7 +9,7 @@ from typing import Optional from loguru import logger -from core.ports import ConversationHistoryStore +from runtime.ports import ConversationHistoryStore @dataclass diff --git a/engine/runtime/pipeline/__init__.py b/engine/runtime/pipeline/__init__.py new file mode 100644 index 0000000..8861a22 --- /dev/null +++ b/engine/runtime/pipeline/__init__.py @@ -0,0 +1 @@ +"""Runtime pipeline package.""" diff --git a/engine/runtime/pipeline/asr_flow.py b/engine/runtime/pipeline/asr_flow.py new file mode 100644 index 0000000..1b539f5 --- /dev/null +++ b/engine/runtime/pipeline/asr_flow.py @@ -0,0 +1,13 @@ +"""ASR flow helpers extracted from the duplex pipeline. + +This module is intentionally lightweight for phase-wise migration. +""" + +from __future__ import annotations + +from providers.common.base import ASRResult + + +def is_final_result(result: ASRResult) -> bool: + """Return whether an ASR result is final.""" + return bool(result.is_final) diff --git a/engine/runtime/pipeline/constants.py b/engine/runtime/pipeline/constants.py new file mode 100644 index 0000000..0109925 --- /dev/null +++ b/engine/runtime/pipeline/constants.py @@ -0,0 +1,6 @@ +"""Shared constants for the runtime duplex pipeline.""" + +TRACK_AUDIO_IN = "audio_in" +TRACK_AUDIO_OUT = "audio_out" +TRACK_CONTROL = "control" +PCM_FRAME_BYTES = 640 # 16k mono pcm_s16le, 20ms diff --git a/engine/core/duplex_pipeline.py b/engine/runtime/pipeline/duplex.py similarity index 99% rename from engine/core/duplex_pipeline.py rename to engine/runtime/pipeline/duplex.py index bbf3d47..aacd0c7 100644 --- a/engine/core/duplex_pipeline.py +++ b/engine/runtime/pipeline/duplex.py @@ -26,10 +26,10 @@ import aiohttp from loguru import logger from app.config import settings -from app.service_factory import DefaultRealtimeServiceFactory -from core.conversation import ConversationManager, ConversationState -from core.events import get_event_bus -from core.ports import ( +from providers.factory.default import DefaultRealtimeServiceFactory +from runtime.conversation import ConversationManager, ConversationState +from runtime.events import get_event_bus +from runtime.ports import ( ASRPort, ASRServiceSpec, LLMPort, @@ -38,13 +38,13 @@ from core.ports import ( TTSPort, TTSServiceSpec, ) -from core.tool_executor import execute_server_tool -from core.transports import BaseTransport -from models.ws_v1 import ev +from tools.executor import execute_server_tool +from runtime.transports import BaseTransport +from protocol.ws_v1.schema import ev from processors.eou import EouDetector from processors.vad import SileroVAD, VADProcessor -from services.base import LLMMessage, LLMStreamEvent -from services.streaming_text import extract_tts_sentence, has_spoken_content +from providers.common.base import LLMMessage, LLMStreamEvent +from providers.common.streaming_text import extract_tts_sentence, has_spoken_content class DuplexPipeline: diff --git a/engine/runtime/pipeline/events_out.py b/engine/runtime/pipeline/events_out.py new file mode 100644 index 0000000..dabbafb --- /dev/null +++ b/engine/runtime/pipeline/events_out.py @@ -0,0 +1,12 @@ +"""Output-event shaping helpers for the runtime pipeline.""" + +from __future__ import annotations + +from typing import Any, Dict + + +def assistant_text_delta_event(text: str, **extra: Any) -> Dict[str, Any]: + """Build a normalized assistant text delta payload.""" + payload: Dict[str, Any] = {"type": "assistant.text.delta", "text": str(text)} + payload.update(extra) + return payload diff --git a/engine/runtime/pipeline/interrupts.py b/engine/runtime/pipeline/interrupts.py new file mode 100644 index 0000000..1960d56 --- /dev/null +++ b/engine/runtime/pipeline/interrupts.py @@ -0,0 +1,8 @@ +"""Interruption-related helpers extracted from the duplex pipeline.""" + +from __future__ import annotations + + +def should_interrupt(min_duration_ms: int, detected_ms: int) -> bool: + """Decide whether interruption conditions are met.""" + return int(detected_ms) >= max(0, int(min_duration_ms)) diff --git a/engine/runtime/pipeline/llm_flow.py b/engine/runtime/pipeline/llm_flow.py new file mode 100644 index 0000000..d938fb0 --- /dev/null +++ b/engine/runtime/pipeline/llm_flow.py @@ -0,0 +1,13 @@ +"""LLM flow helpers extracted from the duplex pipeline. + +This module is intentionally lightweight for phase-wise migration. +""" + +from __future__ import annotations + +from providers.common.base import LLMStreamEvent + + +def is_done_event(event: LLMStreamEvent) -> bool: + """Return whether an LLM stream event signals completion.""" + return str(event.type) == "done" diff --git a/engine/runtime/pipeline/tooling.py b/engine/runtime/pipeline/tooling.py new file mode 100644 index 0000000..459dceb --- /dev/null +++ b/engine/runtime/pipeline/tooling.py @@ -0,0 +1,13 @@ +"""Tooling helpers extracted from the duplex pipeline.""" + +from __future__ import annotations + +from typing import Any + + +def normalize_tool_name(name: Any, aliases: dict[str, str]) -> str: + """Normalize tool name with alias mapping.""" + normalized = str(name or "").strip() + if not normalized: + return "" + return aliases.get(normalized, normalized) diff --git a/engine/runtime/pipeline/tts_flow.py b/engine/runtime/pipeline/tts_flow.py new file mode 100644 index 0000000..156547e --- /dev/null +++ b/engine/runtime/pipeline/tts_flow.py @@ -0,0 +1,15 @@ +"""TTS flow helpers extracted from the duplex pipeline. + +This module is intentionally lightweight for phase-wise migration. +""" + +from __future__ import annotations + +from providers.common.base import TTSChunk + + +def chunk_duration_ms(chunk: TTSChunk) -> float: + """Estimate chunk duration in milliseconds for pcm16 mono.""" + if chunk.sample_rate <= 0: + return 0.0 + return (len(chunk.audio) / 2.0 / float(chunk.sample_rate)) * 1000.0 diff --git a/engine/core/ports/__init__.py b/engine/runtime/ports/__init__.py similarity index 56% rename from engine/core/ports/__init__.py rename to engine/runtime/ports/__init__.py index 2ae96c4..a7cbce3 100644 --- a/engine/core/ports/__init__.py +++ b/engine/runtime/ports/__init__.py @@ -1,16 +1,16 @@ -"""Port interfaces for engine-side integration boundaries.""" +"""Port interfaces for runtime integration boundaries.""" -from core.ports.asr import ASRBufferControl, ASRInterimControl, ASRPort, ASRServiceSpec -from core.ports.control_plane import ( +from runtime.ports.asr import ASRBufferControl, ASRInterimControl, ASRPort, ASRServiceSpec +from runtime.ports.control_plane import ( AssistantRuntimeConfigProvider, ControlPlaneGateway, ConversationHistoryStore, KnowledgeRetriever, ToolCatalog, ) -from core.ports.llm import LLMCancellable, LLMPort, LLMRuntimeConfigurable, LLMServiceSpec -from core.ports.service_factory import RealtimeServiceFactory -from core.ports.tts import TTSPort, TTSServiceSpec +from runtime.ports.llm import LLMCancellable, LLMPort, LLMRuntimeConfigurable, LLMServiceSpec +from runtime.ports.service_factory import RealtimeServiceFactory +from runtime.ports.tts import TTSPort, TTSServiceSpec __all__ = [ "ASRPort", diff --git a/engine/core/ports/asr.py b/engine/runtime/ports/asr.py similarity index 97% rename from engine/core/ports/asr.py rename to engine/runtime/ports/asr.py index fa302cd..8621ed0 100644 --- a/engine/core/ports/asr.py +++ b/engine/runtime/ports/asr.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import AsyncIterator, Awaitable, Callable, Optional, Protocol -from services.base import ASRResult +from providers.common.base import ASRResult TranscriptCallback = Callable[[str, bool], Awaitable[None]] diff --git a/engine/core/ports/control_plane.py b/engine/runtime/ports/control_plane.py similarity index 100% rename from engine/core/ports/control_plane.py rename to engine/runtime/ports/control_plane.py diff --git a/engine/core/ports/llm.py b/engine/runtime/ports/llm.py similarity index 96% rename from engine/core/ports/llm.py rename to engine/runtime/ports/llm.py index ca515ac..a591985 100644 --- a/engine/core/ports/llm.py +++ b/engine/runtime/ports/llm.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, AsyncIterator, Awaitable, Callable, Dict, List, Optional, Protocol -from services.base import LLMMessage, LLMStreamEvent +from providers.common.base import LLMMessage, LLMStreamEvent KnowledgeRetrieverFn = Callable[..., Awaitable[List[Dict[str, Any]]]] diff --git a/engine/core/ports/service_factory.py b/engine/runtime/ports/service_factory.py similarity index 79% rename from engine/core/ports/service_factory.py rename to engine/runtime/ports/service_factory.py index d1d8476..7ce8b77 100644 --- a/engine/core/ports/service_factory.py +++ b/engine/runtime/ports/service_factory.py @@ -4,9 +4,9 @@ from __future__ import annotations from typing import Protocol -from core.ports.asr import ASRPort, ASRServiceSpec -from core.ports.llm import LLMPort, LLMServiceSpec -from core.ports.tts import TTSPort, TTSServiceSpec +from runtime.ports.asr import ASRPort, ASRServiceSpec +from runtime.ports.llm import LLMPort, LLMServiceSpec +from runtime.ports.tts import TTSPort, TTSServiceSpec class RealtimeServiceFactory(Protocol): diff --git a/engine/core/ports/tts.py b/engine/runtime/ports/tts.py similarity index 96% rename from engine/core/ports/tts.py rename to engine/runtime/ports/tts.py index 0693cdb..523dc3c 100644 --- a/engine/core/ports/tts.py +++ b/engine/runtime/ports/tts.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import AsyncIterator, Optional, Protocol -from services.base import TTSChunk +from providers.common.base import TTSChunk @dataclass(frozen=True) diff --git a/engine/runtime/session/__init__.py b/engine/runtime/session/__init__.py new file mode 100644 index 0000000..d224fb9 --- /dev/null +++ b/engine/runtime/session/__init__.py @@ -0,0 +1 @@ +"""Runtime session package.""" diff --git a/engine/runtime/session/lifecycle.py b/engine/runtime/session/lifecycle.py new file mode 100644 index 0000000..9fd8ebf --- /dev/null +++ b/engine/runtime/session/lifecycle.py @@ -0,0 +1,10 @@ +"""Lifecycle helper utilities for runtime sessions.""" + +from __future__ import annotations + +from datetime import datetime, timezone + + +def utc_now_iso() -> str: + """Return current UTC timestamp in ISO 8601 format.""" + return datetime.now(timezone.utc).isoformat() diff --git a/engine/core/session.py b/engine/runtime/session/manager.py similarity index 98% rename from engine/core/session.py rename to engine/runtime/session/manager.py index eff77a6..1f65ea7 100644 --- a/engine/core/session.py +++ b/engine/runtime/session/manager.py @@ -9,22 +9,22 @@ from enum import Enum from typing import Optional, Dict, Any, List from loguru import logger -from app.backend_adapters import build_backend_adapter_from_settings -from core.transports import BaseTransport -from core.ports import ( +from adapters.control_plane.backend import build_backend_adapter_from_settings +from runtime.transports import BaseTransport +from runtime.ports import ( AssistantRuntimeConfigProvider, ControlPlaneGateway, ConversationHistoryStore, KnowledgeRetriever, ToolCatalog, ) -from core.duplex_pipeline import DuplexPipeline -from core.conversation import ConversationTurn -from core.history_bridge import SessionHistoryBridge -from core.workflow_runner import WorkflowRunner, WorkflowTransition, WorkflowNodeDef, WorkflowEdgeDef +from runtime.pipeline.duplex import DuplexPipeline +from runtime.conversation import ConversationTurn +from runtime.history.bridge import SessionHistoryBridge +from workflow.runner import WorkflowRunner, WorkflowTransition, WorkflowNodeDef, WorkflowEdgeDef from app.config import settings -from services.base import LLMMessage -from models.ws_v1 import ( +from providers.common.base import LLMMessage +from protocol.ws_v1.schema import ( parse_client_message, ev, SessionStartMessage, diff --git a/engine/runtime/session/metadata.py b/engine/runtime/session/metadata.py new file mode 100644 index 0000000..ab32971 --- /dev/null +++ b/engine/runtime/session/metadata.py @@ -0,0 +1,9 @@ +"""Metadata helpers extracted from session manager.""" + +from __future__ import annotations + +import re +from typing import Pattern + +DYNAMIC_VARIABLE_KEY_RE: Pattern[str] = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$") +DYNAMIC_VARIABLE_PLACEHOLDER_RE: Pattern[str] = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}") diff --git a/engine/runtime/session/workflow_bridge.py b/engine/runtime/session/workflow_bridge.py new file mode 100644 index 0000000..e03d939 --- /dev/null +++ b/engine/runtime/session/workflow_bridge.py @@ -0,0 +1,12 @@ +"""Workflow bridge helpers for runtime session orchestration.""" + +from __future__ import annotations + +from typing import Optional + +from workflow.runner import WorkflowRunner + + +def has_active_workflow(workflow_runner: Optional[WorkflowRunner]) -> bool: + """Return whether a workflow runner exists and has a current node.""" + return bool(workflow_runner and workflow_runner.current_node is not None) diff --git a/engine/core/transports.py b/engine/runtime/transports.py similarity index 100% rename from engine/core/transports.py rename to engine/runtime/transports.py diff --git a/engine/services/__init__.py b/engine/services/__init__.py deleted file mode 100644 index f64ef05..0000000 --- a/engine/services/__init__.py +++ /dev/null @@ -1,52 +0,0 @@ -"""AI Services package. - -Provides ASR, LLM, TTS, and Realtime API services for voice conversation. -""" - -from services.base import ( - ServiceState, - ASRResult, - LLMMessage, - TTSChunk, - BaseASRService, - BaseLLMService, - BaseTTSService, -) -from services.llm import OpenAILLMService, MockLLMService -from services.dashscope_tts import DashScopeTTSService -from services.tts import MockTTSService -from services.asr import BufferedASRService, MockASRService -from services.openai_compatible_asr import OpenAICompatibleASRService, SiliconFlowASRService -from services.openai_compatible_tts import OpenAICompatibleTTSService, SiliconFlowTTSService -from services.streaming_tts_adapter import StreamingTTSAdapter -from services.realtime import RealtimeService, RealtimeConfig, RealtimePipeline - -__all__ = [ - # Base classes - "ServiceState", - "ASRResult", - "LLMMessage", - "TTSChunk", - "BaseASRService", - "BaseLLMService", - "BaseTTSService", - # LLM - "OpenAILLMService", - "MockLLMService", - # TTS - "DashScopeTTSService", - "MockTTSService", - # ASR - "BufferedASRService", - "MockASRService", - "OpenAICompatibleASRService", - "SiliconFlowASRService", - # TTS (SiliconFlow) - "OpenAICompatibleTTSService", - "SiliconFlowTTSService", - "StreamingTTSAdapter", - # Realtime - "RealtimeService", - "RealtimeConfig", - "RealtimePipeline", -] diff --git a/engine/tests/test_backend_adapters.py b/engine/tests/test_backend_adapters.py index 347df45..70f569e 100644 --- a/engine/tests/test_backend_adapters.py +++ b/engine/tests/test_backend_adapters.py @@ -1,7 +1,7 @@ import aiohttp import pytest -from app.backend_adapters import ( +from adapters.control_plane.backend import ( AssistantConfigSourceAdapter, LocalYamlAssistantConfigAdapter, build_backend_adapter, @@ -120,7 +120,7 @@ async def test_http_backend_adapter_create_call_record_posts_expected_payload(mo }, ) - monkeypatch.setattr("app.backend_adapters.aiohttp.ClientSession", _FakeClientSession) + monkeypatch.setattr("adapters.control_plane.backend.aiohttp.ClientSession", _FakeClientSession) config_dir = tmp_path / "assistants" config_dir.mkdir(parents=True, exist_ok=True) @@ -198,7 +198,7 @@ async def test_with_backend_url_uses_backend_for_assistant_config(monkeypatch, t _ = (url, json) return _FakeResponse(status=200, payload={"id": "call_1"}) - monkeypatch.setattr("app.backend_adapters.aiohttp.ClientSession", _FakeClientSession) + monkeypatch.setattr("adapters.control_plane.backend.aiohttp.ClientSession", _FakeClientSession) config_dir = tmp_path / "assistants" config_dir.mkdir(parents=True, exist_ok=True) @@ -234,7 +234,7 @@ async def test_backend_mode_disabled_uses_local_assistant_config_even_with_url(m _ = timeout raise AssertionError("HTTP client should not be created when backend_mode=disabled") - monkeypatch.setattr("app.backend_adapters.aiohttp.ClientSession", _FailIfCalledClientSession) + monkeypatch.setattr("adapters.control_plane.backend.aiohttp.ClientSession", _FailIfCalledClientSession) config_dir = tmp_path / "assistants" config_dir.mkdir(parents=True, exist_ok=True) diff --git a/engine/tests/test_dynamic_variables.py b/engine/tests/test_dynamic_variables.py index f7982c7..d6744af 100644 --- a/engine/tests/test_dynamic_variables.py +++ b/engine/tests/test_dynamic_variables.py @@ -1,4 +1,4 @@ -from core.session import Session +from runtime.session.manager import Session def _session() -> Session: diff --git a/engine/tests/test_history_bridge.py b/engine/tests/test_history_bridge.py index 2f9dd80..d70fa6e 100644 --- a/engine/tests/test_history_bridge.py +++ b/engine/tests/test_history_bridge.py @@ -3,7 +3,7 @@ import time import pytest -from core.history_bridge import SessionHistoryBridge +from runtime.history.bridge import SessionHistoryBridge class _FakeHistoryWriter: diff --git a/engine/tests/test_tool_call_flow.py b/engine/tests/test_tool_call_flow.py index 11a7b77..d820643 100644 --- a/engine/tests/test_tool_call_flow.py +++ b/engine/tests/test_tool_call_flow.py @@ -4,10 +4,10 @@ from typing import Any, Dict, List import pytest -from core.conversation import ConversationState -from core.duplex_pipeline import DuplexPipeline -from models.ws_v1 import OutputAudioPlayedMessage, ToolCallResultsMessage, parse_client_message -from services.base import LLMStreamEvent +from runtime.conversation import ConversationState +from runtime.pipeline.duplex import DuplexPipeline +from protocol.ws_v1.schema import OutputAudioPlayedMessage, ToolCallResultsMessage, parse_client_message +from providers.common.base import LLMStreamEvent class _DummySileroVAD: @@ -86,9 +86,9 @@ class _CaptureGenerateLLM: def _build_pipeline(monkeypatch, llm_rounds: List[List[LLMStreamEvent]]) -> tuple[DuplexPipeline, List[Dict[str, Any]]]: - monkeypatch.setattr("core.duplex_pipeline.SileroVAD", _DummySileroVAD) - monkeypatch.setattr("core.duplex_pipeline.VADProcessor", _DummyVADProcessor) - monkeypatch.setattr("core.duplex_pipeline.EouDetector", _DummyEouDetector) + monkeypatch.setattr("runtime.pipeline.duplex.SileroVAD", _DummySileroVAD) + monkeypatch.setattr("runtime.pipeline.duplex.VADProcessor", _DummyVADProcessor) + monkeypatch.setattr("runtime.pipeline.duplex.EouDetector", _DummyEouDetector) pipeline = DuplexPipeline( transport=_FakeTransport(), @@ -112,7 +112,7 @@ def _build_pipeline(monkeypatch, llm_rounds: List[List[LLMStreamEvent]]) -> tupl def test_pipeline_uses_default_tools_from_settings(monkeypatch): monkeypatch.setattr( - "core.duplex_pipeline.settings.tools", + "runtime.pipeline.duplex.settings.tools", [ "current_time", "calculator", @@ -141,7 +141,7 @@ def test_pipeline_uses_default_tools_from_settings(monkeypatch): def test_pipeline_exposes_unknown_string_tools_with_fallback_schema(monkeypatch): - monkeypatch.setattr("core.duplex_pipeline.settings.tools", ["custom_system_cmd"]) + monkeypatch.setattr("runtime.pipeline.duplex.settings.tools", ["custom_system_cmd"]) pipeline, _events = _build_pipeline(monkeypatch, [[LLMStreamEvent(type="done")]]) schemas = pipeline._resolved_tool_schemas() @@ -151,7 +151,7 @@ def test_pipeline_exposes_unknown_string_tools_with_fallback_schema(monkeypatch) def test_pipeline_assigns_default_client_executor_for_system_string_tools(monkeypatch): - monkeypatch.setattr("core.duplex_pipeline.settings.tools", ["increase_volume"]) + monkeypatch.setattr("runtime.pipeline.duplex.settings.tools", ["increase_volume"]) pipeline, _events = _build_pipeline(monkeypatch, [[LLMStreamEvent(type="done")]]) tool_call = { @@ -221,9 +221,9 @@ async def test_pipeline_applies_default_args_to_tool_call(monkeypatch): @pytest.mark.asyncio async def test_generated_opener_prompt_uses_system_prompt_only(monkeypatch): - monkeypatch.setattr("core.duplex_pipeline.SileroVAD", _DummySileroVAD) - monkeypatch.setattr("core.duplex_pipeline.VADProcessor", _DummyVADProcessor) - monkeypatch.setattr("core.duplex_pipeline.EouDetector", _DummyEouDetector) + monkeypatch.setattr("runtime.pipeline.duplex.SileroVAD", _DummySileroVAD) + monkeypatch.setattr("runtime.pipeline.duplex.VADProcessor", _DummyVADProcessor) + monkeypatch.setattr("runtime.pipeline.duplex.EouDetector", _DummyEouDetector) llm = _CaptureGenerateLLM("你好") pipeline = DuplexPipeline( @@ -662,7 +662,7 @@ async def test_server_tool_timeout_emits_504_and_continues(monkeypatch): "status": {"code": 200, "message": "ok"}, } - monkeypatch.setattr("core.duplex_pipeline.execute_server_tool", _slow_execute) + monkeypatch.setattr("runtime.pipeline.duplex.execute_server_tool", _slow_execute) pipeline, events = _build_pipeline( monkeypatch, diff --git a/engine/tests/test_tool_executor.py b/engine/tests/test_tool_executor.py index 17345c7..aada0c1 100644 --- a/engine/tests/test_tool_executor.py +++ b/engine/tests/test_tool_executor.py @@ -1,6 +1,6 @@ import pytest -from core.tool_executor import execute_server_tool +from tools.executor import execute_server_tool @pytest.mark.asyncio @@ -38,7 +38,7 @@ async def test_current_time_uses_local_system_clock(monkeypatch): async def _should_not_be_called(_tool_id): raise AssertionError("fetch_tool_resource should not be called for current_time") - monkeypatch.setattr("core.tool_executor.fetch_tool_resource", _should_not_be_called) + monkeypatch.setattr("tools.executor.fetch_tool_resource", _should_not_be_called) result = await execute_server_tool( { diff --git a/engine/tests/test_ws_protocol_session_start.py b/engine/tests/test_ws_protocol_session_start.py index ad68674..e055c75 100644 --- a/engine/tests/test_ws_protocol_session_start.py +++ b/engine/tests/test_ws_protocol_session_start.py @@ -1,7 +1,7 @@ import pytest -from core.session import Session, WsSessionState -from models.ws_v1 import OutputAudioPlayedMessage, SessionStartMessage, parse_client_message +from runtime.session.manager import Session, WsSessionState +from protocol.ws_v1.schema import OutputAudioPlayedMessage, SessionStartMessage, parse_client_message def _session() -> Session: @@ -194,7 +194,7 @@ async def test_handle_session_start_requires_assistant_id_and_closes_transport() @pytest.mark.asyncio async def test_handle_session_start_applies_whitelisted_overrides_and_ignores_workflow(monkeypatch): - monkeypatch.setattr("core.session.settings.ws_emit_config_resolved", False) + monkeypatch.setattr("runtime.session.manager.settings.ws_emit_config_resolved", False) session = Session.__new__(Session) session.id = "sess_start_ok" @@ -289,9 +289,9 @@ async def test_handle_session_start_applies_whitelisted_overrides_and_ignores_wo @pytest.mark.asyncio async def test_handle_session_start_emits_config_resolved_when_enabled(monkeypatch): - monkeypatch.setattr("core.session.settings.ws_emit_config_resolved", True) - monkeypatch.setattr("core.session.settings.ws_protocol_version", "v1-custom") - monkeypatch.setattr("core.session.settings.default_codec", "pcmu") + monkeypatch.setattr("runtime.session.manager.settings.ws_emit_config_resolved", True) + monkeypatch.setattr("runtime.session.manager.settings.ws_protocol_version", "v1-custom") + monkeypatch.setattr("runtime.session.manager.settings.default_codec", "pcmu") session = Session.__new__(Session) session.id = "sess_start_emit_config" @@ -385,8 +385,8 @@ async def test_handle_session_start_emits_config_resolved_when_enabled(monkeypat @pytest.mark.asyncio async def test_handle_audio_uses_chunk_size_for_frame_validation(monkeypatch): - monkeypatch.setattr("core.session.settings.sample_rate", 16000) - monkeypatch.setattr("core.session.settings.chunk_size_ms", 10) + monkeypatch.setattr("runtime.session.manager.settings.sample_rate", 16000) + monkeypatch.setattr("runtime.session.manager.settings.chunk_size_ms", 10) session = Session.__new__(Session) session.id = "sess_chunk_frame" diff --git a/engine/tools/__init__.py b/engine/tools/__init__.py new file mode 100644 index 0000000..29a67f0 --- /dev/null +++ b/engine/tools/__init__.py @@ -0,0 +1 @@ +"""Tools package.""" diff --git a/engine/core/tool_executor.py b/engine/tools/executor.py similarity index 99% rename from engine/core/tool_executor.py rename to engine/tools/executor.py index 899d930..0049cbc 100644 --- a/engine/core/tool_executor.py +++ b/engine/tools/executor.py @@ -8,7 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, Optional import aiohttp -from app.backend_adapters import build_backend_adapter_from_settings +from adapters.control_plane.backend import build_backend_adapter_from_settings ToolResourceFetcher = Callable[[str], Awaitable[Optional[Dict[str, Any]]]] diff --git a/engine/workflow/__init__.py b/engine/workflow/__init__.py new file mode 100644 index 0000000..35ffe2e --- /dev/null +++ b/engine/workflow/__init__.py @@ -0,0 +1 @@ +"""Workflow package.""" diff --git a/engine/core/workflow_runner.py b/engine/workflow/runner.py similarity index 100% rename from engine/core/workflow_runner.py rename to engine/workflow/runner.py From e11c3abb9efcdbd3e9e5e09f4e55f11aa8565ff7 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 6 Mar 2026 11:44:39 +0800 Subject: [PATCH 06/20] Implement DashScope ASR provider and enhance ASR service architecture - Added DashScope ASR service implementation for real-time streaming. - Updated ASR provider logic to support DashScope alongside existing providers. - Enhanced runtime metadata resolution to include DashScope as a valid ASR provider. - Modified configuration files and documentation to reflect the addition of DashScope. - Introduced tests to validate DashScope integration and ASR service behavior. - Refactored ASR service factory to accommodate new provider options and modes. --- api/app/routers/assistants.py | 11 +- api/tests/test_assistants.py | 31 ++ docs/content/customization/asr.md | 7 +- engine/app/config.py | 2 +- engine/config/agents/example.yaml | 6 +- engine/config/agents/tools.yaml | 6 +- engine/docs/extension_ports.md | 4 +- engine/providers/asr/__init__.py | 12 + engine/providers/asr/buffered.py | 34 ++ engine/providers/asr/dashscope.py | 388 ++++++++++++++++++++ engine/providers/asr/openai_compatible.py | 1 + engine/providers/factory/default.py | 14 + engine/runtime/pipeline/duplex.py | 84 +++-- engine/runtime/ports/__init__.py | 13 +- engine/runtime/ports/asr.py | 38 +- engine/tests/test_asr_factory_modes.py | 46 +++ engine/tests/test_dashscope_asr_provider.py | 67 ++++ engine/tests/test_duplex_asr_modes.py | 196 ++++++++++ engine/tests/test_tool_call_flow.py | 24 ++ 19 files changed, 940 insertions(+), 44 deletions(-) create mode 100644 engine/providers/asr/dashscope.py create mode 100644 engine/tests/test_asr_factory_modes.py create mode 100644 engine/tests/test_dashscope_asr_provider.py create mode 100644 engine/tests/test_duplex_asr_modes.py diff --git a/api/app/routers/assistants.py b/api/app/routers/assistants.py index bf43303..b63d0ee 100644 --- a/api/app/routers/assistants.py +++ b/api/app/routers/assistants.py @@ -320,12 +320,17 @@ def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[s if assistant.asr_model_id: asr = db.query(ASRModel).filter(ASRModel.id == assistant.asr_model_id).first() if asr: - asr_provider = "openai_compatible" if _is_openai_compatible_vendor(asr.vendor) else "buffered" + if _is_dashscope_vendor(asr.vendor): + asr_provider = "dashscope" + elif _is_openai_compatible_vendor(asr.vendor): + asr_provider = "openai_compatible" + else: + asr_provider = "buffered" metadata["services"]["asr"] = { "provider": asr_provider, "model": asr.model_name or asr.name, - "apiKey": asr.api_key if asr_provider == "openai_compatible" else None, - "baseUrl": asr.base_url if asr_provider == "openai_compatible" else None, + "apiKey": asr.api_key if asr_provider in {"openai_compatible", "dashscope"} else None, + "baseUrl": asr.base_url if asr_provider in {"openai_compatible", "dashscope"} else None, } else: warnings.append(f"ASR model not found: {assistant.asr_model_id}") diff --git a/api/tests/test_assistants.py b/api/tests/test_assistants.py index 0d880ef..3828688 100644 --- a/api/tests/test_assistants.py +++ b/api/tests/test_assistants.py @@ -343,6 +343,37 @@ class TestAssistantAPI: assert tts["apiKey"] == "dashscope-key" assert tts["baseUrl"] == "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" + def test_runtime_config_dashscope_asr_provider(self, client, sample_assistant_data): + """DashScope ASR models should map to dashscope asr provider in runtime metadata.""" + asr_resp = client.post("/api/asr", json={ + "name": "DashScope Realtime ASR", + "vendor": "DashScope", + "language": "zh", + "base_url": "wss://dashscope.aliyuncs.com/api-ws/v1/realtime", + "api_key": "dashscope-asr-key", + "model_name": "qwen3-asr-flash-realtime", + "hotwords": [], + "enable_punctuation": True, + "enable_normalization": True, + "enabled": True, + }) + assert asr_resp.status_code == 200 + asr_payload = asr_resp.json() + + sample_assistant_data.update({ + "asrModelId": asr_payload["id"], + }) + assistant_resp = client.post("/api/assistants", json=sample_assistant_data) + assert assistant_resp.status_code == 200 + assistant_id = assistant_resp.json()["id"] + + runtime_resp = client.get(f"/api/assistants/{assistant_id}/runtime-config") + assert runtime_resp.status_code == 200 + metadata = runtime_resp.json()["sessionStartMetadata"] + asr = metadata["services"]["asr"] + assert asr["provider"] == "dashscope" + assert asr["baseUrl"] == "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" + def test_assistant_interrupt_and_generated_opener_flags(self, client, sample_assistant_data): sample_assistant_data.update({ "firstTurnMode": "user_first", diff --git a/docs/content/customization/asr.md b/docs/content/customization/asr.md index 2251804..8d73889 100644 --- a/docs/content/customization/asr.md +++ b/docs/content/customization/asr.md @@ -2,6 +2,11 @@ 语音识别(ASR)负责将用户音频实时转写为文本,供对话引擎理解。 +## 模式 + +- `offline`:引擎本地缓冲音频后触发识别(适用于 OpenAI-compatible / SiliconFlow)。 +- `streaming`:音频分片实时发送到服务端,服务端持续返回转写事件(适用于 DashScope Realtime ASR)。 + ## 配置项 | 配置项 | 说明 | @@ -17,8 +22,8 @@ - 客服场景建议开启热词并维护业务词表 - 多语言场景建议按会话入口显式指定语言 - 对延迟敏感场景优先选择流式识别模型 +- 当前支持提供商:`openai_compatible`、`siliconflow`、`dashscope`、`buffered`(回退) ## 相关文档 - [语音配置总览](voices.md) - diff --git a/engine/app/config.py b/engine/app/config.py index 233ba75..62364d1 100644 --- a/engine/app/config.py +++ b/engine/app/config.py @@ -85,7 +85,7 @@ class Settings(BaseSettings): # ASR Configuration asr_provider: str = Field( default="openai_compatible", - description="ASR provider (openai_compatible, buffered, siliconflow)" + description="ASR provider (openai_compatible, buffered, siliconflow, dashscope)" ) asr_api_url: Optional[str] = Field(default=None, description="ASR provider API URL") asr_model: Optional[str] = Field(default=None, description="ASR model name") diff --git a/engine/config/agents/example.yaml b/engine/config/agents/example.yaml index 70f4933..2e9f157 100644 --- a/engine/config/agents/example.yaml +++ b/engine/config/agents/example.yaml @@ -35,7 +35,11 @@ agent: speed: 1.0 asr: - # provider: buffered | openai_compatible | siliconflow + # provider: buffered | openai_compatible | siliconflow | dashscope + # dashscope defaults (if omitted): + # api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime + # model: qwen3-asr-flash-realtime + # note: dashscope uses streaming ASR mode (chunk-by-chunk). provider: openai_compatible api_key: you_asr_api_key api_url: https://api.siliconflow.cn/v1/audio/transcriptions diff --git a/engine/config/agents/tools.yaml b/engine/config/agents/tools.yaml index e2968bb..8657080 100644 --- a/engine/config/agents/tools.yaml +++ b/engine/config/agents/tools.yaml @@ -32,7 +32,11 @@ agent: speed: 1.0 asr: - # provider: buffered | openai_compatible | siliconflow + # provider: buffered | openai_compatible | siliconflow | dashscope + # dashscope defaults (if omitted): + # api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime + # model: qwen3-asr-flash-realtime + # note: dashscope uses streaming ASR mode (chunk-by-chunk). provider: openai_compatible api_key: your_asr_api_key api_url: https://api.siliconflow.cn/v1/audio/transcriptions diff --git a/engine/docs/extension_ports.md b/engine/docs/extension_ports.md index 8566194..36e2aac 100644 --- a/engine/docs/extension_ports.md +++ b/engine/docs/extension_ports.md @@ -20,7 +20,7 @@ This document defines the draft port set used to keep core runtime extensible. - `runtime/ports/asr.py` - `ASRServiceSpec` - `ASRPort` - - optional extensions: `ASRInterimControl`, `ASRBufferControl` + - explicit mode ports: `OfflineASRPort`, `StreamingASRPort` - `runtime/ports/service_factory.py` - `RealtimeServiceFactory` @@ -39,7 +39,7 @@ This document defines the draft port set used to keep core runtime extensible. - supported providers: `dashscope`, `openai_compatible`, `openai-compatible`, `siliconflow` - fallback: `MockTTSService` - ASR: - - supported providers: `openai_compatible`, `openai-compatible`, `siliconflow` + - supported providers: `openai_compatible`, `openai-compatible`, `siliconflow`, `dashscope` - fallback: `BufferedASRService` ## Notes diff --git a/engine/providers/asr/__init__.py b/engine/providers/asr/__init__.py index 2efe6a9..5e659be 100644 --- a/engine/providers/asr/__init__.py +++ b/engine/providers/asr/__init__.py @@ -1 +1,13 @@ """ASR providers.""" + +from providers.asr.buffered import BufferedASRService, MockASRService +from providers.asr.dashscope import DashScopeRealtimeASRService +from providers.asr.openai_compatible import OpenAICompatibleASRService, SiliconFlowASRService + +__all__ = [ + "BufferedASRService", + "MockASRService", + "DashScopeRealtimeASRService", + "OpenAICompatibleASRService", + "SiliconFlowASRService", +] diff --git a/engine/providers/asr/buffered.py b/engine/providers/asr/buffered.py index ce1a248..624963c 100644 --- a/engine/providers/asr/buffered.py +++ b/engine/providers/asr/buffered.py @@ -34,6 +34,7 @@ class BufferedASRService(BaseASRService): language: str = "en" ): super().__init__(sample_rate=sample_rate, language=language) + self.mode = "offline" self._audio_buffer: bytes = b"" self._current_text: str = "" @@ -86,6 +87,23 @@ class BufferedASRService(BaseASRService): self._current_text = "" self._audio_buffer = b"" return text + + async def get_final_transcription(self) -> str: + """Offline compatibility method used by DuplexPipeline.""" + return self.get_and_clear_text() + + def clear_buffer(self) -> None: + """Offline compatibility method used by DuplexPipeline.""" + self._audio_buffer = b"" + self._current_text = "" + + async def start_interim_transcription(self) -> None: + """No-op for plain buffered ASR.""" + return None + + async def stop_interim_transcription(self) -> None: + """No-op for plain buffered ASR.""" + return None def get_audio_buffer(self) -> bytes: """Get accumulated audio buffer.""" @@ -103,6 +121,7 @@ class MockASRService(BaseASRService): def __init__(self, sample_rate: int = 16000, language: str = "en"): super().__init__(sample_rate=sample_rate, language=language) + self.mode = "offline" self._transcript_queue: asyncio.Queue[ASRResult] = asyncio.Queue() self._mock_texts = [ "Hello, how are you?", @@ -145,3 +164,18 @@ class MockASRService(BaseASRService): continue except asyncio.CancelledError: break + + def clear_buffer(self) -> None: + return None + + async def get_final_transcription(self) -> str: + return "" + + def get_and_clear_text(self) -> str: + return "" + + async def start_interim_transcription(self) -> None: + return None + + async def stop_interim_transcription(self) -> None: + return None diff --git a/engine/providers/asr/dashscope.py b/engine/providers/asr/dashscope.py new file mode 100644 index 0000000..bed4ede --- /dev/null +++ b/engine/providers/asr/dashscope.py @@ -0,0 +1,388 @@ +"""DashScope realtime streaming ASR service. + +Uses Qwen-ASR-Realtime via DashScope Python SDK. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import os +import sys +from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Optional + +from loguru import logger + +from providers.common.base import ASRResult, BaseASRService, ServiceState + +try: + import dashscope + from dashscope.audio.qwen_omni import MultiModality, OmniRealtimeCallback, OmniRealtimeConversation + + # Some SDK builds keep TranscriptionParams under qwen_omni.omni_realtime. + try: + from dashscope.audio.qwen_omni import TranscriptionParams + except ImportError: + from dashscope.audio.qwen_omni.omni_realtime import TranscriptionParams + + DASHSCOPE_SDK_AVAILABLE = True + DASHSCOPE_IMPORT_ERROR = "" +except Exception as exc: + DASHSCOPE_IMPORT_ERROR = f"{type(exc).__name__}: {exc}" + dashscope = None # type: ignore[assignment] + MultiModality = None # type: ignore[assignment] + OmniRealtimeConversation = None # type: ignore[assignment] + TranscriptionParams = None # type: ignore[assignment] + DASHSCOPE_SDK_AVAILABLE = False + + class OmniRealtimeCallback: # type: ignore[no-redef] + """Fallback callback base when DashScope SDK is unavailable.""" + + pass + + +class _DashScopeASRCallback(OmniRealtimeCallback): + """Bridge DashScope SDK callbacks into asyncio loop-safe handlers.""" + + def __init__(self, owner: "DashScopeRealtimeASRService", loop: asyncio.AbstractEventLoop): + super().__init__() + self._owner = owner + self._loop = loop + + def _schedule(self, fn: Callable[[], None]) -> None: + try: + self._loop.call_soon_threadsafe(fn) + except RuntimeError: + return + + def on_open(self) -> None: + self._schedule(self._owner._on_ws_open) + + def on_close(self, code: int, msg: str) -> None: + self._schedule(lambda: self._owner._on_ws_close(code, msg)) + + def on_event(self, message: Any) -> None: + self._schedule(lambda: self._owner._on_ws_event(message)) + + def on_error(self, message: Any) -> None: + self._schedule(lambda: self._owner._on_ws_error(message)) + + +class DashScopeRealtimeASRService(BaseASRService): + """Realtime streaming ASR implementation for DashScope Qwen-ASR-Realtime.""" + + DEFAULT_WS_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" + DEFAULT_MODEL = "qwen3-asr-flash-realtime" + DEFAULT_FINAL_TIMEOUT_MS = 800 + + def __init__( + self, + api_key: Optional[str] = None, + api_url: Optional[str] = None, + model: Optional[str] = None, + sample_rate: int = 16000, + language: str = "auto", + on_transcript: Optional[Callable[[str, bool], Awaitable[None]]] = None, + ) -> None: + super().__init__(sample_rate=sample_rate, language=language) + self.mode = "streaming" + self.api_key = ( + api_key + or os.getenv("DASHSCOPE_API_KEY") + or os.getenv("ASR_API_KEY") + ) + self.api_url = api_url or os.getenv("DASHSCOPE_ASR_API_URL") or self.DEFAULT_WS_URL + self.model = model or os.getenv("DASHSCOPE_ASR_MODEL") or self.DEFAULT_MODEL + self.on_transcript = on_transcript + + self._client: Optional[Any] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._callback: Optional[_DashScopeASRCallback] = None + + self._running = False + self._session_ready = asyncio.Event() + self._transcript_queue: "asyncio.Queue[ASRResult]" = asyncio.Queue() + self._final_queue: "asyncio.Queue[str]" = asyncio.Queue() + + self._utterance_active = False + self._audio_sent_in_utterance = False + self._last_interim_text = "" + self._last_error: Optional[str] = None + + async def connect(self) -> None: + if not DASHSCOPE_SDK_AVAILABLE: + py_exec = sys.executable + hint = f"`{py_exec} -m pip install dashscope>=1.25.6`" + detail = f"; import error: {DASHSCOPE_IMPORT_ERROR}" if DASHSCOPE_IMPORT_ERROR else "" + raise RuntimeError( + f"dashscope SDK unavailable in interpreter {py_exec}; install with {hint}{detail}" + ) + if not self.api_key: + raise ValueError("DashScope ASR API key not provided. Configure agent.asr.api_key in YAML.") + + self._loop = asyncio.get_running_loop() + self._callback = _DashScopeASRCallback(owner=self, loop=self._loop) + + if dashscope is not None: + dashscope.api_key = self.api_key + + self._client = OmniRealtimeConversation( # type: ignore[misc] + model=self.model, + url=self.api_url, + callback=self._callback, + ) + await asyncio.to_thread(self._client.connect) + await self._configure_session() + + self._running = True + self.state = ServiceState.CONNECTED + logger.info( + "DashScope realtime ASR connected: model={}, sample_rate={}, language={}", + self.model, + self.sample_rate, + self.language, + ) + + async def disconnect(self) -> None: + self._running = False + self._utterance_active = False + self._audio_sent_in_utterance = False + self._drain_queue(self._final_queue) + self._drain_queue(self._transcript_queue) + self._session_ready.clear() + + if self._client is not None: + close_fn = getattr(self._client, "close", None) + if callable(close_fn): + await asyncio.to_thread(close_fn) + self._client = None + self.state = ServiceState.DISCONNECTED + logger.info("DashScope realtime ASR disconnected") + + async def begin_utterance(self) -> None: + self.clear_utterance() + self._utterance_active = True + + async def send_audio(self, audio: bytes) -> None: + if not self._client: + raise RuntimeError("DashScope ASR service not connected") + if not audio: + return + + if not self._utterance_active: + # Allow graceful fallback if caller sends before begin_utterance. + self._utterance_active = True + + audio_b64 = base64.b64encode(audio).decode("ascii") + append_fn = getattr(self._client, "append_audio", None) + if not callable(append_fn): + raise RuntimeError("DashScope ASR SDK missing append_audio method") + await asyncio.to_thread(append_fn, audio_b64) + self._audio_sent_in_utterance = True + + async def end_utterance(self) -> None: + if not self._client: + return + if not self._utterance_active or not self._audio_sent_in_utterance: + return + + commit_fn = getattr(self._client, "commit", None) + if not callable(commit_fn): + raise RuntimeError("DashScope ASR SDK missing commit method") + await asyncio.to_thread(commit_fn) + self._utterance_active = False + + async def wait_for_final_transcription(self, timeout_ms: int = DEFAULT_FINAL_TIMEOUT_MS) -> str: + if not self._audio_sent_in_utterance: + return "" + timeout_sec = max(0.05, float(timeout_ms) / 1000.0) + try: + text = await asyncio.wait_for(self._final_queue.get(), timeout=timeout_sec) + return str(text or "").strip() + except asyncio.TimeoutError: + logger.debug("DashScope ASR final timeout ({}ms), fallback to last interim", timeout_ms) + return str(self._last_interim_text or "").strip() + + def clear_utterance(self) -> None: + self._utterance_active = False + self._audio_sent_in_utterance = False + self._last_interim_text = "" + self._last_error = None + self._drain_queue(self._final_queue) + + async def receive_transcripts(self) -> AsyncIterator[ASRResult]: + while self._running: + try: + result = await asyncio.wait_for(self._transcript_queue.get(), timeout=0.1) + yield result + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + break + + async def _configure_session(self) -> None: + if not self._client: + raise RuntimeError("DashScope ASR client is not initialized") + + text_modality: Any = "text" + if MultiModality is not None and hasattr(MultiModality, "TEXT"): + text_modality = MultiModality.TEXT + + transcription_params: Optional[Any] = None + if TranscriptionParams is not None: + try: + lang = "zh" if self.language == "auto" else self.language + transcription_params = TranscriptionParams( + language=lang, + sample_rate=self.sample_rate, + input_audio_format="pcm", + ) + except Exception as exc: + logger.debug("DashScope ASR TranscriptionParams init failed: {}", exc) + transcription_params = None + + update_attempts = [ + { + "output_modalities": [text_modality], + "enable_turn_detection": False, + "enable_input_audio_transcription": True, + "transcription_params": transcription_params, + }, + { + "output_modalities": [text_modality], + "enable_turn_detection": False, + "enable_input_audio_transcription": True, + }, + { + "output_modalities": [text_modality], + }, + ] + + update_fn = getattr(self._client, "update_session", None) + if not callable(update_fn): + raise RuntimeError("DashScope ASR SDK missing update_session method") + + last_error: Optional[Exception] = None + for params in update_attempts: + if params.get("transcription_params") is None: + params = {k: v for k, v in params.items() if k != "transcription_params"} + try: + await asyncio.to_thread(update_fn, **params) + break + except TypeError as exc: + last_error = exc + continue + except Exception as exc: + last_error = exc + continue + else: + raise RuntimeError(f"DashScope ASR session.update failed: {last_error}") + + try: + await asyncio.wait_for(self._session_ready.wait(), timeout=6.0) + except asyncio.TimeoutError: + logger.debug("DashScope ASR session ready wait timeout; continuing") + + def _on_ws_open(self) -> None: + return None + + def _on_ws_close(self, code: int, msg: str) -> None: + self._last_error = f"DashScope ASR websocket closed: {code} {msg}" + logger.debug(self._last_error) + + def _on_ws_error(self, message: Any) -> None: + self._last_error = str(message) + logger.error("DashScope ASR error: {}", self._last_error) + + def _on_ws_event(self, message: Any) -> None: + payload = self._coerce_event(message) + event_type = str(payload.get("type") or "").strip() + if not event_type: + return + + if event_type in {"session.created", "session.updated"}: + self._session_ready.set() + return + if event_type == "error" or event_type.endswith(".failed"): + err_text = self._extract_text(payload, keys=("message", "error", "details")) + self._last_error = err_text or event_type + logger.error("DashScope ASR server event error: {}", self._last_error) + return + + if event_type == "conversation.item.input_audio_transcription.text": + stash_text = self._extract_text(payload, keys=("stash", "text", "transcript")) + self._emit_transcript(stash_text, is_final=False) + return + + if event_type == "conversation.item.input_audio_transcription.completed": + final_text = self._extract_text(payload, keys=("transcript", "text", "stash")) + self._emit_transcript(final_text, is_final=True) + return + + def _emit_transcript(self, text: str, *, is_final: bool) -> None: + normalized = str(text or "").strip() + if not normalized: + return + if not is_final and normalized == self._last_interim_text: + return + if not is_final: + self._last_interim_text = normalized + + if self._loop is None: + return + try: + asyncio.run_coroutine_threadsafe( + self._publish_transcript(normalized, is_final=is_final), + self._loop, + ) + except RuntimeError: + return + + async def _publish_transcript(self, text: str, *, is_final: bool) -> None: + await self._transcript_queue.put(ASRResult(text=text, is_final=is_final)) + if is_final: + await self._final_queue.put(text) + if self.on_transcript: + try: + await self.on_transcript(text, is_final) + except Exception as exc: + logger.warning("DashScope ASR transcript callback failed: {}", exc) + + @staticmethod + def _coerce_event(message: Any) -> Dict[str, Any]: + if isinstance(message, dict): + return message + if isinstance(message, str): + try: + parsed = json.loads(message) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + return {"type": "raw", "text": message} + return {"type": "raw", "text": str(message)} + + def _extract_text(self, payload: Dict[str, Any], *, keys: tuple[str, ...]) -> str: + for key in keys: + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(value, dict): + nested = self._extract_text(value, keys=keys) + if nested: + return nested + + for value in payload.values(): + if isinstance(value, dict): + nested = self._extract_text(value, keys=keys) + if nested: + return nested + return "" + + @staticmethod + def _drain_queue(queue: "asyncio.Queue[Any]") -> None: + while True: + try: + queue.get_nowait() + except asyncio.QueueEmpty: + break diff --git a/engine/providers/asr/openai_compatible.py b/engine/providers/asr/openai_compatible.py index 1a2083b..cbff3e5 100644 --- a/engine/providers/asr/openai_compatible.py +++ b/engine/providers/asr/openai_compatible.py @@ -71,6 +71,7 @@ class OpenAICompatibleASRService(BaseASRService): on_transcript: Callback for transcription results (text, is_final) """ super().__init__(sample_rate=sample_rate, language=language) + self.mode = "offline" if not AIOHTTP_AVAILABLE: raise RuntimeError("aiohttp is required for OpenAICompatibleASRService") diff --git a/engine/providers/factory/default.py b/engine/providers/factory/default.py index 4294d3c..0d2912e 100644 --- a/engine/providers/factory/default.py +++ b/engine/providers/factory/default.py @@ -16,6 +16,7 @@ from runtime.ports import ( TTSServiceSpec, ) from providers.asr.buffered import BufferedASRService +from providers.asr.dashscope import DashScopeRealtimeASRService from providers.tts.dashscope import DashScopeTTSService from providers.llm.openai import MockLLMService, OpenAILLMService from providers.asr.openai_compatible import OpenAICompatibleASRService @@ -23,6 +24,7 @@ from providers.tts.openai_compatible import OpenAICompatibleTTSService from providers.tts.mock import MockTTSService _OPENAI_COMPATIBLE_PROVIDERS = {"openai_compatible", "openai-compatible", "siliconflow"} +_DASHSCOPE_PROVIDERS = {"dashscope"} _SUPPORTED_LLM_PROVIDERS = {"openai", *_OPENAI_COMPATIBLE_PROVIDERS} @@ -31,6 +33,8 @@ class DefaultRealtimeServiceFactory(RealtimeServiceFactory): _DEFAULT_DASHSCOPE_TTS_REALTIME_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" _DEFAULT_DASHSCOPE_TTS_MODEL = "qwen3-tts-flash-realtime" + _DEFAULT_DASHSCOPE_ASR_REALTIME_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" + _DEFAULT_DASHSCOPE_ASR_MODEL = "qwen3-asr-flash-realtime" _DEFAULT_OPENAI_COMPATIBLE_TTS_MODEL = "FunAudioLLM/CosyVoice2-0.5B" _DEFAULT_OPENAI_COMPATIBLE_ASR_MODEL = "FunAudioLLM/SenseVoiceSmall" @@ -96,6 +100,16 @@ class DefaultRealtimeServiceFactory(RealtimeServiceFactory): def create_asr_service(self, spec: ASRServiceSpec) -> ASRPort: provider = self._normalize_provider(spec.provider) + if provider in _DASHSCOPE_PROVIDERS and spec.api_key: + return DashScopeRealtimeASRService( + api_key=spec.api_key, + api_url=spec.api_url or self._DEFAULT_DASHSCOPE_ASR_REALTIME_URL, + model=spec.model or self._DEFAULT_DASHSCOPE_ASR_MODEL, + sample_rate=spec.sample_rate, + language=spec.language, + on_transcript=spec.on_transcript, + ) + if provider in _OPENAI_COMPATIBLE_PROVIDERS and spec.api_key: return OpenAICompatibleASRService( api_key=spec.api_key, diff --git a/engine/runtime/pipeline/duplex.py b/engine/runtime/pipeline/duplex.py index aacd0c7..3a6bacc 100644 --- a/engine/runtime/pipeline/duplex.py +++ b/engine/runtime/pipeline/duplex.py @@ -30,11 +30,14 @@ from providers.factory.default import DefaultRealtimeServiceFactory from runtime.conversation import ConversationManager, ConversationState from runtime.events import get_event_bus from runtime.ports import ( + ASRMode, ASRPort, ASRServiceSpec, LLMPort, LLMServiceSpec, + OfflineASRPort, RealtimeServiceFactory, + StreamingASRPort, TTSPort, TTSServiceSpec, ) @@ -77,6 +80,7 @@ class DuplexPipeline: _ASR_DELTA_THROTTLE_MS = 500 _LLM_DELTA_THROTTLE_MS = 80 _ASR_CAPTURE_MAX_MS = 15000 + _ASR_STREAM_FINAL_TIMEOUT_MS = 800 _OPENER_PRE_ROLL_MS = 180 _DEFAULT_TOOL_SCHEMAS: Dict[str, Dict[str, Any]] = { "current_time": { @@ -317,6 +321,10 @@ class DuplexPipeline: self.llm_service = llm_service self.tts_service = tts_service self.asr_service = asr_service # Will be initialized in start() + self._asr_mode: ASRMode = self._resolve_asr_mode( + settings.asr_provider, + getattr(asr_service, "mode", None), + ) self._service_factory = service_factory or DefaultRealtimeServiceFactory() self._knowledge_searcher = knowledge_searcher self._tool_resource_resolver = tool_resource_resolver @@ -324,6 +332,7 @@ class DuplexPipeline: # Track last sent transcript to avoid duplicates self._last_sent_transcript = "" + self._latest_asr_interim_text = "" self._pending_transcript_delta: str = "" self._last_transcript_delta_emit_ms: float = 0.0 @@ -588,6 +597,7 @@ class DuplexPipeline: }, "asr": { "provider": asr_provider, + "mode": self._resolve_asr_mode(asr_provider, self._runtime_asr.get("mode")), "model": str(self._runtime_asr.get("model") or settings.asr_model or ""), "interimIntervalMs": int(self._runtime_asr.get("interimIntervalMs") or settings.asr_interim_interval_ms), "minAudioMs": int(self._runtime_asr.get("minAudioMs") or settings.asr_min_audio_ms), @@ -787,6 +797,22 @@ class DuplexPipeline: normalized = str(provider or "").strip().lower() return normalized == "dashscope" + @staticmethod + def _resolve_asr_mode(provider: Any, raw_mode: Any = None) -> ASRMode: + normalized_mode = str(raw_mode or "").strip().lower() + if normalized_mode in {"offline", "streaming"}: + return normalized_mode # type: ignore[return-value] + normalized_provider = str(provider or "").strip().lower() + if normalized_provider == "dashscope": + return "streaming" + return "offline" + + def _offline_asr(self) -> OfflineASRPort: + return self.asr_service # type: ignore[return-value] + + def _streaming_asr(self) -> StreamingASRPort: + return self.asr_service # type: ignore[return-value] + @staticmethod def _default_llm_base_url(provider: Any) -> Optional[str]: normalized = str(provider or "").strip().lower() @@ -967,11 +993,13 @@ class DuplexPipeline: asr_model = self._runtime_asr.get("model") or settings.asr_model asr_interim_interval = int(self._runtime_asr.get("interimIntervalMs") or settings.asr_interim_interval_ms) asr_min_audio_ms = int(self._runtime_asr.get("minAudioMs") or settings.asr_min_audio_ms) + asr_mode = self._resolve_asr_mode(asr_provider, self._runtime_asr.get("mode")) self.asr_service = self._service_factory.create_asr_service( ASRServiceSpec( provider=asr_provider, sample_rate=settings.sample_rate, + mode=asr_mode, language="auto", api_key=str(asr_api_key).strip() if asr_api_key else None, api_url=str(asr_api_url).strip() if asr_api_url else None, @@ -981,10 +1009,14 @@ class DuplexPipeline: on_transcript=self._on_transcript_callback, ) ) + self._asr_mode = self._resolve_asr_mode( + self._runtime_asr.get("provider") or settings.asr_provider, + getattr(self.asr_service, "mode", self._runtime_asr.get("mode")), + ) await self.asr_service.connect() - logger.info("DuplexPipeline services connected") + logger.info("DuplexPipeline services connected (asr_mode={})", self._asr_mode) if not self._outbound_task or self._outbound_task.done(): self._outbound_task = asyncio.create_task(self._outbound_loop()) @@ -1457,6 +1489,7 @@ class DuplexPipeline: self._last_sent_transcript = text if is_final: + self._latest_asr_interim_text = "" self._pending_transcript_delta = "" self._last_transcript_delta_emit_ms = 0.0 await self._send_event( @@ -1472,6 +1505,7 @@ class DuplexPipeline: logger.debug(f"Sent transcript (final): {text[:50]}...") return + self._latest_asr_interim_text = text self._pending_transcript_delta = text should_emit = ( self._last_transcript_delta_emit_ms <= 0.0 @@ -1495,14 +1529,16 @@ class DuplexPipeline: await self.conversation.start_user_turn() self._audio_buffer = b"" self._last_sent_transcript = "" + self._latest_asr_interim_text = "" self.eou_detector.reset() self._asr_capture_active = False self._asr_capture_started_ms = 0.0 self._pending_speech_audio = b"" - # Clear ASR buffer. Interim starts only after ASR capture is activated. - if hasattr(self.asr_service, 'clear_buffer'): - self.asr_service.clear_buffer() + if self._asr_mode == "streaming": + self._streaming_asr().clear_utterance() + else: + self._offline_asr().clear_buffer() logger.debug("User speech started") @@ -1511,8 +1547,10 @@ class DuplexPipeline: if self._asr_capture_active: return - if hasattr(self.asr_service, 'start_interim_transcription'): - await self.asr_service.start_interim_transcription() + if self._asr_mode == "streaming": + await self._streaming_asr().begin_utterance() + else: + await self._offline_asr().start_interim_transcription() # Prime ASR with a short pre-speech context window so the utterance # start isn't lost while waiting for VAD to transition to Speech. @@ -1545,24 +1583,22 @@ class DuplexPipeline: self._pending_speech_audio = b"" return - # Add a tiny trailing silence tail to stabilize final-token decoding. - if self._asr_final_tail_bytes > 0: - final_tail = b"\x00" * self._asr_final_tail_bytes - await self.asr_service.send_audio(final_tail) - - # Stop interim transcriptions - if hasattr(self.asr_service, 'stop_interim_transcription'): - await self.asr_service.stop_interim_transcription() - - # Get final transcription from ASR service user_text = "" - - if hasattr(self.asr_service, 'get_final_transcription'): - # SiliconFlow ASR - get final transcription - user_text = await self.asr_service.get_final_transcription() - elif hasattr(self.asr_service, 'get_and_clear_text'): - # Buffered ASR - get accumulated text - user_text = self.asr_service.get_and_clear_text() + if self._asr_mode == "streaming": + streaming_asr = self._streaming_asr() + await streaming_asr.end_utterance() + user_text = await streaming_asr.wait_for_final_transcription( + timeout_ms=self._ASR_STREAM_FINAL_TIMEOUT_MS + ) + if not user_text.strip(): + user_text = self._latest_asr_interim_text + else: + # Add a tiny trailing silence tail to stabilize final-token decoding. + if self._asr_final_tail_bytes > 0: + final_tail = b"\x00" * self._asr_final_tail_bytes + await self.asr_service.send_audio(final_tail) + await self._offline_asr().stop_interim_transcription() + user_text = await self._offline_asr().get_final_transcription() # Skip if no meaningful text if not user_text or not user_text.strip(): @@ -1570,6 +1606,7 @@ class DuplexPipeline: # Reset for next utterance self._audio_buffer = b"" self._last_sent_transcript = "" + self._latest_asr_interim_text = "" self._asr_capture_active = False self._asr_capture_started_ms = 0.0 self._pending_speech_audio = b"" @@ -1594,6 +1631,7 @@ class DuplexPipeline: # Clear buffers self._audio_buffer = b"" self._last_sent_transcript = "" + self._latest_asr_interim_text = "" self._pending_transcript_delta = "" self._last_transcript_delta_emit_ms = 0.0 self._asr_capture_active = False diff --git a/engine/runtime/ports/__init__.py b/engine/runtime/ports/__init__.py index a7cbce3..26319b2 100644 --- a/engine/runtime/ports/__init__.py +++ b/engine/runtime/ports/__init__.py @@ -1,6 +1,12 @@ """Port interfaces for runtime integration boundaries.""" -from runtime.ports.asr import ASRBufferControl, ASRInterimControl, ASRPort, ASRServiceSpec +from runtime.ports.asr import ( + ASRMode, + ASRPort, + ASRServiceSpec, + OfflineASRPort, + StreamingASRPort, +) from runtime.ports.control_plane import ( AssistantRuntimeConfigProvider, ControlPlaneGateway, @@ -13,10 +19,11 @@ from runtime.ports.service_factory import RealtimeServiceFactory from runtime.ports.tts import TTSPort, TTSServiceSpec __all__ = [ + "ASRMode", "ASRPort", "ASRServiceSpec", - "ASRInterimControl", - "ASRBufferControl", + "OfflineASRPort", + "StreamingASRPort", "AssistantRuntimeConfigProvider", "ControlPlaneGateway", "ConversationHistoryStore", diff --git a/engine/runtime/ports/asr.py b/engine/runtime/ports/asr.py index 8621ed0..7da547f 100644 --- a/engine/runtime/ports/asr.py +++ b/engine/runtime/ports/asr.py @@ -3,11 +3,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import AsyncIterator, Awaitable, Callable, Optional, Protocol +from typing import AsyncIterator, Awaitable, Callable, Literal, Optional, Protocol from providers.common.base import ASRResult TranscriptCallback = Callable[[str, bool], Awaitable[None]] +ASRMode = Literal["offline", "streaming"] @dataclass(frozen=True) @@ -16,6 +17,7 @@ class ASRServiceSpec: provider: str sample_rate: int + mode: Optional[ASRMode] = None language: str = "auto" api_key: Optional[str] = None api_url: Optional[str] = None @@ -28,6 +30,8 @@ class ASRServiceSpec: class ASRPort(Protocol): """Port for speech recognition providers.""" + mode: ASRMode + async def connect(self) -> None: """Establish connection to ASR provider.""" @@ -41,18 +45,16 @@ class ASRPort(Protocol): """Stream partial/final recognition results.""" -class ASRInterimControl(Protocol): - """Optional extension for explicit interim transcription control.""" +class OfflineASRPort(ASRPort, Protocol): + """Port for offline/buffered ASR providers.""" + + mode: Literal["offline"] async def start_interim_transcription(self) -> None: - """Start interim transcription loop if supported.""" + """Start interim transcription loop.""" async def stop_interim_transcription(self) -> None: - """Stop interim transcription loop if supported.""" - - -class ASRBufferControl(Protocol): - """Optional extension for explicit ASR buffer lifecycle control.""" + """Stop interim transcription loop.""" def clear_buffer(self) -> None: """Clear provider-side ASR buffer.""" @@ -62,3 +64,21 @@ class ASRBufferControl(Protocol): def get_and_clear_text(self) -> str: """Return buffered text and clear internal state.""" + + +class StreamingASRPort(ASRPort, Protocol): + """Port for streaming ASR providers.""" + + mode: Literal["streaming"] + + async def begin_utterance(self) -> None: + """Start a new utterance stream.""" + + async def end_utterance(self) -> None: + """Signal end of current utterance stream.""" + + async def wait_for_final_transcription(self, timeout_ms: int = 800) -> str: + """Wait for final transcript after utterance end.""" + + def clear_utterance(self) -> None: + """Reset utterance-local state.""" diff --git a/engine/tests/test_asr_factory_modes.py b/engine/tests/test_asr_factory_modes.py new file mode 100644 index 0000000..c127399 --- /dev/null +++ b/engine/tests/test_asr_factory_modes.py @@ -0,0 +1,46 @@ +from providers.asr.buffered import BufferedASRService +from providers.asr.dashscope import DashScopeRealtimeASRService +from providers.asr.openai_compatible import OpenAICompatibleASRService +from providers.factory.default import DefaultRealtimeServiceFactory +from runtime.ports import ASRServiceSpec + + +def test_create_asr_service_dashscope_returns_streaming_provider(): + factory = DefaultRealtimeServiceFactory() + service = factory.create_asr_service( + ASRServiceSpec( + provider="dashscope", + mode="streaming", + sample_rate=16000, + api_key="test-key", + model="qwen3-asr-flash-realtime", + ) + ) + assert isinstance(service, DashScopeRealtimeASRService) + assert service.mode == "streaming" + + +def test_create_asr_service_openai_compatible_returns_offline_provider(): + factory = DefaultRealtimeServiceFactory() + service = factory.create_asr_service( + ASRServiceSpec( + provider="openai_compatible", + sample_rate=16000, + api_key="test-key", + model="FunAudioLLM/SenseVoiceSmall", + ) + ) + assert isinstance(service, OpenAICompatibleASRService) + assert service.mode == "offline" + + +def test_create_asr_service_fallback_buffered_for_unsupported_provider(): + factory = DefaultRealtimeServiceFactory() + service = factory.create_asr_service( + ASRServiceSpec( + provider="unknown_provider", + sample_rate=16000, + ) + ) + assert isinstance(service, BufferedASRService) + assert service.mode == "offline" diff --git a/engine/tests/test_dashscope_asr_provider.py b/engine/tests/test_dashscope_asr_provider.py new file mode 100644 index 0000000..123530a --- /dev/null +++ b/engine/tests/test_dashscope_asr_provider.py @@ -0,0 +1,67 @@ +import asyncio + +import pytest + +from providers.asr.dashscope import DashScopeRealtimeASRService + + +@pytest.mark.asyncio +async def test_dashscope_asr_interim_event_emits_interim_transcript(): + received = [] + + async def _on_transcript(text: str, is_final: bool) -> None: + received.append((text, is_final)) + + service = DashScopeRealtimeASRService(api_key="test-key", on_transcript=_on_transcript) + service._loop = asyncio.get_running_loop() + service._running = True + + service._on_ws_event( + { + "type": "conversation.item.input_audio_transcription.text", + "stash": "你好世界", + } + ) + await asyncio.sleep(0.05) + + result = service._transcript_queue.get_nowait() + assert result.text == "你好世界" + assert result.is_final is False + assert received == [("你好世界", False)] + + +@pytest.mark.asyncio +async def test_dashscope_asr_final_event_emits_final_transcript_and_final_queue(): + received = [] + + async def _on_transcript(text: str, is_final: bool) -> None: + received.append((text, is_final)) + + service = DashScopeRealtimeASRService(api_key="test-key", on_transcript=_on_transcript) + service._loop = asyncio.get_running_loop() + service._running = True + service._audio_sent_in_utterance = True + + service._on_ws_event( + { + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "最终识别结果", + } + ) + await asyncio.sleep(0.05) + + result = service._transcript_queue.get_nowait() + assert result.text == "最终识别结果" + assert result.is_final is True + assert service._final_queue.get_nowait() == "最终识别结果" + assert received == [("最终识别结果", True)] + + +@pytest.mark.asyncio +async def test_dashscope_wait_for_final_falls_back_to_latest_interim_on_timeout(): + service = DashScopeRealtimeASRService(api_key="test-key") + service._audio_sent_in_utterance = True + service._last_interim_text = "部分结果" + + text = await service.wait_for_final_transcription(timeout_ms=10) + assert text == "部分结果" diff --git a/engine/tests/test_duplex_asr_modes.py b/engine/tests/test_duplex_asr_modes.py new file mode 100644 index 0000000..76af160 --- /dev/null +++ b/engine/tests/test_duplex_asr_modes.py @@ -0,0 +1,196 @@ +import asyncio +from typing import Any, Dict, List + +import pytest + +from runtime.pipeline.duplex import DuplexPipeline + + +class _DummySileroVAD: + def __init__(self, *args, **kwargs): + pass + + def process_audio(self, _pcm: bytes) -> float: + return 0.0 + + +class _DummyVADProcessor: + def __init__(self, *args, **kwargs): + pass + + def process(self, _speech_prob: float): + return "Silence", 0.0 + + +class _DummyEouDetector: + def __init__(self, *args, **kwargs): + self.is_speaking = True + + def process(self, _vad_status: str, force_eligible: bool = False) -> bool: + _ = force_eligible + return False + + def reset(self) -> None: + self.is_speaking = False + + +class _FakeTransport: + async def send_event(self, _event: Dict[str, Any]) -> None: + return None + + async def send_audio(self, _audio: bytes) -> None: + return None + + +class _FakeStreamingASR: + mode = "streaming" + + def __init__(self): + self.begin_calls = 0 + self.end_calls = 0 + self.wait_calls = 0 + self.sent_audio: List[bytes] = [] + self.wait_text = "" + + async def connect(self) -> None: + return None + + async def disconnect(self) -> None: + return None + + async def send_audio(self, audio: bytes) -> None: + self.sent_audio.append(audio) + + async def receive_transcripts(self): + if False: + yield None + + async def begin_utterance(self) -> None: + self.begin_calls += 1 + + async def end_utterance(self) -> None: + self.end_calls += 1 + + async def wait_for_final_transcription(self, timeout_ms: int = 800) -> str: + _ = timeout_ms + self.wait_calls += 1 + return self.wait_text + + def clear_utterance(self) -> None: + return None + + +class _FakeOfflineASR: + mode = "offline" + + def __init__(self): + self.start_interim_calls = 0 + self.stop_interim_calls = 0 + self.sent_audio: List[bytes] = [] + self.final_text = "offline final" + + async def connect(self) -> None: + return None + + async def disconnect(self) -> None: + return None + + async def send_audio(self, audio: bytes) -> None: + self.sent_audio.append(audio) + + async def receive_transcripts(self): + if False: + yield None + + async def start_interim_transcription(self) -> None: + self.start_interim_calls += 1 + + async def stop_interim_transcription(self) -> None: + self.stop_interim_calls += 1 + + async def get_final_transcription(self) -> str: + return self.final_text + + def clear_buffer(self) -> None: + return None + + def get_and_clear_text(self) -> str: + return self.final_text + + +def _build_pipeline(monkeypatch, asr_service): + monkeypatch.setattr("runtime.pipeline.duplex.SileroVAD", _DummySileroVAD) + monkeypatch.setattr("runtime.pipeline.duplex.VADProcessor", _DummyVADProcessor) + monkeypatch.setattr("runtime.pipeline.duplex.EouDetector", _DummyEouDetector) + return DuplexPipeline( + transport=_FakeTransport(), + session_id="asr_mode_test", + asr_service=asr_service, + ) + + +@pytest.mark.asyncio +async def test_start_asr_capture_uses_streaming_begin(monkeypatch): + asr = _FakeStreamingASR() + pipeline = _build_pipeline(monkeypatch, asr) + pipeline._asr_mode = "streaming" + pipeline._pending_speech_audio = b"\x00" * 320 + pipeline._pre_speech_buffer = b"\x00" * 640 + + await pipeline._start_asr_capture() + + assert asr.begin_calls == 1 + assert asr.sent_audio + assert pipeline._asr_capture_active is True + + +@pytest.mark.asyncio +async def test_start_asr_capture_uses_offline_interim_control(monkeypatch): + asr = _FakeOfflineASR() + pipeline = _build_pipeline(monkeypatch, asr) + pipeline._asr_mode = "offline" + pipeline._pending_speech_audio = b"\x00" * 320 + pipeline._pre_speech_buffer = b"\x00" * 640 + + await pipeline._start_asr_capture() + + assert asr.start_interim_calls == 1 + assert asr.sent_audio + assert pipeline._asr_capture_active is True + + +@pytest.mark.asyncio +async def test_streaming_eou_falls_back_to_latest_interim(monkeypatch): + asr = _FakeStreamingASR() + asr.wait_text = "" + pipeline = _build_pipeline(monkeypatch, asr) + pipeline._asr_mode = "streaming" + pipeline._asr_capture_active = True + pipeline._latest_asr_interim_text = "fallback interim text" + await pipeline.conversation.start_user_turn() + + captured_events = [] + captured_turns = [] + + async def _capture_event(event: Dict[str, Any], priority: int = 20): + _ = priority + captured_events.append(event) + + async def _noop_stop_current_speech() -> None: + return None + + async def _capture_turn(user_text: str, *args, **kwargs) -> None: + _ = (args, kwargs) + captured_turns.append(user_text) + + monkeypatch.setattr(pipeline, "_send_event", _capture_event) + monkeypatch.setattr(pipeline, "_stop_current_speech", _noop_stop_current_speech) + monkeypatch.setattr(pipeline, "_handle_turn", _capture_turn) + + await pipeline._on_end_of_utterance() + await asyncio.sleep(0.05) + + assert asr.end_calls == 1 + assert asr.wait_calls == 1 + assert captured_turns == ["fallback interim text"] + assert any(event.get("type") == "transcript.final" for event in captured_events) diff --git a/engine/tests/test_tool_call_flow.py b/engine/tests/test_tool_call_flow.py index d820643..717f96a 100644 --- a/engine/tests/test_tool_call_flow.py +++ b/engine/tests/test_tool_call_flow.py @@ -52,9 +52,33 @@ class _FakeTTS: class _FakeASR: + mode = "offline" + async def connect(self) -> None: return None + async def disconnect(self) -> None: + return None + + async def send_audio(self, _audio: bytes) -> None: + return None + + async def receive_transcripts(self): + if False: + yield None + + def clear_buffer(self) -> None: + return None + + async def start_interim_transcription(self) -> None: + return None + + async def stop_interim_transcription(self) -> None: + return None + + async def get_final_transcription(self) -> str: + return "" + class _FakeLLM: def __init__(self, rounds: List[List[LLMStreamEvent]]): From da381576381d595a28e9aed5dbe9a37236d8be22 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 6 Mar 2026 12:58:54 +0800 Subject: [PATCH 07/20] Add ASR interim results support in Assistant model and API - Introduced `asr_interim_enabled` field in the Assistant model to control interim ASR results. - Updated AssistantBase and AssistantUpdate schemas to include the new field. - Modified the database schema to add the `asr_interim_enabled` column. - Enhanced runtime metadata to reflect interim ASR settings. - Updated API endpoints and tests to validate the new functionality. - Adjusted documentation to include details about interim ASR results configuration. --- api/app/models.py | 1 + api/app/routers/assistants.py | 14 ++++- api/app/schemas.py | 2 + api/tests/test_assistants.py | 18 +++++++ docs/content/customization/asr.md | 1 + engine/adapters/control_plane/backend.py | 2 + engine/app/config.py | 1 + engine/config/agents/example.yaml | 1 + engine/config/agents/tools.yaml | 1 + engine/providers/asr/openai_compatible.py | 9 ++++ engine/providers/factory/default.py | 1 + engine/runtime/pipeline/duplex.py | 25 ++++++++- engine/runtime/ports/asr.py | 1 + engine/tests/test_asr_factory_modes.py | 1 + engine/tests/test_backend_adapters.py | 4 +- engine/tests/test_duplex_asr_modes.py | 66 ++++++++++++++++++++++- web/pages/Assistants.tsx | 36 +++++++++++++ web/services/backendApi.ts | 3 ++ web/types.ts | 1 + 19 files changed, 183 insertions(+), 5 deletions(-) diff --git a/api/app/models.py b/api/app/models.py index 29579f2..265f83d 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -127,6 +127,7 @@ class Assistant(Base): speed: Mapped[float] = mapped_column(Float, default=1.0) hotwords: Mapped[dict] = mapped_column(JSON, default=list) tools: Mapped[dict] = mapped_column(JSON, default=list) + asr_interim_enabled: Mapped[bool] = mapped_column(default=False) bot_cannot_be_interrupted: Mapped[bool] = mapped_column(default=False) interruption_sensitivity: Mapped[int] = mapped_column(Integer, default=500) config_mode: Mapped[str] = mapped_column(String(32), default="platform") diff --git a/api/app/routers/assistants.py b/api/app/routers/assistants.py index b63d0ee..f517458 100644 --- a/api/app/routers/assistants.py +++ b/api/app/routers/assistants.py @@ -126,6 +126,9 @@ def _ensure_assistant_schema(db: Session) -> None: if "manual_opener_tool_calls" not in columns: db.execute(text("ALTER TABLE assistants ADD COLUMN manual_opener_tool_calls JSON")) altered = True + if "asr_interim_enabled" not in columns: + db.execute(text("ALTER TABLE assistants ADD COLUMN asr_interim_enabled BOOLEAN DEFAULT 0")) + altered = True if altered: db.commit() @@ -317,6 +320,9 @@ def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[s else: warnings.append(f"LLM model not found: {assistant.llm_model_id}") + asr_runtime: Dict[str, Any] = { + "enableInterim": bool(assistant.asr_interim_enabled), + } if assistant.asr_model_id: asr = db.query(ASRModel).filter(ASRModel.id == assistant.asr_model_id).first() if asr: @@ -326,14 +332,15 @@ def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[s asr_provider = "openai_compatible" else: asr_provider = "buffered" - metadata["services"]["asr"] = { + asr_runtime.update({ "provider": asr_provider, "model": asr.model_name or asr.name, "apiKey": asr.api_key if asr_provider in {"openai_compatible", "dashscope"} else None, "baseUrl": asr.base_url if asr_provider in {"openai_compatible", "dashscope"} else None, - } + }) else: warnings.append(f"ASR model not found: {assistant.asr_model_id}") + metadata["services"]["asr"] = asr_runtime if not assistant.voice_output_enabled: metadata["services"]["tts"] = {"enabled": False} @@ -437,6 +444,7 @@ def assistant_to_dict(assistant: Assistant) -> dict: "speed": assistant.speed, "hotwords": assistant.hotwords or [], "tools": _normalize_assistant_tool_ids(assistant.tools), + "asrInterimEnabled": bool(assistant.asr_interim_enabled), "botCannotBeInterrupted": bool(assistant.bot_cannot_be_interrupted), "interruptionSensitivity": assistant.interruption_sensitivity, "configMode": assistant.config_mode, @@ -457,6 +465,7 @@ def _apply_assistant_update(assistant: Assistant, update_data: dict) -> None: "firstTurnMode": "first_turn_mode", "manualOpenerToolCalls": "manual_opener_tool_calls", "interruptionSensitivity": "interruption_sensitivity", + "asrInterimEnabled": "asr_interim_enabled", "botCannotBeInterrupted": "bot_cannot_be_interrupted", "configMode": "config_mode", "voiceOutputEnabled": "voice_output_enabled", @@ -651,6 +660,7 @@ def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)): speed=data.speed, hotwords=data.hotwords, tools=_normalize_assistant_tool_ids(data.tools), + asr_interim_enabled=data.asrInterimEnabled, bot_cannot_be_interrupted=data.botCannotBeInterrupted, interruption_sensitivity=data.interruptionSensitivity, config_mode=data.configMode, diff --git a/api/app/schemas.py b/api/app/schemas.py index 9bf2274..f0ad0c3 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -291,6 +291,7 @@ class AssistantBase(BaseModel): speed: float = 1.0 hotwords: List[str] = [] tools: List[str] = [] + asrInterimEnabled: bool = False botCannotBeInterrupted: bool = False interruptionSensitivity: int = 500 configMode: str = "platform" @@ -322,6 +323,7 @@ class AssistantUpdate(BaseModel): speed: Optional[float] = None hotwords: Optional[List[str]] = None tools: Optional[List[str]] = None + asrInterimEnabled: Optional[bool] = None botCannotBeInterrupted: Optional[bool] = None interruptionSensitivity: Optional[int] = None configMode: Optional[str] = None diff --git a/api/tests/test_assistants.py b/api/tests/test_assistants.py index 3828688..7acbd30 100644 --- a/api/tests/test_assistants.py +++ b/api/tests/test_assistants.py @@ -27,6 +27,7 @@ class TestAssistantAPI: assert data["voiceOutputEnabled"] is True assert data["firstTurnMode"] == "bot_first" assert data["generatedOpenerEnabled"] is False + assert data["asrInterimEnabled"] is False assert data["botCannotBeInterrupted"] is False assert "id" in data assert data["callCount"] == 0 @@ -37,6 +38,7 @@ class TestAssistantAPI: response = client.post("/api/assistants", json=data) assert response.status_code == 200 assert response.json()["name"] == "Minimal Assistant" + assert response.json()["asrInterimEnabled"] is False def test_get_assistant_by_id(self, client, sample_assistant_data): """Test getting a specific assistant by ID""" @@ -68,6 +70,7 @@ class TestAssistantAPI: "prompt": "You are an updated assistant.", "speed": 1.5, "voiceOutputEnabled": False, + "asrInterimEnabled": True, "manualOpenerToolCalls": [ {"toolName": "text_msg_prompt", "arguments": {"msg": "请选择服务类型"}} ], @@ -79,6 +82,7 @@ class TestAssistantAPI: assert data["prompt"] == "You are an updated assistant." assert data["speed"] == 1.5 assert data["voiceOutputEnabled"] is False + assert data["asrInterimEnabled"] is True assert data["manualOpenerToolCalls"] == [ {"toolName": "text_msg_prompt", "arguments": {"msg": "请选择服务类型"}} ] @@ -213,6 +217,7 @@ class TestAssistantAPI: "prompt": "runtime prompt", "opener": "runtime opener", "manualOpenerToolCalls": [{"toolName": "text_msg_prompt", "arguments": {"msg": "欢迎"}}], + "asrInterimEnabled": True, "speed": 1.1, }) assistant_resp = client.post("/api/assistants", json=sample_assistant_data) @@ -232,6 +237,7 @@ class TestAssistantAPI: assert metadata["services"]["llm"]["model"] == sample_llm_model_data["model_name"] assert metadata["services"]["asr"]["model"] == sample_asr_model_data["model_name"] assert metadata["services"]["asr"]["baseUrl"] == sample_asr_model_data["base_url"] + assert metadata["services"]["asr"]["enableInterim"] is True expected_tts_voice = f"{sample_voice_data['model']}:{sample_voice_data['voice_key']}" assert metadata["services"]["tts"]["voice"] == expected_tts_voice assert metadata["services"]["tts"]["baseUrl"] == sample_voice_data["base_url"] @@ -309,6 +315,7 @@ class TestAssistantAPI: assert runtime_resp.status_code == 200 metadata = runtime_resp.json()["sessionStartMetadata"] assert metadata["output"]["mode"] == "text" + assert metadata["services"]["asr"]["enableInterim"] is False assert metadata["services"]["tts"]["enabled"] is False def test_runtime_config_dashscope_voice_provider(self, client, sample_assistant_data): @@ -373,6 +380,17 @@ class TestAssistantAPI: asr = metadata["services"]["asr"] assert asr["provider"] == "dashscope" assert asr["baseUrl"] == "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" + assert asr["enableInterim"] is False + + def test_runtime_config_defaults_asr_interim_disabled_without_asr_model(self, client, sample_assistant_data): + assistant_resp = client.post("/api/assistants", json=sample_assistant_data) + assert assistant_resp.status_code == 200 + assistant_id = assistant_resp.json()["id"] + + runtime_resp = client.get(f"/api/assistants/{assistant_id}/runtime-config") + assert runtime_resp.status_code == 200 + metadata = runtime_resp.json()["sessionStartMetadata"] + assert metadata["services"]["asr"]["enableInterim"] is False def test_assistant_interrupt_and_generated_opener_flags(self, client, sample_assistant_data): sample_assistant_data.update({ diff --git a/docs/content/customization/asr.md b/docs/content/customization/asr.md index 8d73889..2c11a87 100644 --- a/docs/content/customization/asr.md +++ b/docs/content/customization/asr.md @@ -13,6 +13,7 @@ |---|---| | ASR 引擎 | 选择语音识别服务提供商 | | 模型 | 识别模型名称 | +| `enable_interim` | 是否开启离线 ASR 中间结果(默认 `false`,仅离线模式生效) | | 语言 | 中文/英文/多语言 | | 热词 | 提升特定词汇识别准确率 | | 标点与规范化 | 是否自动补全标点、文本规范化 | diff --git a/engine/adapters/control_plane/backend.py b/engine/adapters/control_plane/backend.py index 087f744..09f145d 100644 --- a/engine/adapters/control_plane/backend.py +++ b/engine/adapters/control_plane/backend.py @@ -249,6 +249,8 @@ class LocalYamlAssistantConfigAdapter(NullBackendAdapter): asr_runtime["apiKey"] = cls._as_str(asr.get("api_key")) if cls._as_str(asr.get("api_url")): asr_runtime["baseUrl"] = cls._as_str(asr.get("api_url")) + if asr.get("enable_interim") is not None: + asr_runtime["enableInterim"] = asr.get("enable_interim") if asr.get("interim_interval_ms") is not None: asr_runtime["interimIntervalMs"] = asr.get("interim_interval_ms") if asr.get("min_audio_ms") is not None: diff --git a/engine/app/config.py b/engine/app/config.py index 62364d1..1d8f47b 100644 --- a/engine/app/config.py +++ b/engine/app/config.py @@ -89,6 +89,7 @@ class Settings(BaseSettings): ) asr_api_url: Optional[str] = Field(default=None, description="ASR provider API URL") asr_model: Optional[str] = Field(default=None, description="ASR model name") + asr_enable_interim: bool = Field(default=False, description="Enable interim transcripts for offline ASR") asr_interim_interval_ms: int = Field(default=500, description="Interval for interim ASR results in ms") asr_min_audio_ms: int = Field(default=300, description="Minimum audio duration before first ASR result") asr_start_min_speech_ms: int = Field( diff --git a/engine/config/agents/example.yaml b/engine/config/agents/example.yaml index 2e9f157..d4d6d5d 100644 --- a/engine/config/agents/example.yaml +++ b/engine/config/agents/example.yaml @@ -44,6 +44,7 @@ agent: api_key: you_asr_api_key api_url: https://api.siliconflow.cn/v1/audio/transcriptions model: FunAudioLLM/SenseVoiceSmall + enable_interim: false interim_interval_ms: 500 min_audio_ms: 300 start_min_speech_ms: 160 diff --git a/engine/config/agents/tools.yaml b/engine/config/agents/tools.yaml index 8657080..26b43bf 100644 --- a/engine/config/agents/tools.yaml +++ b/engine/config/agents/tools.yaml @@ -41,6 +41,7 @@ agent: api_key: your_asr_api_key api_url: https://api.siliconflow.cn/v1/audio/transcriptions model: FunAudioLLM/SenseVoiceSmall + enable_interim: false interim_interval_ms: 500 min_audio_ms: 300 start_min_speech_ms: 160 diff --git a/engine/providers/asr/openai_compatible.py b/engine/providers/asr/openai_compatible.py index cbff3e5..6d90e95 100644 --- a/engine/providers/asr/openai_compatible.py +++ b/engine/providers/asr/openai_compatible.py @@ -53,6 +53,7 @@ class OpenAICompatibleASRService(BaseASRService): model: str = "FunAudioLLM/SenseVoiceSmall", sample_rate: int = 16000, language: str = "auto", + enable_interim: bool = False, interim_interval_ms: int = 500, # How often to send interim results min_audio_for_interim_ms: int = 300, # Min audio before first interim on_transcript: Optional[Callable[[str, bool], Awaitable[None]]] = None @@ -66,6 +67,7 @@ class OpenAICompatibleASRService(BaseASRService): model: ASR model name or alias sample_rate: Audio sample rate (16000 recommended) language: Language code (auto for automatic detection) + enable_interim: Whether to generate interim transcriptions in offline mode interim_interval_ms: How often to generate interim transcriptions min_audio_for_interim_ms: Minimum audio duration before first interim on_transcript: Callback for transcription results (text, is_final) @@ -80,6 +82,7 @@ class OpenAICompatibleASRService(BaseASRService): raw_api_url = api_url or os.getenv("ASR_API_URL") or self.API_URL self.api_url = self._resolve_transcriptions_endpoint(raw_api_url) self.model = self.MODELS.get(model.lower(), model) + self.enable_interim = bool(enable_interim) self.interim_interval_ms = interim_interval_ms self.min_audio_for_interim_ms = min_audio_for_interim_ms self.on_transcript = on_transcript @@ -181,6 +184,9 @@ class OpenAICompatibleASRService(BaseASRService): if not self._session: logger.warning("ASR session not connected") return None + + if not is_final and not self.enable_interim: + return None # Check minimum audio duration audio_duration_ms = len(self._audio_buffer) / (self.sample_rate * 2) * 1000 @@ -310,6 +316,9 @@ class OpenAICompatibleASRService(BaseASRService): This periodically transcribes buffered audio for real-time feedback to the user. """ + if not self.enable_interim: + return + if self._interim_task and not self._interim_task.done(): return diff --git a/engine/providers/factory/default.py b/engine/providers/factory/default.py index 0d2912e..3d51fe9 100644 --- a/engine/providers/factory/default.py +++ b/engine/providers/factory/default.py @@ -117,6 +117,7 @@ class DefaultRealtimeServiceFactory(RealtimeServiceFactory): model=spec.model or self._DEFAULT_OPENAI_COMPATIBLE_ASR_MODEL, sample_rate=spec.sample_rate, language=spec.language, + enable_interim=spec.enable_interim, interim_interval_ms=spec.interim_interval_ms, min_audio_for_interim_ms=spec.min_audio_for_interim_ms, on_transcript=spec.on_transcript, diff --git a/engine/runtime/pipeline/duplex.py b/engine/runtime/pipeline/duplex.py index 3a6bacc..cbfabb3 100644 --- a/engine/runtime/pipeline/duplex.py +++ b/engine/runtime/pipeline/duplex.py @@ -599,6 +599,7 @@ class DuplexPipeline: "provider": asr_provider, "mode": self._resolve_asr_mode(asr_provider, self._runtime_asr.get("mode")), "model": str(self._runtime_asr.get("model") or settings.asr_model or ""), + "enableInterim": self._asr_interim_enabled(), "interimIntervalMs": int(self._runtime_asr.get("interimIntervalMs") or settings.asr_interim_interval_ms), "minAudioMs": int(self._runtime_asr.get("minAudioMs") or settings.asr_min_audio_ms), }, @@ -865,6 +866,20 @@ class DuplexPipeline: return self._runtime_barge_in_min_duration_ms return self._barge_in_min_duration_ms + def _asr_interim_enabled(self) -> bool: + current_mode = self._asr_mode + if not self.asr_service: + current_mode = self._resolve_asr_mode( + self._runtime_asr.get("provider") or settings.asr_provider, + self._runtime_asr.get("mode"), + ) + if current_mode != "offline": + return True + enabled = self._coerce_bool(self._runtime_asr.get("enableInterim")) + if enabled is not None: + return enabled + return bool(settings.asr_enable_interim) + def _barge_in_silence_tolerance_frames(self) -> int: """Convert silence tolerance from ms to frame count using current chunk size.""" chunk_ms = max(1, settings.chunk_size_ms) @@ -991,6 +1006,9 @@ class DuplexPipeline: asr_api_key = self._runtime_asr.get("apiKey") asr_api_url = self._runtime_asr.get("baseUrl") or settings.asr_api_url asr_model = self._runtime_asr.get("model") or settings.asr_model + asr_enable_interim = self._coerce_bool(self._runtime_asr.get("enableInterim")) + if asr_enable_interim is None: + asr_enable_interim = bool(settings.asr_enable_interim) asr_interim_interval = int(self._runtime_asr.get("interimIntervalMs") or settings.asr_interim_interval_ms) asr_min_audio_ms = int(self._runtime_asr.get("minAudioMs") or settings.asr_min_audio_ms) asr_mode = self._resolve_asr_mode(asr_provider, self._runtime_asr.get("mode")) @@ -1004,6 +1022,7 @@ class DuplexPipeline: api_key=str(asr_api_key).strip() if asr_api_key else None, api_url=str(asr_api_url).strip() if asr_api_url else None, model=str(asr_model).strip() if asr_model else None, + enable_interim=asr_enable_interim, interim_interval_ms=asr_interim_interval, min_audio_for_interim_ms=asr_min_audio_ms, on_transcript=self._on_transcript_callback, @@ -1481,6 +1500,9 @@ class DuplexPipeline: text: Transcribed text is_final: Whether this is the final transcription """ + if not is_final and not self._asr_interim_enabled(): + return + # Avoid sending duplicate transcripts if text == self._last_sent_transcript and not is_final: return @@ -1550,7 +1572,8 @@ class DuplexPipeline: if self._asr_mode == "streaming": await self._streaming_asr().begin_utterance() else: - await self._offline_asr().start_interim_transcription() + if self._asr_interim_enabled(): + await self._offline_asr().start_interim_transcription() # Prime ASR with a short pre-speech context window so the utterance # start isn't lost while waiting for VAD to transition to Speech. diff --git a/engine/runtime/ports/asr.py b/engine/runtime/ports/asr.py index 7da547f..f3be1d1 100644 --- a/engine/runtime/ports/asr.py +++ b/engine/runtime/ports/asr.py @@ -22,6 +22,7 @@ class ASRServiceSpec: api_key: Optional[str] = None api_url: Optional[str] = None model: Optional[str] = None + enable_interim: bool = False interim_interval_ms: int = 500 min_audio_for_interim_ms: int = 300 on_transcript: Optional[TranscriptCallback] = None diff --git a/engine/tests/test_asr_factory_modes.py b/engine/tests/test_asr_factory_modes.py index c127399..5d3d436 100644 --- a/engine/tests/test_asr_factory_modes.py +++ b/engine/tests/test_asr_factory_modes.py @@ -32,6 +32,7 @@ def test_create_asr_service_openai_compatible_returns_offline_provider(): ) assert isinstance(service, OpenAICompatibleASRService) assert service.mode == "offline" + assert service.enable_interim is False def test_create_asr_service_fallback_buffered_for_unsupported_provider(): diff --git a/engine/tests/test_backend_adapters.py b/engine/tests/test_backend_adapters.py index 70f569e..e4faf81 100644 --- a/engine/tests/test_backend_adapters.py +++ b/engine/tests/test_backend_adapters.py @@ -282,7 +282,7 @@ async def test_local_yaml_adapter_rejects_path_traversal_like_assistant_id(tmp_p @pytest.mark.asyncio -async def test_local_yaml_translates_agent_schema_to_runtime_services(tmp_path): +async def test_local_yaml_translates_agent_schema_with_asr_interim_flag(tmp_path): config_dir = tmp_path / "assistants" config_dir.mkdir(parents=True, exist_ok=True) (config_dir / "default.yaml").write_text( @@ -305,6 +305,7 @@ async def test_local_yaml_translates_agent_schema_to_runtime_services(tmp_path): " model: asr-model", " api_key: sk-asr", " api_url: https://asr.example.com/v1/audio/transcriptions", + " enable_interim: false", " duplex:", " system_prompt: You are test assistant", ] @@ -321,4 +322,5 @@ async def test_local_yaml_translates_agent_schema_to_runtime_services(tmp_path): assert services.get("llm", {}).get("apiKey") == "sk-llm" assert services.get("tts", {}).get("apiKey") == "sk-tts" assert services.get("asr", {}).get("apiKey") == "sk-asr" + assert services.get("asr", {}).get("enableInterim") is False assert assistant.get("systemPrompt") == "You are test assistant" diff --git a/engine/tests/test_duplex_asr_modes.py b/engine/tests/test_duplex_asr_modes.py index 76af160..3e4b1cf 100644 --- a/engine/tests/test_duplex_asr_modes.py +++ b/engine/tests/test_duplex_asr_modes.py @@ -145,10 +145,11 @@ async def test_start_asr_capture_uses_streaming_begin(monkeypatch): @pytest.mark.asyncio -async def test_start_asr_capture_uses_offline_interim_control(monkeypatch): +async def test_start_asr_capture_uses_offline_interim_control_when_enabled(monkeypatch): asr = _FakeOfflineASR() pipeline = _build_pipeline(monkeypatch, asr) pipeline._asr_mode = "offline" + pipeline._runtime_asr["enableInterim"] = True pipeline._pending_speech_audio = b"\x00" * 320 pipeline._pre_speech_buffer = b"\x00" * 640 @@ -159,6 +160,69 @@ async def test_start_asr_capture_uses_offline_interim_control(monkeypatch): assert pipeline._asr_capture_active is True +@pytest.mark.asyncio +async def test_start_asr_capture_skips_offline_interim_control_when_disabled(monkeypatch): + asr = _FakeOfflineASR() + pipeline = _build_pipeline(monkeypatch, asr) + pipeline._asr_mode = "offline" + pipeline._runtime_asr["enableInterim"] = False + pipeline._pending_speech_audio = b"\x00" * 320 + pipeline._pre_speech_buffer = b"\x00" * 640 + + await pipeline._start_asr_capture() + + assert asr.start_interim_calls == 0 + assert asr.sent_audio + assert pipeline._asr_capture_active is True + + +@pytest.mark.asyncio +async def test_offline_interim_callback_ignored_when_disabled(monkeypatch): + asr = _FakeOfflineASR() + pipeline = _build_pipeline(monkeypatch, asr) + pipeline._asr_mode = "offline" + pipeline._runtime_asr["enableInterim"] = False + + captured_events = [] + captured_deltas = [] + + async def _capture_event(event: Dict[str, Any], priority: int = 20): + _ = priority + captured_events.append(event) + + async def _capture_delta(text: str): + captured_deltas.append(text) + + monkeypatch.setattr(pipeline, "_send_event", _capture_event) + monkeypatch.setattr(pipeline, "_emit_transcript_delta", _capture_delta) + + await pipeline._on_transcript_callback("ignored interim", is_final=False) + + assert captured_events == [] + assert captured_deltas == [] + assert pipeline._latest_asr_interim_text == "" + + +@pytest.mark.asyncio +async def test_offline_final_callback_emits_when_interim_disabled(monkeypatch): + asr = _FakeOfflineASR() + pipeline = _build_pipeline(monkeypatch, asr) + pipeline._asr_mode = "offline" + pipeline._runtime_asr["enableInterim"] = False + + captured_events = [] + + async def _capture_event(event: Dict[str, Any], priority: int = 20): + _ = priority + captured_events.append(event) + + monkeypatch.setattr(pipeline, "_send_event", _capture_event) + + await pipeline._on_transcript_callback("final only", is_final=True) + + assert any(event.get("type") == "transcript.final" for event in captured_events) + + @pytest.mark.asyncio async def test_streaming_eou_falls_back_to_latest_interim(monkeypatch): asr = _FakeStreamingASR() diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index 0826745..9545ab4 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -259,6 +259,7 @@ export const AssistantsPage: React.FC = () => { speed: 1, hotwords: [], tools: [], + asrInterimEnabled: false, botCannotBeInterrupted: false, interruptionSensitivity: 180, configMode: 'platform', @@ -1358,6 +1359,41 @@ export const AssistantsPage: React.FC = () => {

+
+
+ +
+ + +
+
+

+ 仅影响离线 ASR 模式(OpenAI Compatible / buffered)。默认关闭。 +

+
+

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 zf`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`b zAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_` z0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qD zARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U z2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg# zfFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|( z2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Ko zf`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`b zAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_` z0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qD zARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U z2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg# zfFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|( z2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Ko zf`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`b zAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_` z0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qD zARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U 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

!sA1s{591 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∈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

m*ud~eX zPq0rFr`Gct=Xg(SgS7TpR&+aLX*8>1yg3wm?q_^*;dm1JU~M#GHMxie7l8-(DqfOm z^b7qFdv-K@##zYVoClZd8Y?Ad5Vd+ls*b_A+z?B<2HNT)PP~@v&X-93S9qWXgL4Hq zFQbfP|7IVijV4N3gqJv$qekAEs~)5NjlFt|)xWugoY`(lWeOxyaW1 z2M^^u`1y|pPS6vjEF(Vv3wJkULN3;6YEE1oS=kF)dH|Mr8vQof^^G8Z^;ERKo^%2% zLKbBteMmdPZGH=0+SK&I-iKXYPj7^^G#;+(`*i60p7l4D_ZP-{QVCy6KfJeO8{lv8 zU^$iNM9)Nh9l0s^(MRyCp`2);tp4=DbHRmIbpR_SH@5btKu)-G7c)1PI3;Fdd7q?b zQxhLSpL@D%eWm^`;dVYz0R+aU! z9A4|z@GIY?C;2K^L`$=>?y!fa(3iqX=cz{iF8<5(GJQzzm$}TrImTmA1&{_$|5kFB z|6%oiPvy{i{6y+x`9NHDyf&IX1ZD89U#Dx>Ui>&u$SbR7JYk6HiSE27xl(I5uU z74KCbk(m$P;$JzxJmdnsgj=^a+16_U-v{dBCmayi7pP^XMAxgs`5TGFbq9I9o*A`S zmG8(|sE5a*8M3n&nHu|$`+w1WN_i*C(_`lg*FBzY6|&_7{(}qT=|thVzl$IDF7oFo zc)v$K+QJNm13p z3+eyTgsx8w==rdVY|=SA?P04mRUom{bu}S|(z zw%BPasqPsE(lO-CZHL1;FP)!8&U*Z2pWM}?{ zcJH#5!#$r5{_4*37Fa`fho3q7{|uB4yzt-hzw|dFgEh*WZe>TWIDp+gp6BbuT3Su! z)R*vw_T==gjpb)iEw+Rx)L(i{W2f=LxJ@=|1d?t&Xtze5oI}=J!AFsq{Z}7O-C=Z1 z7)K9~(mdxDEMY%-=4$)_Q<||GUVWNp0TGfmlLNQK0C!u{5?5q%2Rp*vsza$zm5MZ2AC1_HkZ1`}6%3orgLvogMu73h;Av3f@_uKehS z^|3<>&>1I=@mI!A_LP-dlQocplW{(GUXSgqdJ&W+-gcKv=`zHFcCccnAcdQ-((iFj zv_nJgiRUU0_IsGIlCy9$@^==ecTFT&b2QuU;FCR%mYoNV=ey{3sX4v-lTXru)7uAQ z?|h`%bok7-z;C{bGh`;aZ3&#d)v#Ji;F&0dr{Y)QC`XBI@8$a;d}>jAJ>mRKjBiFo zSE|xi@B#VM@G$n7kLaPhq5TnV>?)AAfwP%&*D)Y$Er{sz)<@wvwjDUdmGOD zLR6pJ+2ekcpa^DZRVFX-aGkt@4|*y|D2 z_ixyP8vAk-mQN&g!NWNpi;}l_k6kd7TBIbzDpq6B_W`|{e0>d;joDG-utF~)bzS&5 zY_vV)C*O|ud7#w*J0}HSUt>=c;OakXl!rud)ZGu&eqZV@7h{>%@Ng za9U($r}RV4*5>S*#o2q4sCfgj23{iVrm-6v;&tpxE&NOJa0YXQ5v&Ohah2AbFz@Iw zcaw@*)%V~C`lj;DEyCVUj9xU2aW3Y3E{(730Z7J>zf=pYbuzZy5v=WpRIF9NfBHL5 z@`fA*N8jBB8q?YBM7gMDs)BVj84dI&a4m#SXc9SJWVG@$F=QLf$2Zmx8*CyLsEr>s z9_f}1fBXZyQf07zFCwjN^3FG~K5cM2gEu<^PoO#^L}A7LgcXE;9Jtv*2SlWw6n%qI=&_hnh4@B!PgK#n;@a6EaG-3wI z6HPkFh)NUdc?8C3ITJRMk6RnhMX%rty~Zw6T>Sq8^*F_H8b$^1Ej)1-*t6=MU*R1X ziM~CblX5FPVK$@HEFzn?BfbyCp+G_-vrw8y+90ap`(X=HSBNz3&uOs^wv&2z;101D zBf0AnEdMX@hlEkXHIQnuDDE4@Om4<9+zi$x{*8uQxh?uZ3N)NkoHVQOEHCCt>v+dm z{6(1<*$n1(56?3WjnKmgRey*5_+K>kMsB?7VZ_iBqemJn=ng#9Tt3kieLD^r5lLpq z0&J{k@))wBmsG_|TNqDyPENQ)ApH#e;S5MdG3y4JOESD|QCNL*$om+NN8vp4SB&-B zmgi9W?FPO+Ro}Y z$L(Y-X23@|h`lk9Op8WHZ=HE~!wiJ6mTK^~TFgXlp2h-G^?DMnnuX^s%)cHiiD)=e z_OQxMfkQkT7bTGmRTzH`Wb8wb`J;P4K$R*jKRnb})k$+3DY6yAHumr7~G! zur_z`WOu<*G4+(^uE|&jN0DFKc%o~3A}cdco7MOgc%@`TDVC)WuAPoA#V4$Io)u4R za>nu{r%nT~P!@RzS(*QTwIDK4%g*kozRt5F`KSgx;F zQsY423$9p^bzY9or4D*AZnD3x^9(ORRk2z-OqYsFGo0(H+@4hY?B%X^So={t$y?^c z!~GI*p8#kphEo-Vi{P2^^M6{NLoqHLXTDG2efyU+sMwaAISc_ihcQcWPpO~OY^X6S zR@;~Su4YehZKvWZCHq&+WD33%$Bi1J;uL?y|8d};R(>YF((^nvS5h6OU+_#O(a119 ztyrb}JfFjF`;jMC>{^QdN-OI!P zd)}=gbs4!|IBV-YcT>AUt-D9OS1~#%mRQA?o+3En)Xap%{U3qc4c-?M9HHX(vcOxh za4D8q^*Jx2!6@absx#&@?&|O=zhWKvf-|Eyu@o1)Vw#QROYtc4_TYaUNZi+9*mHRI z9DY5;1?CT4S+Pl~F9Vzu1`d;%;x3Nk`CbP{s_<8HNrA)vtOLcN?XZPCVdfPMif{TI zpHf`Y>T2q~Y8KU96fdcZPkdxlF(7!CaVrj-h zxz^|TRj8={)Dvr5A%UAzVTJ3FyDKY`R3Gl9*i9V-o!6aDD0WkI7411YqQX*r zsn^w~)LK>VOccaV@w}-KC+By?rLUe|eMYhPhqDV4cCuo2SATK-Q}1$^-W*m|hj(7F zs5(-}d8c{=HJ*g;37>U7qmWYn*}?U!R<#<9`mWxues=y-Pwnh3^-jgAsMf5S1+_mM zso?Ahg{i}d=kWO{em{pH(AhQWs_GNY6%_xT`f}c(){Ei*RJ+q*N>=}>zpJY_KdJA| z?p4=SGEl8v=Pn6P<8VVeBT@HDxU(9IGyea6cV7L!{mnt=zweiy1pz@o5D)|e0YN|z z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e z0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n z5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF z1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#= zKoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}? z1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAP zK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e z0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n z5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF z1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#= zKoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}? z1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAP zK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e z0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n z5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF z1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#= zKoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}? z1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAP zK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e z0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n z5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF z1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#= zKoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}? u1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1cCpb2>dVMCe>a5 From aeeeee20d1371431addaa9c6e77b6b5678c7b4e8 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Sun, 8 Mar 2026 23:09:50 +0800 Subject: [PATCH 10/20] Add Volcengine support for TTS and ASR services - Introduced Volcengine as a new provider for both TTS and ASR services. - Updated configuration files to include Volcengine-specific parameters such as app_id, resource_id, and uid. - Enhanced the ASR service to support streaming mode with Volcengine's API. - Modified existing tests to validate the integration of Volcengine services. - Updated documentation to reflect the addition of Volcengine as a supported provider for TTS and ASR. - Refactored service factory to accommodate Volcengine alongside existing providers. --- docs/content/customization/asr.md | 6 +- engine/adapters/control_plane/backend.py | 18 + engine/app/config.py | 16 +- engine/config/agents/example.yaml | 19 +- engine/config/agents/tools.yaml | 19 +- engine/docs/extension_ports.md | 4 +- engine/providers/asr/__init__.py | 2 + engine/providers/asr/volcengine.py | 666 +++++++++++++++++++ engine/providers/factory/default.py | 34 + engine/providers/tts/__init__.py | 4 + engine/providers/tts/volcengine.py | 219 ++++++ engine/runtime/pipeline/duplex.py | 39 +- engine/runtime/ports/asr.py | 7 +- engine/runtime/ports/tts.py | 4 + engine/tests/test_asr_factory_modes.py | 24 + engine/tests/test_backend_adapters.py | 56 ++ engine/tests/test_tts_factory_modes.py | 45 ++ engine/tests/test_volcengine_asr_provider.py | 86 +++ 18 files changed, 1256 insertions(+), 12 deletions(-) create mode 100644 engine/providers/asr/volcengine.py create mode 100644 engine/providers/tts/volcengine.py create mode 100644 engine/tests/test_tts_factory_modes.py create mode 100644 engine/tests/test_volcengine_asr_provider.py diff --git a/docs/content/customization/asr.md b/docs/content/customization/asr.md index 2c11a87..e0b7e3a 100644 --- a/docs/content/customization/asr.md +++ b/docs/content/customization/asr.md @@ -5,7 +5,7 @@ ## 模式 - `offline`:引擎本地缓冲音频后触发识别(适用于 OpenAI-compatible / SiliconFlow)。 -- `streaming`:音频分片实时发送到服务端,服务端持续返回转写事件(适用于 DashScope Realtime ASR)。 +- `streaming`:音频分片实时发送到服务端,服务端持续返回转写事件(适用于 DashScope Realtime ASR、Volcengine BigASR)。 ## 配置项 @@ -14,6 +14,8 @@ | ASR 引擎 | 选择语音识别服务提供商 | | 模型 | 识别模型名称 | | `enable_interim` | 是否开启离线 ASR 中间结果(默认 `false`,仅离线模式生效) | +| `app_id` / `resource_id` | Volcengine 等厂商的应用标识与资源标识 | +| `request_params` | 厂商原生请求参数透传,例如 `end_window_size`、`force_to_speech_time`、`context` | | 语言 | 中文/英文/多语言 | | 热词 | 提升特定词汇识别准确率 | | 标点与规范化 | 是否自动补全标点、文本规范化 | @@ -23,7 +25,7 @@ - 客服场景建议开启热词并维护业务词表 - 多语言场景建议按会话入口显式指定语言 - 对延迟敏感场景优先选择流式识别模型 -- 当前支持提供商:`openai_compatible`、`siliconflow`、`dashscope`、`buffered`(回退) +- 当前支持提供商:`openai_compatible`、`siliconflow`、`dashscope`、`volcengine`、`buffered`(回退) ## 相关文档 diff --git a/engine/adapters/control_plane/backend.py b/engine/adapters/control_plane/backend.py index 09f145d..9f8914d 100644 --- a/engine/adapters/control_plane/backend.py +++ b/engine/adapters/control_plane/backend.py @@ -230,6 +230,14 @@ class LocalYamlAssistantConfigAdapter(NullBackendAdapter): tts_runtime["baseUrl"] = cls._as_str(tts.get("api_url")) if cls._as_str(tts.get("voice")): tts_runtime["voice"] = cls._as_str(tts.get("voice")) + if cls._as_str(tts.get("app_id")): + tts_runtime["appId"] = cls._as_str(tts.get("app_id")) + if cls._as_str(tts.get("resource_id")): + tts_runtime["resourceId"] = cls._as_str(tts.get("resource_id")) + if cls._as_str(tts.get("cluster")): + tts_runtime["cluster"] = cls._as_str(tts.get("cluster")) + if cls._as_str(tts.get("uid")): + tts_runtime["uid"] = cls._as_str(tts.get("uid")) if tts.get("speed") is not None: tts_runtime["speed"] = tts.get("speed") dashscope_mode = cls._as_str(tts.get("dashscope_mode")) or cls._as_str(tts.get("mode")) @@ -249,6 +257,16 @@ class LocalYamlAssistantConfigAdapter(NullBackendAdapter): asr_runtime["apiKey"] = cls._as_str(asr.get("api_key")) if cls._as_str(asr.get("api_url")): asr_runtime["baseUrl"] = cls._as_str(asr.get("api_url")) + if cls._as_str(asr.get("app_id")): + asr_runtime["appId"] = cls._as_str(asr.get("app_id")) + if cls._as_str(asr.get("resource_id")): + asr_runtime["resourceId"] = cls._as_str(asr.get("resource_id")) + if cls._as_str(asr.get("cluster")): + asr_runtime["cluster"] = cls._as_str(asr.get("cluster")) + if cls._as_str(asr.get("uid")): + asr_runtime["uid"] = cls._as_str(asr.get("uid")) + if isinstance(asr.get("request_params"), dict): + asr_runtime["requestParams"] = dict(asr.get("request_params") or {}) if asr.get("enable_interim") is not None: asr_runtime["enableInterim"] = asr.get("enable_interim") if asr.get("interim_interval_ms") is not None: diff --git a/engine/app/config.py b/engine/app/config.py index 1d8f47b..8edf7ce 100644 --- a/engine/app/config.py +++ b/engine/app/config.py @@ -71,11 +71,15 @@ class Settings(BaseSettings): # TTS Configuration tts_provider: str = Field( default="openai_compatible", - description="TTS provider (openai_compatible, siliconflow, dashscope)" + description="TTS provider (openai_compatible, siliconflow, dashscope, volcengine)" ) tts_api_url: Optional[str] = Field(default=None, description="TTS provider API URL") tts_model: Optional[str] = Field(default=None, description="TTS model name") tts_voice: str = Field(default="anna", description="TTS voice name") + tts_app_id: Optional[str] = Field(default=None, description="Provider-specific TTS app ID") + tts_resource_id: Optional[str] = Field(default=None, description="Provider-specific TTS resource ID") + tts_cluster: Optional[str] = Field(default=None, description="Provider-specific TTS cluster") + tts_uid: Optional[str] = Field(default=None, description="Provider-specific TTS user ID") tts_mode: str = Field( default="commit", description="DashScope-only TTS mode (commit, server_commit). Ignored for non-dashscope providers." @@ -85,10 +89,18 @@ class Settings(BaseSettings): # ASR Configuration asr_provider: str = Field( default="openai_compatible", - description="ASR provider (openai_compatible, buffered, siliconflow, dashscope)" + description="ASR provider (openai_compatible, buffered, siliconflow, dashscope, volcengine)" ) asr_api_url: Optional[str] = Field(default=None, description="ASR provider API URL") asr_model: Optional[str] = Field(default=None, description="ASR model name") + asr_app_id: Optional[str] = Field(default=None, description="Provider-specific ASR app ID") + asr_resource_id: Optional[str] = Field(default=None, description="Provider-specific ASR resource ID") + asr_cluster: Optional[str] = Field(default=None, description="Provider-specific ASR cluster") + asr_uid: Optional[str] = Field(default=None, description="Provider-specific ASR user ID") + asr_request_params_json: Optional[str] = Field( + default=None, + description="Provider-specific ASR request params as JSON string" + ) asr_enable_interim: bool = Field(default=False, description="Enable interim transcripts for offline ASR") asr_interim_interval_ms: int = Field(default=500, description="Interval for interim ASR results in ms") asr_min_audio_ms: int = Field(default=300, description="Minimum audio duration before first ASR result") diff --git a/engine/config/agents/example.yaml b/engine/config/agents/example.yaml index d4d6d5d..e68b6f3 100644 --- a/engine/config/agents/example.yaml +++ b/engine/config/agents/example.yaml @@ -21,12 +21,17 @@ agent: api_url: https://api.qnaigc.com/v1 tts: - # provider: openai_compatible | siliconflow | dashscope + # provider: openai_compatible | siliconflow | dashscope | volcengine # dashscope defaults (if omitted): # api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime # model: qwen3-tts-flash-realtime # dashscope_mode: commit (engine splits) | server_commit (dashscope splits) # note: dashscope_mode/mode is ONLY used when provider=dashscope. + # volcengine defaults (if omitted): + # api_url: https://openspeech.bytedance.com/api/v3/tts/unidirectional + # resource_id: seed-tts-2.0 + # app_id: your volcengine app key + # api_key: your volcengine access key provider: openai_compatible api_key: your_tts_api_key api_url: https://api.siliconflow.cn/v1/audio/speech @@ -35,11 +40,21 @@ agent: speed: 1.0 asr: - # provider: buffered | openai_compatible | siliconflow | dashscope + # provider: buffered | openai_compatible | siliconflow | dashscope | volcengine # dashscope defaults (if omitted): # api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime # model: qwen3-asr-flash-realtime # note: dashscope uses streaming ASR mode (chunk-by-chunk). + # volcengine defaults (if omitted): + # api_url: wss://openspeech.bytedance.com/api/v3/sauc/bigmodel + # model: bigmodel + # resource_id: volc.bigasr.sauc.duration + # app_id: your volcengine app key + # api_key: your volcengine access key + # request_params: + # end_window_size: 800 + # force_to_speech_time: 1000 + # note: volcengine uses streaming ASR mode (chunk-by-chunk). provider: openai_compatible api_key: you_asr_api_key api_url: https://api.siliconflow.cn/v1/audio/transcriptions diff --git a/engine/config/agents/tools.yaml b/engine/config/agents/tools.yaml index 26b43bf..11cd7c3 100644 --- a/engine/config/agents/tools.yaml +++ b/engine/config/agents/tools.yaml @@ -18,12 +18,17 @@ agent: api_url: https://api.qnaigc.com/v1 tts: - # provider: openai_compatible | siliconflow | dashscope + # provider: openai_compatible | siliconflow | dashscope | volcengine # dashscope defaults (if omitted): # api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime # model: qwen3-tts-flash-realtime # dashscope_mode: commit (engine splits) | server_commit (dashscope splits) # note: dashscope_mode/mode is ONLY used when provider=dashscope. + # volcengine defaults (if omitted): + # api_url: https://openspeech.bytedance.com/api/v3/tts/unidirectional + # resource_id: seed-tts-2.0 + # app_id: your volcengine app key + # api_key: your volcengine access key provider: openai_compatible api_key: your_tts_api_key api_url: https://api.siliconflow.cn/v1/audio/speech @@ -32,11 +37,21 @@ agent: speed: 1.0 asr: - # provider: buffered | openai_compatible | siliconflow | dashscope + # provider: buffered | openai_compatible | siliconflow | dashscope | volcengine # dashscope defaults (if omitted): # api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime # model: qwen3-asr-flash-realtime # note: dashscope uses streaming ASR mode (chunk-by-chunk). + # volcengine defaults (if omitted): + # api_url: wss://openspeech.bytedance.com/api/v3/sauc/bigmodel + # model: bigmodel + # resource_id: volc.bigasr.sauc.duration + # app_id: your volcengine app key + # api_key: your volcengine access key + # request_params: + # end_window_size: 800 + # force_to_speech_time: 1000 + # note: volcengine uses streaming ASR mode (chunk-by-chunk). provider: openai_compatible api_key: your_asr_api_key api_url: https://api.siliconflow.cn/v1/audio/transcriptions diff --git a/engine/docs/extension_ports.md b/engine/docs/extension_ports.md index 36e2aac..c0f65f6 100644 --- a/engine/docs/extension_ports.md +++ b/engine/docs/extension_ports.md @@ -36,10 +36,10 @@ This document defines the draft port set used to keep core runtime extensible. - supported providers: `openai`, `openai_compatible`, `openai-compatible`, `siliconflow` - fallback: `MockLLMService` - TTS: - - supported providers: `dashscope`, `openai_compatible`, `openai-compatible`, `siliconflow` + - supported providers: `dashscope`, `volcengine`, `openai_compatible`, `openai-compatible`, `siliconflow` - fallback: `MockTTSService` - ASR: - - supported providers: `openai_compatible`, `openai-compatible`, `siliconflow`, `dashscope` + - supported providers: `openai_compatible`, `openai-compatible`, `siliconflow`, `dashscope`, `volcengine` - fallback: `BufferedASRService` ## Notes diff --git a/engine/providers/asr/__init__.py b/engine/providers/asr/__init__.py index 5e659be..5e5dc29 100644 --- a/engine/providers/asr/__init__.py +++ b/engine/providers/asr/__init__.py @@ -3,6 +3,7 @@ from providers.asr.buffered import BufferedASRService, MockASRService from providers.asr.dashscope import DashScopeRealtimeASRService from providers.asr.openai_compatible import OpenAICompatibleASRService, SiliconFlowASRService +from providers.asr.volcengine import VolcengineRealtimeASRService __all__ = [ "BufferedASRService", @@ -10,4 +11,5 @@ __all__ = [ "DashScopeRealtimeASRService", "OpenAICompatibleASRService", "SiliconFlowASRService", + "VolcengineRealtimeASRService", ] diff --git a/engine/providers/asr/volcengine.py b/engine/providers/asr/volcengine.py new file mode 100644 index 0000000..1f7c18e --- /dev/null +++ b/engine/providers/asr/volcengine.py @@ -0,0 +1,666 @@ +"""Volcengine realtime ASR service. + +Supports both: +- Volcengine Edge Gateway realtime transcription websocket, and +- Volcengine BigASR Seed websocket at openspeech.bytedance.com/api/v3/sauc/bigmodel. +""" + +from __future__ import annotations + +import asyncio +import base64 +import gzip +import json +import os +import uuid +from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Literal, Optional +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse + +import aiohttp +from loguru import logger + +from providers.common.base import ASRResult, BaseASRService, ServiceState + +VolcengineASRProtocol = Literal["gateway", "seed"] + + +class VolcengineRealtimeASRService(BaseASRService): + """Realtime streaming ASR backed by Volcengine websocket APIs.""" + + DEFAULT_WS_URL = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel" + DEFAULT_GATEWAY_WS_URL = "wss://ai-gateway.vei.volces.com/v1/realtime" + DEFAULT_MODEL = "bigmodel" + DEFAULT_FINAL_TIMEOUT_MS = 1200 + DEFAULT_SEED_RESOURCE_ID = "volc.bigasr.sauc.duration" + _SEED_FRAME_MS = 100 + _SEED_PROTOCOL_VERSION = 0b0001 + _SEED_FULL_CLIENT_REQUEST = 0b0001 + _SEED_AUDIO_ONLY_REQUEST = 0b0010 + _SEED_FULL_SERVER_RESPONSE = 0b1001 + _SEED_SERVER_ACK = 0b1011 + _SEED_SERVER_ERROR_RESPONSE = 0b1111 + _SEED_NO_SEQUENCE = 0b0000 + _SEED_POS_SEQUENCE = 0b0001 + _SEED_NEG_WITH_SEQUENCE = 0b0011 + _SEED_NO_SERIALIZATION = 0b0000 + _SEED_JSON = 0b0001 + _SEED_NO_COMPRESSION = 0b0000 + _SEED_GZIP = 0b0001 + + def __init__( + self, + api_key: Optional[str] = None, + api_url: Optional[str] = None, + model: Optional[str] = None, + sample_rate: int = 16000, + language: str = "auto", + app_id: Optional[str] = None, + resource_id: Optional[str] = None, + uid: Optional[str] = None, + request_params: Optional[Dict[str, Any]] = None, + on_transcript: Optional[Callable[[str, bool], Awaitable[None]]] = None, + ) -> None: + super().__init__(sample_rate=sample_rate, language=language) + self.mode = "streaming" + self.api_key = api_key or os.getenv("VOLCENGINE_ASR_API_KEY") or os.getenv("ASR_API_KEY") + self.model = str(model or os.getenv("VOLCENGINE_ASR_MODEL") or self.DEFAULT_MODEL).strip() + raw_api_url = api_url or os.getenv("VOLCENGINE_ASR_API_URL") or self.DEFAULT_WS_URL + self.protocol = self._detect_protocol(raw_api_url) + self.api_url = self._resolve_api_url(raw_api_url, self.model, self.protocol) + self.app_id = app_id or os.getenv("VOLCENGINE_ASR_APP_ID") or os.getenv("ASR_APP_ID") + self.resource_id = ( + resource_id + or os.getenv("VOLCENGINE_ASR_RESOURCE_ID") + or (self.DEFAULT_SEED_RESOURCE_ID if self.protocol == "seed" else None) + ) + self.uid = uid or os.getenv("VOLCENGINE_ASR_UID") + self.request_params = self._load_request_params(request_params) + self.on_transcript = on_transcript + + self._session: Optional[aiohttp.ClientSession] = None + self._ws: Optional[aiohttp.ClientWebSocketResponse] = None + self._reader_task: Optional[asyncio.Task[None]] = None + + self._running = False + self._session_ready = asyncio.Event() + self._transcript_queue: "asyncio.Queue[ASRResult]" = asyncio.Queue() + self._final_queue: "asyncio.Queue[str]" = asyncio.Queue() + + self._utterance_active = False + self._audio_sent_in_utterance = False + self._last_interim_text = "" + self._last_error: Optional[str] = None + + self._seed_audio_buffer = bytearray() + self._seed_sequence = 1 + self._seed_request_id: Optional[str] = None + self._seed_frame_bytes = max(2, int((self.sample_rate * self._SEED_FRAME_MS / 1000) * 2)) + + @classmethod + def _detect_protocol(cls, api_url: str) -> VolcengineASRProtocol: + parsed = urlparse(str(api_url or "").strip()) + host = parsed.netloc.lower() + path = parsed.path.lower() + if "openspeech.bytedance.com" in host and "/api/v3/sauc/bigmodel" in path: + return "seed" + return "gateway" + + @classmethod + def _resolve_api_url(cls, api_url: str, model: str, protocol: VolcengineASRProtocol) -> str: + raw = str(api_url or "").strip() + if not raw: + raw = cls.DEFAULT_WS_URL if protocol == "seed" else cls.DEFAULT_GATEWAY_WS_URL + if protocol != "gateway": + return raw + + parsed = urlparse(raw) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query.setdefault("model", model or cls.DEFAULT_MODEL) + return urlunparse(parsed._replace(query=urlencode(query))) + + @staticmethod + def _load_request_params(request_params: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if isinstance(request_params, dict): + return dict(request_params) + raw = os.getenv("VOLCENGINE_ASR_REQUEST_PARAMS_JSON", "").strip() + if not raw: + return {} + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Ignoring invalid VOLCENGINE_ASR_REQUEST_PARAMS_JSON") + return {} + if isinstance(parsed, dict): + return parsed + return {} + + async def connect(self) -> None: + if not self.api_key: + raise ValueError("Volcengine ASR API key not provided. Configure agent.asr.api_key in YAML.") + + timeout = aiohttp.ClientTimeout(total=None, sock_read=None, sock_connect=15) + self._session = aiohttp.ClientSession(timeout=timeout) + self._running = True + + if self.protocol == "gateway": + await self._connect_gateway() + logger.info( + "Volcengine gateway ASR connected: model={}, sample_rate={}, url={}", + self.model, + self.sample_rate, + self.api_url, + ) + else: + if not self.app_id: + raise ValueError("Volcengine ASR app_id not provided. Configure agent.asr.app_id in YAML.") + logger.info( + "Volcengine BigASR Seed ready: model={}, sample_rate={}, resource_id={}", + self.model, + self.sample_rate, + self.resource_id, + ) + + self.state = ServiceState.CONNECTED + + async def disconnect(self) -> None: + self._running = False + self._utterance_active = False + self._audio_sent_in_utterance = False + self._session_ready.clear() + self._seed_audio_buffer = bytearray() + self._drain_queue(self._final_queue) + self._drain_queue(self._transcript_queue) + + await self._close_ws() + + if self._session is not None: + await self._session.close() + self._session = None + + self.state = ServiceState.DISCONNECTED + logger.info("Volcengine ASR disconnected") + + async def begin_utterance(self) -> None: + self.clear_utterance() + if self.protocol == "seed": + await self._open_seed_stream() + self._utterance_active = True + + async def send_audio(self, audio: bytes) -> None: + if not audio: + return + + if self.protocol == "seed": + await self._send_seed_audio(audio) + return + + if not self._ws: + raise RuntimeError("Volcengine ASR websocket is not connected") + if not self._utterance_active: + self._utterance_active = True + + await self._ws.send_json( + { + "type": "input_audio_buffer.append", + "audio": base64.b64encode(audio).decode("ascii"), + } + ) + self._audio_sent_in_utterance = True + + async def end_utterance(self) -> None: + if not self._utterance_active: + return + + if self.protocol == "seed": + await self._end_seed_utterance() + return + + if not self._ws or not self._audio_sent_in_utterance: + return + await self._ws.send_json({"type": "input_audio_buffer.commit"}) + self._utterance_active = False + + async def wait_for_final_transcription(self, timeout_ms: int = DEFAULT_FINAL_TIMEOUT_MS) -> str: + if not self._audio_sent_in_utterance: + return "" + + timeout_sec = max(0.05, float(timeout_ms) / 1000.0) + try: + return str(await asyncio.wait_for(self._final_queue.get(), timeout=timeout_sec) or "").strip() + except asyncio.TimeoutError: + logger.debug("Volcengine ASR final timeout ({}ms), fallback to last interim", timeout_ms) + return str(self._last_interim_text or "").strip() + finally: + if self.protocol == "seed": + await self._close_ws() + + def clear_utterance(self) -> None: + self._utterance_active = False + self._audio_sent_in_utterance = False + self._last_interim_text = "" + self._last_error = None + self._seed_audio_buffer = bytearray() + self._seed_sequence = 1 + self._seed_request_id = None + self._drain_queue(self._final_queue) + + async def receive_transcripts(self) -> AsyncIterator[ASRResult]: + while self._running: + try: + yield await asyncio.wait_for(self._transcript_queue.get(), timeout=0.1) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + break + + async def _connect_gateway(self) -> None: + assert self._session is not None + headers = {"Authorization": f"Bearer {self.api_key}"} + if self.resource_id: + headers["X-Api-Resource-Id"] = self.resource_id + + self._ws = await self._session.ws_connect(self.api_url, headers=headers, heartbeat=20) + self._reader_task = asyncio.create_task(self._reader_loop()) + await self._configure_gateway_session() + + async def _configure_gateway_session(self) -> None: + if not self._ws: + raise RuntimeError("Volcengine ASR websocket is not initialized") + + session_payload: Dict[str, Any] = { + "input_audio_format": "pcm", + "input_audio_codec": "raw", + "input_audio_sample_rate": self.sample_rate, + "input_audio_bits": 16, + "input_audio_channel": 1, + "result_type": 0, + "input_audio_transcription": { + "model": self.model, + }, + } + + await self._ws.send_json( + { + "type": "transcription_session.update", + "session": session_payload, + } + ) + + try: + await asyncio.wait_for(self._session_ready.wait(), timeout=8.0) + except asyncio.TimeoutError as exc: + raise RuntimeError("Volcengine ASR session update timeout") from exc + + async def _open_seed_stream(self) -> None: + if not self._session: + raise RuntimeError("Volcengine ASR session is not initialized") + + await self._close_ws() + self._seed_request_id = uuid.uuid4().hex + headers = self._build_seed_headers(self._seed_request_id) + self._ws = await self._session.ws_connect( + self.api_url, + headers=headers, + heartbeat=20, + max_msg_size=1_000_000_000, + ) + self._reader_task = asyncio.create_task(self._reader_loop()) + await self._ws.send_bytes(self._build_seed_start_request()) + + async def _send_seed_audio(self, audio: bytes) -> None: + if not self._utterance_active: + await self.begin_utterance() + if not self._ws: + raise RuntimeError("Volcengine BigASR websocket is not connected") + + self._seed_audio_buffer.extend(audio) + while len(self._seed_audio_buffer) >= self._seed_frame_bytes: + chunk = bytes(self._seed_audio_buffer[: self._seed_frame_bytes]) + del self._seed_audio_buffer[: self._seed_frame_bytes] + self._seed_sequence += 1 + await self._ws.send_bytes(self._build_seed_audio_request(chunk, sequence=self._seed_sequence)) + self._audio_sent_in_utterance = True + + async def _end_seed_utterance(self) -> None: + if not self._ws: + return + if not self._audio_sent_in_utterance and not self._seed_audio_buffer: + self._utterance_active = False + return + + final_chunk = bytes(self._seed_audio_buffer) + self._seed_audio_buffer = bytearray() + self._seed_sequence += 1 + await self._ws.send_bytes( + self._build_seed_audio_request(final_chunk, sequence=-self._seed_sequence, is_last=True) + ) + self._audio_sent_in_utterance = True + self._utterance_active = False + + async def _close_ws(self) -> None: + reader_task = self._reader_task + ws = self._ws + self._reader_task = None + self._ws = None + + if reader_task: + reader_task.cancel() + try: + await reader_task + except asyncio.CancelledError: + pass + + if ws is not None: + await ws.close() + + async def _reader_loop(self) -> None: + ws = self._ws + if ws is None: + return + + try: + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + if self.protocol == "gateway": + self._handle_gateway_event(msg.data) + else: + self._handle_seed_text(msg.data) + continue + if msg.type == aiohttp.WSMsgType.BINARY: + if self.protocol == "seed": + self._handle_seed_binary(msg.data) + continue + if msg.type == aiohttp.WSMsgType.ERROR: + self._last_error = str(ws.exception()) + logger.error("Volcengine ASR websocket error: {}", self._last_error) + break + if msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE}: + break + except asyncio.CancelledError: + raise + except Exception as exc: + self._last_error = str(exc) + logger.error("Volcengine ASR reader loop failed: {}", exc) + finally: + if self._ws is ws: + self._ws = None + + def _handle_gateway_event(self, message: str) -> None: + payload = self._coerce_event(message) + event_type = str(payload.get("type") or "").strip() + if not event_type: + return + + if event_type in {"transcription_session.created", "transcription_session.updated"}: + self._session_ready.set() + return + + if event_type == "error": + self._last_error = self._extract_text(payload, ("message", "error")) + logger.error("Volcengine ASR server error: {}", self._last_error or "unknown") + return + + if event_type.endswith(".failed"): + self._last_error = self._extract_text(payload, ("message", "error", "transcript")) + logger.error("Volcengine ASR failed event: {}", self._last_error or event_type) + return + + if event_type == "conversation.item.input_audio_transcription.result": + transcript = self._extract_text(payload, ("transcript", "result")) + self._emit_transcript_sync(transcript, is_final=False) + return + + if event_type == "conversation.item.input_audio_transcription.delta": + transcript = self._extract_text(payload, ("delta",)) + self._emit_transcript_sync(transcript, is_final=False) + return + + if event_type == "conversation.item.input_audio_transcription.completed": + transcript = self._extract_text(payload, ("transcript", "result")) + self._emit_transcript_sync(transcript, is_final=True) + + def _handle_seed_text(self, message: str) -> None: + payload = self._coerce_event(message) + if payload.get("type") == "error": + self._last_error = self._extract_text(payload, ("message", "error")) + logger.error("Volcengine BigASR error: {}", self._last_error or "unknown") + + def _handle_seed_binary(self, message: bytes) -> None: + payload = self._parse_seed_response(message) + if payload.get("code"): + self._last_error = self._extract_text(payload, ("payload_msg",)) + logger.error("Volcengine BigASR server error: {}", self._last_error or payload["code"]) + return + + body = payload.get("payload_msg") + if not isinstance(body, dict): + return + result = body.get("result") + if not isinstance(result, dict): + return + + text = str(result.get("text") or "").strip() + if not text: + return + + utterances = result.get("utterances") + if not isinstance(utterances, list) or not utterances: + return + first_utterance = utterances[0] if isinstance(utterances[0], dict) else {} + is_final = self._coerce_bool(first_utterance.get("definite")) is True + self._emit_transcript_sync(text, is_final=is_final) + + def _emit_transcript_sync(self, text: str, *, is_final: bool) -> None: + cleaned = str(text or "").strip() + if not cleaned: + return + + if not is_final: + self._last_interim_text = cleaned + else: + self._last_interim_text = "" + + result = ASRResult(text=cleaned, is_final=is_final) + try: + self._transcript_queue.put_nowait(result) + except asyncio.QueueFull: + logger.debug("Volcengine ASR transcript queue full; dropping transcript") + + if is_final: + try: + self._final_queue.put_nowait(cleaned) + except asyncio.QueueFull: + logger.debug("Volcengine ASR final queue full; dropping transcript") + + if self.on_transcript: + asyncio.create_task(self.on_transcript(cleaned, is_final)) + + def _build_seed_headers(self, request_id: str) -> Dict[str, str]: + if not self.app_id: + raise ValueError("Volcengine ASR app_id not provided. Configure agent.asr.app_id in YAML.") + if not self.api_key: + raise ValueError("Volcengine ASR api_key not provided. Configure agent.asr.api_key in YAML.") + + return { + "X-Api-App-Key": str(self.app_id), + "X-Api-Access-Key": str(self.api_key), + "X-Api-Resource-Id": str(self.resource_id or self.DEFAULT_SEED_RESOURCE_ID), + "X-Api-Request-Id": str(request_id), + } + + def _build_seed_start_payload(self) -> Dict[str, Any]: + user_payload: Dict[str, Any] = {"uid": str(self.uid or self._seed_request_id or self.app_id or uuid.uuid4().hex)} + audio_payload: Dict[str, Any] = { + "format": "pcm", + "rate": self.sample_rate, + "bits": 16, + "channels": 1, + "codec": "raw", + } + if self.language and self.language != "auto": + audio_payload["language"] = self.language + + request_payload: Dict[str, Any] = { + "model_name": self.model or self.DEFAULT_MODEL, + "enable_itn": False, + "enable_punc": True, + "enable_ddc": False, + "show_utterance": True, + "result_type": "single", + "vad_segment_duration": 3000, + "end_window_size": 500, + "force_to_speech_time": 1000, + } + + extra = dict(self.request_params) + user_payload.update(self._as_dict(extra.pop("user", None))) + audio_payload.update(self._as_dict(extra.pop("audio", None))) + request_payload.update(self._as_dict(extra.pop("request", None))) + request_payload.update(extra) + + return { + "user": user_payload, + "audio": audio_payload, + "request": request_payload, + } + + def _build_seed_start_request(self) -> bytes: + payload = gzip.compress(json.dumps(self._build_seed_start_payload()).encode("utf-8")) + frame = bytearray( + self._build_seed_header( + message_type=self._SEED_FULL_CLIENT_REQUEST, + message_type_specific_flags=self._SEED_POS_SEQUENCE, + ) + ) + frame.extend((1).to_bytes(4, "big", signed=True)) + frame.extend(len(payload).to_bytes(4, "big")) + frame.extend(payload) + return bytes(frame) + + def _build_seed_audio_request(self, chunk: bytes, *, sequence: int, is_last: bool = False) -> bytes: + payload = gzip.compress(chunk) + frame = bytearray( + self._build_seed_header( + message_type=self._SEED_AUDIO_ONLY_REQUEST, + message_type_specific_flags=self._SEED_NEG_WITH_SEQUENCE if is_last else self._SEED_POS_SEQUENCE, + ) + ) + frame.extend(int(sequence).to_bytes(4, "big", signed=True)) + frame.extend(len(payload).to_bytes(4, "big")) + frame.extend(payload) + return bytes(frame) + + @classmethod + def _build_seed_header( + cls, + *, + message_type: int, + message_type_specific_flags: int, + serial_method: int = _SEED_JSON, + compression_type: int = _SEED_GZIP, + reserved_data: int = 0x00, + ) -> bytes: + header = bytearray() + header.append((cls._SEED_PROTOCOL_VERSION << 4) | 0b0001) + header.append((message_type << 4) | message_type_specific_flags) + header.append((serial_method << 4) | compression_type) + header.append(reserved_data) + return bytes(header) + + @classmethod + def _parse_seed_response(cls, response: bytes) -> Dict[str, Any]: + header_size = response[0] & 0x0F + message_type = response[1] >> 4 + message_type_specific_flags = response[1] & 0x0F + serialization_method = response[2] >> 4 + compression_type = response[2] & 0x0F + payload = response[header_size * 4 :] + + result: Dict[str, Any] = {"is_last_package": False} + payload_message: Any = None + + if message_type_specific_flags & 0x01: + result["payload_sequence"] = int.from_bytes(payload[:4], "big", signed=True) + payload = payload[4:] + + if message_type_specific_flags & 0x02: + result["is_last_package"] = True + + if message_type == cls._SEED_FULL_SERVER_RESPONSE: + result["payload_size"] = int.from_bytes(payload[:4], "big", signed=True) + payload_message = payload[4:] + elif message_type == cls._SEED_SERVER_ACK: + result["seq"] = int.from_bytes(payload[:4], "big", signed=True) + if len(payload) >= 8: + result["payload_size"] = int.from_bytes(payload[4:8], "big", signed=False) + payload_message = payload[8:] + elif message_type == cls._SEED_SERVER_ERROR_RESPONSE: + result["code"] = int.from_bytes(payload[:4], "big", signed=False) + result["payload_size"] = int.from_bytes(payload[4:8], "big", signed=False) + payload_message = payload[8:] + + if payload_message is None: + return result + if compression_type == cls._SEED_GZIP: + payload_message = gzip.decompress(payload_message) + if serialization_method == cls._SEED_JSON: + payload_message = json.loads(payload_message.decode("utf-8")) + elif serialization_method != cls._SEED_NO_SERIALIZATION: + payload_message = payload_message.decode("utf-8") + + result["payload_msg"] = payload_message + return result + + @staticmethod + def _coerce_event(message: Any) -> Dict[str, Any]: + if isinstance(message, dict): + return message + if isinstance(message, str): + try: + loaded = json.loads(message) + if isinstance(loaded, dict): + return loaded + except json.JSONDecodeError: + return {"type": "raw", "message": message} + return {"type": "raw", "message": str(message)} + + @staticmethod + def _extract_text(payload: Dict[str, Any], keys: tuple[str, ...]) -> str: + for key in keys: + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(value, dict): + for nested_key in ("message", "text", "transcript", "result", "delta"): + nested = value.get(nested_key) + if isinstance(nested, str) and nested.strip(): + return nested.strip() + return "" + + @staticmethod + def _coerce_bool(value: Any) -> Optional[bool]: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return None + + @staticmethod + def _as_dict(value: Any) -> Dict[str, Any]: + if isinstance(value, dict): + return dict(value) + return {} + + @staticmethod + def _drain_queue(queue: "asyncio.Queue[Any]") -> None: + while True: + try: + queue.get_nowait() + except asyncio.QueueEmpty: + break diff --git a/engine/providers/factory/default.py b/engine/providers/factory/default.py index 3d51fe9..de72af6 100644 --- a/engine/providers/factory/default.py +++ b/engine/providers/factory/default.py @@ -17,14 +17,17 @@ from runtime.ports import ( ) from providers.asr.buffered import BufferedASRService from providers.asr.dashscope import DashScopeRealtimeASRService +from providers.asr.volcengine import VolcengineRealtimeASRService from providers.tts.dashscope import DashScopeTTSService from providers.llm.openai import MockLLMService, OpenAILLMService from providers.asr.openai_compatible import OpenAICompatibleASRService from providers.tts.openai_compatible import OpenAICompatibleTTSService from providers.tts.mock import MockTTSService +from providers.tts.volcengine import VolcengineTTSService _OPENAI_COMPATIBLE_PROVIDERS = {"openai_compatible", "openai-compatible", "siliconflow"} _DASHSCOPE_PROVIDERS = {"dashscope"} +_VOLCENGINE_PROVIDERS = {"volcengine"} _SUPPORTED_LLM_PROVIDERS = {"openai", *_OPENAI_COMPATIBLE_PROVIDERS} @@ -37,6 +40,10 @@ class DefaultRealtimeServiceFactory(RealtimeServiceFactory): _DEFAULT_DASHSCOPE_ASR_MODEL = "qwen3-asr-flash-realtime" _DEFAULT_OPENAI_COMPATIBLE_TTS_MODEL = "FunAudioLLM/CosyVoice2-0.5B" _DEFAULT_OPENAI_COMPATIBLE_ASR_MODEL = "FunAudioLLM/SenseVoiceSmall" + _DEFAULT_VOLCENGINE_TTS_URL = "https://openspeech.bytedance.com/api/v3/tts/unidirectional" + _DEFAULT_VOLCENGINE_TTS_RESOURCE_ID = "seed-tts-2.0" + _DEFAULT_VOLCENGINE_ASR_REALTIME_URL = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel" + _DEFAULT_VOLCENGINE_ASR_MODEL = "bigmodel" @staticmethod def _normalize_provider(provider: Any) -> str: @@ -81,6 +88,19 @@ class DefaultRealtimeServiceFactory(RealtimeServiceFactory): speed=spec.speed, ) + if provider in _VOLCENGINE_PROVIDERS and spec.api_key: + return VolcengineTTSService( + api_key=spec.api_key, + api_url=spec.api_url or self._DEFAULT_VOLCENGINE_TTS_URL, + voice=spec.voice, + model=spec.model, + app_id=spec.app_id, + resource_id=spec.resource_id or self._DEFAULT_VOLCENGINE_TTS_RESOURCE_ID, + uid=spec.uid, + sample_rate=spec.sample_rate, + speed=spec.speed, + ) + if provider in _OPENAI_COMPATIBLE_PROVIDERS and spec.api_key: return OpenAICompatibleTTSService( api_key=spec.api_key, @@ -110,6 +130,20 @@ class DefaultRealtimeServiceFactory(RealtimeServiceFactory): on_transcript=spec.on_transcript, ) + if provider in _VOLCENGINE_PROVIDERS and spec.api_key: + return VolcengineRealtimeASRService( + api_key=spec.api_key, + api_url=spec.api_url or self._DEFAULT_VOLCENGINE_ASR_REALTIME_URL, + model=spec.model or self._DEFAULT_VOLCENGINE_ASR_MODEL, + sample_rate=spec.sample_rate, + language=spec.language, + app_id=spec.app_id, + resource_id=spec.resource_id, + uid=spec.uid, + request_params=spec.request_params, + on_transcript=spec.on_transcript, + ) + if provider in _OPENAI_COMPATIBLE_PROVIDERS and spec.api_key: return OpenAICompatibleASRService( api_key=spec.api_key, diff --git a/engine/providers/tts/__init__.py b/engine/providers/tts/__init__.py index 531ecfa..b2b237a 100644 --- a/engine/providers/tts/__init__.py +++ b/engine/providers/tts/__init__.py @@ -1 +1,5 @@ """TTS providers.""" + +from providers.tts.volcengine import VolcengineTTSService + +__all__ = ["VolcengineTTSService"] diff --git a/engine/providers/tts/volcengine.py b/engine/providers/tts/volcengine.py new file mode 100644 index 0000000..d7502a1 --- /dev/null +++ b/engine/providers/tts/volcengine.py @@ -0,0 +1,219 @@ +"""Volcengine TTS service. + +Uses Volcengine's unidirectional HTTP streaming TTS API and adapts streamed +base64 audio chunks into engine-native ``TTSChunk`` events. +""" + +from __future__ import annotations + +import asyncio +import base64 +import codecs +import json +import os +import uuid +from typing import Any, AsyncIterator, Optional + +import aiohttp +from loguru import logger + +from providers.common.base import BaseTTSService, ServiceState, TTSChunk + + +class VolcengineTTSService(BaseTTSService): + """Streaming TTS adapter for Volcengine's HTTP v3 API.""" + + DEFAULT_API_URL = "https://openspeech.bytedance.com/api/v3/tts/unidirectional" + DEFAULT_RESOURCE_ID = "seed-tts-2.0" + + def __init__( + self, + api_key: Optional[str] = None, + api_url: Optional[str] = None, + voice: str = "zh_female_shuangkuaisisi_moon_bigtts", + model: Optional[str] = None, + app_id: Optional[str] = None, + resource_id: Optional[str] = None, + uid: Optional[str] = None, + sample_rate: int = 16000, + speed: float = 1.0, + ) -> None: + super().__init__(voice=voice, sample_rate=sample_rate, speed=speed) + self.api_key = api_key or os.getenv("VOLCENGINE_TTS_API_KEY") or os.getenv("TTS_API_KEY") + self.api_url = api_url or os.getenv("VOLCENGINE_TTS_API_URL") or self.DEFAULT_API_URL + self.model = str(model or os.getenv("VOLCENGINE_TTS_MODEL") or "").strip() or None + self.app_id = app_id or os.getenv("VOLCENGINE_TTS_APP_ID") or os.getenv("TTS_APP_ID") + self.resource_id = resource_id or os.getenv("VOLCENGINE_TTS_RESOURCE_ID") or self.DEFAULT_RESOURCE_ID + self.uid = uid or os.getenv("VOLCENGINE_TTS_UID") + + self._session: Optional[aiohttp.ClientSession] = None + self._cancel_event = asyncio.Event() + self._synthesis_lock = asyncio.Lock() + self._pending_audio: list[bytes] = [] + + async def connect(self) -> None: + if not self.api_key: + raise ValueError("Volcengine TTS API key not provided. Configure agent.tts.api_key in YAML.") + if not self.app_id: + raise ValueError("Volcengine TTS app_id not provided. Configure agent.tts.app_id in YAML.") + + timeout = aiohttp.ClientTimeout(total=None, sock_read=None, sock_connect=15) + self._session = aiohttp.ClientSession(timeout=timeout) + self.state = ServiceState.CONNECTED + logger.info( + "Volcengine TTS service ready: speaker={}, sample_rate={}, resource_id={}", + self.voice, + self.sample_rate, + self.resource_id, + ) + + async def disconnect(self) -> None: + self._cancel_event.set() + if self._session is not None: + await self._session.close() + self._session = None + self.state = ServiceState.DISCONNECTED + logger.info("Volcengine TTS service disconnected") + + async def synthesize(self, text: str) -> bytes: + audio = b"" + async for chunk in self.synthesize_stream(text): + audio += chunk.audio + return audio + + async def synthesize_stream(self, text: str) -> AsyncIterator[TTSChunk]: + if not self._session: + raise RuntimeError("Volcengine TTS service not connected") + if not text.strip(): + return + + async with self._synthesis_lock: + self._cancel_event.clear() + + headers = { + "Content-Type": "application/json", + "X-Api-App-Key": str(self.app_id), + "X-Api-Access-Key": str(self.api_key), + "X-Api-Resource-Id": str(self.resource_id), + "X-Api-Request-Id": str(uuid.uuid4()), + } + payload = { + "user": { + "uid": str(self.uid or self.app_id), + }, + "req_params": { + "text": text, + "speaker": self.voice, + "audio_params": { + "format": "pcm", + "sample_rate": self.sample_rate, + "speech_rate": self._speech_rate_percent(self.speed), + }, + }, + } + if self.model: + payload["req_params"]["model"] = self.model + + chunk_size = max(1, self.sample_rate * 2 // 10) + audio_buffer = b"" + pending_chunk: Optional[bytes] = None + + try: + async with self._session.post(self.api_url, headers=headers, json=payload) as response: + if response.status != 200: + error_text = await response.text() + raise RuntimeError(f"Volcengine TTS error {response.status}: {error_text}") + + async for audio_bytes in self._iter_audio_bytes(response): + if self._cancel_event.is_set(): + logger.info("Volcengine TTS synthesis cancelled") + return + + audio_buffer += audio_bytes + while len(audio_buffer) >= chunk_size: + emitted = audio_buffer[:chunk_size] + audio_buffer = audio_buffer[chunk_size:] + if pending_chunk is not None: + yield TTSChunk(audio=pending_chunk, sample_rate=self.sample_rate, is_final=False) + pending_chunk = emitted + + if self._cancel_event.is_set(): + return + + if pending_chunk is not None: + if audio_buffer: + yield TTSChunk(audio=pending_chunk, sample_rate=self.sample_rate, is_final=False) + pending_chunk = None + else: + yield TTSChunk(audio=pending_chunk, sample_rate=self.sample_rate, is_final=True) + pending_chunk = None + + if audio_buffer: + yield TTSChunk(audio=audio_buffer, sample_rate=self.sample_rate, is_final=True) + + except asyncio.CancelledError: + logger.info("Volcengine TTS synthesis cancelled via asyncio") + raise + except Exception as exc: + logger.error("Volcengine TTS synthesis error: {}", exc) + raise + + async def cancel(self) -> None: + self._cancel_event.set() + + async def _iter_audio_bytes(self, response: aiohttp.ClientResponse) -> AsyncIterator[bytes]: + decoder = json.JSONDecoder() + utf8_decoder = codecs.getincrementaldecoder("utf-8")() + text_buffer = "" + self._pending_audio.clear() + + async for raw_chunk in response.content.iter_any(): + text_buffer += utf8_decoder.decode(raw_chunk) + text_buffer = self._yield_audio_payloads(decoder, text_buffer) + while self._pending_audio: + yield self._pending_audio.pop(0) + + text_buffer += utf8_decoder.decode(b"", final=True) + text_buffer = self._yield_audio_payloads(decoder, text_buffer) + while self._pending_audio: + yield self._pending_audio.pop(0) + + def _yield_audio_payloads(self, decoder: json.JSONDecoder, text_buffer: str) -> str: + while True: + stripped = text_buffer.lstrip() + if not stripped: + return "" + if len(stripped) != len(text_buffer): + text_buffer = stripped + + try: + payload, idx = decoder.raw_decode(text_buffer) + except json.JSONDecodeError: + return text_buffer + + text_buffer = text_buffer[idx:] + audio = self._extract_audio_bytes(payload) + if audio: + self._pending_audio.append(audio) + + def _extract_audio_bytes(self, payload: Any) -> bytes: + if not isinstance(payload, dict): + return b"" + + code = payload.get("code") + if code not in (None, 0, 20000000): + message = str(payload.get("message") or "unknown error") + raise RuntimeError(f"Volcengine TTS stream error {code}: {message}") + + encoded = payload.get("data") + if isinstance(encoded, str) and encoded.strip(): + try: + return base64.b64decode(encoded) + except Exception as exc: + logger.warning("Failed to decode Volcengine TTS audio chunk: {}", exc) + return b"" + + @staticmethod + def _speech_rate_percent(speed: float) -> int: + clamped = max(0.5, min(2.0, float(speed or 1.0))) + return int(round((clamped - 1.0) * 100)) diff --git a/engine/runtime/pipeline/duplex.py b/engine/runtime/pipeline/duplex.py index cbfabb3..dcf198f 100644 --- a/engine/runtime/pipeline/duplex.py +++ b/engine/runtime/pipeline/duplex.py @@ -793,6 +793,23 @@ class DuplexPipeline: return False return None + @staticmethod + def _coerce_json_object(value: Any) -> Optional[Dict[str, Any]]: + if isinstance(value, dict): + return dict(value) + if isinstance(value, str): + raw = value.strip() + if not raw: + return None + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Ignoring invalid JSON object config: {}", raw[:120]) + return None + if isinstance(parsed, dict): + return parsed + return None + @staticmethod def _is_dashscope_tts_provider(provider: Any) -> bool: normalized = str(provider or "").strip().lower() @@ -804,7 +821,7 @@ class DuplexPipeline: if normalized_mode in {"offline", "streaming"}: return normalized_mode # type: ignore[return-value] normalized_provider = str(provider or "").strip().lower() - if normalized_provider == "dashscope": + if normalized_provider in {"dashscope", "volcengine"}: return "streaming" return "offline" @@ -963,6 +980,10 @@ class DuplexPipeline: tts_api_url = self._runtime_tts.get("baseUrl") or settings.tts_api_url tts_voice = self._runtime_tts.get("voice") or settings.tts_voice tts_model = self._runtime_tts.get("model") or settings.tts_model + tts_app_id = self._runtime_tts.get("appId") or settings.tts_app_id + tts_resource_id = self._runtime_tts.get("resourceId") or settings.tts_resource_id + tts_cluster = self._runtime_tts.get("cluster") or settings.tts_cluster + tts_uid = self._runtime_tts.get("uid") or settings.tts_uid tts_speed = float(self._runtime_tts.get("speed") or settings.tts_speed) tts_mode = self._resolved_dashscope_tts_mode() runtime_mode = str(self._runtime_tts.get("mode") or "").strip() @@ -978,6 +999,10 @@ class DuplexPipeline: api_url=str(tts_api_url).strip() if tts_api_url else None, voice=str(tts_voice), model=str(tts_model).strip() if tts_model else None, + app_id=str(tts_app_id).strip() if tts_app_id else None, + resource_id=str(tts_resource_id).strip() if tts_resource_id else None, + cluster=str(tts_cluster).strip() if tts_cluster else None, + uid=str(tts_uid).strip() if tts_uid else None, sample_rate=settings.sample_rate, speed=tts_speed, mode=str(tts_mode), @@ -1006,6 +1031,13 @@ class DuplexPipeline: asr_api_key = self._runtime_asr.get("apiKey") asr_api_url = self._runtime_asr.get("baseUrl") or settings.asr_api_url asr_model = self._runtime_asr.get("model") or settings.asr_model + asr_app_id = self._runtime_asr.get("appId") or settings.asr_app_id + asr_resource_id = self._runtime_asr.get("resourceId") or settings.asr_resource_id + asr_cluster = self._runtime_asr.get("cluster") or settings.asr_cluster + asr_uid = self._runtime_asr.get("uid") or settings.asr_uid + asr_request_params = self._coerce_json_object(self._runtime_asr.get("requestParams")) + if asr_request_params is None: + asr_request_params = self._coerce_json_object(settings.asr_request_params_json) asr_enable_interim = self._coerce_bool(self._runtime_asr.get("enableInterim")) if asr_enable_interim is None: asr_enable_interim = bool(settings.asr_enable_interim) @@ -1022,6 +1054,11 @@ class DuplexPipeline: api_key=str(asr_api_key).strip() if asr_api_key else None, api_url=str(asr_api_url).strip() if asr_api_url else None, model=str(asr_model).strip() if asr_model else None, + app_id=str(asr_app_id).strip() if asr_app_id else None, + resource_id=str(asr_resource_id).strip() if asr_resource_id else None, + cluster=str(asr_cluster).strip() if asr_cluster else None, + uid=str(asr_uid).strip() if asr_uid else None, + request_params=asr_request_params, enable_interim=asr_enable_interim, interim_interval_ms=asr_interim_interval, min_audio_for_interim_ms=asr_min_audio_ms, diff --git a/engine/runtime/ports/asr.py b/engine/runtime/ports/asr.py index f3be1d1..b1310b1 100644 --- a/engine/runtime/ports/asr.py +++ b/engine/runtime/ports/asr.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import AsyncIterator, Awaitable, Callable, Literal, Optional, Protocol +from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Literal, Optional, Protocol from providers.common.base import ASRResult @@ -22,6 +22,11 @@ class ASRServiceSpec: api_key: Optional[str] = None api_url: Optional[str] = None model: Optional[str] = None + app_id: Optional[str] = None + resource_id: Optional[str] = None + cluster: Optional[str] = None + uid: Optional[str] = None + request_params: Optional[Dict[str, Any]] = None enable_interim: bool = False interim_interval_ms: int = 500 min_audio_for_interim_ms: int = 300 diff --git a/engine/runtime/ports/tts.py b/engine/runtime/ports/tts.py index 523dc3c..a98e17d 100644 --- a/engine/runtime/ports/tts.py +++ b/engine/runtime/ports/tts.py @@ -19,6 +19,10 @@ class TTSServiceSpec: api_key: Optional[str] = None api_url: Optional[str] = None model: Optional[str] = None + app_id: Optional[str] = None + resource_id: Optional[str] = None + cluster: Optional[str] = None + uid: Optional[str] = None mode: str = "commit" diff --git a/engine/tests/test_asr_factory_modes.py b/engine/tests/test_asr_factory_modes.py index 5d3d436..5cd78f8 100644 --- a/engine/tests/test_asr_factory_modes.py +++ b/engine/tests/test_asr_factory_modes.py @@ -1,6 +1,7 @@ from providers.asr.buffered import BufferedASRService from providers.asr.dashscope import DashScopeRealtimeASRService from providers.asr.openai_compatible import OpenAICompatibleASRService +from providers.asr.volcengine import VolcengineRealtimeASRService from providers.factory.default import DefaultRealtimeServiceFactory from runtime.ports import ASRServiceSpec @@ -35,6 +36,29 @@ def test_create_asr_service_openai_compatible_returns_offline_provider(): assert service.enable_interim is False +def test_create_asr_service_volcengine_returns_streaming_provider(): + factory = DefaultRealtimeServiceFactory() + service = factory.create_asr_service( + ASRServiceSpec( + provider="volcengine", + mode="streaming", + sample_rate=16000, + api_key="test-key", + api_url="wss://openspeech.bytedance.com/api/v3/sauc/bigmodel", + model="bigmodel", + app_id="app-1", + uid="caller-1", + request_params={"end_window_size": 800}, + ) + ) + assert isinstance(service, VolcengineRealtimeASRService) + assert service.mode == "streaming" + assert service.protocol == "seed" + assert service.app_id == "app-1" + assert service.uid == "caller-1" + assert service.request_params["end_window_size"] == 800 + + def test_create_asr_service_fallback_buffered_for_unsupported_provider(): factory = DefaultRealtimeServiceFactory() service = factory.create_asr_service( diff --git a/engine/tests/test_backend_adapters.py b/engine/tests/test_backend_adapters.py index e4faf81..9cce105 100644 --- a/engine/tests/test_backend_adapters.py +++ b/engine/tests/test_backend_adapters.py @@ -227,6 +227,62 @@ async def test_with_backend_url_uses_backend_for_assistant_config(monkeypatch, t assert payload["assistant"]["systemPrompt"] == "backend prompt" +def test_translate_agent_schema_maps_volcengine_fields(): + payload = { + "agent": { + "tts": { + "provider": "volcengine", + "api_key": "tts-key", + "api_url": "https://openspeech.bytedance.com/api/v3/tts/unidirectional", + "app_id": "app-123", + "resource_id": "seed-tts-2.0", + "uid": "caller-1", + "voice": "zh_female_shuangkuaisisi_moon_bigtts", + "speed": 1.1, + }, + "asr": { + "provider": "volcengine", + "api_key": "asr-key", + "api_url": "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel", + "model": "bigmodel", + "app_id": "app-123", + "resource_id": "volc.bigasr.sauc.duration", + "uid": "caller-1", + "request_params": { + "end_window_size": 800, + "force_to_speech_time": 1000, + }, + }, + } + } + + translated = LocalYamlAssistantConfigAdapter._translate_agent_schema("assistant_demo", payload) + assert translated is not None + assert translated["services"]["tts"] == { + "provider": "volcengine", + "apiKey": "tts-key", + "baseUrl": "https://openspeech.bytedance.com/api/v3/tts/unidirectional", + "voice": "zh_female_shuangkuaisisi_moon_bigtts", + "appId": "app-123", + "resourceId": "seed-tts-2.0", + "uid": "caller-1", + "speed": 1.1, + } + assert translated["services"]["asr"] == { + "provider": "volcengine", + "model": "bigmodel", + "apiKey": "asr-key", + "baseUrl": "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel", + "appId": "app-123", + "resourceId": "volc.bigasr.sauc.duration", + "uid": "caller-1", + "requestParams": { + "end_window_size": 800, + "force_to_speech_time": 1000, + }, + } + + @pytest.mark.asyncio async def test_backend_mode_disabled_uses_local_assistant_config_even_with_url(monkeypatch, tmp_path): class _FailIfCalledClientSession: diff --git a/engine/tests/test_tts_factory_modes.py b/engine/tests/test_tts_factory_modes.py new file mode 100644 index 0000000..987fc10 --- /dev/null +++ b/engine/tests/test_tts_factory_modes.py @@ -0,0 +1,45 @@ +from providers.factory.default import DefaultRealtimeServiceFactory +from providers.tts.mock import MockTTSService +from providers.tts.openai_compatible import OpenAICompatibleTTSService +from providers.tts.volcengine import VolcengineTTSService +from runtime.ports import TTSServiceSpec + + +def test_create_tts_service_volcengine_returns_native_provider(): + factory = DefaultRealtimeServiceFactory() + service = factory.create_tts_service( + TTSServiceSpec( + provider="volcengine", + api_key="test-key", + app_id="app-1", + resource_id="seed-tts-2.0", + voice="zh_female_shuangkuaisisi_moon_bigtts", + sample_rate=16000, + ) + ) + assert isinstance(service, VolcengineTTSService) + + +def test_create_tts_service_openai_compatible_returns_provider(): + factory = DefaultRealtimeServiceFactory() + service = factory.create_tts_service( + TTSServiceSpec( + provider="openai_compatible", + api_key="test-key", + voice="anna", + sample_rate=16000, + ) + ) + assert isinstance(service, OpenAICompatibleTTSService) + + +def test_create_tts_service_fallbacks_to_mock_without_key(): + factory = DefaultRealtimeServiceFactory() + service = factory.create_tts_service( + TTSServiceSpec( + provider="volcengine", + voice="anna", + sample_rate=16000, + ) + ) + assert isinstance(service, MockTTSService) diff --git a/engine/tests/test_volcengine_asr_provider.py b/engine/tests/test_volcengine_asr_provider.py new file mode 100644 index 0000000..c5756c0 --- /dev/null +++ b/engine/tests/test_volcengine_asr_provider.py @@ -0,0 +1,86 @@ +import gzip +import json + +from providers.asr.volcengine import VolcengineRealtimeASRService + + +def test_volcengine_seed_protocol_defaults_and_headers(): + service = VolcengineRealtimeASRService( + api_key="access-token", + api_url="wss://openspeech.bytedance.com/api/v3/sauc/bigmodel", + app_id="app-1", + uid="caller-1", + ) + + assert service.protocol == "seed" + assert service.resource_id == "volc.bigasr.sauc.duration" + + headers = service._build_seed_headers("req-1") + assert headers == { + "X-Api-App-Key": "app-1", + "X-Api-Access-Key": "access-token", + "X-Api-Resource-Id": "volc.bigasr.sauc.duration", + "X-Api-Request-Id": "req-1", + } + + +def test_volcengine_seed_start_payload_merges_request_params(): + service = VolcengineRealtimeASRService( + api_key="access-token", + api_url="wss://openspeech.bytedance.com/api/v3/sauc/bigmodel", + app_id="app-1", + uid="caller-1", + language="zh-CN", + request_params={ + "request": { + "end_window_size": 800, + "force_to_speech_time": 1000, + "context": "{\"hotwords\":[{\"word\":\"doubao\"}]}", + }, + "audio": {"codec": "raw"}, + }, + ) + + payload = service._build_seed_start_payload() + assert payload["user"] == {"uid": "caller-1"} + assert payload["audio"] == { + "format": "pcm", + "rate": 16000, + "bits": 16, + "channels": 1, + "codec": "raw", + "language": "zh-CN", + } + assert payload["request"]["model_name"] == "bigmodel" + assert payload["request"]["end_window_size"] == 800 + assert payload["request"]["force_to_speech_time"] == 1000 + assert payload["request"]["context"] == "{\"hotwords\":[{\"word\":\"doubao\"}]}" + + +def test_volcengine_seed_start_request_encodes_gzip_json_payload(): + service = VolcengineRealtimeASRService( + api_key="access-token", + api_url="wss://openspeech.bytedance.com/api/v3/sauc/bigmodel", + app_id="app-1", + uid="caller-1", + ) + + frame = service._build_seed_start_request() + assert frame[0] == 0x11 + assert frame[1] == 0x11 + + payload_length = int.from_bytes(frame[8:12], "big") + payload = json.loads(gzip.decompress(frame[12 : 12 + payload_length]).decode("utf-8")) + assert payload["user"]["uid"] == "caller-1" + assert payload["request"]["model_name"] == "bigmodel" + + +def test_volcengine_gateway_protocol_keeps_model_query(): + service = VolcengineRealtimeASRService( + api_key="access-token", + api_url="wss://ai-gateway.vei.volces.com/v1/realtime", + model="bigmodel", + ) + + assert service.protocol == "gateway" + assert service.api_url == "wss://ai-gateway.vei.volces.com/v1/realtime?model=bigmodel" From e41d34fe23f276123b43c9d4fbc42d67c6600080 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Sun, 8 Mar 2026 23:28:08 +0800 Subject: [PATCH 11/20] Add DashScope agent configuration files for VAD, LLM, TTS, and ASR services - Introduced new YAML configuration files for DashScope, detailing agent behavior settings for VAD, LLM, TTS, and ASR. - Configured parameters including model paths, API keys, and service URLs for real-time processing. - Ensured compatibility with existing agent-side behavior management while providing specific settings for DashScope integration. --- engine/config/agents/dashscope.yaml | 47 +++++++++++++++++++ engine/config/agents/volcengine.yaml | 68 ++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 engine/config/agents/dashscope.yaml create mode 100644 engine/config/agents/volcengine.yaml diff --git a/engine/config/agents/dashscope.yaml b/engine/config/agents/dashscope.yaml new file mode 100644 index 0000000..3491d68 --- /dev/null +++ b/engine/config/agents/dashscope.yaml @@ -0,0 +1,47 @@ +# Agent behavior configuration for DashScope realtime ASR/TTS. +# This file only controls agent-side behavior (VAD/LLM/TTS/ASR providers). +# Infra/server/network settings should stay in .env. + +agent: + vad: + type: silero + model_path: data/vad/silero_vad.onnx + threshold: 0.5 + min_speech_duration_ms: 100 + eou_threshold_ms: 800 + + llm: + # provider: openai | openai_compatible | siliconflow + provider: openai_compatible + model: deepseek-v3 + temperature: 0.7 + api_key: your_llm_api_key + api_url: https://api.qnaigc.com/v1 + + tts: + provider: dashscope + api_key: your_tts_api_key + api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime + model: qwen3-tts-flash-realtime + voice: Cherry + dashscope_mode: commit + speed: 1.0 + + asr: + provider: dashscope + api_key: your_asr_api_key + api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime + model: qwen3-asr-flash-realtime + interim_interval_ms: 500 + min_audio_ms: 300 + start_min_speech_ms: 160 + pre_speech_ms: 240 + final_tail_ms: 120 + + duplex: + enabled: true + system_prompt: You are a helpful, friendly voice assistant. Keep your responses concise and conversational. + + barge_in: + min_duration_ms: 200 + silence_tolerance_ms: 60 diff --git a/engine/config/agents/volcengine.yaml b/engine/config/agents/volcengine.yaml new file mode 100644 index 0000000..acd66b3 --- /dev/null +++ b/engine/config/agents/volcengine.yaml @@ -0,0 +1,68 @@ +# Agent behavior configuration (safe to edit per profile) +# This file only controls agent-side behavior (VAD/LLM/TTS/ASR providers). +# Infra/server/network settings should stay in .env. + +agent: + vad: + type: silero + model_path: data/vad/silero_vad.onnx + threshold: 0.5 + min_speech_duration_ms: 100 + eou_threshold_ms: 800 + + llm: + # provider: openai | openai_compatible | siliconflow + provider: openai_compatible + model: deepseek-v3 + temperature: 0.7 + # Required: no fallback. You can still reference env explicitly. + api_key: your_llm_api_key + # Optional for OpenAI-compatible endpoints: + api_url: https://api.qnaigc.com/v1 + + tts: + # provider: edge | openai_compatible | siliconflow | dashscope + # dashscope defaults (if omitted): + # api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime + # model: qwen3-tts-flash-realtime + # dashscope_mode: commit (engine splits) | server_commit (dashscope splits) + # note: dashscope_mode/mode is ONLY used when provider=dashscope. + # volcengine defaults (if omitted): + provider: volcengine + api_url: https://openspeech.bytedance.com/api/v3/tts/unidirectional + resource_id: seed-tts-2.0 + app_id: your_tts_app_id + api_key: your_tts_api_key + speed: 1.1 + voice: zh_female_vv_uranus_bigtts + + asr: + asr: + provider: volcengine + api_url: wss://openspeech.bytedance.com/api/v3/sauc/bigmodel + app_id: your_asr_app_id + api_key: your_asr_api_key + resource_id: volc.bigasr.sauc.duration + uid: caller-1 + model: bigmodel + request_params: + end_window_size: 800 + force_to_speech_time: 1000 + enable_punc: true + enable_itn: false + enable_ddc: false + show_utterance: true + result_type: single + interim_interval_ms: 500 + min_audio_ms: 300 + start_min_speech_ms: 160 + pre_speech_ms: 240 + final_tail_ms: 120 + + duplex: + enabled: true + system_prompt: 你是一个人工智能助手,你用简答语句回答,避免使用标点符号和emoji。 + + barge_in: + min_duration_ms: 200 + silence_tolerance_ms: 60 From b300b469dc8aa456819ae2a4e667997c6e6fae64 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Mon, 9 Mar 2026 05:38:43 +0800 Subject: [PATCH 12/20] Update documentation for Realtime Agent Studio with enhanced content and structure - Revised site name and description for clarity and detail. - Updated navigation structure to better reflect the organization of content. - Improved changelog entries for better readability and consistency. - Migrated assistant configuration and prompt guidelines to new documentation paths. - Enhanced core concepts section to clarify the roles and capabilities of assistants and engines. - Streamlined workflow documentation to provide clearer guidance on configuration and usage. --- docs/content/analysis/evaluation.md | 2 +- docs/content/api-reference/index.md | 5 +- docs/content/assistants/configuration.md | 220 +--------- docs/content/assistants/index.md | 61 +-- docs/content/assistants/prompts.md | 186 +-------- docs/content/assistants/testing.md | 164 +------- .../assistants/workflow-configuration.md | 69 +--- docs/content/changelog.md | 4 +- docs/content/concepts/assistants.md | 275 +++++-------- .../concepts/assistants/configuration.md | 218 ++++++++++ docs/content/concepts/assistants/prompts.md | 184 +++++++++ docs/content/concepts/assistants/testing.md | 162 ++++++++ docs/content/concepts/engines.md | 378 ++++-------------- docs/content/concepts/index.md | 317 ++------------- docs/content/concepts/pipeline-engine.md | 137 +++++++ docs/content/concepts/realtime-engine.md | 97 +++++ docs/content/customization/asr.md | 37 +- docs/content/customization/knowledge-base.md | 107 +++-- docs/content/customization/models.md | 75 ++-- docs/content/customization/tools.md | 257 ++++-------- docs/content/customization/tts.md | 34 +- docs/content/customization/voices.md | 77 ++-- docs/content/customization/workflows.md | 125 ++++-- docs/content/getting-started/configuration.md | 5 +- docs/content/getting-started/index.md | 159 ++------ docs/content/getting-started/requirements.md | 5 +- docs/content/index.md | 309 ++++---------- docs/content/overview/architecture.md | 130 ++---- docs/content/overview/index.md | 160 +++----- docs/content/quickstart/dashboard.md | 247 ++---------- docs/content/quickstart/index.md | 290 +++----------- docs/content/resources/faq.md | 125 ++---- docs/content/roadmap.md | 68 ++-- docs/mkdocs.yml | 68 ++-- 34 files changed, 1776 insertions(+), 2981 deletions(-) create mode 100644 docs/content/concepts/assistants/configuration.md create mode 100644 docs/content/concepts/assistants/prompts.md create mode 100644 docs/content/concepts/assistants/testing.md create mode 100644 docs/content/concepts/pipeline-engine.md create mode 100644 docs/content/concepts/realtime-engine.md diff --git a/docs/content/analysis/evaluation.md b/docs/content/analysis/evaluation.md index afe6816..91ec136 100644 --- a/docs/content/analysis/evaluation.md +++ b/docs/content/analysis/evaluation.md @@ -163,4 +163,4 @@ - [自动化测试](autotest.md) - 批量测试助手 - [历史记录](history.md) - 查看对话详情 -- [提示词指南](../assistants/prompts.md) - 优化提示词 +- [提示词指南](../concepts/assistants/prompts.md) - 优化提示词 diff --git a/docs/content/api-reference/index.md b/docs/content/api-reference/index.md index 1f22bd2..2ff42df 100644 --- a/docs/content/api-reference/index.md +++ b/docs/content/api-reference/index.md @@ -1,4 +1,4 @@ -# API 参考 +# API 参考 本节提供 Realtime Agent Studio (RAS) 的完整 API 文档。 @@ -163,6 +163,8 @@ WebSocket API 使用双向消息通信: ## SDK +> 下面的 SDK 包名和类名沿用当前包标识;产品名称在文档中统一使用 Realtime Agent Studio(RAS)。 + ### JavaScript SDK ```bash @@ -230,3 +232,4 @@ async with client.connect(assistant.id) as conv: - [WebSocket 协议](websocket.md) - 实时对话协议详解 - [错误码](errors.md) - 错误处理参考 - [快速开始](../quickstart/index.md) - 快速创建助手 + diff --git a/docs/content/assistants/configuration.md b/docs/content/assistants/configuration.md index 962c63f..f2405df 100644 --- a/docs/content/assistants/configuration.md +++ b/docs/content/assistants/configuration.md @@ -1,218 +1,8 @@ -# 配置选项 +# 配置选项(旧入口) -助手配置界面包含多个标签页,每个标签页负责不同方面的配置。 +本页保留旧链接,用于承接历史导航或外部引用。助手配置的正式文档已经迁移到: -## 全局设置 +- [配置选项](../concepts/assistants/configuration.md) - 助手配置界面与运行时配置层说明 +- [助手概念](../concepts/assistants.md) - 先理解助手对象、会话与动态变量 -全局设置定义助手的核心对话能力。 - -| 配置项 | 说明 | 建议值 | -|-------|------|--------| -| 助手名称 | 用于标识和管理 | 简洁明确 | -| 系统提示词 | 定义角色、任务和约束 | 详见[提示词指南](prompts.md) | -| 开场白 | 对话开始时的问候语 | 简短友好 | -| 温度参数 | 控制回复随机性 | 0.7(通用)/ 0.3(严谨) | -| 上下文长度 | 保留的历史消息数 | 10-20 | - -### 高级选项 - -- **首轮模式** - 设置首次对话的触发方式 -- **打断检测** - 用户打断时的处理策略 -- **超时设置** - 无响应时的处理 - -## 语音配置 - -配置语音识别和语音合成参数。 - -### TTS 语音合成 - -| 配置 | 说明 | -|------|------| -| TTS 引擎 | 选择语音合成服务(阿里/火山/Minimax) | -| 音色 | 选择语音风格和性别 | -| 语速 | 语音播放速度(0.5-2.0) | -| 音量 | 语音输出音量(0-100) | -| 音调 | 语音音调高低(0.5-2.0) | - -### ASR 语音识别 - -| 配置 | 说明 | -|------|------| -| ASR 引擎 | 选择语音识别服务 | -| 语言 | 识别语言(中文/英文/多语言) | -| 热词 | 提高特定词汇识别准确率 | - -## 工具绑定 - -配置助手可调用的外部工具。 - -### 可用工具类型 - -| 工具 | 说明 | -|------|------| -| 搜索工具 | 网络搜索获取信息 | -| 天气查询 | 查询天气预报 | -| 计算器 | 数学计算 | -| 知识库检索 | RAG 知识检索 | -| 自定义工具 | HTTP 回调外部 API | - -### 配置步骤 - -1. 在工具列表中勾选需要的工具 -2. 配置工具参数(如有) -3. 测试工具调用是否正常 - -## 知识关联 - -关联 RAG 知识库,让助手能够回答专业领域问题。 - -### 配置参数 - -| 参数 | 说明 | 建议值 | -|------|------|--------| -| 知识库 | 选择要关联的知识库 | - | -| 相似度阈值 | 低于此分数不返回 | 0.7 | -| 返回数量 | 单次检索返回条数 | 3 | -| 检索策略 | 混合/向量/关键词 | 混合 | - -### 多知识库 - -支持关联多个知识库,系统会自动合并检索结果。 - -## 外部链接 - -配置第三方服务集成和 Webhook 回调。 - -### Webhook 配置 - -| 字段 | 说明 | -|------|------| -| 回调 URL | 接收事件的 HTTP 端点 | -| 事件类型 | 订阅的事件(对话开始/结束/工具调用等) | -| 认证方式 | API Key / Bearer Token / 无 | - -### 支持的事件 - -- `conversation.started` - 对话开始 -- `conversation.ended` - 对话结束 -- `tool.called` - 工具被调用 -- `human.transfer` - 转人工 - -## 配置持久化与运行时覆盖 - -助手配置分为两层: - -1. **数据库持久化配置(基线配置)**:通过助手管理 API 保存,后续会话默认读取这一层。 -2. **会话级覆盖配置(runtime overrides)**:仅对当前 WebSocket 会话生效,不会写回数据库。 - -### 哪些配置会存到数据库 - -以下字段会持久化在 `assistants` / `assistant_opener_audio` 等表中(通过创建/更新助手写入): - -| 类别 | 典型字段 | -|------|---------| -| 对话行为 | `name`、`prompt`、`opener`、`firstTurnMode`、`generatedOpenerEnabled` | -| 输出与打断 | `voiceOutputEnabled`、`voice`、`speed`、`botCannotBeInterrupted`、`interruptionSensitivity` | -| 工具与知识库 | `tools`、`knowledgeBaseId` | -| 模型与外部模式 | `configMode`、`apiUrl`、`apiKey`、`llmModelId`、`asrModelId`、`embeddingModelId`、`rerankModelId` | -| 开场音频 | `openerAudioEnabled` 及音频文件状态(`ready`、`durationMs` 等) | - -> 引擎在连接时通过 `assistant_id` 从后端读取该助手的 `sessionStartMetadata` 作为默认运行配置。 - -### 哪些配置可以在会话中覆盖 - -客户端可在 `session.start.metadata.overrides` 中覆盖以下白名单字段(仅当前会话有效): - -- `systemPrompt` -- `greeting` -- `firstTurnMode` -- `generatedOpenerEnabled` -- `output` -- `bargeIn` -- `knowledgeBaseId` -- `knowledge` -- `tools` -- `openerAudio` - -以下字段不能由客户端覆盖: - -- `services`(模型 provider / apiKey / baseUrl 等) -- `assistantId` / `appId` / `configVersionId`(及下划线变体) -- 包含密钥语义的字段(如 `apiKey`、`token`、`secret`、`password`、`authorization`) - -### 覆盖示例(代码) - -下面示例展示「数据库基线配置 + 会话 overrides」的最终效果。 - -```json -// 1) 数据库存储的基线配置(示意) -// GET /api/v1/assistants/asst_demo/config -> sessionStartMetadata -{ - "systemPrompt": "你是电商客服助手,回答要简洁。", - "greeting": "你好,我是你的客服助手。", - "firstTurnMode": "bot_first", - "output": { "mode": "audio" }, - "knowledgeBaseId": "kb_orders", - "tools": [ - { "type": "function", "function": { "name": "query_order" } } - ] -} -``` - -```json -// 2) 客户端发起会话时的覆盖 -{ - "type": "session.start", - "metadata": { - "channel": "web", - "history": { "userId": 1001 }, - "overrides": { - "greeting": "你好,我来帮你查订单进度。", - "output": { "mode": "text" }, - "knowledgeBaseId": "kb_vip_orders", - "tools": [ - { "type": "function", "function": { "name": "query_vip_order" } } - ] - } - } -} -``` - -```json -// 3) 引擎合并后的有效配置(示意) -{ - "assistantId": "asst_demo", - "systemPrompt": "你是电商客服助手,回答要简洁。", - "greeting": "你好,我来帮你查订单进度。", - "firstTurnMode": "bot_first", - "output": { "mode": "text" }, - "knowledgeBaseId": "kb_vip_orders", - "tools": [ - { "type": "function", "function": { "name": "query_vip_order" } } - ], - "channel": "web", - "history": { "userId": 1001 } -} -``` - -合并规则可简化为: - -```python -effective = {**db_session_start_metadata, **metadata.overrides} -``` - -当 `WS_EMIT_CONFIG_RESOLVED=true` 时,服务端会返回 `config.resolved`(公开、安全裁剪后的快照)用于前端调试当前生效配置。 - -## 配置导入导出 - -### 导出配置 - -1. 在助手详情页点击 **更多** -2. 选择 **导出配置** -3. 下载 JSON 格式的配置文件 - -### 导入配置 - -1. 点击 **新建助手** -2. 选择 **从配置导入** -3. 上传配置文件 +如果你是从创建路径进入,也可以直接回到 [快速开始](../quickstart/index.md)。 diff --git a/docs/content/assistants/index.md b/docs/content/assistants/index.md index 110a8dd..ea0611d 100644 --- a/docs/content/assistants/index.md +++ b/docs/content/assistants/index.md @@ -1,57 +1,10 @@ -# 助手管理 +# 助手管理(旧入口) -助手是 Realtime Agent Studio (RAS) 的核心模块,用于创建和配置智能对话机器人。每个助手都可以独立配置提示词、语音、知识库和工具。 +本页保留旧链接,用于承接历史导航或外部引用。助手相关内容已经拆分到更明确的文档中: -## 概述 +- [助手概念](../concepts/assistants.md) - 了解助手是什么、由哪些部分组成,以及会话如何运行 +- [配置选项](../concepts/assistants/configuration.md) - 查看控制台和运行时配置项的分工 +- [提示词指南](../concepts/assistants/prompts.md) - 编写高质量系统提示词 +- [测试调试](../concepts/assistants/testing.md) - 验证助手行为并排查问题 -![助手管理](../images/assistants.png) - -## 助手能力 - -| 能力 | 说明 | -|------|------| -| **智能对话** | 基于 LLM 的自然语言理解和生成 | -| **语音交互** | 支持语音识别和语音合成 | -| **知识检索** | 关联知识库回答专业问题 | -| **工具调用** | 调用外部 API 执行操作 | -| **工作流** | 支持复杂的多轮对话流程 | - -## 创建助手 - -### 步骤 - -1. 进入 **助手管理** 页面 -2. 点击 **新建助手** 按钮 -3. 填写基本信息 -4. 配置各项参数 -5. 保存并发布 - -### 基本信息 - -| 配置项 | 说明 | -|-------|------| -| 助手名称 | 唯一标识,用于区分不同助手 | -| 提示词 | 定义助手的角色和行为 | -| 温度参数 | 控制回复的随机性(0-1) | - -## 调试助手 - -在助手详情页可进行实时调试: - -- **文本对话测试** - 快速验证回复质量 -- **语音输入测试** - 测试 ASR 识别效果 -- **工具调用验证** - 确认工具正常执行 - -## 发布助手 - -配置完成后: - -1. 点击 **保存** - 保存当前配置 -2. 点击 **发布** - 发布到生产环境 -3. 获取 API 调用地址 - 用于集成 - -## 下一步 - -- [配置选项](configuration.md) - 详细的配置标签页说明 -- [提示词指南](prompts.md) - 如何编写高质量的系统提示词 -- [测试调试](testing.md) - 助手测试与问题排查 +如果你是第一次上手,建议直接从 [快速开始](../quickstart/index.md) 进入。 diff --git a/docs/content/assistants/prompts.md b/docs/content/assistants/prompts.md index d359111..466339d 100644 --- a/docs/content/assistants/prompts.md +++ b/docs/content/assistants/prompts.md @@ -1,184 +1,8 @@ -# 提示词指南 +# 提示词指南(旧入口) -系统提示词(System Prompt)是定义助手行为的核心配置。本指南介绍如何编写高质量的提示词。 +本页保留旧链接,用于承接历史导航或外部引用。提示词的正式文档已经迁移到: -## 提示词结构 +- [提示词指南](../concepts/assistants/prompts.md) - 设计角色、任务、限制与风格 +- [助手概念](../concepts/assistants.md) - 理解提示词在助手体系中的位置 -一个完整的系统提示词通常包含以下部分: - -``` -[角色定义] -[任务描述] -[行为约束] -[输出格式] -[示例(可选)] -``` - -## 编写原则 - -### 1. 明确角色 - -告诉助手它是谁: - -``` -你是一个专业的技术支持工程师,专门负责解答产品使用问题。 -``` - -### 2. 定义任务 - -明确助手需要完成什么: - -``` -你的主要任务是: -1. 解答用户关于产品功能的问题 -2. 提供使用指导和最佳实践 -3. 帮助用户排查常见故障 -``` - -### 3. 设置约束 - -限制不希望出现的行为: - -``` -请注意: -- 不要讨论与产品无关的话题 -- 不要编造不存在的功能 -- 如果不确定答案,请建议用户联系人工客服 -``` - -### 4. 指定风格 - -定义回复的语气和风格: - -``` -回复风格要求: -- 使用友好、专业的语气 -- 回答简洁明了,避免冗长 -- 适当使用列表和步骤说明 -``` - -## 提示词模板 - -### 客服助手 - -``` -你是 [公司名称] 的智能客服助手。 - -## 你的职责 -- 解答用户关于产品和服务的问题 -- 处理常见的投诉和建议 -- 引导用户完成操作流程 - -## 回复要求 -- 保持友好和耐心 -- 回答简洁,一般不超过 3 句话 -- 如果问题复杂,建议转接人工客服 - -## 禁止行为 -- 不要讨论竞争对手 -- 不要承诺无法兑现的事项 -- 不要透露内部信息 -``` - -### 技术支持 - -``` -你是一个技术支持工程师,专门帮助用户解决技术问题。 - -## 工作流程 -1. 首先了解用户遇到的具体问题 -2. 询问必要的环境信息(系统版本、错误信息等) -3. 提供分步骤的解决方案 -4. 确认问题是否解决 - -## 回复格式 -- 使用编号列表说明操作步骤 -- 提供代码示例时使用代码块 -- 复杂问题可以分多次回复 -``` - -### 销售顾问 - -``` -你是一个产品销售顾问,帮助用户了解产品并做出购买决策。 - -## 沟通策略 -- 先了解用户需求,再推荐合适的产品 -- 突出产品优势,但不贬低竞品 -- 提供真实的价格和优惠信息 - -## 目标 -- 帮助用户找到最适合的方案 -- 解答购买相关的疑问 -- 促进成交但不过度推销 -``` - -## 动态变量 - -提示词支持动态变量,使用 `{{变量名}}` 语法: - -``` -你好 {{customer_name}},欢迎来到 {{company_name}}。 -你当前的会员等级是 {{membership_tier}}。 -``` - -在 `session.start` 时通过 `dynamicVariables` 传入: - -```json -{ - "type": "session.start", - "metadata": { - "dynamicVariables": { - "customer_name": "张三", - "company_name": "AI 公司", - "membership_tier": "黄金会员" - } - } -} -``` - -## 常见问题 - -### 回复太长 - -在提示词中明确限制: - -``` -回复长度要求: -- 一般问题:1-2 句话 -- 复杂问题:不超过 5 句话 -- 避免重复和冗余内容 -``` - -### 答非所问 - -增加任务边界说明: - -``` -重要提示: -- 只回答与 [产品/服务] 相关的问题 -- 对于无关问题,礼貌地拒绝并引导回正题 -``` - -### 编造信息 - -强调诚实原则: - -``` -信息准确性要求: -- 只提供你确定的信息 -- 不确定时说"我不太确定,建议您..." -- 绝对不要编造数据或功能 -``` - -## 最佳实践 - -1. **迭代优化** - 根据实际对话效果持续调整 -2. **测试覆盖** - 用各种场景测试提示词效果 -3. **版本管理** - 保存历史版本,便于回退 -4. **定期复盘** - 分析对话记录,发现改进点 - -## 下一步 - -- [测试调试](testing.md) - 验证提示词效果 -- [知识库配置](../customization/knowledge-base.md) - 补充专业知识 +如果你想先完成最小可用配置,请从 [快速开始](../quickstart/index.md) 继续。 diff --git a/docs/content/assistants/testing.md b/docs/content/assistants/testing.md index ca4bd06..5b1a039 100644 --- a/docs/content/assistants/testing.md +++ b/docs/content/assistants/testing.md @@ -1,162 +1,8 @@ -# 测试调试 +# 测试调试(旧入口) -本指南介绍如何测试和调试 AI 助手,确保其行为符合预期。 +本页保留旧链接,用于承接历史导航或外部引用。测试与调试的正式文档已经迁移到: -## 测试面板 +- [测试调试](../concepts/assistants/testing.md) - 验证助手行为、事件流和常见问题定位 +- [故障排查](../resources/troubleshooting.md) - 进入更细的链路排查步骤 -在助手详情页,点击 **测试** 按钮打开测试面板。 - -### 功能介绍 - -| 功能 | 说明 | -|------|------| -| 文本对话 | 直接输入文字进行测试 | -| 语音测试 | 使用麦克风进行语音对话 | -| 查看日志 | 实时查看系统日志 | -| 事件追踪 | 查看 WebSocket 事件流 | - -## 测试用例设计 - -### 基础功能测试 - -| 测试项 | 输入 | 预期结果 | -|--------|------|---------| -| 问候响应 | "你好" | 友好的问候回复 | -| 功能介绍 | "你能做什么?" | 准确描述能力范围 | -| 开场白 | 连接后自动 | 播放配置的开场白 | - -### 业务场景测试 - -根据助手定位设计测试用例: - -``` -场景:产品咨询助手 - -测试用例 1:常见问题 -- 输入:"产品有哪些功能?" -- 预期:准确列出主要功能 - -测试用例 2:价格询问 -- 输入:"多少钱?" -- 预期:提供价格信息或引导方式 - -测试用例 3:超出范围 -- 输入:"帮我写一首诗" -- 预期:礼貌拒绝并引导回业务话题 -``` - -### 边界测试 - -| 测试项 | 输入 | 预期结果 | -|--------|------|---------| -| 空输入 | "" | 提示用户输入内容 | -| 超长输入 | 1000+ 字符 | 正常处理或提示过长 | -| 特殊字符 | "" | 安全处理,不执行 | -| 敏感内容 | 不当言论 | 拒绝回复并提示 | - -## 日志分析 - -### 查看日志 - -在测试面板的 **日志** 标签页,可以看到: - -- ASR 识别结果 -- LLM 推理过程 -- TTS 合成状态 -- 工具调用记录 - -### 常见日志 - -``` -[ASR] transcript.final: "你好,请问有什么可以帮你" -[LLM] request: messages=[...] -[LLM] response: "您好!我是..." -[TTS] synthesizing: "您好!我是..." -[TTS] audio.start -[TTS] audio.end -``` - -## 事件追踪 - -在 **事件** 标签页查看完整的 WebSocket 事件流: - -```json -{"type": "session.started", "timestamp": 1704067200000} -{"type": "input.speech_started", "timestamp": 1704067201000} -{"type": "transcript.delta", "data": {"text": "你"}} -{"type": "transcript.delta", "data": {"text": "好"}} -{"type": "transcript.final", "data": {"text": "你好"}} -{"type": "assistant.response.delta", "data": {"text": "您"}} -{"type": "assistant.response.final", "data": {"text": "您好!..."}} -{"type": "output.audio.start"} -{"type": "output.audio.end"} -``` - -## 性能指标 - -关注以下性能指标: - -| 指标 | 说明 | 建议值 | -|------|------|--------| -| TTFB | 首字节时间 | < 500ms | -| 识别延迟 | ASR 处理时间 | < 1s | -| 回复延迟 | LLM 推理时间 | < 2s | -| 合成延迟 | TTS 处理时间 | < 500ms | - -## 常见问题排查 - -### 助手不响应 - -1. **检查连接状态** - - 确认 WebSocket 连接成功 - - 查看是否收到 `session.started` 事件 - -2. **检查模型配置** - - 确认 LLM 模型 API Key 有效 - - 测试模型连接是否正常 - -3. **查看错误日志** - - 打开浏览器开发者工具 - - 检查 Console 和 Network 标签 - -### 回复质量差 - -1. **优化提示词** - - 增加更明确的指令 - - 添加示例和约束 - -2. **调整温度参数** - - 降低 temperature 提高一致性 - - 适当值通常在 0.3-0.7 - -3. **补充知识库** - - 上传相关文档 - - 提高检索相关性 - -### 语音问题 - -1. **ASR 识别不准** - - 检查麦克风权限 - - 尝试更换 ASR 引擎 - - 添加热词提高识别率 - -2. **TTS 不播放** - - 检查浏览器自动播放限制 - - 确认 TTS 配置正确 - -## 自动化测试 - -使用自动化测试功能进行批量测试: - -1. 进入 **自动化测试** 页面 -2. 创建测试任务 -3. 配置测试用例 -4. 运行测试并查看报告 - -详见 [自动化测试](../analysis/autotest.md)。 - -## 下一步 - -- [自动化测试](../analysis/autotest.md) - 批量测试 -- [历史记录](../analysis/history.md) - 查看对话记录 -- [效果评估](../analysis/evaluation.md) - 评估对话质量 +如果你还没创建助手,请先完成 [快速开始](../quickstart/index.md)。 diff --git a/docs/content/assistants/workflow-configuration.md b/docs/content/assistants/workflow-configuration.md index f2f4861..facf111 100644 --- a/docs/content/assistants/workflow-configuration.md +++ b/docs/content/assistants/workflow-configuration.md @@ -1,68 +1,7 @@ -# 工作流配置选项(TODO 版本) +# 工作流配置(旧入口) -本文档是工作流配置页的第一版草稿,后续会根据实际能力继续细化。 +本页保留旧链接,用于承接早期草稿和历史引用。工作流的正式文档已收敛到: -## 配置目标 - -- 将多步骤对话拆分为可编排节点 -- 为不同分支定义独立提示词和工具权限 -- 在会话中按条件切换节点并透传上下文 - -## 基础配置项(建议) - -| 配置项 | 说明 | 建议值 | -|---|---|---| -| 工作流名称 | 用于区分业务流程 | 简洁、业务语义明确 | -| 入口节点 | 用户进入后的首个节点 | 固定单入口 | -| 全局提示词 | 对所有节点生效的共性约束 | 保持简短,避免与节点提示词冲突 | -| 节点提示词 | 当前节点的任务说明 | 单一职责,明确输入/输出 | -| 节点工具白名单 | 当前节点可调用工具集合 | 最小权限原则 | -| 节点超时 | 节点等待超时处理 | 3-10 秒 | -| 失败回退节点 | 异常时兜底节点 | 建议统一到人工或澄清节点 | - -## 节点建议类型 - -- 意图识别节点:判断用户诉求并路由 -- 信息收集节点:收集订单号、手机号等关键信息 -- 处理节点:执行查询、计算、调用工具 -- 回复节点:组织最终答复 -- 结束节点:输出结束语并关闭会话 - -## 配置示例 - -```yaml -workflow: - name: "订单咨询流程" - entry: "intent_router" - global_prompt: "优先给出可执行步骤,必要时先澄清信息。" - nodes: - - id: "intent_router" - type: "router" - prompt: "识别用户意图:查订单、退款、投诉" - next: - - when: "intent == query_order" - to: "collect_order_id" - - when: "intent == refund" - to: "refund_policy" - - id: "collect_order_id" - type: "collect" - prompt: "请用户提供订单号" - tools: ["query_order"] - fallback: "human_handoff" - - id: "human_handoff" - type: "end" - prompt: "转人工处理" -``` - -## 已知限制(当前) - -- 不支持在文档中完整定义所有表达式语法 -- 不同执行引擎的节点字段可能存在差异 -- 可视化编排与 YAML 字段暂未完全一一对应 - -## 后续计划 - -- 补充节点字段的完整 Schema -- 补充路由条件表达式规范 -- 增加“调试与回放”章节 +- [工作流](../customization/workflows.md) - 了解工作流的定位、节点结构、设计建议和当前边界 +如果你正在配置助手中的流程能力,请优先阅读上述页面,再结合 [工具](../customization/tools.md) 与 [助手概念](../concepts/assistants.md) 一起使用。 diff --git a/docs/content/changelog.md b/docs/content/changelog.md index b99bb81..149d557 100644 --- a/docs/content/changelog.md +++ b/docs/content/changelog.md @@ -1,4 +1,4 @@ -# 更新日志 +# 更新日志 本文档记录 Realtime Agent Studio 的所有重要变更。 @@ -29,7 +29,7 @@ - **OpenAI 兼容接口** - 支持 OpenAI Compatible 的 ASR/TTS 服务 - **DashScope TTS** - 阿里云语音合成服务适配 -#### 智能体配置 +#### 助手配置 - **系统提示词** - 支持角色定义和动态变量 `{{variable}}` - **模型管理** - LLM/ASR/TTS 模型统一管理界面 diff --git a/docs/content/concepts/assistants.md b/docs/content/concepts/assistants.md index 2d560ff..8e17ab5 100644 --- a/docs/content/concepts/assistants.md +++ b/docs/content/concepts/assistants.md @@ -1,236 +1,147 @@ # 助手概念详解 -深入了解助手(Assistant)的设计理念和配置细节。 +助手(Assistant)是 Realtime Agent Studio(RAS)中最核心的配置单元,也是控制台和 API 对外暴露能力的基本对象。 --- -## 什么是助手? +## 什么是助手 -**助手**是 RAS 中的核心实体,代表一个具有特定角色、能力和行为的 AI 对话智能体。每个助手都是独立配置的,可以服务于不同的业务场景。 +一个助手代表一个可接入、可测试、可发布的实时 AI 入口。它回答三个问题: -### 助手的组成 +- **它是谁**:角色、语气、目标、限制、开场方式、静默时候的行动(比如静默时候的询问 Ask-on-Idle) +- **它能做什么**:语言模型能力、语音模型能力(ASR、TTS、用户打断灵敏度(Barge-in)、语句端点设置(End-of-Utterance))、知识库、记忆、工具(Webhook、客户端工具、系统工具、MCP)、输出模式 +- **它在一次会话中如何运行**:通过 `assistant_id` 载入配置,并在运行时接收动态变量、对话时候的上下文更新 -```mermaid -flowchart - subgraph Assistant["助手"] - direction TB - Prompts[指令配置] - Audio[语音配置] - Interaction[交互配置] - tool[工具配置] - knowledge[知识配置] - webhooks[webhooks] - end +如果把引擎理解为“运行时”,那么助手就是“运行时要执行的那份定义”。 - subgraph Prompts - Name[名称] - Prompt[提示词] - Opener[开场白] - end +## 助手由哪些部分组成 - subgraph Audio - LLM[LLM 模型] - ASR[ASR 模型] - TTS[TTS 声音] - end +| 层次 | 负责什么 | 典型内容 | +|------|----------|----------| +| **身份层** | 定义助手角色和交互风格 | 系统提示词、限制、开场白、静默处理 | +| **模型层** | 决定理解与生成能力 | LLM、ASR、TTS、引擎类型、用户打断、语句端点 | +| **能力层** | 扩展知识和执行能力 | 知识库、工具、记忆 | +| **会话层** | 决定运行时上下文如何注入 | `assistant_id`、动态变量 | - subgraph Interaction - Tools[工具调用] - KB[知识库] - end +## 身份层 - subgraph tool - Greeting[开场白] - Interruption[打断设置] - Output[输出模式] - end - - subgraph knowledge - Greeting[开场白] - Interruption[打断设置] - Output[输出模式] - end - - subgraph webhooks - Greeting[开场白] - Interruption[打断设置] - Output[输出模式] - end -``` - ---- - -## 身份定义 +助手首先是一个“被约束的角色”,而不是一段孤立的模型调用。 ### 系统提示词 -系统提示词是助手最重要的配置,它定义了: +系统提示词定义助手的角色、任务、边界和风格,是所有能力组合的基础。 -| 要素 | 说明 | 示例 | +| 要素 | 作用 | 示例 | |------|------|------| -| **角色** | 助手扮演什么身份 | "你是一名专业的医疗咨询顾问" | -| **能力** | 助手能做什么 | "你可以回答健康问题,但不能开具处方" | -| **限制** | 助手不能做什么 | "不要讨论政治话题" | -| **风格** | 回复的语气和格式 | "保持友好专业,回答简洁" | +| **角色** | 告诉模型“自己是谁” | 客服助手、销售顾问、培训教练 | +| **任务** | 指定要完成的结果 | 解答咨询、收集信息、调用工具处理业务 | +| **限制** | 明确哪些事不能做 | 不承诺超权限优惠、不输出未经验证的结论 | +| **风格** | 约束回答节奏和措辞 | 简洁、口语化、每次 2-3 句 | -### 提示词模板 +### 开场白 -```markdown -## 角色 -你是{{company}}的智能客服助手"小智"。 +一个助手还要定义会话应该如何开始,以及用户静默时候如何处理,包括: -## 任务 -- 回答用户关于产品和服务的问题 -- 协助处理订单查询和售后问题 -- 收集用户反馈 +- **首轮模式**:助手先说、用户先说或者机器先说 +- **开场白**:使用固定开场白或者AI生成开场白 -## 限制 -- 不讨论竞争对手产品 -- 不承诺超出权限的优惠 -- 遇到复杂问题引导用户联系人工客服 +### 静默处理 -## 风格 -- 语气友好亲切 -- 回答简洁明了,每次 2-3 句话 -- 适当使用语气词使对话更自然 -``` +用户静默时候是否询问用户是否在线 ---- +## 模型层 -## 模型配置 +模型决定助手的基础理解、推理和表达能力,但不是助手定义的全部。 -### LLM 模型 +- **LLM** 决定对话推理与文本生成能力 +- **ASR** 决定语音输入如何被实时转写 +- **TTS** 决定文本回复如何转成可播放语音 +- **引擎类型** 决定运行链路是分段可控还是端到端低延迟 +- **VAD** 声音活动模型,判断用户是否在说话 +- **EOU** 语句端点模型,判断用户是否完成一段语句等待回复 +- **Barge In** 由于用户声音活动或者手动请求,是否打断助手当前的回复 -大语言模型是助手的"大脑",负责理解用户意图和生成回复。 +## 能力层 -| 参数 | 说明 | 建议值 | -|------|------|--------| -| **温度** | 回复随机性,越高越发散 | 0.7 (对话) / 0.3 (问答) | -| **最大 Token** | 单次回复长度上限 | 256-512 | -| **上下文长度** | 记忆的对话轮数 | 10-20 轮 | +### 知识库 -### ASR 模型 - -语音识别模型将用户语音转为文字。 - -| 配置 | 说明 | -|------|------| -| **语言** | 识别语言,如中文、英文 | -| **热词** | 提高特定词汇识别率 | -| **标点** | 是否自动添加标点 | - -### TTS 声音 - -语音合成将助手回复转为语音输出。 - -| 配置 | 说明 | -|------|------| -| **音色** | 选择声音角色 | -| **语速** | 说话速度,0.5-2.0 | -| **音调** | 声音高低 | - ---- - -## 能力扩展 - -### 工具调用 - -通过工具让助手能够执行外部操作: +知识库用于补充私有领域知识,让助手回答超出基础模型常识之外的问题。 ```mermaid flowchart LR - User[用户] -->|"查询订单"| Assistant[助手] - Assistant -->|调用工具| API[订单 API] - API -->|返回数据| Assistant - Assistant -->|回复| User -``` - -**工具定义示例:** - -```json -{ - "name": "get_order_status", - "description": "查询用户订单状态", - "parameters": { - "type": "object", - "properties": { - "order_id": { - "type": "string", - "description": "订单编号" - } - }, - "required": ["order_id"] - } -} -``` - -### 知识库关联 - -让助手基于私有文档回答问题: - -```mermaid -flowchart LR - Question[用户问题] --> Search[知识检索] - Search --> KB[(知识库)] - KB --> Context[相关内容] + Question[用户问题] --> Retrieval[检索] + Retrieval --> KB[(知识库)] + KB --> Context[相关片段] Context --> LLM[LLM] LLM --> Answer[回答] ``` ---- +知识库适合承载政策、产品资料、流程说明、FAQ 和内部文档,而不是把所有业务知识堆进系统提示词。 -## 行为控制 +### 工具 -### 开场白设置 +工具让助手从“会说”变成“能做事”。 -| 模式 | 说明 | -|------|------| -| **助手先说** | 连接后助手主动问候 | -| **用户先说** | 等待用户开口 | -| **静默** | 不自动开场 | +```mermaid +flowchart LR + User[用户] --> Assistant[助手] + Assistant --> Tool[工具 / 外部系统] + Tool --> Assistant + Assistant --> User +``` -### 打断设置 +适合用工具处理的任务包括:订单查询、预约、外部搜索、写入业务系统、调用客户端能力等。 -| 选项 | 说明 | -|------|------| -| **允许打断** | 用户可随时插话 | -| **禁止打断** | 助手说完才能输入 | -| **灵敏度** | 打断触发的敏感程度 | +## 会话层 -### 输出模式 +### `assistant_id` 的作用 -| 模式 | 说明 | -|------|------| -| **语音** | TTS 语音输出 | -| **文本** | 纯文本输出 | -| **混合** | 同时输出语音和文本 | +在接入层面,客户端通过 `assistant_id` 指定要加载哪一个助手。引擎据此读取默认配置,并把同一份助手定义应用到当前会话。 ---- +### 会话生命周期 -## 最佳实践 +```mermaid +stateDiagram-v2 + [*] --> Connecting: WebSocket 连接 + Connecting --> Started: session.started + Started --> Active: config.resolved / 开始对话 + Active --> Active: 多轮交互 + Active --> Stopped: session.stop 或连接关闭 + Stopped --> [*] +``` -### 1. 提示词工程 +一次会话通常会沉淀以下信息: -- **明确角色**: 清晰定义助手身份 -- **设定边界**: 明确能做什么、不能做什么 -- **控制长度**: 语音场景下回复要简短 +- 用户与助手消息时间线 +- 音频流、转写结果和模型输出 +- 工具调用记录与中间事件 +- 自定义 metadata、渠道和业务上下文 -### 2. 模型选择 -- **平衡成本与效果**: 不一定需要最强模型 -- **测试不同供应商**: 找到最适合场景的组合 -- **考虑延迟**: 语音交互对延迟敏感 +### 动态变量与会话级覆盖 -### 3. 工具设计 +助手的默认配置不需要为每个用户都重新复制一份。RAS 提供两种常见的运行时注入方式: -- **单一职责**: 每个工具做一件事 -- **清晰描述**: 让 LLM 正确理解何时调用 -- **错误处理**: 工具失败时优雅降级 +- **动态变量**:在提示词中使用 `{{variable}}` 占位,并在会话开始时传入具体值 +- **会话级覆盖**:仅对当前会话覆盖部分运行时参数,不回写助手基线配置 ---- +```json +{ + "type": "session.start", + "metadata": { + "dynamicVariables": { + "company_name": "ABC 公司", + "customer_name": "张三", + "tier": "VIP" + } + } +} +``` + +这种设计让你既能复用标准助手,又能在每次接入时注入渠道、用户、订单或上下文信息。 ## 相关文档 -- [助手配置](../assistants/configuration.md) - 配置界面详解 -- [提示词指南](../assistants/prompts.md) - 编写高质量提示词 -- [工具集成](../customization/tools.md) - 工具配置详情 +- [配置选项](assistants/configuration.md) - 查看助手在控制台和运行时有哪些配置层 +- [提示词指南](assistants/prompts.md) - 设计角色、任务、限制和语气 +- [测试调试](assistants/testing.md) - 验证助手质量并定位问题 diff --git a/docs/content/concepts/assistants/configuration.md b/docs/content/concepts/assistants/configuration.md new file mode 100644 index 0000000..962c63f --- /dev/null +++ b/docs/content/concepts/assistants/configuration.md @@ -0,0 +1,218 @@ +# 配置选项 + +助手配置界面包含多个标签页,每个标签页负责不同方面的配置。 + +## 全局设置 + +全局设置定义助手的核心对话能力。 + +| 配置项 | 说明 | 建议值 | +|-------|------|--------| +| 助手名称 | 用于标识和管理 | 简洁明确 | +| 系统提示词 | 定义角色、任务和约束 | 详见[提示词指南](prompts.md) | +| 开场白 | 对话开始时的问候语 | 简短友好 | +| 温度参数 | 控制回复随机性 | 0.7(通用)/ 0.3(严谨) | +| 上下文长度 | 保留的历史消息数 | 10-20 | + +### 高级选项 + +- **首轮模式** - 设置首次对话的触发方式 +- **打断检测** - 用户打断时的处理策略 +- **超时设置** - 无响应时的处理 + +## 语音配置 + +配置语音识别和语音合成参数。 + +### TTS 语音合成 + +| 配置 | 说明 | +|------|------| +| TTS 引擎 | 选择语音合成服务(阿里/火山/Minimax) | +| 音色 | 选择语音风格和性别 | +| 语速 | 语音播放速度(0.5-2.0) | +| 音量 | 语音输出音量(0-100) | +| 音调 | 语音音调高低(0.5-2.0) | + +### ASR 语音识别 + +| 配置 | 说明 | +|------|------| +| ASR 引擎 | 选择语音识别服务 | +| 语言 | 识别语言(中文/英文/多语言) | +| 热词 | 提高特定词汇识别准确率 | + +## 工具绑定 + +配置助手可调用的外部工具。 + +### 可用工具类型 + +| 工具 | 说明 | +|------|------| +| 搜索工具 | 网络搜索获取信息 | +| 天气查询 | 查询天气预报 | +| 计算器 | 数学计算 | +| 知识库检索 | RAG 知识检索 | +| 自定义工具 | HTTP 回调外部 API | + +### 配置步骤 + +1. 在工具列表中勾选需要的工具 +2. 配置工具参数(如有) +3. 测试工具调用是否正常 + +## 知识关联 + +关联 RAG 知识库,让助手能够回答专业领域问题。 + +### 配置参数 + +| 参数 | 说明 | 建议值 | +|------|------|--------| +| 知识库 | 选择要关联的知识库 | - | +| 相似度阈值 | 低于此分数不返回 | 0.7 | +| 返回数量 | 单次检索返回条数 | 3 | +| 检索策略 | 混合/向量/关键词 | 混合 | + +### 多知识库 + +支持关联多个知识库,系统会自动合并检索结果。 + +## 外部链接 + +配置第三方服务集成和 Webhook 回调。 + +### Webhook 配置 + +| 字段 | 说明 | +|------|------| +| 回调 URL | 接收事件的 HTTP 端点 | +| 事件类型 | 订阅的事件(对话开始/结束/工具调用等) | +| 认证方式 | API Key / Bearer Token / 无 | + +### 支持的事件 + +- `conversation.started` - 对话开始 +- `conversation.ended` - 对话结束 +- `tool.called` - 工具被调用 +- `human.transfer` - 转人工 + +## 配置持久化与运行时覆盖 + +助手配置分为两层: + +1. **数据库持久化配置(基线配置)**:通过助手管理 API 保存,后续会话默认读取这一层。 +2. **会话级覆盖配置(runtime overrides)**:仅对当前 WebSocket 会话生效,不会写回数据库。 + +### 哪些配置会存到数据库 + +以下字段会持久化在 `assistants` / `assistant_opener_audio` 等表中(通过创建/更新助手写入): + +| 类别 | 典型字段 | +|------|---------| +| 对话行为 | `name`、`prompt`、`opener`、`firstTurnMode`、`generatedOpenerEnabled` | +| 输出与打断 | `voiceOutputEnabled`、`voice`、`speed`、`botCannotBeInterrupted`、`interruptionSensitivity` | +| 工具与知识库 | `tools`、`knowledgeBaseId` | +| 模型与外部模式 | `configMode`、`apiUrl`、`apiKey`、`llmModelId`、`asrModelId`、`embeddingModelId`、`rerankModelId` | +| 开场音频 | `openerAudioEnabled` 及音频文件状态(`ready`、`durationMs` 等) | + +> 引擎在连接时通过 `assistant_id` 从后端读取该助手的 `sessionStartMetadata` 作为默认运行配置。 + +### 哪些配置可以在会话中覆盖 + +客户端可在 `session.start.metadata.overrides` 中覆盖以下白名单字段(仅当前会话有效): + +- `systemPrompt` +- `greeting` +- `firstTurnMode` +- `generatedOpenerEnabled` +- `output` +- `bargeIn` +- `knowledgeBaseId` +- `knowledge` +- `tools` +- `openerAudio` + +以下字段不能由客户端覆盖: + +- `services`(模型 provider / apiKey / baseUrl 等) +- `assistantId` / `appId` / `configVersionId`(及下划线变体) +- 包含密钥语义的字段(如 `apiKey`、`token`、`secret`、`password`、`authorization`) + +### 覆盖示例(代码) + +下面示例展示「数据库基线配置 + 会话 overrides」的最终效果。 + +```json +// 1) 数据库存储的基线配置(示意) +// GET /api/v1/assistants/asst_demo/config -> sessionStartMetadata +{ + "systemPrompt": "你是电商客服助手,回答要简洁。", + "greeting": "你好,我是你的客服助手。", + "firstTurnMode": "bot_first", + "output": { "mode": "audio" }, + "knowledgeBaseId": "kb_orders", + "tools": [ + { "type": "function", "function": { "name": "query_order" } } + ] +} +``` + +```json +// 2) 客户端发起会话时的覆盖 +{ + "type": "session.start", + "metadata": { + "channel": "web", + "history": { "userId": 1001 }, + "overrides": { + "greeting": "你好,我来帮你查订单进度。", + "output": { "mode": "text" }, + "knowledgeBaseId": "kb_vip_orders", + "tools": [ + { "type": "function", "function": { "name": "query_vip_order" } } + ] + } + } +} +``` + +```json +// 3) 引擎合并后的有效配置(示意) +{ + "assistantId": "asst_demo", + "systemPrompt": "你是电商客服助手,回答要简洁。", + "greeting": "你好,我来帮你查订单进度。", + "firstTurnMode": "bot_first", + "output": { "mode": "text" }, + "knowledgeBaseId": "kb_vip_orders", + "tools": [ + { "type": "function", "function": { "name": "query_vip_order" } } + ], + "channel": "web", + "history": { "userId": 1001 } +} +``` + +合并规则可简化为: + +```python +effective = {**db_session_start_metadata, **metadata.overrides} +``` + +当 `WS_EMIT_CONFIG_RESOLVED=true` 时,服务端会返回 `config.resolved`(公开、安全裁剪后的快照)用于前端调试当前生效配置。 + +## 配置导入导出 + +### 导出配置 + +1. 在助手详情页点击 **更多** +2. 选择 **导出配置** +3. 下载 JSON 格式的配置文件 + +### 导入配置 + +1. 点击 **新建助手** +2. 选择 **从配置导入** +3. 上传配置文件 diff --git a/docs/content/concepts/assistants/prompts.md b/docs/content/concepts/assistants/prompts.md new file mode 100644 index 0000000..c6ea015 --- /dev/null +++ b/docs/content/concepts/assistants/prompts.md @@ -0,0 +1,184 @@ +# 提示词指南 + +系统提示词(System Prompt)是定义助手行为的核心配置。本指南介绍如何编写高质量的提示词。 + +## 提示词结构 + +一个完整的系统提示词通常包含以下部分: + +``` +[角色定义] +[任务描述] +[行为约束] +[输出格式] +[示例(可选)] +``` + +## 编写原则 + +### 1. 明确角色 + +告诉助手它是谁: + +``` +你是一个专业的技术支持工程师,专门负责解答产品使用问题。 +``` + +### 2. 定义任务 + +明确助手需要完成什么: + +``` +你的主要任务是: +1. 解答用户关于产品功能的问题 +2. 提供使用指导和最佳实践 +3. 帮助用户排查常见故障 +``` + +### 3. 设置约束 + +限制不希望出现的行为: + +``` +请注意: +- 不要讨论与产品无关的话题 +- 不要编造不存在的功能 +- 如果不确定答案,请建议用户联系人工客服 +``` + +### 4. 指定风格 + +定义回复的语气和风格: + +``` +回复风格要求: +- 使用友好、专业的语气 +- 回答简洁明了,避免冗长 +- 适当使用列表和步骤说明 +``` + +## 提示词模板 + +### 客服助手 + +``` +你是 [公司名称] 的智能客服助手。 + +## 你的职责 +- 解答用户关于产品和服务的问题 +- 处理常见的投诉和建议 +- 引导用户完成操作流程 + +## 回复要求 +- 保持友好和耐心 +- 回答简洁,一般不超过 3 句话 +- 如果问题复杂,建议转接人工客服 + +## 禁止行为 +- 不要讨论竞争对手 +- 不要承诺无法兑现的事项 +- 不要透露内部信息 +``` + +### 技术支持 + +``` +你是一个技术支持工程师,专门帮助用户解决技术问题。 + +## 工作流程 +1. 首先了解用户遇到的具体问题 +2. 询问必要的环境信息(系统版本、错误信息等) +3. 提供分步骤的解决方案 +4. 确认问题是否解决 + +## 回复格式 +- 使用编号列表说明操作步骤 +- 提供代码示例时使用代码块 +- 复杂问题可以分多次回复 +``` + +### 销售顾问 + +``` +你是一个产品销售顾问,帮助用户了解产品并做出购买决策。 + +## 沟通策略 +- 先了解用户需求,再推荐合适的产品 +- 突出产品优势,但不贬低竞品 +- 提供真实的价格和优惠信息 + +## 目标 +- 帮助用户找到最适合的方案 +- 解答购买相关的疑问 +- 促进成交但不过度推销 +``` + +## 动态变量 + +提示词支持动态变量,使用 `{{变量名}}` 语法: + +``` +你好 {{customer_name}},欢迎来到 {{company_name}}。 +你当前的会员等级是 {{membership_tier}}。 +``` + +在 `session.start` 时通过 `dynamicVariables` 传入: + +```json +{ + "type": "session.start", + "metadata": { + "dynamicVariables": { + "customer_name": "张三", + "company_name": "AI 公司", + "membership_tier": "黄金会员" + } + } +} +``` + +## 常见问题 + +### 回复太长 + +在提示词中明确限制: + +``` +回复长度要求: +- 一般问题:1-2 句话 +- 复杂问题:不超过 5 句话 +- 避免重复和冗余内容 +``` + +### 答非所问 + +增加任务边界说明: + +``` +重要提示: +- 只回答与 [产品/服务] 相关的问题 +- 对于无关问题,礼貌地拒绝并引导回正题 +``` + +### 编造信息 + +强调诚实原则: + +``` +信息准确性要求: +- 只提供你确定的信息 +- 不确定时说"我不太确定,建议您..." +- 绝对不要编造数据或功能 +``` + +## 最佳实践 + +1. **迭代优化** - 根据实际对话效果持续调整 +2. **测试覆盖** - 用各种场景测试提示词效果 +3. **版本管理** - 保存历史版本,便于回退 +4. **定期复盘** - 分析对话记录,发现改进点 + +## 下一步 + +- [测试调试](testing.md) - 验证提示词效果 +- [知识库配置](../../customization/knowledge-base.md) - 补充专业知识 diff --git a/docs/content/concepts/assistants/testing.md b/docs/content/concepts/assistants/testing.md new file mode 100644 index 0000000..21839ac --- /dev/null +++ b/docs/content/concepts/assistants/testing.md @@ -0,0 +1,162 @@ +# 测试调试 + +本指南介绍如何测试和调试 AI 助手,确保其行为符合预期。 + +## 测试面板 + +在助手详情页,点击 **测试** 按钮打开测试面板。 + +### 功能介绍 + +| 功能 | 说明 | +|------|------| +| 文本对话 | 直接输入文字进行测试 | +| 语音测试 | 使用麦克风进行语音对话 | +| 查看日志 | 实时查看系统日志 | +| 事件追踪 | 查看 WebSocket 事件流 | + +## 测试用例设计 + +### 基础功能测试 + +| 测试项 | 输入 | 预期结果 | +|--------|------|---------| +| 问候响应 | "你好" | 友好的问候回复 | +| 功能介绍 | "你能做什么?" | 准确描述能力范围 | +| 开场白 | 连接后自动 | 播放配置的开场白 | + +### 业务场景测试 + +根据助手定位设计测试用例: + +``` +场景:产品咨询助手 + +测试用例 1:常见问题 +- 输入:"产品有哪些功能?" +- 预期:准确列出主要功能 + +测试用例 2:价格询问 +- 输入:"多少钱?" +- 预期:提供价格信息或引导方式 + +测试用例 3:超出范围 +- 输入:"帮我写一首诗" +- 预期:礼貌拒绝并引导回业务话题 +``` + +### 边界测试 + +| 测试项 | 输入 | 预期结果 | +|--------|------|---------| +| 空输入 | "" | 提示用户输入内容 | +| 超长输入 | 1000+ 字符 | 正常处理或提示过长 | +| 特殊字符 | "" | 安全处理,不执行 | +| 敏感内容 | 不当言论 | 拒绝回复并提示 | + +## 日志分析 + +### 查看日志 + +在测试面板的 **日志** 标签页,可以看到: + +- ASR 识别结果 +- LLM 推理过程 +- TTS 合成状态 +- 工具调用记录 + +### 常见日志 + +``` +[ASR] transcript.final: "你好,请问有什么可以帮你" +[LLM] request: messages=[...] +[LLM] response: "您好!我是..." +[TTS] synthesizing: "您好!我是..." +[TTS] audio.start +[TTS] audio.end +``` + +## 事件追踪 + +在 **事件** 标签页查看完整的 WebSocket 事件流: + +```json +{"type": "session.started", "timestamp": 1704067200000} +{"type": "input.speech_started", "timestamp": 1704067201000} +{"type": "transcript.delta", "data": {"text": "你"}} +{"type": "transcript.delta", "data": {"text": "好"}} +{"type": "transcript.final", "data": {"text": "你好"}} +{"type": "assistant.response.delta", "data": {"text": "您"}} +{"type": "assistant.response.final", "data": {"text": "您好!..."}} +{"type": "output.audio.start"} +{"type": "output.audio.end"} +``` + +## 性能指标 + +关注以下性能指标: + +| 指标 | 说明 | 建议值 | +|------|------|--------| +| TTFB | 首字节时间 | < 500ms | +| 识别延迟 | ASR 处理时间 | < 1s | +| 回复延迟 | LLM 推理时间 | < 2s | +| 合成延迟 | TTS 处理时间 | < 500ms | + +## 常见问题排查 + +### 助手不响应 + +1. **检查连接状态** + - 确认 WebSocket 连接成功 + - 查看是否收到 `session.started` 事件 + +2. **检查模型配置** + - 确认 LLM 模型 API Key 有效 + - 测试模型连接是否正常 + +3. **查看错误日志** + - 打开浏览器开发者工具 + - 检查 Console 和 Network 标签 + +### 回复质量差 + +1. **优化提示词** + - 增加更明确的指令 + - 添加示例和约束 + +2. **调整温度参数** + - 降低 temperature 提高一致性 + - 适当值通常在 0.3-0.7 + +3. **补充知识库** + - 上传相关文档 + - 提高检索相关性 + +### 语音问题 + +1. **ASR 识别不准** + - 检查麦克风权限 + - 尝试更换 ASR 引擎 + - 添加热词提高识别率 + +2. **TTS 不播放** + - 检查浏览器自动播放限制 + - 确认 TTS 配置正确 + +## 自动化测试 + +使用自动化测试功能进行批量测试: + +1. 进入 **自动化测试** 页面 +2. 创建测试任务 +3. 配置测试用例 +4. 运行测试并查看报告 + +详见 [自动化测试](../../analysis/autotest.md)。 + +## 下一步 + +- [自动化测试](../../analysis/autotest.md) - 批量测试 +- [历史记录](../../analysis/history.md) - 查看对话记录 +- [效果评估](../../analysis/evaluation.md) - 评估对话质量 diff --git a/docs/content/concepts/engines.md b/docs/content/concepts/engines.md index 16d06af..400d0cd 100644 --- a/docs/content/concepts/engines.md +++ b/docs/content/concepts/engines.md @@ -1,349 +1,107 @@ -# 引擎架构详解 +# 引擎架构 -深入了解 RAS 的两种引擎架构:管线式引擎和多模态引擎。 +RAS 提供两类实时运行时:**Pipeline 引擎** 和 **Realtime 引擎**。本页只回答一个问题:你的助手应该跑在哪种引擎上。 --- -## 引擎概述 +## 先记住这条判断标准 -引擎是 RAS 的核心,负责处理实时语音交互。根据不同需求,可以选择两种架构: +- 如果你优先考虑 **可控性、可替换性、成本管理、工具 / 知识 / 流程编排**,优先选 **Pipeline 引擎** +- 如果你优先考虑 **超低延迟、更自然的端到端语音体验**,优先选 **Realtime 引擎** -| 架构 | 特点 | 适用场景 | -|------|------|---------| -| **管线式** | 灵活、可定制、成本可控 | 大多数场景 | -| **多模态** | 低延迟、自然、简单 | 高端体验场景 | +## 两类引擎的区别 ---- +| 维度 | Pipeline 引擎 | Realtime 引擎 | +|------|---------------|---------------| +| **交互路径** | VAD → ASR → TD → LLM → TTS | 端到端实时模型 | +| **可控性** | 高,每个环节可替换 | 中,更多依赖模型供应商 | +| **延迟** | 中等,通常由多环节累加 | 低,链路更短 | +| **能力编排** | 更适合接入工具、知识库、工作流 | 也可接工具,但流程可控性较弱 | +| **成本结构** | 可按环节优化 | 往往更依赖单一供应商定价 | +| **适合场景** | 企业客服、流程型助手、电话场景、知识问答 | 高拟真语音助手、多模态入口、高自然度体验 | -## 管线式引擎 (Pipeline) +## Pipeline 引擎是什么 -### 架构设计 - -管线式引擎包含 **声音活动检测(VAD)**、**语音识别(ASR)**、**回合检测(TD)**、**大语言模型(LLM)**、**语音合成(TTS)**,各环节可对接**外部服务**(OpenAI、SiliconFlow、DashScope、本地模型)。LLM 可连接**工具**(Webhook、客户端工具、内建工具)。 +Pipeline 引擎把实时语音拆成多个明确环节: ```mermaid flowchart LR - subgraph Input["输入处理"] - Audio[用户音频] --> VAD[声音活动检测 VAD] - VAD --> ASR[语音识别 ASR] - ASR --> Text[转写文本] - Text --> TD[回合检测 TD] - end - - subgraph Process["语义处理"] - TD --> LLM[大语言模型 LLM] - LLM --> Response[回复文本] - LLM --> Tools[工具] - end - - subgraph Output["输出生成"] - Response --> TTS[语音合成 TTS] - TTS --> OutputAudio[助手音频] - end + VAD[VAD] --> ASR[ASR] + ASR --> TD[回合检测] + TD --> LLM[LLM] + LLM --> TTS[TTS] ``` -### 数据流详解 +这样做的好处是: -```mermaid -sequenceDiagram - participant U as 用户 - participant E as 引擎 - participant ASR as ASR 服务 - participant LLM as LLM 服务 - participant TTS as TTS 服务 +- 你可以分别选择 ASR、LLM、TTS 的供应商 +- 你可以单独优化某一个环节,而不是整体替换 +- 工具、知识库和工作流更容易插入到链路中 - U->>E: 音频帧 (PCM 16kHz) - - Note over E: VAD 检测语音活动 - E->>E: 累积音频缓冲 - - Note over E: 回合检测 (TD) 确定可送 LLM 的输入 - E->>ASR: 发送音频 - ASR-->>E: 转写文本 (流式) - E-->>U: transcript.delta - E-->>U: transcript.final - - E->>LLM: 发送对话历史 + 用户输入 - LLM-->>E: 回复文本 (流式) - E-->>U: assistant.response.delta - - loop 流式合成 - E->>TTS: 文本片段 - TTS-->>E: 音频片段 - E-->>U: 音频帧 - end - - E-->>U: assistant.response.final -``` +代价是: -### 延迟分析 +- 延迟会累加 +- 系统集成更复杂 +- 你需要同时管理多类外部依赖 -管线式引擎的延迟由各环节累加: +## Realtime 引擎是什么 -| 环节 | 典型延迟 | 优化方向 | -|------|---------|---------| -| VAD/EOU | 200-500ms | 调整灵敏度 | -| ASR | 100-300ms | 选择快速模型 | -| LLM TTFT | 200-500ms | 选择低延迟模型 | -| TTS | 100-200ms | 流式合成 | -| **总计** | **600-1500ms** | - | - -### 流式优化 - -为降低感知延迟,采用流式处理: - -```mermaid -gantt - title 非流式 vs 流式处理 - dateFormat X - axisFormat %s - - section 非流式 - ASR完成 :a1, 0, 300ms - LLM完成 :a2, after a1, 800ms - TTS完成 :a3, after a2, 500ms - 播放 :a4, after a3, 500ms - - section 流式 - ASR :b1, 0, 300ms - LLM开始 :b2, after b1, 200ms - TTS开始 :b3, after b2, 100ms - 边生成边播放 :b4, after b3, 600ms -``` - ---- - -## 实时交互引擎与多模态 - -### 实时交互引擎连接 - -实时交互引擎可连接**实时交互引擎**后端,包括: - -| 后端 | 说明 | -|------|------| -| **OpenAI Realtime** | OpenAI 实时语音模型 | -| **Gemini Live** | Google 实时多模态 | -| **Doubao 实时交互引擎** | 豆包实时交互 | - -实时交互引擎与管线式引擎中的 LLM 一样,均可连接**工具**:Webhook、客户端工具、内建工具。 - -### 多模态引擎架构 - -多模态引擎使用端到端模型,直接处理音频输入输出: +Realtime 引擎直接连接端到端实时模型,让模型同时处理输入、理解、生成与打断。 ```mermaid flowchart LR - subgraph Client["客户端"] - Mic[麦克风] --> AudioIn[音频输入] - AudioOut[音频输出] --> Speaker[扬声器] - end - - subgraph Engine["引擎"] - AudioIn --> RT[Realtime Model] - RT --> AudioOut - RT --> Tools[工具] - end - - subgraph Model["实时交互引擎"] - RT --> GPT4o[OpenAI Realtime] - RT --> Gemini[Gemini Live] - RT --> Doubao[Doubao 实时] - end + Input[音频 / 视频 / 文本输入] --> RT[Realtime Model] + RT --> Output[音频 / 文本输出] + RT --> Tools[工具] ``` -### 数据流详解 +这样做的好处是: -```mermaid -sequenceDiagram - participant U as 用户 - participant E as 引擎 - participant RT as Realtime Model +- 链路更短,延迟更低 +- 全双工与打断通常更自然 +- 接入路径更简单,适合强调体验的入口 - U->>E: 音频帧 - E->>RT: 转发音频 - - Note over RT: 端到端处理 - - RT-->>E: 音频响应 (流式) - E-->>U: 播放音频 - - Note over U,RT: 支持全双工
用户可随时打断 -``` +代价是: -### 外部服务(管线式) +- 更依赖特定模型供应商 +- 对 ASR / TTS / 回合检测的独立控制更弱 +- 成本和能力边界受实时模型限制更大 -管线式引擎各环节可选用以下**外部服务**: +## 怎么选 -| 服务 | 说明 | -|------|------| -| **OpenAI** | LLM / ASR / TTS 等 | -| **SiliconFlow** | 国内 API 服务 | -| **DashScope** | 阿里云灵积 | -| **本地模型** | 私有化部署模型 | +### 适合选择 Pipeline 的情况 -### 支持的实时交互模型 +- 你要接入特定 ASR 或 TTS 供应商 +- 你需要知识库、工具、工作流形成稳定业务流程 +- 你更在意可解释性、观测和分段优化 +- 你需要把成本按环节精细控制 -| 模型 | 供应商 | 特点 | -|------|--------|------| -| **OpenAI Realtime** | OpenAI | 最自然的语音,延迟极低 | -| **Gemini Live** | Google | 多模态能力强 | -| **Doubao 实时交互** | 字节跳动 | 国内可用,中文优化 | +### 适合选择 Realtime 的情况 -### 延迟对比 +- 你把“自然对话感”放在首位 +- 你需要更低的首响和更顺滑的打断体验 +- 你可以接受对某个模型供应商的依赖 +- 你的场景更接近语音助手、陪练、虚拟角色或多模态入口 -```mermaid -xychart-beta - title "端到端延迟对比" - x-axis ["管线式 (普通)", "管线式 (优化)", "多模态"] - y-axis "延迟 (ms)" 0 --> 1500 - bar [1200, 700, 300] -``` +## 简化决策表 ---- +| 场景 | 推荐引擎 | 原因 | +|------|----------|------| +| 企业客服 / 电话机器人 | Pipeline | 可控、可审计、易接工具与业务系统 | +| 知识问答 / 业务流程助手 | Pipeline | 更适合接知识库与工作流 | +| 高拟真语音助手 | Realtime | 更自然、更低延迟 | +| 多模态入口 | Realtime | 端到端处理音频 / 视频 / 文本 | +| 预算敏感场景 | Pipeline | 更容易逐环节优化成本 | -## 智能打断机制 +## 智能打断的差异 -两种引擎都支持智能打断,但实现方式不同。 +两类引擎都支持打断,但边界不同: -### 管线式引擎打断 +- **Pipeline**:由 VAD / 回合检测与 TTS 停止逻辑协同实现,行为更可控 +- **Realtime**:更多由实时模型内部完成,体验更自然,但可解释性更低 -```mermaid -sequenceDiagram - participant U as 用户 - participant E as 引擎 - participant TTS as TTS +## 继续阅读 - Note over E,TTS: TTS 正在合成播放 - E->>U: 音频帧... - - U->>E: 用户说话 (检测到 VAD) - E->>E: 判断是否有效打断 - - alt 有效打断 - E->>TTS: 停止合成 - E->>E: 清空音频缓冲 - E-->>U: output.audio.interrupted - Note over E: 处理新输入 - else 噪音/误触发 - Note over E: 继续播放 - end -``` - -### 多模态引擎打断 - -多模态模型原生支持全双工,打断由模型内部处理: - -```mermaid -sequenceDiagram - participant U as 用户 - participant E as 引擎 - participant RT as Realtime Model - - Note over RT: 模型正在输出 - RT-->>E: 音频流... - E-->>U: 播放 - - U->>E: 用户说话 - E->>RT: 转发用户音频 - - Note over RT: 模型检测到打断
自动停止输出 - - RT-->>E: 新的响应 - E-->>U: 播放新响应 -``` - ---- - -## 引擎选择指南 - -### 决策流程 - -```mermaid -flowchart TD - Start[选择引擎] --> Q1{延迟要求?} - - Q1 -->|< 500ms| Q2{预算充足?} - Q1 -->|> 500ms 可接受| Pipeline[管线式引擎] - - Q2 -->|是| Q3{模型可用?} - Q2 -->|否| Pipeline - - Q3 -->|GPT-4o/Gemini 可用| Multimodal[多模态引擎] - Q3 -->|国内环境受限| Q4{Step Audio?} - - Q4 -->|可用| Multimodal - Q4 -->|不可用| Pipeline -``` - -### 场景推荐 - -| 场景 | 推荐引擎 | 理由 | -|------|---------|------| -| **企业客服** | 管线式 | 成本可控,可定制 ASR | -| **高端虚拟人** | 多模态 | 最自然的交互体验 | -| **电话机器人** | 管线式 | 可对接电信 ASR | -| **语音助手** | 多模态 | 低延迟,自然对话 | -| **口语练习** | 管线式 | 需要精确的 ASR 评分 | - -### 混合方案 - -也可以根据用户等级使用不同引擎: - -```mermaid -flowchart LR - User[用户请求] --> Router{路由判断} - - Router -->|VIP 用户| Multimodal[多模态引擎] - Router -->|普通用户| Pipeline[管线式引擎] - - Multimodal --> Response[响应] - Pipeline --> Response -``` - ---- - -## 配置示例 - -### 管线式引擎配置 - -```json -{ - "engine": "pipeline", - "asr": { - "provider": "openai-compatible", - "model": "FunAudioLLM/SenseVoiceSmall", - "language": "zh" - }, - "llm": { - "provider": "openai", - "model": "gpt-4o-mini", - "temperature": 0.7 - }, - "tts": { - "provider": "openai-compatible", - "model": "FunAudioLLM/CosyVoice2-0.5B", - "voice": "anna" - } -} -``` - -### 多模态引擎配置 - -```json -{ - "engine": "multimodal", - "model": { - "provider": "openai", - "model": "gpt-4o-realtime-preview", - "voice": "alloy" - } -} -``` - ---- - -## 相关文档 - -- [系统架构](../overview/architecture.md) - 整体架构设计 -- [WebSocket 协议](../api-reference/websocket.md) - 协议详情 -- [部署指南](../deployment/index.md) - 引擎部署配置 +- [Pipeline 引擎](pipeline-engine.md) - 查看分段链路、延迟构成与配置示例 +- [Realtime 引擎](realtime-engine.md) - 查看端到端实时模型的交互路径 +- [系统架构](../overview/architecture.md) - 从服务边界理解引擎在整体系统中的位置 diff --git a/docs/content/concepts/index.md b/docs/content/concepts/index.md index 43df59b..2051b80 100644 --- a/docs/content/concepts/index.md +++ b/docs/content/concepts/index.md @@ -1,296 +1,49 @@ -# 核心概念 +# 核心概念 -本章节介绍 Realtime Agent Studio 中的核心概念,帮助你更好地理解和使用平台。 +本章节只解释 Realtime Agent Studio 的关键心智模型,不重复环境部署或助手构建的操作细节。 --- -## 概念总览 +## 先建立这三个概念 -```mermaid -flowchart TB - subgraph Platform["RAS 平台"] - Assistant[助手 Assistant] - - subgraph Resources["资源库"] - LLM[LLM 模型] - ASR[ASR 模型] - TTS[TTS 声音] - KB[知识库] - end - - subgraph Engine["交互引擎"] - Pipeline[Pipeline引擎] - Multimodal[Realtime引擎] - end - - Session[会话 Session] - end +### 1. 助手是“对外提供能力的配置单元” - Assistant --> LLM - Assistant --> ASR - Assistant --> TTS - Assistant --> KB - Assistant --> Engine - Engine --> Session -``` +助手决定了一个实时 AI 入口对外表现成什么角色:它使用什么提示词、哪些模型、能访问哪些知识和工具、会话如何开始以及运行时如何被覆盖。 + +- [助手概念](assistants.md) — 统一理解助手、会话、动态变量与能力边界 +- [配置选项](assistants/configuration.md) — 了解界面层和运行时配置项如何分工 +- [提示词指南](assistants/prompts.md) — 学会定义助手的角色、任务、风格与约束 +- [测试调试](assistants/testing.md) — 理解如何验证助手行为和定位问题 + +### 2. 引擎是“承载实时交互的运行时” + +RAS 同时提供 Pipeline 引擎与 Realtime 引擎。它们都能驱动实时助手,但在延迟、可控性、成本和可替换性上各有取舍。 + +- [引擎概览](engines.md) — 两类引擎的能力边界与选择建议 +- [Pipeline 引擎](pipeline-engine.md) — VAD/ASR/TD/LLM/TTS 串联的可组合链路 +- [Realtime 引擎](realtime-engine.md) — 面向端到端实时模型的低延迟交互路径 + +### 3. 工作流是“把复杂业务拆成步骤和分支的方法” + +当单一提示词不足以稳定处理多步骤、多条件、多工具的业务流程时,应使用工作流来显式编排节点、路由和回退策略。 + +- [工作流](../customization/workflows.md) — 了解何时需要工作流、它由哪些部分组成、如何设计可维护的流程 --- -## 助手 (Assistant) +## 本章节不负责什么 -**助手**是 RAS 的核心实体,代表一个可对话的 AI 智能体。 +以下内容属于“如何搭建和使用”,不在本章节展开说明: -### 助手配置 +- 助手搭建、模型/知识库/工具/工作流配置:从 [助手概览](assistants.md) 进入构建链路 +- 部署与环境变量:见 [环境与部署](../getting-started/index.md) +- 第一个助手的最短操作路径:见 [快速开始](../quickstart/index.md) +- 事件格式与接入协议:见 [API 参考](../api-reference/index.md) -每个助手包含以下配置: +## 建议阅读顺序 -| 配置项 | 说明 | -|-------|------| -| **名称** | 助手的显示名称 | -| **指令配置** | 使用提示词指令定义助手角色、行为、限制 | -| **语音设置** | 包括语音识别模型,语音合成模型 | -| **交互设置** | 包括用户打断机器人的灵敏度,检测用户语句结束的灵敏度 | -| **工具配置** | 配置助手可调用的外部工具 | -| **知识配置** | 关联的知识库(用于 RAG) | -| **Webhooks** | 用于订阅助手的活动 | +1. 先读 [助手概念](assistants.md),明确你要配置的对象到底是什么 +2. 再读 [引擎概览](engines.md),决定应该选择 Pipeline 还是 Realtime +3. 如果场景涉及多步骤流程,再读 [工作流](../customization/workflows.md) +4. 最后回到 [快速开始](../quickstart/index.md) 或 [助手概览](assistants.md) 开始具体配置 ---- - -## 会话 (Session) - -**会话**代表一次完整的对话交互,从用户连接到断开。 - -### 会话状态 - -```mermaid -stateDiagram-v2 - [*] --> Connecting: WebSocket 连接 - Connecting --> Started: session.started - Started --> Active: 对话中 - Active --> Active: 多轮对话 - Active --> Stopped: session.stop - Stopped --> [*]: 连接关闭 -``` - -### 会话数据 - -每个会话记录包含: - -- **基本信息** - ID、时长、时间戳 -- **音频数据** - 用户和助手的音频记录 -- **转写文本** - ASR 识别结果 -- **LLM 交互** - 输入输出和工具调用 -- **元数据** - 渠道、来源、自定义变量 - ---- - -## Pipeline引擎 vs Realtime引擎 - -RAS 支持两种引擎架构,适用于不同场景。 - -### 管线式引擎 (Pipeline) - -将语音交互拆分为多个环节,包含 **VAD(声音活动检测)**、**ASR(语音识别)**、**TD(回合检测)**、**LLM(大语言模型)**、**TTS(语音合成)**。外部服务可选 **OpenAI**、**SiliconFlow**、**DashScope**、**本地模型**。LLM 与实时交互引擎均可连接**工具**(Webhook、客户端工具、内建工具)。 - -``` -用户语音 → [VAD] → [ASR] → [TD] → 文本 → [LLM] → 回复 → [TTS] → 助手语音 -``` - -**优点:** - -- 灵活选择各环节供应商(OpenAI、SiliconFlow、DashScope、本地模型) -- 可独立优化 VAD、ASR、TD、LLM、TTS 每个环节 -- 成本可控 - -**缺点:** - -- 延迟较高(累加延迟) -- 需要协调多个服务 - -### 实时交互引擎 (Realtime) - -实时交互引擎可连接 **OpenAI Realtime**、**Gemini Live**、**Doubao 实时交互引擎** 等,同样可连接工具。使用端到端模型直接处理: - -``` -用户语音 → [Realtime Model] → 助手语音 -``` - -**优点:** - -- 更低延迟 -- 更自然的语音 -- 架构简单 - -**缺点:** - -- 依赖特定供应商 -- 成本较高 -- 可定制性有限 - -### 选择建议 - -| 场景 | 推荐引擎 | -|------|---------| -| 成本敏感 | 管线式 | -| 延迟敏感 | 多模态 | -| 需要特定 ASR/TTS | 管线式 | -| 追求最自然体验 | 多模态 | - ---- - -## 智能打断 (Barge-in) - -**智能打断**是指用户在助手说话时可以随时插话,系统能够: - -1. 检测用户开始说话 -2. 立即停止 TTS 播放 -3. 处理用户新的输入 - -### 打断检测方式 - -| 方式 | 说明 | -|------|------| -| **VAD** | Voice Activity Detection,检测到声音活动即打断 | -| **语义** | 基于语音内容判断是否有意义的打断 | -| **混合** | VAD + 语义结合,减少误触发 | - -### 打断流程 - -```mermaid -sequenceDiagram - participant User as 用户 - participant Engine as 引擎 - participant TTS as TTS - - Note over Engine,TTS: 助手正在播放回复 - Engine->>User: 音频流... - User->>Engine: 开始说话 (VAD 触发) - Engine->>Engine: 打断判断 - Engine->>TTS: 停止合成 - Engine->>User: output.audio.interrupted - Note over Engine: 处理新输入 -``` - -## 用户语句端点(EoU End-of-Utterance) - -**用户语句端点(EoU)** 指系统判断「用户已经说完」的时刻。在管线式引擎中,只有检测到 EoU 后,才会把当前轮次的转写文本送给 LLM 并触发回复,避免用户短暂停顿时就误判为说完。 - -### 检测方式 - -EoU 基于 **VAD(声音活动检测)** 的输出:在用户**连续静音**达到设定时长后触发一次 EoU。若静音期间用户再次说话,静音计时会重置,因此句间短暂停顿不会触发 EoU,只有用户真正停止说话后才触发。 - -| 概念 | 说明 | -|------|------| -| **静音阈值** | 连续静音超过该时长(毫秒)即判定为 EoU,对应配置如 `vad_eou_threshold_ms`(默认约 800ms) | -| **最短语音** | 若语音过短(如杂音),不触发 EoU,避免误判 | -| **一次一轮** | 每轮用户输入只产生一次 EoU,之后需重新检测语音再静音才会再次触发 | - -### 在管线中的位置 - -``` -用户语音 → [VAD] → [EoU 检测] → 静音达阈值 → 文本送 LLM → 回复 → [TTS] -``` - -助手配置中的 **「检测用户语句结束的灵敏度」** 即对应 EoU 的静音阈值:阈值越小,越容易判定为「说完」,响应更快但易在用户思考或短暂停顿时误触发;阈值越大,更稳但响应会稍慢。 - ---- - -## 工具调用 (Tool Calling) - -助手可以通过**工具**扩展能力,访问外部系统或执行特定操作。 - -### 工具类型 - -管线式引擎中的 LLM 与实时交互引擎均可连接**工具**,包括: - -| 类型 | 说明 | 示例 | -|------|------|------| -| **Webhook** | 调用外部 HTTP API | 查询订单、预约日程 | -| **客户端工具** | 由客户端执行的操作 | 获取客户端地理位置、请求用户同意、打开客户端相机 | -| **内建工具** | 平台提供的工具 | 代码执行、计算器 | - -### 工具调用流程 - -```mermaid -sequenceDiagram - participant User as 用户 - participant LLM as LLM - participant Tool as 工具 - - User->>LLM: "帮我查一下订单状态" - LLM->>LLM: 决定调用工具 - LLM->>Tool: get_order_status(order_id) - Tool-->>LLM: {status: "已发货"} - LLM->>User: "您的订单已发货" -``` - ---- - -## 知识库 (Knowledge Base) - -**知识库**让助手能够基于私有文档回答问题,实现 RAG(检索增强生成)。 - -### 工作原理 - -```mermaid -flowchart LR - subgraph Indexing["索引阶段"] - Doc[文档] --> Chunk[分块] - Chunk --> Embed[向量化] - Embed --> Store[(向量数据库)] - end - - subgraph Query["查询阶段"] - Q[用户问题] --> QEmbed[问题向量化] - QEmbed --> Search[相似度搜索] - Store --> Search - Search --> Context[相关上下文] - Context --> LLM[LLM 生成回答] - end -``` - -### 支持的文档格式 - -- PDF -- Word (.docx) -- Markdown -- 纯文本 -- HTML - ---- - -## 动态变量 - -**动态变量**允许在运行时向助手注入上下文信息。 - -### 使用方式 - -在系统提示词中使用 `{{variable}}` 占位符: - -``` -你是{{company_name}}的客服助手。 -当前用户是{{customer_name}},会员等级为{{tier}}。 -``` - -连接时通过 `dynamicVariables` 传入: - -```json -{ - "type": "session.start", - "metadata": { - "dynamicVariables": { - "company_name": "ABC 公司", - "customer_name": "张三", - "tier": "VIP" - } - } -} -``` - ---- - -## 下一步 - -- [快速开始](../quickstart/index.md) - 创建第一个助手 -- [助手配置](../assistants/configuration.md) - 详细配置说明 -- [WebSocket 协议](../api-reference/websocket.md) - API 接口详情 diff --git a/docs/content/concepts/pipeline-engine.md b/docs/content/concepts/pipeline-engine.md new file mode 100644 index 0000000..1f4d5e5 --- /dev/null +++ b/docs/content/concepts/pipeline-engine.md @@ -0,0 +1,137 @@ +# Pipeline 引擎 + +Pipeline 引擎把实时对话拆成多个清晰环节,适合需要高可控性、可替换外部能力和复杂业务编排的场景。 + +--- + +## 运行链路 + +```mermaid +flowchart LR + subgraph Input["输入处理"] + Audio[用户音频] --> VAD[声音活动检测 VAD] + VAD --> ASR[语音识别 ASR] + ASR --> TD[回合检测 TD] + end + + subgraph Reasoning["语义处理"] + TD --> LLM[大语言模型 LLM] + LLM --> Tools[工具] + LLM --> Text[回复文本] + end + + subgraph Output["输出生成"] + Text --> TTS[语音合成 TTS] + TTS --> AudioOut[助手音频] + end +``` + +Pipeline 的关键价值不在于“环节多”,而在于每个环节都可以被单独选择、单独优化、单独观测。 + +## 它适合什么场景 + +- 需要接特定 ASR / TTS 供应商 +- 需要稳定接入知识库、工具和工作流 +- 需要把问题定位到具体环节,而不是只看到整体失败 +- 需要按延迟、成本、质量对不同环节分别优化 + +## 数据流 + +```mermaid +sequenceDiagram + participant U as 用户 + participant E as 引擎 + participant ASR as ASR 服务 + participant LLM as LLM 服务 + participant TTS as TTS 服务 + + U->>E: 音频帧 (PCM) + E->>E: VAD / 回合检测 + E->>ASR: 发送可识别音频 + ASR-->>E: transcript.delta / transcript.final + E->>LLM: 发送对话历史与当前输入 + LLM-->>E: assistant.response.delta + E->>TTS: 文本片段 + TTS-->>E: 音频片段 + E-->>U: 音频流与事件 +``` + +## 延迟来自哪里 + +| 环节 | 典型影响 | 常见优化点 | +|------|----------|------------| +| **VAD / EoU** | 用户说完后多久触发回复 | 调整静音阈值和最短语音门限 | +| **ASR** | 语音转写速度和准确率 | 选择合适模型、热词和语言设置 | +| **LLM** | 首个 token 返回速度 | 选择低延迟模型、优化上下文 | +| **TTS** | 文字到音频的生成速度 | 选择流式 TTS,缩短单次回复 | + +Pipeline 的总延迟通常不是单点问题,而是链路总和。因此更适合做“逐环节调优”。 + +## EoU(用户说完)为什么重要 + +Pipeline 必须决定“什么时候把当前轮输入正式交给 LLM”。这个判断通常由 **EoU** 完成。 + +- 阈值小:响应更快,但更容易把用户停顿误判为说完 +- 阈值大:更稳,但首次响应会更慢 + +你可以把它理解为 Pipeline 中最直接影响“对话节奏感”的参数之一。 + +## 工具、知识库和工作流如何插入 + +Pipeline 特别适合把业务能力插入到对话中: + +- **知识库**:在 LLM 生成前补充领域事实 +- **工具**:在需要外部信息或动作时调用系统能力 +- **工作流**:在多步骤、多分支流程中决定接下来走哪个节点 + +这也是它在企业客服、流程助手和知识问答场景中更常见的原因。 + +## 智能打断 + +在 Pipeline 中,打断通常由 VAD 检测和 TTS 停止逻辑协同完成: + +```mermaid +sequenceDiagram + participant U as 用户 + participant E as 引擎 + participant TTS as TTS + + Note over E,TTS: 正在播放回复 + E->>U: 音频流... + U->>E: 用户开始说话 + E->>E: 判定是否触发打断 + E->>TTS: 停止合成 / 播放 + E-->>U: output.audio.interrupted +``` + +相比端到端实时模型,这种方式更容易解释“为什么打断”以及“在哪个环节发生了问题”。 + +## 配置示例 + +```json +{ + "engine": "pipeline", + "asr": { + "provider": "openai-compatible", + "model": "FunAudioLLM/SenseVoiceSmall", + "language": "zh" + }, + "llm": { + "provider": "openai", + "model": "gpt-4o-mini", + "temperature": 0.7 + }, + "tts": { + "provider": "openai-compatible", + "model": "FunAudioLLM/CosyVoice2-0.5B", + "voice": "anna" + } +} +``` + +## 相关文档 + +- [引擎架构](engines.md) - 回到选择指南 +- [Realtime 引擎](realtime-engine.md) - 对比端到端实时模型路径 +- [工具](../customization/tools.md) - 设计可被 LLM 安全调用的工具 +- [知识库](../customization/knowledge-base.md) - 在对话中补充领域知识 diff --git a/docs/content/concepts/realtime-engine.md b/docs/content/concepts/realtime-engine.md new file mode 100644 index 0000000..757bd7b --- /dev/null +++ b/docs/content/concepts/realtime-engine.md @@ -0,0 +1,97 @@ +# Realtime 引擎 + +Realtime 引擎直接连接端到端实时模型,适合把低延迟和自然语音体验放在第一位的场景。 + +--- + +## 运行链路 + +```mermaid +flowchart LR + Input[音频 / 视频 / 文本输入] --> RT[Realtime Model] + RT --> Output[音频 / 文本输出] + RT --> Tools[工具] +``` + +与 Pipeline 不同,Realtime 引擎不会把 ASR、回合检测、LLM、TTS 作为独立阶段暴露出来,而是更多依赖实时模型整体处理。 + +## 常见后端 + +| 后端 | 特点 | +|------|------| +| **OpenAI Realtime** | 语音交互自然,延迟低 | +| **Gemini Live** | 多模态能力强 | +| **Doubao 实时交互** | 更适合国内环境与中文场景 | + +## 它适合什么场景 + +- 语音助手、陪练、虚拟角色等高自然度体验场景 +- 对首响和连续打断体验要求高的入口 +- 希望减少链路拼装复杂度,直接接入端到端模型的团队 + +## 数据流 + +```mermaid +sequenceDiagram + participant U as 用户 + participant E as 引擎 + participant RT as Realtime Model + + U->>E: 音频 / 视频 / 文本输入 + E->>RT: 转发实时流 + RT-->>E: 流式文本 / 音频输出 + E-->>U: 播放或渲染结果 +``` + +## Realtime 的优势 + +- **延迟更低**:链路更短,用户感知更自然 +- **全双工更顺滑**:用户插话时,模型更容易在内部处理打断 +- **多模态更直接**:适合音频、视频、文本混合输入输出场景 + +## Realtime 的取舍 + +- 更依赖实时模型供应商的能力边界 +- 不容易对 ASR / TTS / 回合检测做独立替换 +- 成本和可观测性往往不如 Pipeline 那样可逐环节拆分 + +## 智能打断 + +Realtime 模型通常原生支持全双工和打断: + +```mermaid +sequenceDiagram + participant U as 用户 + participant E as 引擎 + participant RT as Realtime Model + + Note over RT: 模型正在输出 + RT-->>E: 音频流... + E-->>U: 播放 + U->>E: 用户开始说话 + E->>RT: 转发新输入 + Note over RT: 模型内部处理中断并切换回复 + RT-->>E: 新的响应 + E-->>U: 播放新响应 +``` + +这种方式更自然,但你通常只能看到模型的整体行为,而不是每个中间阶段的细节。 + +## 配置示例 + +```json +{ + "engine": "multimodal", + "model": { + "provider": "openai", + "model": "gpt-4o-realtime-preview", + "voice": "alloy" + } +} +``` + +## 相关文档 + +- [引擎架构](engines.md) - 回到两类引擎的选择指南 +- [Pipeline 引擎](pipeline-engine.md) - 查看分段可控的运行路径 +- [WebSocket 协议](../api-reference/websocket.md) - 了解客户端如何与引擎建立会话 diff --git a/docs/content/customization/asr.md b/docs/content/customization/asr.md index 2251804..56f51cc 100644 --- a/docs/content/customization/asr.md +++ b/docs/content/customization/asr.md @@ -1,24 +1,31 @@ -# 语音识别 +# 语音识别 -语音识别(ASR)负责将用户音频实时转写为文本,供对话引擎理解。 +语音识别(ASR)负责把用户音频实时转写成文本,供引擎继续理解和处理。 -## 配置项 +## 关键配置项 | 配置项 | 说明 | -|---|---| -| ASR 引擎 | 选择语音识别服务提供商 | -| 模型 | 识别模型名称 | -| 语言 | 中文/英文/多语言 | -| 热词 | 提升特定词汇识别准确率 | -| 标点与规范化 | 是否自动补全标点、文本规范化 | +|--------|------| +| **ASR 引擎** | 选择语音识别服务提供商或自建服务 | +| **模型** | 实际使用的识别模型名称 | +| **语言** | 中文、英文或多语言 | +| **热词** | 提高业务词汇、品牌词、专有名词识别率 | +| **标点与规范化** | 自动补全标点、规范数字和日期等 | -## 建议 +## 选择建议 -- 客服场景建议开启热词并维护业务词表 -- 多语言场景建议按会话入口显式指定语言 -- 对延迟敏感场景优先选择流式识别模型 +- 客服、外呼等业务场景建议维护热词表,并按业务线持续更新 +- 多语言入口建议显式指定语言,避免模型自动判断带来的波动 +- 对延迟敏感的场景优先选择流式识别模型 +- 对准确率敏感的场景,先评估专有名词、数字、地址等样本的识别表现 + +## 运行建议 + +- 使用与接入端一致的采样率和编码方式,减少额外转换 +- 在测试阶段准备固定样本,便于对比不同模型或参数的变化 +- 把“识别准确率”和“识别延迟”一起看,不要只看其中一项 ## 相关文档 -- [语音配置总览](voices.md) - +- [声音资源](voices.md) - 完整语音输入输出链路中的 TTS 侧配置 +- [快速开始](../quickstart/index.md) - 以任务路径接入第一个 ASR 资源 diff --git a/docs/content/customization/knowledge-base.md b/docs/content/customization/knowledge-base.md index 8678f6a..1b742b2 100644 --- a/docs/content/customization/knowledge-base.md +++ b/docs/content/customization/knowledge-base.md @@ -1,53 +1,86 @@ -# 知识库 +# 知识库 -知识库基于 RAG(检索增强生成)技术,让 AI 能够回答私有领域问题。 +知识库负责承载助手需要引用的私有事实、业务资料和长文档内容,是 RAG(检索增强生成)能力的正式说明页。 -## 概述 +## 什么时候应该用知识库 -![知识库](../images/knowledge.png) +当问题答案主要来自“稳定文档”而不是实时外部动作时,优先使用知识库: -## 创建知识库 +- 产品说明、政策条款、操作流程、培训材料 +- 内部手册、FAQ、规范文档 +- 需要被多位助手复用的领域知识 -### 步骤 +如果任务本质上是“查状态、写数据、执行动作”,那通常更适合 [工具](tools.md),而不是知识库。 -1. 进入 **知识库** 页面 -2. 点击 **新建知识库** -3. 填写知识库名称 -4. 上传文档 +## 工作原理 -### 支持格式 +```mermaid +flowchart LR + subgraph Indexing["索引阶段"] + Doc[文档] --> Chunk[分块] + Chunk --> Embed[向量化] + Embed --> Store[(向量数据库)] + end -| 格式 | 说明 | -|------|------| -| Markdown | 最佳选择,格式清晰 | -| PDF | 自动提取文本 | -| TXT | 纯文本支持 | -| Word | 需转换为其他格式 | + subgraph Query["查询阶段"] + Q[用户问题] --> Search[相似度检索] + Store --> Search + Search --> Context[相关片段] + Context --> LLM[LLM 生成回答] + end +``` -### 文档上传 +核心原则很简单:把长文档转成可检索的片段,在用户提问时只把最相关的内容送给模型。 -- 拖拽上传或点击选择 -- 单文件大小限制 10MB -- 建议单文档不超过 50000 字 +## 适合放进知识库的内容 -## 配置检索参数 +| 适合 | 不适合 | +|------|--------| +| 稳定规则、标准答案、产品文档 | 高频变化的实时状态 | +| 领域术语、说明手册、培训材料 | 需要外部系统写入或变更的动作 | +| 需要跨助手复用的内容 | 只在单次会话里临时生成的数据 | -| 参数 | 说明 | 默认值 | -|------|------|--------| -| 相似度阈值 | 低于此分数的结果不返回 | 0.7 | -| 返回数量 | 单次检索返回的结果数 | 3 | -| 分块大小 | 文档分块的最大长度 | 500 | +## 内容准备建议 -## 管理知识库 +- 优先上传结构清晰、主题明确的文档 +- 对超长文档按主题拆分,减少一次索引的噪声 +- 标题、章节名和表格说明对召回质量很重要,不要全部删掉格式信息 +- 与其堆很多相近文档,不如先清理重复、过期和相互冲突的内容 -- **查看文档** - 浏览已上传的文件 -- **删除文档** - 移除不需要的内容 -- **更新文档** - 重新上传覆盖 -- **测试检索** - 验证知识库效果 +## 常见配置项 -## 关联助手 +| 配置项 | 作用 | 常见做法 | +|--------|------|----------| +| **相似度阈值** | 过滤弱相关结果 | 从保守值起步,再按误召回调 | +| **返回数量** | 控制一次送给模型的候选片段数 | 先少后多,避免上下文污染 | +| **分块大小** | 决定每个文档片段的长度 | 按文档类型和问题粒度调整 | -在助手配置的 **知识** 标签页中: -1. 选择要关联的知识库 -2. 设置检索策略 -3. 保存配置 +## 创建与维护 + +### 最小流程 + +1. 新建知识库 +2. 上传文档 +3. 完成索引 +4. 用典型问题测试召回结果 +5. 绑定到目标助手 + +### 日常维护 + +- 删除过期或互相矛盾的文档 +- 当业务口径变化时,优先更新知识库而不是只改提示词 +- 为关键问题准备固定测试问句,观察召回是否稳定 + +## 与助手的关系 + +知识库不是独立产品入口,而是助手的能力层: + +- 助手决定是否、何时、以什么风格使用知识 +- 知识库决定能够提供哪些事实片段 +- 工作流和工具可以与知识库并用,但承担不同职责 + +## 相关文档 + +- [助手概念](../concepts/assistants.md) - 知识库在助手能力层中的位置 +- [LLM 模型](models.md) - 为知识库准备嵌入或重排模型 +- [工具](tools.md) - 当任务需要执行动作时,优先考虑工具而不是知识库 diff --git a/docs/content/customization/models.md b/docs/content/customization/models.md index f149c81..8343d11 100644 --- a/docs/content/customization/models.md +++ b/docs/content/customization/models.md @@ -1,44 +1,53 @@ -# 模型配置 +# LLM 模型 -## LLM 模型库 +本页是资源库中 LLM 模型的正式说明页,聚焦文本生成、嵌入和重排模型的接入与选择。 -![LLM模型库](../images/llms.png) +## 这页负责什么 -### 支持的模型 +当你需要为助手配置“理解与生成能力”时,请从这里开始决定: -| 供应商 | 模型 | 特点 | +- 使用哪个供应商或模型家族 +- 该模型负责文本生成、嵌入还是重排 +- 接口地址、认证信息和默认参数如何设置 + +语音识别和语音合成分别由 [语音识别](asr.md) 与 [声音资源](voices.md) 说明,不在本页重复。 + +## 模型类型 + +| 类型 | 用途 | 常见场景 | +|------|------|----------| +| **文本模型** | 生成回复、总结、分类、规划 | 助手主对话、工具调用决策 | +| **嵌入模型** | 向量化文档或查询 | 知识库检索 | +| **重排模型** | 对检索结果再次排序 | 提升知识召回质量 | + +## 配置清单 + +| 配置项 | 说明 | 建议 | |--------|------|------| -| **OpenAI** | GPT-4 / GPT-3.5 | 通用能力强 | -| **DeepSeek** | DeepSeek Chat | 高性价比 | -| **SiliconFlow** | 多种开源模型 | 本地部署友好 | -| **Google** | Gemini Pro | 多模态支持 | +| **供应商** | OpenAI 兼容、托管平台或自建服务 | 用统一命名规范区分环境 | +| **模型名称** | 控制台中的显示名称 | 体现厂商、用途和环境 | +| **模型标识** | 请求中实际使用的 model 名称 | 保持与供应商文档一致 | +| **Base URL** | 接口地址 | 为不同环境分别配置 | +| **API Key / Token** | 鉴权凭证 | 与显示名称配套管理 | +| **默认参数** | Temperature、Max Tokens、上下文长度等 | 按业务场景收敛默认值 | -### 配置步骤 +## 选择建议 -1. 进入 **LLM 库** 页面 -2. 点击 **添加模型** -3. 选择供应商 -4. 填写 API Key 和 Endpoint -5. 设置默认参数 +- **先按用途选模型,再按成本和延迟筛选供应商** +- **文本模型不要承担知识库检索职责**:检索应交给嵌入与重排模型 +- **为不同环境建立清晰命名**:如 `prod-gpt4o-mini`、`staging-qwen-text` +- **默认参数要保守**:让助手默认稳定,再在单个场景内按需调优 -### 参数说明 +## 常见组合 -| 参数 | 说明 | 建议值 | -|------|------|--------| -| Temperature | 随机性 | 0.7 | -| Max Tokens | 最大输出长度 | 2048 | -| Top P | 核采样 | 0.9 | +| 目标 | 推荐组合 | +|------|----------| +| **通用对话助手** | 1 个文本模型 | +| **知识问答助手** | 文本模型 + 嵌入模型 | +| **高质量知识召回** | 文本模型 + 嵌入模型 + 重排模型 | -## ASR 语音识别 +## 下一步 -### 支持引擎 - -- **Whisper** - OpenAI 通用语音识别 -- **SenseVoice** - 高精度中文语音识别 - -### 配置方法 - -1. 进入 **ASR 库** 页面 -2. 选择识别引擎 -3. 配置音频参数(采样率、编码) -4. 测试识别效果 +- [语音识别](asr.md) - 为语音输入选择 ASR +- [声音资源](voices.md) - 为语音输出准备 TTS 资源 +- [知识库](knowledge-base.md) - 把嵌入 / 重排模型接入 RAG 链路 diff --git a/docs/content/customization/tools.md b/docs/content/customization/tools.md index 2c5a5b0..993846d 100644 --- a/docs/content/customization/tools.md +++ b/docs/content/customization/tools.md @@ -1,38 +1,60 @@ -# 工具集成 +# 工具 -工具(Tools)让助手能够执行外部操作,如查询天气、搜索信息、调用 API 等。 +工具让助手从“会回答”扩展成“能执行动作”。本页是工具能力的正式说明页。 -## 概述 +## 什么时候应该用工具 -工具是助手能力的扩展。当用户的请求需要外部数据或操作时,助手会调用相应的工具。 +当用户请求需要依赖外部系统、实时数据或执行某个动作时,应该使用工具,而不是只靠提示词或知识库。 -## 内置工具 +典型场景包括: -| 工具 | 说明 | 参数 | -|------|------|------| -| `search` | 网络搜索 | query: 搜索关键词 | -| `weather` | 天气查询 | city: 城市名称 | -| `calculator` | 数学计算 | expression: 计算表达式 | -| `knowledge` | 知识库检索 | query: 查询内容 | +- 查询订单、库存、物流、天气等实时信息 +- 创建预约、提交表单、写入业务系统 +- 获取客户端环境能力,如定位、相机、权限确认 -### 启用内置工具 +如果问题本质上是“查阅稳定资料”,优先用 [知识库](knowledge-base.md);如果问题是“执行动作或读写实时状态”,优先用工具。 -在助手配置的 **工具** 标签页: +## 工具类型 -1. 勾选需要启用的工具 -2. 配置工具参数(如有) -3. 保存配置 +| 类型 | 说明 | 常见场景 | +|------|------|----------| +| **Webhook 工具** | 调用外部 HTTP API | 订单查询、CRM 写入、预约服务 | +| **客户端工具** | 由接入端在本地执行 | 获取定位、打开相机、请求用户授权 | +| **内建工具** | 平台或运行时直接提供 | 搜索、计算、知识检索等 | -## 自定义工具 +## 工具调用的基本过程 -支持通过 HTTP 回调实现自定义工具。 +```mermaid +sequenceDiagram + participant User as 用户 + participant Assistant as 助手 / 模型 + participant Tool as 工具 -### 定义工具 + User->>Assistant: 发起请求 + Assistant->>Assistant: 判断是否需要工具 + Assistant->>Tool: 发起工具调用 + Tool-->>Assistant: 返回结构化结果 + Assistant->>User: 组织最终回复 +``` + +关键点不是“模型会不会调用工具”,而是“工具的定义是否足够清晰,能让模型在正确时机调用”。 + +## 如何定义一个好工具 + +| 要素 | 为什么重要 | +|------|------------| +| **清晰名称** | 让模型知道它是做什么的,而不是猜用途 | +| **明确描述** | 告诉模型何时调用、何时不要调用 | +| **完整参数定义** | 降低缺参、错参和歧义调用 | +| **稳定返回结构** | 让模型更容易根据结果组织回复 | +| **明确错误语义** | 让失败时也能安全退回用户对话 | + +## Webhook 工具示例 ```json { "name": "query_order", - "description": "查询用户订单信息", + "description": "根据订单号查询当前订单状态,仅用于用户已提供订单号的场景。", "parameters": { "type": "object", "properties": { @@ -42,188 +64,45 @@ } }, "required": ["order_id"] - }, - "endpoint": { - "url": "https://api.example.com/orders", - "method": "GET", - "headers": { - "Authorization": "Bearer {{api_key}}" - } } } ``` -### 工具字段说明 +## 客户端工具的作用 -| 字段 | 说明 | -|------|------| -| name | 工具名称(英文标识符) | -| description | 工具描述(LLM 用于理解工具用途) | -| parameters | 参数定义(JSON Schema 格式) | -| endpoint | HTTP 调用配置 | +某些动作必须在接入端执行,例如: -### 参数映射 +- 获取当前位置 +- 请求麦克风或相机权限 +- 打开特定页面或原生能力 -工具参数自动映射到 HTTP 请求: +这类工具通常通过事件流和客户端配合完成,而不是由后端直接执行。 -- **GET 请求**:参数作为 query string -- **POST 请求**:参数作为 JSON body +## 工具设计建议 -## 客户端工具 +- **一工具一职责**:不要把多个业务动作塞进同一个工具 +- **名称与描述写给模型看**:必须明确何时用、何时不用 +- **先设计错误返回**:失败时模型应该知道如何解释给用户 +- **减少高权限工具暴露面**:不是每个助手、每个工作流节点都需要全部工具 +- **把业务规则放回系统**:工具负责执行,提示词负责决策边界 -某些工具需要在客户端执行(如获取地理位置)。 +## 与知识库、工作流的分工 -### 工作流程 +- **知识库**:提供稳定事实 +- **工具**:执行动作或读取实时状态 +- **工作流**:决定何时进入某个步骤、调用哪个工具、失败如何回退 -1. 助手返回 `assistant.tool_call` 事件 -2. 客户端执行工具并获取结果 -3. 客户端发送 `tool_call.results` 消息 -4. 助手继续生成回复 +当一个助手开始涉及多步骤、多系统调用时,工具通常应与 [工作流](workflows.md) 一起设计,而不是孤立配置。 -### 服务端事件 +## 安全与治理 -```json -{ - "type": "assistant.tool_call", - "data": { - "tool_call_id": "call_abc123", - "tool_name": "get_location", - "arguments": {} - } -} -``` +- 校验输入,不直接信任模型生成的参数 +- 为工具设置最小权限和清晰的可见范围 +- 记录调用日志,便于审计和回放 +- 对外部接口增加超时、重试和速率限制策略 -### 客户端响应 +## 相关文档 -```json -{ - "type": "tool_call.results", - "results": [ - { - "tool_call_id": "call_abc123", - "name": "get_location", - "output": { - "latitude": 39.9042, - "longitude": 116.4074, - "city": "北京" - }, - "status": { - "code": 200, - "message": "ok" - } - } - ] -} -``` - -## 工具调用示例 - -### 天气查询 - -用户:"北京今天天气怎么样?" - -助手调用工具: -```json -{ - "tool_name": "weather", - "arguments": { - "city": "北京" - } -} -``` - -工具返回: -```json -{ - "temperature": 25, - "condition": "晴", - "humidity": 40 -} -``` - -助手回复:"北京今天天气晴朗,气温 25 度,湿度 40%。" - -### 订单查询 - -用户:"帮我查一下订单 12345" - -助手调用工具: -```json -{ - "tool_name": "query_order", - "arguments": { - "order_id": "12345" - } -} -``` - -工具返回: -```json -{ - "order_id": "12345", - "status": "已发货", - "tracking": "SF1234567890" -} -``` - -助手回复:"您的订单 12345 已发货,快递单号是 SF1234567890。" - -## 工具配置最佳实践 - -### 1. 清晰的描述 - -工具描述应该让 LLM 准确理解何时使用: - -``` -好的描述: -"查询指定城市的实时天气信息,包括温度、天气状况和湿度" - -不好的描述: -"天气工具" -``` - -### 2. 完整的参数定义 - -```json -{ - "parameters": { - "type": "object", - "properties": { - "city": { - "type": "string", - "description": "城市名称,如 '北京'、'上海'" - }, - "date": { - "type": "string", - "description": "日期,格式 YYYY-MM-DD,可选,默认今天" - } - }, - "required": ["city"] - } -} -``` - -### 3. 错误处理 - -工具应返回清晰的错误信息: - -```json -{ - "status": { - "code": 404, - "message": "未找到该城市的天气数据" - } -} -``` - -## 安全注意事项 - -1. **验证输入** - 不要直接信任用户输入 -2. **限制权限** - 工具只应有必要的权限 -3. **审计日志** - 记录所有工具调用 -4. **速率限制** - 防止滥用 - -## 下一步 - -- [知识库配置](knowledge-base.md) - 让助手具备专业知识 -- [工作流编排](workflows.md) - 复杂对话流程 +- [知识库](knowledge-base.md) - 当问题更适合“查资料”时使用知识库 +- [工作流](workflows.md) - 当工具调用需要流程控制和分支逻辑时接入工作流 +- [助手概念](../concepts/assistants.md) - 理解工具在助手能力层中的位置 diff --git a/docs/content/customization/tts.md b/docs/content/customization/tts.md index 3915f29..2b311dc 100644 --- a/docs/content/customization/tts.md +++ b/docs/content/customization/tts.md @@ -1,25 +1,25 @@ -# 语音生成 +# TTS 参数 -语音生成(TTS)负责将助手回复文本转换为可播放音频。 +TTS 参数决定助手语音输出的节奏、音量和听感。本页只讨论参数层面的调优建议。 -## 配置项 +## 常用参数 -| 配置项 | 说明 | -|---|---| -| TTS 引擎 | 选择语音合成服务提供商 | -| 声音/音色 | 选择目标音色或发音人 | -| 模型 | 语音合成模型名称 | -| 语速 | 播放速度,通常 0.5-2.0 | -| 音量/增益 | 输出音量控制 | -| 音调 | 声线高低调整 | +| 参数 | 说明 | 常见范围 | +|------|------|----------| +| **语速** | 说话速度 | `0.5 - 2.0` | +| **音量 / 增益** | 输出音量强弱 | 供应商自定义 | +| **音调** | 声线高低 | 供应商自定义 | +| **模型** | 合成模型名称 | 依供应商而定 | +| **声音 ID** | 发音人或音色标识 | 依供应商而定 | -## 建议 +## 调优建议 -- 对话助手建议保持语速在 `0.9-1.2` -- 生产环境建议固定主音色,降低体验波动 -- 若需要打断能力,优先使用低延迟流式 TTS +- 对话助手通常建议把语速控制在 `0.9 - 1.2` +- 需要打断能力的场景,优先选择低延迟流式 TTS,并避免过长的单次回复 +- 如果业务强调可信度或专业感,先保证清晰度和稳定性,再追求个性化音色 +- 不要只试听一句问候语,至少用三类文案对比:短答复、长答复、数字或专有名词较多的答复 ## 相关文档 -- [语音配置总览](voices.md) - +- [声音资源](voices.md) - 先选择适合的供应商、模型和音色 +- [语音识别](asr.md) - 结合输入侧延迟一起评估整条语音链路 diff --git a/docs/content/customization/voices.md b/docs/content/customization/voices.md index 866b682..a756b49 100644 --- a/docs/content/customization/voices.md +++ b/docs/content/customization/voices.md @@ -1,58 +1,43 @@ -# 语音合成 +# 声音资源 -语音合成(TTS)模块提供自然流畅的语音输出能力。 +本页是资源库中 TTS 声音与发音人资源的正式说明页,聚焦“选择哪种声音给助手输出”。 -## 概述 +## 这页负责什么 -![语音合成](../images/voices.png) +当你已经决定启用语音输出后,需要在这里完成: -## 支持的引擎 +- 选择供应商、模型和声音资源 +- 为不同业务或语言准备不同音色 +- 通过预览和测试确定默认发音人 -| 供应商 | 特点 | 适用场景 | -|--------|------|---------| -| **阿里云** | 多音色、高自然度 | 通用场景 | -| **火山引擎** | 低延迟、实时性好 | 实时对话 | -| **Minimax** | 高性价比 | 批量合成 | +更细的速度、音量、音调等参数建议见 [TTS 参数](tts.md)。 -## 配置方法 +## 选择声音时要考虑什么 -### 添加语音配置 - -1. 进入 **语音库** 页面 -2. 点击 **添加语音** -3. 选择供应商 -4. 填写 API 凭证 -5. 保存配置 - -### 测试语音 - -- 在线预览发音效果 -- 调整语速和音量 -- 切换不同音色 - -## 音色选择 - -### 中文音色 - -| 音色 | 风格 | +| 维度 | 说明 | |------|------| -| 晓晓 | 标准女声 | -| 晓北 | 知性女声 | -| 逍遥 | 青年男声 | -| 丫丫 | 活泼童声 | +| **语言与口音** | 是否覆盖目标用户语言与地区口音 | +| **风格** | 专业、亲切、活泼、沉稳等输出气质 | +| **延迟** | 是否适合实时对话,而不仅是离线合成 | +| **稳定性** | 长文本、多轮会话中的音色一致性 | +| **成本** | 单次调用成本和高并发可用性 | -### 英文音色 +## 推荐做法 -| 音色 | 风格 | -|------|------| -| Joanna | 专业女声 | -| Matthew | 沉稳男声 | -| Amy | 亲切女声 | +1. 先为每类业务角色确定一条主音色 +2. 再按语言或渠道补充少量备选音色 +3. 通过固定测试文案试听,统一比较自然度、节奏和可懂度 +4. 上线后尽量保持默认音色稳定,避免频繁切换影响用户体验 -## 参数调优 +## 常见资源组织方式 -| 参数 | 范围 | 说明 | -|------|------|------| -| 语速 | 0.5-2.0 | 1.0 为正常速度 | -| 音量 | 0-100 | 输出音量百分比 | -| 音调 | 0.5-2.0 | 语音音调高低 | +| 组织方式 | 适用场景 | +|----------|----------| +| **按语言区分** | 中英文或多语种助手 | +| **按业务角色区分** | 客服、销售、培训、提醒类助手 | +| **按环境区分** | 开发、预发、生产使用不同供应商或凭证 | + +## 下一步 + +- [TTS 参数](tts.md) - 调整语速、增益、音调等输出参数 +- [快速开始](../quickstart/index.md) - 把声音资源绑定到第一个助手 diff --git a/docs/content/customization/workflows.md b/docs/content/customization/workflows.md index d0803a7..5702cbe 100644 --- a/docs/content/customization/workflows.md +++ b/docs/content/customization/workflows.md @@ -1,53 +1,106 @@ -# 工作流管理 +# 工作流 -工作流提供可视化的对话流程编排能力,支持复杂的业务场景。 +工作流用于把复杂业务拆成明确的步骤、分支和回退策略,是 RAS 中承载流程逻辑的正式能力页。 -## 概述 +## 什么时候需要工作流 -![工作流](../images/workflows.png) +当一个助手同时满足以下任一情况时,通常应考虑工作流,而不是继续堆叠单一提示词: -## 节点类型 +- 需要多轮收集信息,例如订单号、手机号、预约时间等 +- 需要按意图或条件走不同分支 +- 需要串联多个工具或业务系统 +- 需要在异常或信息不足时统一回退到澄清、兜底或人工节点 -| 节点 | 图标 | 功能说明 | -|------|------|---------| -| **对话节点** | 💬 | AI 自动回复,可设置回复策略 | -| **工具节点** | 🔧 | 调用外部 API 或自定义工具 | -| **人工节点** | 👤 | 转接人工客服 | -| **结束节点** | 🏁 | 结束对话流程 | +## 工作流与助手的关系 -## 创建工作流 +助手负责对外表现、全局策略和渠道接入;工作流负责把某个业务流程拆成可维护的节点。 -### 步骤 +```mermaid +flowchart LR + Assistant[助手] --> Workflow[工作流] + Workflow --> Nodes[节点与分支] + Nodes --> Tools[工具 / 知识库 / 人工] +``` -1. 进入 **工作流** 页面 -2. 点击 **新建工作流** -3. 从左侧拖拽节点到画布 -4. 连接节点建立流程 -5. 配置各节点参数 -6. 保存并发布 +这意味着: -### 节点配置 +- 助手定义角色、提示词基线、模型和输出方式 +- 工作流定义“这类问题该按什么顺序被处理” +- 工具和知识库作为节点可调用的能力,被有选择地暴露给流程 -#### 对话节点配置 +## 关键组成 -- 回复模板 -- 条件分支 -- 知识库检索 +| 组成 | 作用 | 设计建议 | +|------|------|----------| +| **工作流名称** | 区分业务流程 | 用业务语义命名,避免过于技术化 | +| **入口节点** | 用户进入后的第一步 | 保持单入口,便于理解和测试 | +| **全局提示词** | 对所有节点生效的共性约束 | 保持简短,避免与节点提示词冲突 | +| **节点提示词** | 当前节点的任务说明 | 单一职责,明确输入 / 输出 | +| **节点工具白名单** | 控制当前节点可调用的工具集合 | 遵循最小权限原则 | +| **超时与回退** | 异常、超时、缺信息时的处理方式 | 优先回到澄清、兜底或人工节点 | +| **上下文透传** | 在节点之间共享状态 | 只传递后续节点真正需要的信息 | -#### 工具节点配置 +## 常见节点类型 -- 选择工具类型 -- 配置输入参数 -- 设置输出处理 +| 节点类型 | 适合做什么 | +|----------|------------| +| **路由节点** | 判断用户意图并进入不同分支 | +| **信息收集节点** | 收集订单号、联系方式、时间等关键信息 | +| **处理节点** | 调用工具、执行查询、计算或写入系统 | +| **回复节点** | 组织最终答复并控制输出风格 | +| **人工节点** | 转接人工、排队或发起通知 | +| **结束节点** | 输出结束语并关闭流程 | -#### 人工节点配置 +## 推荐编排步骤 -- 转接规则 -- 排队策略 -- 通知设置 +1. 先写清楚流程目标:这条工作流要解决哪一类业务问题 +2. 画出最小节点图:入口、关键分支、结束和兜底 +3. 为每个节点定义唯一职责和输入 / 输出 +4. 再绑定知识库、工具和回退策略 +5. 在测试面板或流程调试工具中验证每条主路径和异常路径 -## 流程测试 +## 配置示例 -- 支持单步调试 -- 可查看执行日志 -- 实时验证流程逻辑 +```yaml +workflow: + name: "订单咨询流程" + entry: "intent_router" + global_prompt: "优先给出可执行步骤,必要时先澄清信息。" + nodes: + - id: "intent_router" + type: "router" + prompt: "识别用户意图:查订单、退款、投诉" + next: + - when: "intent == query_order" + to: "collect_order_id" + - when: "intent == refund" + to: "refund_policy" + - id: "collect_order_id" + type: "collect" + prompt: "请用户提供订单号" + tools: ["query_order"] + fallback: "human_handoff" + - id: "human_handoff" + type: "end" + prompt: "转人工处理" +``` + +## 设计建议 + +- **让每个节点只做一件事**:避免单节点同时负责路由、收集信息和最终回复 +- **工具按节点授权**:不要把所有工具暴露给整条流程中的每个节点 +- **把失败路径设计出来**:超时、无结果、参数缺失都应该有明确回退 +- **优先传状态,不传长文本**:节点之间共享必要结构化信息,比传递大段自然语言更稳 +- **为流程保留可观测性**:每条主路径都应能在调试时解释“为什么走到这里” + +## 当前边界 + +- 文档不会完整覆盖所有表达式或节点字段的最终 Schema +- 不同执行引擎下,可用节点字段和运行行为可能存在差异 +- 可视化编排与底层字段映射可能不会一一对应 + +## 相关文档 + +- [助手概念](../concepts/assistants.md) - 工作流在助手体系中的位置 +- [工具](tools.md) - 设计可被流程安全调用的工具 +- [知识库](knowledge-base.md) - 让流程中的节点使用 RAG 能力 diff --git a/docs/content/getting-started/configuration.md b/docs/content/getting-started/configuration.md index 14c2e84..beeac47 100644 --- a/docs/content/getting-started/configuration.md +++ b/docs/content/getting-started/configuration.md @@ -1,4 +1,4 @@ -# 配置说明 +# 配置说明 本页面介绍 Realtime Agent Studio 各组件的配置方法。 @@ -274,5 +274,6 @@ python -c "from config import settings; print(settings)" ## 下一步 -- [安装部署](index.md) - 开始安装服务 +- [环境与部署](index.md) - 开始安装服务 - [Docker 部署](../deployment/docker.md) - 容器化部署 + diff --git a/docs/content/getting-started/index.md b/docs/content/getting-started/index.md index 6363418..1b1064b 100644 --- a/docs/content/getting-started/index.md +++ b/docs/content/getting-started/index.md @@ -1,12 +1,12 @@ -# 安装部署 +# 环境与部署 -本章节介绍如何安装和配置 Realtime Agent Studio (RAS) 开发环境。 +本页属于“快速开始”中的环境与部署路径,只负责把服务跑起来、说明配置入口和部署方式。首次创建助手请转到 [创建第一个助手](../quickstart/index.md)。 --- -## 系统组件 +## 先理解部署对象 -RAS 由三个核心服务组成: +Realtime Agent Studio(RAS)通常由三个核心服务组成: ```mermaid flowchart LR @@ -26,47 +26,32 @@ flowchart LR Engine <--> API ``` -| 组件 | 端口 | 说明 | -|------|------|------| -| **Web 前端** | 3000 | React + TypeScript 管理控制台 | -| **API 服务** | 8080 | Python FastAPI 后端 | -| **Engine 服务** | 8000 | 实时对话引擎(WebSocket) | +| 组件 | 默认端口 | 负责什么 | +|------|----------|----------| +| **Web 前端** | 3000 | 管理控制台与调试界面 | +| **API 服务** | 8080 | 资源管理、配置持久化、历史数据 | +| **Engine 服务** | 8000 | 实时会话、事件流和音频流 | ---- +## 选择你的安装方式 -## 快速安装 +### 方式一:Docker Compose -### 方式一:Docker Compose(推荐) - -最快捷的启动方式,适合快速体验和生产部署。 +适合希望尽快跑通一套完整环境的团队。 ```bash -# 1. 克隆项目 +# 仓库目录示例沿用当前代码仓库 slug +# 你本地实际目录名可以不同 git clone https://github.com/your-org/AI-VideoAssistant.git cd AI-VideoAssistant -# 2. 启动服务 docker-compose up -d - -# 3. 访问控制台 -open http://localhost:3000 ``` -!!! tip "首次启动" - 首次启动需要构建镜像,可能需要几分钟时间。 - ### 方式二:本地开发 -适合需要修改代码的开发者。 +适合需要分别调试前端、API 和 Engine 的开发者。 -#### 1. 克隆项目 - -```bash -git clone https://github.com/your-org/AI-VideoAssistant.git -cd AI-VideoAssistant -``` - -#### 2. 启动 API 服务 +#### 启动 API 服务 ```bash cd api @@ -76,7 +61,7 @@ pip install -r requirements.txt uvicorn main:app --host 0.0.0.0 --port 8080 --reload ``` -#### 3. 启动 Engine 服务 +#### 启动 Engine 服务 ```bash cd engine @@ -86,7 +71,7 @@ pip install -r requirements.txt python main.py ``` -#### 4. 启动 Web 前端 +#### 启动 Web 前端 ```bash cd web @@ -94,97 +79,37 @@ npm install npm run dev ``` -访问 `http://localhost:3000` +## 基础验证 ---- +完成安装后,至少确认以下入口可访问: -## 验证安装 +| 服务 | 地址 | 用途 | +|------|------|------| +| Web | `http://localhost:3000` | 打开控制台 | +| API | `http://localhost:8080/docs` | 查看管理接口 | +| Engine | `http://localhost:8000/health` | 检查实时引擎健康状态 | -### 检查服务状态 +如果你需要更完整的环境变量、配置文件和部署说明,请继续阅读本章节其他页面: -| 服务 | URL | 预期结果 | -|------|-----|---------| -| Web | http://localhost:3000 | 看到登录/控制台页面 | -| API | http://localhost:8080/docs | 看到 Swagger 文档 | -| Engine | http://localhost:8000/health | 返回 `{"status": "ok"}` | +- [环境要求](requirements.md) +- [配置说明](configuration.md) +- [部署概览](../deployment/index.md) +- [Docker 部署](../deployment/docker.md) -### 测试 WebSocket 连接 +## 目录结构(阅读导向) -```javascript -const ws = new WebSocket('ws://localhost:8000/ws?assistant_id=test'); -ws.onopen = () => console.log('Connected!'); -ws.onerror = (e) => console.error('Error:', e); +```text +repo/ +├── web/ # 管理控制台 +├── api/ # 控制面与管理接口 +├── engine/ # 实时交互引擎 +├── docker/ # 部署编排与镜像配置 +└── docs/ # 当前文档站点 ``` ---- +## 遇到问题时去哪里 -## 目录结构 +- 需要“快速判断往哪看”:先看 [常见问题](../resources/faq.md) +- 需要“按步骤排查”:直接看 [故障排查](../resources/troubleshooting.md) +- 已经跑通环境,准备创建助手:回到 [快速开始](../quickstart/index.md) -``` -AI-VideoAssistant/ -├── web/ # React 前端 -│ ├── src/ -│ │ ├── components/ # UI 组件 -│ │ ├── pages/ # 页面 -│ │ ├── stores/ # Zustand 状态 -│ │ └── api/ # API 客户端 -│ └── package.json -├── api/ # FastAPI 后端 -│ ├── app/ -│ │ ├── routers/ # API 路由 -│ │ ├── models/ # 数据模型 -│ │ └── services/ # 业务逻辑 -│ └── requirements.txt -├── engine/ # 实时交互引擎 -│ ├── app/ -│ │ ├── pipeline/ # 管线引擎 -│ │ └── multimodal/ # 多模态引擎 -│ └── requirements.txt -├── docker/ # Docker 配置 -│ └── docker-compose.yml -└── docs/ # 文档 -``` - ---- - -## 常见问题 - -### 端口被占用 - -```bash -# 查看端口占用 -# Linux/Mac -lsof -i :3000 - -# Windows -netstat -ano | findstr :3000 -``` - -修改对应服务的端口配置后重启。 - -### Docker 构建失败 - -```bash -# 清理 Docker 缓存 -docker system prune -a - -# 重新构建 -docker-compose build --no-cache -``` - -### Python 依赖安装失败 - -确保使用 Python 3.10+: - -```bash -python --version # 需要 3.10+ -``` - ---- - -## 下一步 - -- [环境要求](requirements.md) - 详细的软件版本要求 -- [配置说明](configuration.md) - 环境变量配置指南 -- [快速开始](../quickstart/index.md) - 创建第一个助手 -- [Docker 部署](../deployment/docker.md) - 镜像构建与编排 diff --git a/docs/content/getting-started/requirements.md b/docs/content/getting-started/requirements.md index e925de6..36d3b75 100644 --- a/docs/content/getting-started/requirements.md +++ b/docs/content/getting-started/requirements.md @@ -1,4 +1,4 @@ -# 环境要求 +# 环境要求 本页面列出运行 Realtime Agent Studio 所需的软件和硬件要求。 @@ -145,5 +145,6 @@ wsl --install -d Ubuntu ## 下一步 - [配置说明](configuration.md) - 环境变量配置 -- [安装部署](index.md) - 开始安装 +- [环境与部署](index.md) - 开始安装 - [Docker 部署](../deployment/docker.md) - 容器化部署 + diff --git a/docs/content/index.md b/docs/content/index.md index 0dd2f8c..d1f033a 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -1,9 +1,9 @@ -

+

Realtime Agent Studio

- 构建实时交互音视频智能体的开源工作平台 + 通过管理控制台与 API 构建、部署和运营实时多模态助手

@@ -14,66 +14,65 @@

+ 产品概览 · 快速开始 · - API 文档 · - 安装部署 · - 路线图 + 构建助手 · + 核心概念 · + API 参考

--- -## 什么是 Realtime Agent Studio? +Realtime Agent Studio (RAS) 是一个通过管理控制台与 API 构建、部署和运营实时多模态助手的开源平台。 -Realtime Agent Studio (RAS) 是一款以大语言模型为核心,构建实时交互音视频智能体的工作平台。支持管线式的全双工交互引擎和原生多模态模型两种架构,覆盖实时交互智能体的配置、测试、发布、监控全流程。 +## 适合谁 -可以将 RAS 看作 [Vapi](https://vapi.ai)、[Retell](https://retellai.com)、[ElevenLabs Agents](https://elevenlabs.io) 的开源替代方案。 +- 需要把实时语音或视频助手接入产品、设备或内部系统的开发团队 +- 需要通过控制台快速配置提示词、模型、知识库、工具和工作流的运营团队 +- 需要私有化部署、模型可替换、链路可观测的企业场景 ---- - -## 核心特性 +## 核心能力
-- :zap: **低延迟实时引擎** +- :material-robot-outline: **助手构建** --- - 管线式全双工架构,VAD/ASR/TD/LLM/TTS 流水线处理,支持智能打断,端到端延迟 < 500ms + 用统一的助手对象管理提示词、模型、知识库、工具、开场白和会话策略。 -- :brain: **多模态模型支持** +- :material-pulse: **双引擎运行时** --- - 支持 GPT-4o Realtime、Gemini Live、Step Audio 等原生多模态模型直连 + 同时支持 Pipeline 引擎与 Realtime 引擎,可按延迟、成本和可控性选择运行方式。 -- :wrench: **可视化配置** +- :material-source-branch: **能力扩展** --- - 无代码配置助手、提示词、工具调用、知识库关联,所见即所得 + 通过资源库、知识库、工具与工作流扩展助手能力,而不是把全部逻辑塞进单一提示词。 -- :electric_plug: **开放 API** +- :material-api: **开放集成** --- - 标准 WebSocket 协议,RESTful 管理接口,支持 Webhook 回调 + 使用 REST API 管理资源,使用 WebSocket API 接入实时对话,面向 Web、移动端和第三方系统。 -- :shield: **私有化部署** +- :material-shield-lock-outline: **私有化部署** --- - Docker 一键部署,数据完全自主可控,支持本地模型 + 支持 Docker 部署、自有模型服务和企业内网运行,便于满足合规与成本要求。 -- :chart_with_upwards_trend: **全链路监控** +- :material-chart-line: **可观测与评估** --- - 完整会话回放,实时仪表盘,自动化测试与效果评估 + 提供会话历史、实时指标、自动化测试和效果评估,帮助持续改进助手质量。
---- - ## 系统架构 平台架构层级: @@ -81,243 +80,107 @@ Realtime Agent Studio (RAS) 是一款以大语言模型为核心,构建实时 ```mermaid flowchart TB -%% ================= ACCESS ================= -subgraph Access["Access Layer"] -direction TB -API[API] -SDK[SDK] -Browser[Browser UI] -Embed[Web Embed] -end + subgraph Access["Access Layer"] + API["API"] + SDK["SDK"] + Browser["Browser UI"] + Embed["Web Embed"] + end + subgraph Runtime["Realtime Interaction Engine"] + direction LR -%% ================= REALTIME ENGINE ================= -subgraph Runtime["Realtime Interaction Engine"] + subgraph Duplex["Duplex Interaction Engine"] + direction LR -direction LR + subgraph Pipeline["Pipeline Engine"] + direction LR + VAD["VAD"] + ASR["ASR"] + TD["Turn Detection"] + LLM["LLM"] + TTS["TTS"] + end -%% -------- Duplex Engine -------- -subgraph Duplex["Duplex Interaction Engine"] -direction LR + subgraph Multi["Realtime Engine"] + MM["Realtime Model"] + end + end -subgraph Pipeline["Pipeline Engine"] -direction LR -VAD[VAD] -ASR[ASR] -TD[Turn Detection] -LLM[LLM] -TTS[TTS] -end + subgraph Capability["Agent Capabilities"] + subgraph Tools["Tool System"] + Webhook["Webhook"] + ClientTool["Client Tools"] + Builtin["Builtin Tools"] + end -subgraph Multi["Realtime Engine"] -MM[Realtime Model] -end + subgraph KB["Knowledge System"] + Docs["Documents"] + Vector[("Vector Index")] + Retrieval["Retrieval"] + end + end + end -end + subgraph Platform["Platform Services"] + direction TB + Backend["Backend Service"] + Frontend["Frontend Console"] + DB[("Database")] + end - -%% -------- Capabilities -------- -subgraph Capability["Agent Capabilities"] - -subgraph Tools["Tool System"] -Webhook[Webhook] -ClientTool[Client Tools] -Builtin[Builtin Tools] -end - -subgraph KB["Knowledge System"] -Docs[Documents] -Vector[(Vector Index)] -Retrieval[Retrieval] -end - -end - -end - - -%% ================= PLATFORM ================= -subgraph Platform["Platform Services"] -direction TB -Backend[Backend Service] -Frontend[Frontend Console] -DB[(Database)] -end - - -%% ================= CONNECTIONS ================= - -Access --> Runtime - -Runtime <--> Backend -Backend <--> DB -Backend <--> Frontend - -LLM --> Tools -MM --> Tools - -LLM <--> KB -MM <--> KB + Access --> Runtime + Runtime <--> Backend + Backend <--> DB + Backend <--> Frontend + LLM --> Tools + MM --> Tools + LLM <--> KB + MM <--> KB ``` -管线式引擎交互引擎对话流程图: - -```mermaid -flowchart LR - -User((User Speech)) -Audio[Audio Stream] - -VAD[VAD\nVoice Activity Detection] -ASR[ASR\nSpeech Recognition] - -TD[Turn Detection] - -LLM[LLM\nReasoning] - -Tools[Tools / APIs] - -TTS[TTS\nSpeech Synthesis] - -AudioOut[Audio Stream Out] - -User --> Audio -Audio --> VAD -VAD --> ASR -ASR --> TD -TD --> LLM - -LLM --> Tools -Tools --> LLM - -LLM --> TTS -TTS --> AudioOut -AudioOut --> User -``` - -基于实时交互模型的对话流程图: - -```mermaid -flowchart LR - -User((User)) - -Input[Audio / Video / Text] - -MM[Multimodal Model] - -Tools[Tools / APIs] -KB[Knowledge Base] - -Output[Audio / Video / Text] - -User --> Input -Input --> MM - -MM --> Tools -Tools --> MM - -MM --> KB -KB --> MM - -MM --> Output -Output --> User -``` - ---- - -## 技术栈 - -| 层级 | 技术 | -|------|------| -| **前端** | React 18, TypeScript, Tailwind CSS, Zustand | -| **后端** | FastAPI (Python 3.10+) | -| **引擎** | Python, WebSocket, asyncio | -| **数据库** | SQLite | -| **知识库** | chroma | -| **部署** | Docker | - ---- - -## 快速导航 +## 从这里开始
-- :rocket: **[快速开始](quickstart/index.md)** +- :material-compass-outline: **[了解产品](overview/index.md)** --- - 5 分钟创建你的第一个 AI 助手 + 先看产品定位、核心模块、适用场景,以及 RAS 与其他方案的差异。 -- :book: **[核心概念](concepts/index.md)** +- :material-cog-outline: **[环境与部署](getting-started/index.md)** --- - 了解助手、管线、多模态等核心概念 + 先把服务跑起来,了解环境要求、配置入口和部署方式。 -- :wrench: **[安装部署](getting-started/index.md)** +- :material-rocket-launch-outline: **[创建第一个助手](quickstart/index.md)** --- - 环境准备、本地开发与 Docker/生产部署 + 按最短路径准备资源、创建助手、测试效果并拿到接入所需信息。 -- :robot: **[助手管理](assistants/index.md)** +- :material-tune: **[构建助手](concepts/assistants.md)** --- - 创建和配置智能对话助手 + 按完整链路配置助手、提示词、模型、知识库、工具与工作流。 -- :gear: **[功能定制](customization/knowledge-base.md)** +- :material-connection: **[接入应用](api-reference/index.md)** --- - 知识库、工具、语音、工作流 + 查看 REST 与 WebSocket 接口,把助手嵌入到你的 Web、移动端或服务端系统。 -- :bar_chart: **[数据分析](analysis/dashboard.md)** +- :material-lifebuoy: **[排查问题](resources/troubleshooting.md)** --- - 仪表盘、历史记录、测试评估 - -- :electric_plug: **[API 参考](api-reference/index.md)** - - --- - - WebSocket 协议与 REST 接口文档 + 当连接、对话质量或部署链路出现问题时,从这里进入可执行的排查步骤。
---- -## 快速体验 -### 使用 Docker 启动 -```bash -git clone https://github.com/your-org/AI-VideoAssistant.git -cd docker -docker-compose up -d -# for development -# docker compose --profile dev up -d -``` - -访问 `http://localhost:3000` 即可使用控制台。 - -### WebSocket 连接示例 - -```javascript -const ws = new WebSocket('ws://localhost:8000/ws?assistant_id=YOUR_ID'); - -ws.onopen = () => { - ws.send(JSON.stringify({ - type: 'session.start', - audio: { encoding: 'pcm_s16le', sample_rate_hz: 16000, channels: 1 } - })); -}; -``` - ---- - -## 许可证 - -本项目基于 [MIT 许可证](https://github.com/your-org/AI-VideoAssistant/blob/main/LICENSE) 开源。 diff --git a/docs/content/overview/architecture.md b/docs/content/overview/architecture.md index bfa830b..8262644 100644 --- a/docs/content/overview/architecture.md +++ b/docs/content/overview/architecture.md @@ -1,6 +1,6 @@ -# 系统架构 +# 系统架构 -本文档详细介绍 Realtime Agent Studio (RAS) 的系统架构设计。 +本文档只解释 Realtime Agent Studio (RAS) 的服务边界、数据流、部署形态和关键技术选型,不重复产品定位或上手流程。 --- @@ -61,12 +61,12 @@ flowchart TB ### 1. Web 前端 (React) -管理控制台,提供可视化的配置和监控界面。 +管理控制台,提供可视化的配置、测试和监控界面。 | 功能模块 | 说明 | |---------|------| | 助手管理 | 创建、配置、测试智能助手 | -| 资源库 | LLM/ASR/TTS/VAD 等模型管理 | +| 资源库 | LLM / ASR / TTS 等模型管理 | | 知识库 | RAG 文档上传与管理 | | 历史记录 | 会话日志查询与回放 | | 仪表盘 | 实时数据统计 | @@ -74,7 +74,7 @@ flowchart TB ### 2. API 服务 (FastAPI) -RESTful API 后端,处理所有管理操作。 +REST API 后端,处理资源管理、持久化配置和历史数据等控制面能力。 ```mermaid flowchart LR @@ -100,7 +100,7 @@ flowchart LR ### 3. 实时交互引擎 (Engine) -核心组件,处理实时音视频对话。 +处理实时音视频对话、事件流转、模型调用与工具执行。 ```mermaid flowchart TB @@ -116,7 +116,7 @@ flowchart TB TTS[语音合成 TTS] end - subgraph Realtime["实时交互引擎连接"] + subgraph Realtime["实时引擎连接"] RTOpenAI[OpenAI Realtime] RTGemini[Gemini Live] RTDoubao[Doubao 实时交互] @@ -144,9 +144,9 @@ flowchart TB | 类别 | 说明 | 可选项 | |------|------|--------| -| **外部服务** | 管线式引擎各环节所依赖的云/本地服务 | OpenAI、SiliconFlow、DashScope、本地模型 | -| **实时交互引擎** | 实时交互引擎可连接的后端 | OpenAI Realtime、Gemini Live、Doubao 实时交互引擎 | -| **工具** | 管线式 LLM 与实时交互引擎均可调用 | Webhook、客户端工具、内建工具 | +| **外部模型服务** | Pipeline 引擎各环节依赖的云端或本地服务 | OpenAI、SiliconFlow、DashScope、本地模型 | +| **实时模型连接** | Realtime 引擎可直接连接的后端 | OpenAI Realtime、Gemini Live、Doubao 实时交互 | +| **工具系统** | 由助手或引擎调用的外部执行能力 | Webhook、客户端工具、内建工具 | --- @@ -154,7 +154,7 @@ flowchart TB ### 管线式全双工引擎 -管线式引擎包含:**声音活动检测(VAD)**、**语音识别(ASR)**、**回合检测(TD)**、**大语言模型(LLM)**、**语音合成(TTS)**。外部服务可选用 **OpenAI**、**SiliconFlow**、**DashScope**、**本地模型**。LLM 可连接**工具**(Webhook、客户端工具、内建工具)。 +管线式引擎由 **VAD → ASR → TD → LLM → TTS** 组成。每个环节可替换,适合需要精细控制、工具扩展和较高可解释性的场景。 ```mermaid sequenceDiagram @@ -170,33 +170,28 @@ sequenceDiagram C->>E: 音频流 (PCM) E->>VAD: 检测语音活动 VAD-->>E: 有效语音段 - E->>ASR: 语音转文字 + E->>ASR: 语音转写 ASR-->>E: 转写文本 - E->>TD: 回合边界 - TD-->>E: 可送 LLM 的输入 + E->>TD: 判断回合边界 + TD-->>E: 可送入 LLM 的输入 E->>LLM: 生成回复 LLM->>Tools: 可选:调用工具 Tools-->>LLM: 工具结果 LLM-->>E: 回复文本 (流式) - E->>TTS: 文字转语音 + E->>TTS: 文本转语音 TTS-->>E: 音频流 E->>C: 播放音频 ``` **特点:** -- 灵活选择各环节供应商(OpenAI、SiliconFlow、DashScope、本地模型) -- 可独立优化 VAD、ASR、TD、LLM、TTS 每个环节 -- LLM 与工具联动(Webhook、客户端工具、内建工具) -- 延迟约 500-1500ms +- 各环节可单独替换和优化 +- 便于接入知识库、工具、工作流等能力 +- 延迟通常高于端到端实时模型,但可控性更强 -### 实时交互引擎 +### Realtime 引擎 -实时交互引擎可连接**实时交互引擎**,包括 **OpenAI Realtime**、**Gemini Live**、**Doubao 实时交互引擎**等,同样可连接**工具**(Webhook、客户端工具、内建工具)。 - -### 原生多模态引擎 - -使用端到端多模态模型(如 GPT-4o Realtime): +Realtime 引擎直接连接端到端实时模型,适合追求更低延迟和更自然多模态交互的场景。 ```mermaid sequenceDiagram @@ -204,17 +199,17 @@ sequenceDiagram participant E as 引擎 participant RT as Realtime Model - C->>E: 音频流 - E->>RT: 音频输入 - RT-->>E: 音频输出 (流式) - E->>C: 播放音频 + C->>E: 音频/视频/文本输入 + E->>RT: 实时流输入 + RT-->>E: 流式文本/音频输出 + E->>C: 播放或渲染结果 ``` **特点:** -- 更低延迟 (< 300ms) -- 更自然的语音交互 -- 依赖特定模型供应商 +- 交互链路更短,延迟更低 +- 更依赖具体模型供应商的能力边界 +- 适合强调自然对话和多模态体验的入口 --- @@ -234,11 +229,11 @@ sequenceDiagram API->>DB: 查询助手 DB-->>API: 助手数据 API-->>E: 配置信息 - + C->>E: session.start E-->>C: session.started E-->>C: config.resolved - + loop 对话循环 C->>E: 音频帧 (binary) E-->>C: input.speech_started @@ -249,7 +244,7 @@ sequenceDiagram E-->>C: 音频帧 (binary) E-->>C: output.audio.end end - + C->>E: session.stop E->>API: 保存会话记录 API->>DB: 存储 @@ -266,19 +261,19 @@ sequenceDiagram Note over E: 正在播放 TTS 音频 E->>C: 音频帧... - + C->>E: 用户说话 (VAD 检测) E->>E: 触发打断 E->>TTS: 停止合成 E-->>C: output.audio.interrupted - + Note over E: 处理新的用户输入 E-->>C: input.speech_started ``` --- -## 部署架构 +## 部署形态 ### 开发环境 @@ -299,56 +294,19 @@ flowchart LR ## 技术选型 -| 组件 | 技术 | 选型理由 | -|------|------|---------| -| **前端框架** | React 18 | 成熟生态,组件化开发 | -| **状态管理** | Zustand | 轻量级,TypeScript 友好 | -| **UI 组件** | Tailwind CSS | 原子化 CSS,快速开发 | -| **后端框架** | FastAPI | 高性能,自动 API 文档 | -| **WebSocket** | websockets | Python 异步 WebSocket | -| **ORM** | SQLAlchemy | 功能完善,支持多数据库 | -| **数据库** | SQLite/PostgreSQL | 开发简单/生产可靠 | - ---- - -## 扩展性设计 - -### 模型适配器模式 - -```mermaid -classDiagram - class ModelAdapter { - <> - +generate(prompt) string - +stream(prompt) AsyncIterator - } - - class OpenAIAdapter { - +generate(prompt) string - +stream(prompt) AsyncIterator - } - - class AzureAdapter { - +generate(prompt) string - +stream(prompt) AsyncIterator - } - - class LocalAdapter { - +generate(prompt) string - +stream(prompt) AsyncIterator - } - - ModelAdapter <|-- OpenAIAdapter - ModelAdapter <|-- AzureAdapter - ModelAdapter <|-- LocalAdapter -``` - -通过适配器模式,可以轻松接入新的模型供应商。 +| 组件 | 技术 | 说明 | +|------|------|------| +| **前端框架** | React 18 | 管理控制台与调试界面 | +| **状态管理** | Zustand | 前端轻量状态管理 | +| **UI 样式** | Tailwind CSS | 快速构建控制台界面 | +| **后端框架** | FastAPI | 管理接口与配置持久化 | +| **WebSocket** | websockets | 实时事件与音频流通信 | +| **数据库** | SQLite / PostgreSQL | 配置与历史数据存储 | --- ## 相关文档 -- [WebSocket 协议](../api-reference/websocket.md) - 详细的协议规范 -- [部署概览](../deployment/index.md) - Docker 部署 -- [核心概念](../concepts/index.md) - 助手、管线等概念说明 +- [产品概览](index.md) - 产品定位、核心模块与适用场景 +- [引擎架构](../concepts/engines.md) - Pipeline 与 Realtime 的选择指南 +- [WebSocket 协议](../api-reference/websocket.md) - 实时对话事件和消息格式 diff --git a/docs/content/overview/index.md b/docs/content/overview/index.md index b9d0398..3f18efd 100644 --- a/docs/content/overview/index.md +++ b/docs/content/overview/index.md @@ -1,148 +1,84 @@ -# 产品概览 +# 产品概览 -了解 Realtime Agent Studio 的核心功能和设计理念。 +Realtime Agent Studio (RAS) 是一个通过管理控制台与 API 构建、部署和运营实时多模态助手的开源平台。 --- -## 什么是 RAS? +## 产品定位 -Realtime Agent Studio (RAS) 是一个**开源的实时交互智能体工作平台**,让开发者能够快速构建和部署具备语音对话能力的 AI 助手。 +RAS 面向需要构建实时语音或视频助手的团队,目标不是替代你的业务系统,而是提供一套可组合的助手基础设施: -### 核心价值 +- **控制台**:让团队快速配置助手、资源库、知识库、工具、工作流与评估策略 +- **API 与实时运行时**:让应用、设备和第三方系统稳定接入实时对话能力 +- **运维与分析能力**:让团队能观察会话效果、排查问题并持续迭代助手质量 -| 价值主张 | 说明 | -|---------|------| -| **低代码配置** | 可视化界面配置助手,无需编写复杂代码 | -| **实时交互** | 毫秒级响应,支持语音打断,自然对话体验 | -| **开放灵活** | 支持多种模型供应商,自由选择最适合的方案 | -| **私有部署** | 完全自主可控,数据不出域 | +如果你把实时助手看作一条完整的产品链路,RAS 负责其中的“构建、接入、运行、观测”四个阶段。 ---- +## 核心模块 -## 功能模块 +| 模块 | 负责什么 | 适合谁使用 | +|------|----------|------------| +| **助手** | 定义角色、行为、模型、知识、工具和会话策略 | 产品、运营、算法、开发 | +| **引擎** | 承载实时语音/多模态对话,输出事件流和音频流 | 开发、基础设施 | +| **资源库** | 管理 LLM、ASR、TTS 等外部能力接入 | 平台管理员、开发 | +| **知识库 / 工具 / 工作流** | 让助手获得领域知识、外部执行能力和复杂流程控制 | 业务设计者、开发 | +| **分析与评估** | 记录会话、监控指标、做自动化回归和效果评估 | 运营、QA、开发 | -```mermaid -mindmap - root((RAS)) - 助手管理 - 创建配置 - 提示词编辑 - 模型选择 - 工具调用 - 资源库 - LLM 模型 - ASR 模型 - TTS 声音 - 知识库 - 文档上传 - 向量检索 - RAG 问答 - 监控分析 - 会话回放 - 数据统计 - 自动测试 - 部署集成 - WebSocket API - REST API - SDK -``` +## 为什么是“控制台 + API” -### 助手管理 +RAS 采用“控制台配置 + API 接入”的组合方式,而不是把所有内容都固化在代码里: -创建和配置智能对话助手: +- **控制台负责提效**:让非后端角色也能参与提示词、工具、知识、流程的配置与调优 +- **API 负责集成**:让产品团队继续用自己的前端、服务端或设备侧应用承载最终体验 +- **同一套助手配置可复用**:控制台保存的助手定义可以被不同渠道重复接入和评估 -- **系统提示词** - 定义助手角色和行为 -- **模型配置** - 选择 LLM、ASR、TTS 模型 -- **工具调用** - 配置 Webhook 和客户端工具 -- **开场白** - 设置首轮对话模式 - -### 资源库 - -集中管理各类模型资源: - -- **语音识别 (ASR)** - 多供应商 ASR 模型管理 -- **大语言模型 (LLM)** - OpenAI、Azure、本地模型 -- **语音合成 (TTS)** - 多音色声音资源 - -### 知识库 - -为助手提供专业知识: - -- **文档上传** - 支持 PDF、Word、Markdown 等格式 -- **向量化索引** - 自动分块和向量化 -- **RAG 检索** - 基于语义的知识检索 - -### 监控分析 - -全面的数据分析能力: - -- **会话回放** - 完整链路日志和音频回放 -- **实时仪表盘** - 并发数、延迟、错误率统计 -- **自动化测试** - 批量测试和效果评估 - ---- - -## 对比其他方案 - -| 特性 | RAS | Vapi | Retell | ElevenLabs | -|------|-----|------|--------|------------| -| **开源** | :white_check_mark: | :x: | :x: | :x: | -| **私有部署** | :white_check_mark: | :x: | :x: | :x: | -| **管线式引擎** | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | -| **多模态模型** | :white_check_mark: | :white_check_mark: | :x: | :white_check_mark: | -| **自定义 ASR/TTS** | :white_check_mark: | 有限 | 有限 | :x: | -| **知识库** | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | -| **工作流编辑** | 开发中 | :white_check_mark: | :x: | :x: | -| **定价** | 免费 | 按量付费 | 按量付费 | 按量付费 | - ---- - -## 适用场景 +## 典型使用方式
-- :telephone_receiver: **智能客服** +- :material-headset: **客户服务与运营自动化** --- - 7x24 小时自动接听,处理常见咨询,复杂问题转人工 + 在客服、外呼、预约、售后等场景中接入实时语音助手,并保留人工接管与工具调用能力。 -- :hospital: **医疗问诊** +- :material-school-outline: **培训、陪练与问答** --- - 预问诊信息收集,健康咨询,用药提醒 + 用知识库、提示词和流程编排构建可持续优化的教学、培训或辅导助手。 -- :school: **教育培训** +- :material-domain: **企业内部助手** --- - 口语练习,知识问答,个性化辅导 + 通过私有部署、内部知识库和业务系统工具,把助手接入内部流程或设备终端。 -- :handshake: **销售助手** +- :material-devices: **多端集成** --- - 产品介绍,需求挖掘,预约安排 - -- :headphones: **语音助手** - - --- - - 智能家居控制,日程管理,信息查询 - -- :robot: **虚拟人** - - --- - - 数字人直播,虚拟主播,交互式展示 + 通过 WebSocket API 将同一个助手接入 Web、移动端、坐席工作台或自有硬件设备。
---- +## 与其他方案的差异 -## 下一步 +本页是站内唯一保留“产品对比”视角的地方,用于帮助你快速判断 RAS 的定位边界。 -- [快速开始](../quickstart/index.md) - 5 分钟创建第一个助手 -- [系统架构](architecture.md) - 深入了解技术实现 -- [核心概念](../concepts/index.md) - 学习关键概念 +| 特性 | RAS | Vapi | Retell | ElevenLabs Agents | +|------|-----|------|--------|-------------------| +| **开源** | :white_check_mark: | :x: | :x: | :x: | +| **私有部署** | :white_check_mark: | :x: | :x: | :x: | +| **Pipeline 引擎** | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | +| **Realtime / 多模态引擎** | :white_check_mark: | :white_check_mark: | :x: | :white_check_mark: | +| **自定义 ASR / TTS** | :white_check_mark: | 有限 | 有限 | :x: | +| **知识库与工具扩展** | :white_check_mark: | :white_check_mark: | :white_check_mark: | 有限 | +| **工作流编排** | 开发中 | :white_check_mark: | :x: | :x: | +| **数据与链路可观测** | :white_check_mark: | 有限 | 有限 | 有限 | + +## 继续阅读 + +- [系统架构](architecture.md) - 从服务边界、数据流和部署形态理解系统如何组成 +- [核心概念](../concepts/index.md) - 先建立助手、引擎与工作流的心智模型 +- [快速开始](../quickstart/index.md) - 以最短路径创建第一个助手 diff --git a/docs/content/quickstart/dashboard.md b/docs/content/quickstart/dashboard.md index 57643c0..9c1e049 100644 --- a/docs/content/quickstart/dashboard.md +++ b/docs/content/quickstart/dashboard.md @@ -1,233 +1,44 @@ -# 资源库配置详解 +# 资源准备清单 -本页面详细介绍资源库中各类资源的配置方法和最佳实践。 +本页保留原“资源库配置详解”链接,但在本轮文档收敛后,它只承担快速开始阶段的资源核对职责。 -## 语音识别 (ASR) 配置 +## 你至少要准备什么 -### 支持的接口类型 +在创建第一个助手前,至少确认以下三类资源都已经可用: -| 接口类型 | 说明 | -|---------|------| -| OpenAI Compatible | 兼容 OpenAI 语音识别 API 格式的服务 | +| 资源 | 为什么需要 | 正式说明页 | +|------|------------|------------| +| **LLM 模型** | 负责理解与生成回复 | [LLM 模型](../customization/models.md) | +| **ASR 资源** | 负责把语音输入转写为文本 | [语音识别](../customization/asr.md) | +| **TTS 声音资源** | 负责把文本回复合成为语音 | [声音资源](../customization/voices.md) | -### 配置字段说明 +## 上手前自检 -| 字段 | 必填 | 说明 | -|-----|-----|------| -| 模型名称 | 是 | 自定义显示名称,便于识别 | -| 接口类型 | 是 | 当前支持 OpenAI Compatible | -| 语言 | 是 | 识别语言:中文/英文/多语言 | -| Model Name | 否 | API 请求中的 model 参数 | -| Base URL | 是 | API 服务地址 | -| API Key | 是 | 服务认证密钥 | -| 热词 | 否 | 逗号分隔的专有名词列表 | -| 标点增强 | 否 | 是否自动添加标点 | -| 文本归一化 | 否 | 规范化数字、日期等格式 | -| 启用 | 否 | 是否在选择列表中显示 | +### LLM -### 推荐配置示例 +- 已配置供应商、模型名称、Base URL 和凭证 +- 已明确该模型用于文本生成、嵌入还是重排 +- 已准备保守的默认参数,而不是先追求极端效果 -**硅基流动 SenseVoice** +### ASR -``` -模型名称:SenseVoice 中文 -Model Name:FunAudioLLM/SenseVoiceSmall -Base URL:https://api.siliconflow.cn/v1 -语言:中文 -``` +- 已确认目标语言与模型匹配 +- 已准备必要热词或专有名词词表 +- 已能用固定样本测试识别准确率和延迟 -### 测试识别效果 +### TTS -1. 在 ASR 列表中找到目标模型 -2. 点击 **试听识别** 按钮 -3. 选择以下测试方式之一: - - **上传文件**:拖拽或选择音频文件 - - **麦克风录音**:点击录音按钮开始录制 -4. 点击 **开始识别** 查看结果 -5. 检查识别文本、延迟和置信度 +- 已选择主音色,并完成至少一次试听 +- 已确认该声音适合实时对话,而不是仅适合离线播报 +- 已为默认语速、音量等参数设定初始值 ---- +## 不在本页展开的内容 -## 大语言模型 (LLM) 配置 +字段说明、供应商差异、参数建议和最佳实践已经分别收敛到正式能力页: -### 支持的模型类型 +- [LLM 模型](../customization/models.md) +- [语音识别](../customization/asr.md) +- [声音资源](../customization/voices.md) +- [TTS 参数](../customization/tts.md) -| 类型 | 用途 | -|-----|------| -| 文本 (text) | 对话生成,用于助手核心交互 | -| 嵌入 (embedding) | 向量化,用于知识库检索 | -| 重排 (rerank) | 结果重排序,优化检索结果 | - -### 配置字段说明 - -| 字段 | 必填 | 说明 | -|-----|-----|------| -| 厂商 | 是 | 当前支持 OpenAI Compatible | -| 模型类型 | 是 | 文本/嵌入/重排 | -| 模型名称 | 是 | 自定义显示名称 | -| 模型标识 | 否 | API 请求中的 model 参数 | -| Base URL | 是 | API 服务地址 | -| API Key | 是 | 服务认证密钥 | -| 温度 | 否 | 输出随机性 (0-2),仅文本模型 | -| 上下文长度 | 否 | 最大 token 数 | -| 启用 | 否 | 是否在选择列表中显示 | - -### 推荐配置示例 - -**OpenAI GPT-4o Mini** - -``` -模型名称:GPT-4o Mini -模型类型:文本 -模型标识:gpt-4o-mini -Base URL:https://api.openai.com/v1 -温度:0.7 -上下文长度:8192 -``` - -**硅基流动 Qwen** - -``` -模型名称:Qwen2.5-7B -模型类型:文本 -模型标识:Qwen/Qwen2.5-7B-Instruct -Base URL:https://api.siliconflow.cn/v1 -温度:0.7 -``` - -### 测试模型效果 - -1. 在 LLM 列表中找到目标模型 -2. 点击 **预览** 按钮 -3. 配置测试参数: - - **System Prompt**:系统提示词 - - **User Message**:测试消息 - - **Temperature**:温度参数 - - **Max Tokens**:最大输出长度 -4. 点击 **开始预览** 查看模型回复 -5. 检查回复内容、延迟和 token 用量 - ---- - -## 声音资源 (TTS) 配置 - -### 支持的接口类型 - -| 接口类型 | 说明 | -|---------|------| -| OpenAI Compatible | 兼容 OpenAI TTS API 格式的服务 | -| DashScope | 阿里云 DashScope 语音合成服务 | - -### 配置字段说明 - -| 字段 | 必填 | 说明 | -|-----|-----|------| -| 厂商 | 是 | OpenAI Compatible 或 DashScope | -| 声音名称 | 是 | 自定义显示名称 | -| 模型 | 是 | TTS 模型标识 | -| 声音 ID | 是 | 音色标识符 | -| Base URL | 否 | API 服务地址 | -| API Key | 是 | 服务认证密钥 | -| 语速 | 否 | 说话速度 (0.5-2.0),默认 1.0 | -| 增益 | 否 | 音量调节 (-10 to 10 dB) | -| 音调 | 否 | 声音高低 (-12 to 12) | -| 性别 | 否 | 声音性别标签 | -| 语言 | 否 | 声音语言标签 | -| 备注 | 否 | 声音特点描述 | - -### 推荐配置示例 - -**硅基流动 CosyVoice** - -``` -厂商:OpenAI Compatible -声音名称:Anna 中文女声 -模型:FunAudioLLM/CosyVoice2-0.5B -声音 ID:FunAudioLLM/CosyVoice2-0.5B:anna -Base URL:https://api.siliconflow.cn/v1 -语速:1.0 -性别:女 -语言:中文 -``` - -**DashScope TTS** - -``` -厂商:DashScope -声音名称:Cherry -模型:qwen3-tts-flash-realtime -声音 ID:Cherry -Base URL:wss://dashscope.aliyuncs.com/api-ws/v1/realtime -语速:1.0 -``` - -### CosyVoice 可用音色 - -| 音色 ID | 性别 | 风格 | -|--------|-----|------| -| alex | 男 | 成熟稳重 | -| anna | 女 | 温柔亲切 | -| bella | 女 | 活泼甜美 | -| benjamin | 男 | 年轻活力 | -| charles | 男 | 专业商务 | -| claire | 女 | 清新自然 | -| david | 男 | 沉稳大气 | -| diana | 女 | 优雅知性 | - -### 试听声音效果 - -1. 在声音列表中找到目标声音 -2. 点击 **播放** 按钮 -3. 系统会自动合成一段试听语音 -4. 检查声音效果是否符合预期 - -### 克隆声音 - -如需使用自定义声音: - -1. 点击 **克隆声音** 按钮 -2. 上传参考音频文件(WAV/MP3) -3. 填写声音名称和描述 -4. 点击 **开始克隆** - -!!! note "声音克隆说明" - 声音克隆功能需要 TTS 服务支持。上传的参考音频建议为 10-30 秒的清晰人声录音。 - ---- - -## 配置最佳实践 - -### 资源命名规范 - -建议使用清晰的命名规范,便于后续管理: - -``` -[厂商/模型]-[用途/语言]-[特点] -``` - -示例: -- `SF-SenseVoice-中文` -- `OpenAI-GPT4o-对话` -- `SF-CosyVoice-Anna女声` - -### 多环境管理 - -如果有测试和生产环境,建议: - -1. 为不同环境创建独立的资源配置 -2. 在名称中标注环境,如 `GPT4o-Prod`、`GPT4o-Test` -3. 通过"启用"开关控制可见性 - -### 成本优化 - -| 场景 | 推荐配置 | -|-----|---------| -| 开发测试 | 使用低成本模型,如 GPT-4o-mini | -| 生产环境 | 根据质量要求选择合适模型 | -| 高并发 | 考虑使用本地部署的开源模型 | - ---- - -## 下一步 - -资源配置完成后,请返回 [快速开始](index.md) 继续创建助手。 +准备完成后,请回到 [快速开始](index.md) 继续创建助手。 diff --git a/docs/content/quickstart/index.md b/docs/content/quickstart/index.md index 3e15783..47f3b7c 100644 --- a/docs/content/quickstart/index.md +++ b/docs/content/quickstart/index.md @@ -1,221 +1,69 @@ -# 快速开始 +# 快速开始 -5 分钟创建你的第一个 AI 助手。 +本页负责“创建第一个助手”的最短路径。环境要求、配置文件和部署方式统一放在 [环境与部署](../getting-started/index.md)。 -## 概述 +## 目标 -本指南将帮助你通过控制台快速创建一个能够进行语音对话的智能助手。在创建助手之前,需要先在资源库(Library)中配置所需的模型资源。 +完成本页后,你应该已经: + +1. 准备好 1 个 LLM、1 个 ASR、1 个 TTS 资源 +2. 创建并保存 1 个助手 +3. 完成至少 1 轮测试对话 +4. 拿到接入应用所需的 `assistant_id` 和 WebSocket 地址 ## 前提条件 -- 已部署 Realtime Agent Studio (RAS) 服务 -- 拥有 LLM / ASR / TTS 服务的 API Key +- 已部署 Realtime Agent Studio(RAS)服务 +- 已准备可用的 LLM / ASR / TTS 凭证 +- 已能访问控制台与 WebSocket 服务 -## 配置流程 +## 第一步:准备资源 -创建助手前,需要先准备好三种核心资源: +创建助手之前,先准备三类资源: -``` -┌─────────────────────────────────────────────────────────┐ -│ 资源库配置 │ -├─────────────────────────────────────────────────────────┤ -│ 1. 语音识别 (ASR) ─→ 将用户语音转为文字 │ -│ 2. 模型接入 (LLM) ─→ 理解用户意图并生成回复 │ -│ 3. 声音资源 (TTS) ─→ 将文字回复转为语音输出 │ -└─────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────┐ -│ 创建助手 │ -├─────────────────────────────────────────────────────────┤ -│ 配置提示词 → 选择模型 → 配置语音 → 测试 → 发布 │ -└─────────────────────────────────────────────────────────┘ -``` +- **LLM 模型**:决定助手如何理解和生成回复。详见 [LLM 模型](../customization/models.md) +- **ASR 资源**:决定语音输入如何转写。详见 [语音识别](../customization/asr.md) +- **TTS 声音资源**:决定回复如何被合成为语音。详见 [声音资源](../customization/voices.md) ---- - -## 第一步:配置资源库 - -在创建助手之前,需要先在资源库中添加 ASR、LLM、TTS 三种资源。 - -### 1.1 添加语音识别模型 (ASR) - -语音识别模型负责将用户的语音输入转换为文字。 - -1. 在左侧导航栏点击 **语音识别** -2. 点击 **添加模型** 按钮 -3. 填写配置信息: - -| 配置项 | 说明 | 示例值 | -|-------|------|--------| -| 模型名称 | 自定义显示名称 | SenseVoice CN | -| 接口类型 | 选择 OpenAI Compatible | OpenAI Compatible | -| 语言 | 识别语言 | 中文 (Chinese) | -| Model Name | 模型标识符 | FunAudioLLM/SenseVoiceSmall | -| Base URL | API 服务地址 | https://api.siliconflow.cn/v1 | -| API Key | 服务密钥 | sk-xxxxxxxx | - -4. 可选配置: - - **热词**:添加专有名词提高识别准确率 - - **标点增强**:自动添加标点符号 - - **文本归一化**:规范化数字、日期等格式 - -5. 点击 **确认添加** - -!!! tip "试听识别功能" - 添加完成后,可以点击列表中的试听按钮,上传或录制音频测试识别效果。 - -### 1.2 添加大语言模型 (LLM) - -大语言模型是助手的"大脑",负责理解用户意图并生成回复。 - -1. 在左侧导航栏点击 **模型接入** -2. 点击 **添加模型** 按钮 -3. 填写配置信息: - -| 配置项 | 说明 | 示例值 | -|-------|------|--------| -| 厂商 | 接口类型 | OpenAI Compatible | -| 模型类型 | 文本/嵌入/重排 | 文本 | -| 模型名称 | 自定义显示名称 | GPT-4o Mini | -| 模型标识 | API 中的 model 参数 | gpt-4o-mini | -| Base URL | API 服务地址 | https://api.openai.com/v1 | -| API Key | 服务密钥 | sk-xxxxxxxx | -| 温度 | 输出随机性 (0-2) | 0.7 | -| 上下文长度 | 最大 token 数 | 8192 | - -4. 点击 **确认添加** - -!!! tip "预览功能" - 添加完成后,可以点击预览按钮测试模型是否配置正确。 - -### 1.3 添加声音资源 (TTS) - -声音资源用于将助手的文字回复转换为语音输出。 - -1. 在左侧导航栏点击 **声音资源** -2. 点击 **添加声音** 按钮 -3. 填写配置信息: - -| 配置项 | 说明 | 示例值 | -|-------|------|--------| -| 厂商 | 接口类型 | OpenAI Compatible 或 DashScope | -| 声音名称 | 自定义显示名称 | 客服小美 | -| 模型 | TTS 模型标识 | FunAudioLLM/CosyVoice2-0.5B | -| 声音 ID | 音色标识 | FunAudioLLM/CosyVoice2-0.5B:anna | -| Base URL | API 服务地址 | https://api.siliconflow.cn/v1 | -| API Key | 服务密钥 | sk-xxxxxxxx | -| 语速 | 说话速度 (0.5-2.0) | 1.0 | -| 增益 | 音量调节 (-10 to 10 dB) | 0 | -| 音调 | 声音高低 (-12 to 12) | 0 | -| 性别 | 声音性别 | 女 | -| 语言 | 声音语言 | 中文 | - -4. 点击 **确认添加** - -!!! tip "试听功能" - 添加完成后,可以在列表中点击播放按钮试听声音效果。 - ---- +如果你想先检查“资源是否准备齐”,可以看 [资源准备清单](dashboard.md)。 ## 第二步:创建助手 -资源配置完成后,可以开始创建助手。 +1. 进入控制台中的 **助手** 页面 +2. 新建一个助手,并填写最小必要信息: + - **助手名称**:让团队知道它服务于什么场景 + - **系统提示词**:先定义角色、任务和限制 + - **首轮模式**:决定由助手先说还是等待用户开口 +3. 绑定默认模型: + - 文本生成使用一个 LLM + - 语音输入使用一个 ASR + - 语音输出使用一个 TTS 声音资源 -### 2.1 新建助手 +如果你想把助手设计得更稳,继续阅读: -1. 在左侧导航栏点击 **助手管理** -2. 点击 **新建助手** 按钮 -3. 系统会自动创建一个名为 "New Assistant" 的助手 +- [助手概念](../concepts/assistants.md) +- [配置选项](../concepts/assistants/configuration.md) +- [提示词指南](../concepts/assistants/prompts.md) -### 2.2 配置全局设置 +## 第三步:补充能力 -在助手详情页的 **全局** 标签页中配置: +最小助手可以只依赖提示词和模型;更复杂的场景通常还需要以下能力: -#### 基本信息 +- **知识库**:让助手回答私有领域问题。见 [知识库](../customization/knowledge-base.md) +- **工具**:让助手执行查单、预约、查询等外部操作。见 [工具](../customization/tools.md) +- **工作流**:让助手处理多步骤、多分支流程。见 [工作流](../customization/workflows.md) -- **助手名称**:修改为有意义的名称,如 "客服助手" -- **语言**:选择助手的对话语言 +## 第四步:测试并发布 -#### 系统提示词 +1. 打开助手测试面板,先验证文本对话,再验证语音输入输出 +2. 观察事件流、转写、工具调用和最终回复是否符合预期 +3. 保存当前配置,并确认该助手已可用于外部接入 -配置系统提示词,定义助手的角色和行为: +更系统的验证方式见 [测试调试](../concepts/assistants/testing.md)。 -``` -你是一个友好的客服助手。你的任务是帮助用户解答问题。 +## 第五步:接入应用 -要求: -- 保持友好和专业的语气 -- 回答要简洁明了,每次回复控制在 2-3 句话 -- 如果不确定答案,请如实告知 -``` - -#### 开场白配置 - -设置对话开始时助手的问候语: - -- **首回合模式**:选择 "助手先说" 让助手主动开场 -- **开场白内容**:如 "你好,我是智能客服助手,请问有什么可以帮您?" - -### 2.3 配置模型 - -在 **模型** 标签页中选择之前添加的资源: - -| 配置项 | 说明 | -|-------|------| -| LLM 模型 | 选择在模型接入中添加的大语言模型 | -| ASR 模型 | 选择在语音识别中添加的 ASR 模型 | - -### 2.4 配置语音 - -在 **语音** 标签页中配置: - -| 配置项 | 说明 | -|-------|------| -| 启用语音输出 | 开启后助手会用语音回复 | -| 选择声音 | 选择在声音资源中添加的音色 | -| 语速 | 可微调当前助手的说话速度 | - -### 2.5 保存配置 - -完成配置后,点击页面顶部的 **保存** 按钮。 - ---- - -## 第三步:测试助手 - -### 3.1 打开测试面板 - -点击助手卡片右上角的 **测试** 按钮,打开实时调试面板。 - -### 3.2 进行对话测试 - -| 测试场景 | 示例问题 | 预期结果 | -|---------|---------|---------| -| 基础问候 | "你好" | 助手友好回应 | -| 功能询问 | "你能做什么?" | 介绍自身能力 | -| 业务问题 | 根据你的场景设计 | 正确回答 | -| 边界测试 | 无关问题 | 婉拒或引导 | - -### 3.3 检查各环节 - -在调试面板中可以看到: - -- **ASR 输出**:用户语音识别结果 -- **LLM 输入/输出**:模型的输入和生成内容 -- **TTS 状态**:语音合成状态 - ---- - -## 第四步:发布助手 - -测试通过后: - -1. 点击 **发布** 按钮 -2. 复制生成的连接信息: - - `assistant_id`:用于 API 调用 - - WebSocket 地址:用于实时对话 - -### 嵌入到应用 +最小接入方式是使用 WebSocket API 建立实时会话: ```javascript const ws = new WebSocket('ws://your-server/ws?assistant_id=YOUR_ASSISTANT_ID'); @@ -223,54 +71,28 @@ const ws = new WebSocket('ws://your-server/ws?assistant_id=YOUR_ASSISTANT_ID'); ws.onopen = () => { ws.send(JSON.stringify({ type: 'session.start', - audio: { - encoding: 'pcm_s16le', - sample_rate_hz: 16000, - channels: 1 - } + audio: { encoding: 'pcm_s16le', sample_rate_hz: 16000, channels: 1 } })); }; - -ws.onmessage = (event) => { - console.log('收到消息:', event.data); -}; ``` ---- +你通常只需要两项信息: -## 常见问题 +- `assistant_id`:指定接入哪个助手 +- WebSocket 地址:由引擎服务提供实时对话入口 -### 资源库中添加模型失败? +完整协议见 [WebSocket 协议](../api-reference/websocket.md)。 -1. 检查 API Key 是否正确 -2. 确认 Base URL 格式正确(通常以 `/v1` 结尾) -3. 验证网络能否访问对应的 API 服务 +## 常见卡点 -### 助手不回复? - -1. 检查是否已选择 LLM 模型 -2. 确认 LLM 模型配置正确(可在模型接入页面预览测试) -3. 查看浏览器控制台是否有错误 - -### 语音识别不准确? - -1. 检查是否选择了正确的语言 -2. 尝试添加热词提高专有名词识别率 -3. 确保录音设备工作正常 - -### 语音无法播放? - -1. 检查浏览器是否允许自动播放音频 -2. 确认已选择声音并正确配置 -3. 在声音资源页面点击试听确认配置正确 - ---- +- 资源配置不生效:回到 [资源准备清单](dashboard.md) 检查三类资源是否都已准备好 +- 助手不回复:先看 [测试调试](../concepts/assistants/testing.md),再进入 [故障排查](../resources/troubleshooting.md) +- 回复质量不稳定:优先检查 [提示词指南](../concepts/assistants/prompts.md) 与 [知识库](../customization/knowledge-base.md) ## 下一步 -恭喜!你已成功创建了第一个 AI 助手。接下来可以: +- [环境与部署](../getting-started/index.md) - 补全环境、配置和部署细节 +- [构建助手](../concepts/assistants.md) - 深入配置助手、模型、知识库、工具与工作流 +- [API 参考](../api-reference/index.md) - 查看管理接口与实时协议 + -- [配置知识库](../customization/knowledge-base.md) - 让助手回答专业问题 -- [添加工具](../customization/tools.md) - 扩展助手能力 -- [查看 API 文档](../api-reference/websocket.md) - 深入了解协议细节 -- [Docker 部署](../deployment/index.md) - 使用容器运行 diff --git a/docs/content/resources/faq.md b/docs/content/resources/faq.md index 0b1b729..1c59e42 100644 --- a/docs/content/resources/faq.md +++ b/docs/content/resources/faq.md @@ -1,110 +1,59 @@ -# 常见问题 +# 常见问题 -## API Key 配置 +本页只提供简短回答和跳转建议;如果你需要逐步排查,请直接进入 [故障排查](troubleshooting.md)。 -### Q: 如何配置 API Key? +## Q: 我应该先看哪一部分文档? -进入 **LLM 库** 或 **语音库** 页面,点击对应模型的配置按钮填写 API Key。 +- 想了解产品是什么:看 [产品概览](../overview/index.md) +- 想先把服务跑起来:看 [环境与部署](../getting-started/index.md) +- 想最快创建第一个助手:看 [快速开始](../quickstart/index.md) +- 想系统完成助手配置:从 [助手概览](../concepts/assistants.md) 开始 -**步骤:** +## Q: 如何配置模型或 API Key? -1. 在左侧导航栏选择 **模型配置** -2. 选择 **LLM 库** 或 **语音库** -3. 点击已添加模型的 **编辑** 按钮 -4. 在 API Key 字段填写你的密钥 -5. 点击 **保存** +进入对应资源页完成配置: -## 助手问题 +- LLM:见 [LLM 模型](../customization/models.md) +- ASR:见 [语音识别](../customization/asr.md) +- TTS:见 [声音资源](../customization/voices.md) -### Q: 助手无法回复? +## Q: 助手为什么不回复? -可能的原因和解决方案: +通常先检查三件事: -1. **检查模型配置是否正确** - - 确认 API Key 已正确填写 - - 测试模型连接是否正常 +- 助手是否已绑定可用的模型资源 +- 提示词、知识库或工具是否配置完整 +- WebSocket 会话是否已经正常建立 -2. **确认知识库已正确关联** - - 进入助手配置的 **知识** 标签页 - - 检查是否已选择知识库 +下一步: -3. **查看系统日志排查错误** - - 打开浏览器开发者工具(F12) - - 检查 Console 和 Network 标签页 +- 助手行为验证:看 [测试调试](../concepts/assistants/testing.md) +- 逐步排查:看 [故障排查](troubleshooting.md) -### Q: 助手回复内容不相关? +## Q: 回复为什么不准确或不稳定? -- 检查系统提示词是否清晰明确 -- 调整 Temperature 参数(降低可提高准确性) -- 确认知识库内容与问题相关 -- 增加知识库相似度阈值 +优先检查: -## 语音识别 +- 提示词是否明确了角色、任务和限制 +- 是否应该补充知识库,而不是继续堆叠提示词 +- 是否需要把复杂业务改成工作流,而不是单轮问答 -### Q: 语音识别不准确? +相关文档: -1. **确认 ASR 模型选择正确** - - 中文场景推荐使用 SenseVoice - - 英文场景推荐使用 Whisper +- [提示词指南](../concepts/assistants/prompts.md) +- [知识库](../customization/knowledge-base.md) +- [工作流](../customization/workflows.md) -2. **检查音频采样率** - - 推荐采样率:16kHz - - 推荐格式:PCM 16-bit +## Q: 语音识别或语音播放效果不好怎么办? -3. **确认语言设置匹配** - - 在 ASR 配置中选择正确的语言 +- 输入侧问题先看 [语音识别](../customization/asr.md) +- 输出侧问题先看 [声音资源](../customization/voices.md) 和 [TTS 参数](../customization/tts.md) +- 需要逐步定位链路问题时,再看 [故障排查](troubleshooting.md) -### Q: 语音延迟较高? +## Q: 页面空白、接口报错或连接不上怎么办? -- 检查网络连接稳定性 -- 尝试切换 ASR 服务提供商 -- 降低音频质量以减少传输数据量 +这是典型的环境或链路问题: -## 语音合成 +- 先确认 [环境与部署](../getting-started/index.md) 中的三个服务都已启动 +- 再进入 [故障排查](troubleshooting.md) 按连接、API、页面加载或性能问题分类处理 -### Q: TTS 声音不自然? - -- 尝试不同的音色选项 -- 调整语速参数(推荐 0.8-1.2) -- 选择与内容风格匹配的声音 - -### Q: TTS 无法播放? - -1. 检查浏览器是否允许自动播放音频 -2. 确认 TTS API Key 配置正确 -3. 检查网络连接 - -## 知识库 - -### Q: 知识库检索无结果? - -- 确认文档已成功上传 -- 降低相似度阈值(默认 0.7) -- 增加返回结果数量 -- 检查文档内容是否与查询相关 - -### Q: 文档上传失败? - -- 检查文件大小是否超过 10MB -- 确认文件格式支持(MD/PDF/TXT) -- 尝试减小文档内容 - -## 部署问题 - -### Q: 页面空白或加载失败? - -1. 检查浏览器控制台错误信息 -2. 确认后端服务已启动 -3. 检查 VITE_API_URL 环境变量配置 - -### Q: API 请求失败? - -- 确认 VITE_API_URL 配置正确 -- 检查后端服务是否运行 -- 查看网络请求响应状态码 - -### Q: 静态资源 404? - -- 检查 Nginx `try_files` 配置 -- 确认构建产物路径正确 -- 检查文件权限设置 diff --git a/docs/content/roadmap.md b/docs/content/roadmap.md index bd96694..d7cc311 100644 --- a/docs/content/roadmap.md +++ b/docs/content/roadmap.md @@ -1,4 +1,4 @@ -# 开发路线图 +# 开发路线图 本页面展示 Realtime Agent Studio 的开发计划和进度。 @@ -8,50 +8,47 @@ ### 实时交互引擎 -- [x] **管线式全双工引擎** - ASR/LLM/TTS 流水线架构 +- [x] **管线式全双工引擎** - ASR / LLM / TTS 流水线架构 - [x] **智能打断处理** - VAD + EOU 检测 -- [x] **OpenAI 兼容接口** - ASR/TTS 标准接口适配 +- [x] **OpenAI 兼容接口** - ASR / TTS 标准接口适配 - [x] **DashScope TTS** - 阿里云语音合成适配 -### 智能体配置管理 +### 助手配置管理 - [x] **系统提示词编辑** - Prompt 配置,动态变量注入 -- [x] **模型选择** - LLM/ASR/TTS 模型管理界面 +- [x] **模型选择** - LLM / ASR / TTS 模型管理界面 - [x] **工具调用配置** - Webhook 工具 + 客户端工具 -### 交互测试工具 +### 调试与观察 - [x] **实时调试控制台** - WebSocket 调试连接示例 +- [x] **完整会话回放** - 音频 + 转写 + LLM 响应 +- [x] **会话检索筛选** - 按时间 / 助手 / 状态筛选 ### 开放接口 - [x] **WebSocket 协议** - `/ws` 端点完整实现 - [x] **RESTful 接口** - 完整的 CRUD API -### 交互历史监控 - -- [x] **完整会话回放** - 音频 + 转写 + LLM 响应 -- [x] **会话检索筛选** - 按时间/助手/状态筛选 - --- ## 开发中 :construction: -### 智能体配置管理 +### 助手与能力编排 -- [ ] **私有化 ASR/TTS 适配** - 本地模型接入 +- [ ] **私有化 ASR / TTS 适配** - 本地模型接入 - [ ] **工作流编辑** - 可视化流程编排 - [ ] **知识库关联** - RAG 文档管理 ### 实时交互引擎 -- [ ] **原生多模态模型** - Step Audio 接入(GPT-4o Realtime/Gemini Live 国内环境受限) +- [ ] **原生多模态模型** - Step Audio 接入(GPT-4o Realtime / Gemini Live 国内环境受限) +- [ ] **WebRTC 协议** - `/webrtc` 端点 ### 开放接口 -- [ ] **SDK 支持** - JavaScript/Python SDK -- [ ] **电话接入** - 电话呼入自动接听/自动呼出接口和批量呼出 -- [ ] **WebRTC 协议** - `/webrtc` 端点 +- [ ] **SDK 支持** - JavaScript / Python SDK +- [ ] **电话接入** - 电话呼入自动接听 / 自动呼出接口和批量呼出 ### 效果评估 @@ -65,13 +62,14 @@ - [ ] **Webhook 回调** - 会话事件通知机制 -### 效果评估 +### 数据与评估 - [ ] **实时仪表盘增强** - 完善统计看板功能 +- [ ] **评估闭环** - 测试、评分、回归与变更追踪 -### 企业特性 +### 企业能力 -- [ ] **多租户支持** - 团队/组织管理 +- [ ] **多租户支持** - 团队 / 组织管理 - [ ] **权限管理** - RBAC 角色权限控制 - [ ] **审计日志** - 操作记录追踪 @@ -79,7 +77,7 @@ - [ ] **更多模型供应商** - 讯飞、百度、腾讯等 - [ ] **CRM 集成** - Salesforce、HubSpot 等 -- [ ] **呼叫中心集成** - SIP/PSTN 网关 +- [ ] **呼叫中心集成** - SIP / PSTN 网关 --- @@ -94,23 +92,19 @@ --- -## 参考项目 +## 生态参考 ### 开源项目 -* [Livekit Agent](https://github.com/livekit/agents) -* [Pipecat](https://github.com/pipecat-ai/pipecat) -* [vison-agent](https://github.com/GetStream/Vision-Agents) -* [active-call](https://github.com/miuda-ai/active-call) -* [TEN](https://github.com/TEN-framework/ten-framework) -* [airi](https://github.com/moeru-ai/airi) -* [Vocode Core](https://github.com/vocodedev/vocode-core) -* [awesome-voice-agents](https://github.com/yzfly/awesome-voice-agents) -### 商业项目 -* [Vapi](https://vapi.ai) -* [Retell](https://www.retellai.com) -* [Sierra](https://sierra.ai/product/voice) -* [Bolna](https://platform.bolna.ai) +- [Livekit Agent](https://github.com/livekit/agents) +- [Pipecat](https://github.com/pipecat-ai/pipecat) +- [Vision Agents](https://github.com/GetStream/Vision-Agents) +- [active-call](https://github.com/miuda-ai/active-call) +- [TEN](https://github.com/TEN-framework/ten-framework) +- [airi](https://github.com/moeru-ai/airi) +- [Vocode Core](https://github.com/vocodedev/vocode-core) +- [awesome-voice-agents](https://github.com/yzfly/awesome-voice-agents) -### 文档 -* [Voice AI & Voice Agents](https://voiceaiandvoiceagents.com/) \ No newline at end of file +### 文档与研究参考 + +- [Voice AI & Voice Agents](https://voiceaiandvoiceagents.com/) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c9cecff..f940546 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,5 +1,5 @@ -site_name: "Realtime Agent Studio" -site_description: "构建实时交互音视频智能体的开源工作平台" +site_name: "Realtime Agent Studio" +site_description: "Realtime Agent Studio(RAS)是一个通过管理控制台与 API 构建、部署和运营实时多模态助手的开源平台。" site_url: "https://your-org.github.io/AI-VideoAssistant" copyright: "Copyright © 2025 RAS Team" site_author: "RAS Team" @@ -9,51 +9,45 @@ site_dir: "site" nav: - 首页: index.md - - 产品概览: - - 概述: overview/index.md - - 系统架构: overview/architecture.md - 快速开始: - - 5 分钟入门: quickstart/index.md - - 资源库配置: quickstart/dashboard.md - - 核心概念: - - 概述: concepts/index.md - - 助手详解: concepts/assistants.md - - 引擎架构: concepts/engines.md - - 安装部署: - - 概述: getting-started/index.md + - 创建第一个助手: quickstart/index.md + - 环境与部署: getting-started/index.md - 环境要求: getting-started/requirements.md - 配置说明: getting-started/configuration.md - 部署概览: deployment/index.md - Docker 部署: deployment/docker.md - - 助手管理: - - 创建助手: - - 小助手: - - 配置选项: assistants/configuration.md - - 提示词指南: assistants/prompts.md - - 测试调试: assistants/testing.md - - 工作流: - - 配置选项: assistants/workflow-configuration.md - - 组件库: - - 模型接入: customization/models.md - - 语音识别: customization/asr.md - - 语音生成: customization/tts.md - - 知识库: customization/knowledge-base.md - - 工具与插件: customization/tools.md - - 数据分析: - - 仪表盘: analysis/dashboard.md - - 历史记录: analysis/history.md - - 效果评估: analysis/evaluation.md - - 自动化测试: analysis/autotest.md - - API 参考: - - 概述: api-reference/index.md + - 构建助手: + - 助手概览: concepts/assistants.md + - 基础配置: concepts/assistants/configuration.md + - 提示词: concepts/assistants/prompts.md + - LLM 模型: customization/models.md + - 语音识别: customization/asr.md + - 声音资源: customization/voices.md + - TTS 参数: customization/tts.md + - 知识库: customization/knowledge-base.md + - 工具: customization/tools.md + - 工作流: customization/workflows.md + - 测试与调试: concepts/assistants/testing.md + - 核心概念: + - 产品概览: overview/index.md + - 概念总览: concepts/index.md + - 引擎架构: concepts/engines.md + - Pipeline 引擎: concepts/pipeline-engine.md + - Realtime 引擎: concepts/realtime-engine.md + - 系统架构: overview/architecture.md + - 集成: + - API 参考: api-reference/index.md - WebSocket 协议: api-reference/websocket.md - 错误码: api-reference/errors.md - - 资源: + - 运维: + - 仪表盘: analysis/dashboard.md + - 历史记录: analysis/history.md + - 效果评估: analysis/evaluation.md + - 自动化测试: analysis/autotest.md - 常见问题: resources/faq.md - 故障排查: resources/troubleshooting.md - 更新日志: changelog.md - 路线图: roadmap.md - theme: name: material language: zh @@ -148,7 +142,6 @@ plugins: minify_html: true extra: - # version.provider: mike — only enable when deploying with mike (versions.json is generated on deploy) social: - icon: fontawesome/brands/github link: https://github.com/your-org/AI-VideoAssistant @@ -164,3 +157,4 @@ extra_css: extra_javascript: - javascripts/mermaid.mjs - javascripts/extra.js + From e07e5128fce1e0a2e86d9db215d924189d9a2c7f Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Mon, 9 Mar 2026 06:54:05 +0800 Subject: [PATCH 13/20] Update mkdocs configuration to streamline navigation structure - Removed redundant entries from the quick start section for clarity. - Maintained the inclusion of essential topics to ensure comprehensive guidance for users. --- docs/mkdocs.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index f940546..ac06612 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -10,12 +10,8 @@ site_dir: "site" nav: - 首页: index.md - 快速开始: - - 创建第一个助手: quickstart/index.md - 环境与部署: getting-started/index.md - - 环境要求: getting-started/requirements.md - - 配置说明: getting-started/configuration.md - - 部署概览: deployment/index.md - - Docker 部署: deployment/docker.md + - 创建第一个助手: quickstart/index.md - 构建助手: - 助手概览: concepts/assistants.md - 基础配置: concepts/assistants/configuration.md @@ -158,3 +154,4 @@ extra_javascript: - javascripts/mermaid.mjs - javascripts/extra.js + From bfe165daaece6b8cf1f1504aa7eeb46e1a442f5d Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Mon, 9 Mar 2026 07:37:00 +0800 Subject: [PATCH 14/20] Add DashScope ASR model support and enhance related components - Introduced DashScope as a new ASR model in the database initialization. - Updated ASRModel schema to include vendor information. - Enhanced ASR router to support DashScope-specific functionality, including connection testing and preview capabilities. - Modified frontend components to accommodate DashScope as a selectable vendor with appropriate default settings. - Added tests to validate DashScope ASR model creation, updates, and connectivity. - Updated backend API to handle DashScope-specific base URLs and vendor normalization. --- api/app/routers/asr.py | 463 ++++++++++++++++++++++++++++++++++++- api/app/schemas.py | 1 + api/init_db.py | 15 ++ api/tests/test_asr.py | 85 ++++++- web/pages/ASRLibrary.tsx | 73 +++++- web/services/backendApi.ts | 22 +- 6 files changed, 638 insertions(+), 21 deletions(-) diff --git a/api/app/routers/asr.py b/api/app/routers/asr.py index b167802..07596a6 100644 --- a/api/app/routers/asr.py +++ b/api/app/routers/asr.py @@ -1,6 +1,14 @@ +import asyncio +import base64 +import io +import json import os +import sys +import threading import time -from typing import List, Optional +import wave +from array import array +from typing import Any, Dict, List, Optional, Tuple import httpx from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile @@ -17,6 +25,32 @@ from ..schemas import ( router = APIRouter(prefix="/asr", tags=["ASR Models"]) OPENAI_COMPATIBLE_DEFAULT_ASR_MODEL = "FunAudioLLM/SenseVoiceSmall" +DASHSCOPE_DEFAULT_ASR_MODEL = "qwen3-asr-flash-realtime" +DASHSCOPE_DEFAULT_BASE_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" + +try: + import dashscope + from dashscope.audio.qwen_omni import MultiModality, OmniRealtimeCallback, OmniRealtimeConversation + + try: + from dashscope.audio.qwen_omni import TranscriptionParams + except ImportError: + from dashscope.audio.qwen_omni.omni_realtime import TranscriptionParams + + DASHSCOPE_SDK_AVAILABLE = True + DASHSCOPE_IMPORT_ERROR = "" +except Exception as exc: + dashscope = None # type: ignore[assignment] + MultiModality = None # type: ignore[assignment] + OmniRealtimeConversation = None # type: ignore[assignment] + TranscriptionParams = None # type: ignore[assignment] + DASHSCOPE_SDK_AVAILABLE = False + DASHSCOPE_IMPORT_ERROR = f"{type(exc).__name__}: {exc}" + + class OmniRealtimeCallback: # type: ignore[no-redef] + """Fallback callback base when DashScope SDK is unavailable.""" + + pass def _is_openai_compatible_vendor(vendor: str) -> bool: @@ -29,12 +63,377 @@ def _is_openai_compatible_vendor(vendor: str) -> bool: } +def _is_dashscope_vendor(vendor: str) -> bool: + return (vendor or "").strip().lower() == "dashscope" + + def _default_asr_model(vendor: str) -> str: if _is_openai_compatible_vendor(vendor): return OPENAI_COMPATIBLE_DEFAULT_ASR_MODEL + if _is_dashscope_vendor(vendor): + return DASHSCOPE_DEFAULT_ASR_MODEL return "whisper-1" +def _dashscope_language(language: Optional[str]) -> Optional[str]: + normalized = (language or "").strip().lower() + if not normalized or normalized in {"multi-lingual", "multilingual", "multi_lingual", "auto"}: + return None + if normalized.startswith("zh"): + return "zh" + if normalized.startswith("en"): + return "en" + return normalized + + +class _DashScopePreviewCallback(OmniRealtimeCallback): + """Collect DashScope ASR websocket events for preview/test flows.""" + + def __init__(self) -> None: + super().__init__() + self._open_event = threading.Event() + self._session_ready_event = threading.Event() + self._done_event = threading.Event() + self._lock = threading.Lock() + self._final_text = "" + self._last_interim_text = "" + self._error_message: Optional[str] = None + + def on_open(self) -> None: + self._open_event.set() + + def on_close(self, code: int, reason: str) -> None: + if self._done_event.is_set(): + return + self._error_message = f"DashScope websocket closed unexpectedly: {code} {reason}" + self._done_event.set() + self._session_ready_event.set() + + def on_error(self, message: Any) -> None: + self._error_message = str(message) + self._done_event.set() + self._session_ready_event.set() + + def on_event(self, response: Any) -> None: + payload = _coerce_dashscope_event(response) + event_type = str(payload.get("type") or "").strip() + if not event_type: + return + + if event_type in {"session.created", "session.updated"}: + self._session_ready_event.set() + return + + if event_type == "error" or event_type.endswith(".failed"): + self._error_message = _format_dashscope_error_event(payload) + self._done_event.set() + self._session_ready_event.set() + return + + if event_type == "conversation.item.input_audio_transcription.text": + interim_text = _extract_dashscope_text(payload, keys=("stash", "text", "transcript")) + if interim_text: + with self._lock: + self._last_interim_text = interim_text + return + + if event_type == "conversation.item.input_audio_transcription.completed": + final_text = _extract_dashscope_text(payload, keys=("transcript", "text", "stash")) + with self._lock: + if final_text: + self._final_text = final_text + self._done_event.set() + return + + if event_type in {"response.done", "session.finished"}: + self._done_event.set() + + def wait_for_open(self, timeout: float = 10.0) -> None: + if not self._open_event.wait(timeout): + raise TimeoutError("DashScope websocket open timeout") + + def wait_for_session_ready(self, timeout: float = 6.0) -> bool: + return self._session_ready_event.wait(timeout) + + def wait_for_done(self, timeout: float = 20.0) -> None: + if not self._done_event.wait(timeout): + raise TimeoutError("DashScope transcription timeout") + + def raise_if_error(self) -> None: + if self._error_message: + raise RuntimeError(self._error_message) + + def read_text(self) -> str: + with self._lock: + return self._final_text or self._last_interim_text + + +def _coerce_dashscope_event(response: Any) -> Dict[str, Any]: + if isinstance(response, dict): + return response + if isinstance(response, str): + try: + parsed = json.loads(response) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + pass + return {"type": "raw", "message": str(response)} + + +def _format_dashscope_error_event(payload: Dict[str, Any]) -> str: + error = payload.get("error") + if isinstance(error, dict): + code = str(error.get("code") or "").strip() + message = str(error.get("message") or "").strip() + if code and message: + return f"{code}: {message}" + return message or str(error) + return str(error or "DashScope realtime ASR error") + + +def _extract_dashscope_text(payload: Dict[str, Any], *, keys: Tuple[str, ...]) -> str: + for key in keys: + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(value, dict): + nested = _extract_dashscope_text(value, keys=keys) + if nested: + return nested + + for value in payload.values(): + if isinstance(value, dict): + nested = _extract_dashscope_text(value, keys=keys) + if nested: + return nested + return "" + + +def _create_dashscope_realtime_client( + *, + model: str, + callback: _DashScopePreviewCallback, + url: str, + api_key: str, +) -> Any: + if OmniRealtimeConversation is None: + raise RuntimeError("DashScope SDK unavailable") + + init_kwargs = { + "model": model, + "callback": callback, + "url": url, + } + try: + return OmniRealtimeConversation(api_key=api_key, **init_kwargs) # type: ignore[misc] + except TypeError as exc: + if "api_key" not in str(exc): + raise + return OmniRealtimeConversation(**init_kwargs) # type: ignore[misc] + + +def _close_dashscope_client(client: Any) -> None: + finish_fn = getattr(client, "finish", None) + if callable(finish_fn): + try: + finish_fn() + except Exception: + pass + + close_fn = getattr(client, "close", None) + if callable(close_fn): + try: + close_fn() + except Exception: + pass + + +def _configure_dashscope_session( + *, + client: Any, + callback: _DashScopePreviewCallback, + sample_rate: int, + language: Optional[str], +) -> None: + update_fn = getattr(client, "update_session", None) + if not callable(update_fn): + raise RuntimeError("DashScope ASR SDK missing update_session method") + + text_modality: Any = "text" + if MultiModality is not None and hasattr(MultiModality, "TEXT"): + text_modality = MultiModality.TEXT + + transcription_params: Optional[Any] = None + language_hint = _dashscope_language(language) + if TranscriptionParams is not None: + try: + params_kwargs: Dict[str, Any] = { + "sample_rate": sample_rate, + "input_audio_format": "pcm", + } + if language_hint: + params_kwargs["language"] = language_hint + transcription_params = TranscriptionParams(**params_kwargs) + except Exception: + transcription_params = None + + update_attempts = [ + { + "output_modalities": [text_modality], + "enable_turn_detection": False, + "enable_input_audio_transcription": True, + "transcription_params": transcription_params, + }, + { + "output_modalities": [text_modality], + "enable_turn_detection": False, + "enable_input_audio_transcription": True, + }, + { + "output_modalities": [text_modality], + }, + ] + + last_error: Optional[Exception] = None + for params in update_attempts: + if params.get("transcription_params") is None: + params = {key: value for key, value in params.items() if key != "transcription_params"} + try: + update_fn(**params) + callback.wait_for_session_ready() + callback.raise_if_error() + return + except TypeError as exc: + last_error = exc + continue + except Exception as exc: + last_error = exc + continue + + raise RuntimeError(f"DashScope ASR session.update failed: {last_error}") + + +def _load_wav_pcm16_mono(audio_bytes: bytes) -> Tuple[bytes, int]: + try: + with wave.open(io.BytesIO(audio_bytes), "rb") as wav_file: + channel_count = wav_file.getnchannels() + sample_width = wav_file.getsampwidth() + sample_rate = wav_file.getframerate() + compression = wav_file.getcomptype() + pcm_frames = wav_file.readframes(wav_file.getnframes()) + except wave.Error as exc: + raise RuntimeError("DashScope preview currently supports WAV audio. Record in browser or upload a .wav file.") from exc + + if compression != "NONE": + raise RuntimeError("DashScope preview requires uncompressed PCM WAV audio.") + if sample_width != 2: + raise RuntimeError("DashScope preview requires 16-bit PCM WAV audio.") + if not pcm_frames: + raise RuntimeError("Uploaded WAV file is empty") + if channel_count <= 1: + return pcm_frames, sample_rate + + samples = array("h") + samples.frombytes(pcm_frames) + if sys.byteorder == "big": + samples.byteswap() + + mono_samples = array( + "h", + ( + int(sum(samples[index:index + channel_count]) / channel_count) + for index in range(0, len(samples), channel_count) + ), + ) + if sys.byteorder == "big": + mono_samples.byteswap() + return mono_samples.tobytes(), sample_rate + + +def _probe_dashscope_asr_connection(*, api_key: str, base_url: str, model: str, language: Optional[str]) -> None: + if not DASHSCOPE_SDK_AVAILABLE: + hint = f"`{sys.executable} -m pip install dashscope>=1.25.11`" + detail = f"; import error: {DASHSCOPE_IMPORT_ERROR}" if DASHSCOPE_IMPORT_ERROR else "" + raise RuntimeError(f"dashscope package not installed; install with {hint}{detail}") + + callback = _DashScopePreviewCallback() + if dashscope is not None: + dashscope.api_key = api_key + client = _create_dashscope_realtime_client( + model=model, + callback=callback, + url=base_url, + api_key=api_key, + ) + + try: + client.connect() + callback.wait_for_open() + _configure_dashscope_session( + client=client, + callback=callback, + sample_rate=16000, + language=language, + ) + finally: + _close_dashscope_client(client) + + +def _transcribe_dashscope_preview( + *, + audio_bytes: bytes, + api_key: str, + base_url: str, + model: str, + language: Optional[str], +) -> Dict[str, Any]: + if not DASHSCOPE_SDK_AVAILABLE: + hint = f"`{sys.executable} -m pip install dashscope>=1.25.11`" + detail = f"; import error: {DASHSCOPE_IMPORT_ERROR}" if DASHSCOPE_IMPORT_ERROR else "" + raise RuntimeError(f"dashscope package not installed; install with {hint}{detail}") + + pcm_audio, sample_rate = _load_wav_pcm16_mono(audio_bytes) + callback = _DashScopePreviewCallback() + if dashscope is not None: + dashscope.api_key = api_key + client = _create_dashscope_realtime_client( + model=model, + callback=callback, + url=base_url, + api_key=api_key, + ) + + try: + client.connect() + callback.wait_for_open() + _configure_dashscope_session( + client=client, + callback=callback, + sample_rate=sample_rate, + language=language, + ) + + append_fn = getattr(client, "append_audio", None) + if not callable(append_fn): + raise RuntimeError("DashScope ASR SDK missing append_audio method") + commit_fn = getattr(client, "commit", None) + if not callable(commit_fn): + raise RuntimeError("DashScope ASR SDK missing commit method") + + append_fn(base64.b64encode(pcm_audio).decode("ascii")) + commit_fn() + callback.wait_for_done() + callback.raise_if_error() + return { + "transcript": callback.read_text(), + "language": _dashscope_language(language) or "Multi-lingual", + "confidence": None, + } + finally: + _close_dashscope_client(client) + + # ============ ASR Models CRUD ============ @router.get("") def list_asr_models( @@ -132,6 +531,27 @@ def test_asr_model( start_time = time.time() try: + if _is_dashscope_vendor(model.vendor): + effective_api_key = (model.api_key or "").strip() or os.getenv("DASHSCOPE_API_KEY", "").strip() or os.getenv("ASR_API_KEY", "").strip() + if not effective_api_key: + return ASRTestResponse(success=False, error=f"API key is required for ASR model: {model.name}") + + base_url = (model.base_url or "").strip() or DASHSCOPE_DEFAULT_BASE_URL + selected_model = (model.model_name or "").strip() or _default_asr_model(model.vendor) + _probe_dashscope_asr_connection( + api_key=effective_api_key, + base_url=base_url, + model=selected_model, + language=model.language, + ) + latency_ms = int((time.time() - start_time) * 1000) + return ASRTestResponse( + success=True, + language=model.language, + latency_ms=latency_ms, + message="DashScope realtime ASR connected", + ) + # 连接性测试优先,避免依赖真实音频输入 headers = {"Authorization": f"Bearer {model.api_key}"} with httpx.Client(timeout=60.0) as client: @@ -246,7 +666,7 @@ async def preview_asr_model( api_key: Optional[str] = Form(None), db: Session = Depends(get_db), ): - """预览 ASR:上传音频并调用 OpenAI-compatible /audio/transcriptions。""" + """预览 ASR:根据供应商调用 OpenAI-compatible 或 DashScope 实时识别。""" model = db.query(ASRModel).filter(ASRModel.id == id).first() if not model: raise HTTPException(status_code=404, detail="ASR Model not found") @@ -264,18 +684,50 @@ async def preview_asr_model( raise HTTPException(status_code=400, detail="Uploaded audio file is empty") effective_api_key = (api_key or "").strip() or (model.api_key or "").strip() - if not effective_api_key and _is_openai_compatible_vendor(model.vendor): - effective_api_key = os.getenv("SILICONFLOW_API_KEY", "").strip() + if not effective_api_key: + if _is_openai_compatible_vendor(model.vendor): + effective_api_key = os.getenv("SILICONFLOW_API_KEY", "").strip() + elif _is_dashscope_vendor(model.vendor): + effective_api_key = os.getenv("DASHSCOPE_API_KEY", "").strip() or os.getenv("ASR_API_KEY", "").strip() if not effective_api_key: raise HTTPException(status_code=400, detail=f"API key is required for ASR model: {model.name}") base_url = (model.base_url or "").strip().rstrip("/") + if _is_dashscope_vendor(model.vendor) and not base_url: + base_url = DASHSCOPE_DEFAULT_BASE_URL if not base_url: raise HTTPException(status_code=400, detail=f"Base URL is required for ASR model: {model.name}") selected_model = (model.model_name or "").strip() or _default_asr_model(model.vendor) - data = {"model": selected_model} effective_language = (language or "").strip() or None + + start_time = time.time() + if _is_dashscope_vendor(model.vendor): + try: + payload = await asyncio.to_thread( + _transcribe_dashscope_preview, + audio_bytes=audio_bytes, + api_key=effective_api_key, + base_url=base_url, + model=selected_model, + language=effective_language or model.language, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"DashScope ASR request failed: {exc}") from exc + + transcript = str(payload.get("transcript") or "") + response_language = str(payload.get("language") or effective_language or model.language) + latency_ms = int((time.time() - start_time) * 1000) + return ASRTestResponse( + success=bool(transcript), + transcript=transcript, + language=response_language, + confidence=None, + latency_ms=latency_ms, + message=None if transcript else "No transcript in response", + ) + + data = {"model": selected_model} if effective_language: data["language"] = effective_language if model.hotwords: @@ -284,7 +736,6 @@ async def preview_asr_model( headers = {"Authorization": f"Bearer {effective_api_key}"} files = {"file": (filename, audio_bytes, content_type)} - start_time = time.time() try: with httpx.Client(timeout=90.0) as client: response = client.post( diff --git a/api/app/schemas.py b/api/app/schemas.py index f0ad0c3..cbce453 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -191,6 +191,7 @@ class ASRModelCreate(ASRModelBase): class ASRModelUpdate(BaseModel): name: Optional[str] = None + vendor: Optional[str] = None language: Optional[str] = None base_url: Optional[str] = None api_key: Optional[str] = None diff --git a/api/init_db.py b/api/init_db.py index e3373f6..162eb99 100644 --- a/api/init_db.py +++ b/api/init_db.py @@ -34,6 +34,7 @@ SEED_LLM_IDS = { SEED_ASR_IDS = { "sensevoice_small": short_id("asr"), "telespeech_asr": short_id("asr"), + "dashscope_realtime": short_id("asr"), } SEED_ASSISTANT_IDS = { @@ -408,6 +409,20 @@ def init_default_asr_models(): enable_normalization=True, enabled=True, ), + ASRModel( + id=SEED_ASR_IDS["dashscope_realtime"], + user_id=1, + name="DashScope Realtime ASR", + vendor="DashScope", + language="Multi-lingual", + base_url=DASHSCOPE_REALTIME_URL, + api_key="YOUR_API_KEY", + model_name="qwen3-asr-flash-realtime", + hotwords=[], + enable_punctuation=True, + enable_normalization=True, + enabled=True, + ), ] seed_if_empty(db, ASRModel, asr_models, "✅ 默认ASR模型已初始化") diff --git a/api/tests/test_asr.py b/api/tests/test_asr.py index 209116c..1cd3c01 100644 --- a/api/tests/test_asr.py +++ b/api/tests/test_asr.py @@ -1,8 +1,21 @@ """Tests for ASR Model API endpoints""" +import io +import wave + import pytest from unittest.mock import patch, MagicMock +def _make_wav_bytes(sample_rate: int = 16000) -> bytes: + with io.BytesIO() as buffer: + with wave.open(buffer, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(sample_rate) + wav_file.writeframes(b"\x00\x00" * sample_rate) + return buffer.getvalue() + + class TestASRModelAPI: """Test cases for ASR Model endpoints""" @@ -75,6 +88,24 @@ class TestASRModelAPI: assert data["language"] == "en" assert data["enable_punctuation"] == False + def test_update_asr_model_vendor(self, client, sample_asr_model_data): + """Test updating ASR vendor metadata.""" + create_response = client.post("/api/asr", json=sample_asr_model_data) + model_id = create_response.json()["id"] + + response = client.put( + f"/api/asr/{model_id}", + json={ + "vendor": "DashScope", + "model_name": "qwen3-asr-flash-realtime", + "base_url": "wss://dashscope.aliyuncs.com/api-ws/v1/realtime", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["vendor"] == "DashScope" + assert data["model_name"] == "qwen3-asr-flash-realtime" + def test_delete_asr_model(self, client, sample_asr_model_data): """Test deleting an ASR model""" # Create first @@ -234,6 +265,28 @@ class TestASRModelAPI: response = client.post(f"/api/asr/{model_id}/test") assert response.status_code == 200 + def test_test_asr_model_dashscope(self, client, sample_asr_model_data, monkeypatch): + """Test DashScope ASR connectivity probe.""" + from app.routers import asr as asr_router + + sample_asr_model_data["vendor"] = "DashScope" + sample_asr_model_data["base_url"] = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" + sample_asr_model_data["model_name"] = "qwen3-asr-flash-realtime" + create_response = client.post("/api/asr", json=sample_asr_model_data) + model_id = create_response.json()["id"] + + def fake_probe(**kwargs): + assert kwargs["api_key"] == sample_asr_model_data["api_key"] + assert kwargs["model"] == "qwen3-asr-flash-realtime" + + monkeypatch.setattr(asr_router, "_probe_dashscope_asr_connection", fake_probe) + + response = client.post(f"/api/asr/{model_id}/test") + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["message"] == "DashScope realtime ASR connected" + @patch('httpx.Client') def test_test_asr_model_failure(self, mock_client_class, client, sample_asr_model_data): """Test testing an ASR model with failed connection""" @@ -274,7 +327,7 @@ class TestASRModelAPI: def test_different_asr_vendors(self, client): """Test creating ASR models with different vendors""" - vendors = ["SiliconFlow", "OpenAI", "Azure"] + vendors = ["SiliconFlow", "OpenAI", "Azure", "DashScope"] for vendor in vendors: data = { "id": f"asr-vendor-{vendor.lower()}", @@ -345,3 +398,33 @@ class TestASRModelAPI: ) assert response.status_code == 400 assert "Only audio files are supported" in response.text + + def test_preview_asr_model_dashscope(self, client, sample_asr_model_data, monkeypatch): + """Test ASR preview endpoint with DashScope realtime helper.""" + from app.routers import asr as asr_router + + sample_asr_model_data["vendor"] = "DashScope" + sample_asr_model_data["base_url"] = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" + sample_asr_model_data["model_name"] = "qwen3-asr-flash-realtime" + create_response = client.post("/api/asr", json=sample_asr_model_data) + model_id = create_response.json()["id"] + + def fake_preview(**kwargs): + assert kwargs["base_url"] == sample_asr_model_data["base_url"] + assert kwargs["model"] == sample_asr_model_data["model_name"] + return { + "transcript": "你好,这是实时识别", + "language": "zh", + "confidence": None, + } + + monkeypatch.setattr(asr_router, "_transcribe_dashscope_preview", fake_preview) + + response = client.post( + f"/api/asr/{model_id}/preview", + files={"file": ("sample.wav", _make_wav_bytes(), "audio/wav")}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is True + assert payload["transcript"] == "你好,这是实时识别" diff --git a/web/pages/ASRLibrary.tsx b/web/pages/ASRLibrary.tsx index 03f54ae..52a08f8 100644 --- a/web/pages/ASRLibrary.tsx +++ b/web/pages/ASRLibrary.tsx @@ -82,6 +82,16 @@ const convertRecordedBlobToWav = async (blob: Blob): Promise => { } }; +const OPENAI_COMPATIBLE_DEFAULT_MODEL = 'FunAudioLLM/SenseVoiceSmall'; +const OPENAI_COMPATIBLE_DEFAULT_BASE_URL = 'https://api.siliconflow.cn/v1'; +const DASHSCOPE_DEFAULT_MODEL = 'qwen3-asr-flash-realtime'; +const DASHSCOPE_DEFAULT_BASE_URL = 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime'; + +type ASRVendor = 'OpenAI Compatible' | 'DashScope'; + +const normalizeVendor = (value?: string): ASRVendor => + String(value || '').trim().toLowerCase() === 'dashscope' ? 'DashScope' : 'OpenAI Compatible'; + export const ASRLibraryPage: React.FC = () => { const [models, setModels] = useState([]); const [searchTerm, setSearchTerm] = useState(''); @@ -271,10 +281,10 @@ const ASRModelModal: React.FC<{ initialModel?: ASRModel; }> = ({ isOpen, onClose, onSubmit, initialModel }) => { const [name, setName] = useState(''); - const [vendor, setVendor] = useState('OpenAI Compatible'); + const [vendor, setVendor] = useState('OpenAI Compatible'); const [language, setLanguage] = useState('zh'); - const [modelName, setModelName] = useState('FunAudioLLM/SenseVoiceSmall'); - const [baseUrl, setBaseUrl] = useState('https://api.siliconflow.cn/v1'); + const [modelName, setModelName] = useState(OPENAI_COMPATIBLE_DEFAULT_MODEL); + const [baseUrl, setBaseUrl] = useState(OPENAI_COMPATIBLE_DEFAULT_BASE_URL); const [apiKey, setApiKey] = useState(''); const [hotwords, setHotwords] = useState(''); const [enablePunctuation, setEnablePunctuation] = useState(true); @@ -282,14 +292,40 @@ const ASRModelModal: React.FC<{ const [enabled, setEnabled] = useState(true); const [saving, setSaving] = useState(false); + const getDefaultModel = (nextVendor: ASRVendor): string => + nextVendor === 'DashScope' ? DASHSCOPE_DEFAULT_MODEL : OPENAI_COMPATIBLE_DEFAULT_MODEL; + + const getDefaultBaseUrl = (nextVendor: ASRVendor): string => + nextVendor === 'DashScope' ? DASHSCOPE_DEFAULT_BASE_URL : OPENAI_COMPATIBLE_DEFAULT_BASE_URL; + + const handleVendorChange = (nextVendor: ASRVendor) => { + const previousVendor = vendor; + setVendor(nextVendor); + + const previousDefaultModel = getDefaultModel(previousVendor); + const nextDefaultModel = getDefaultModel(nextVendor); + const trimmedModelName = modelName.trim(); + if (!trimmedModelName || trimmedModelName === previousDefaultModel) { + setModelName(nextDefaultModel); + } + + const previousDefaultBaseUrl = getDefaultBaseUrl(previousVendor); + const nextDefaultBaseUrl = getDefaultBaseUrl(nextVendor); + const trimmedBaseUrl = baseUrl.trim(); + if (!trimmedBaseUrl || trimmedBaseUrl === previousDefaultBaseUrl) { + setBaseUrl(nextDefaultBaseUrl); + } + }; + useEffect(() => { if (!isOpen) return; if (initialModel) { + const nextVendor = normalizeVendor(initialModel.vendor); setName(initialModel.name || ''); - setVendor(initialModel.vendor || 'OpenAI Compatible'); + setVendor(nextVendor); setLanguage(initialModel.language || 'zh'); - setModelName(initialModel.modelName || 'FunAudioLLM/SenseVoiceSmall'); - setBaseUrl(initialModel.baseUrl || 'https://api.siliconflow.cn/v1'); + setModelName(initialModel.modelName || getDefaultModel(nextVendor)); + setBaseUrl(initialModel.baseUrl || getDefaultBaseUrl(nextVendor)); setApiKey(initialModel.apiKey || ''); setHotwords(toHotwordsValue(initialModel.hotwords)); setEnablePunctuation(initialModel.enablePunctuation ?? true); @@ -301,8 +337,8 @@ const ASRModelModal: React.FC<{ setName(''); setVendor('OpenAI Compatible'); setLanguage('zh'); - setModelName('FunAudioLLM/SenseVoiceSmall'); - setBaseUrl('https://api.siliconflow.cn/v1'); + setModelName(OPENAI_COMPATIBLE_DEFAULT_MODEL); + setBaseUrl(OPENAI_COMPATIBLE_DEFAULT_BASE_URL); setApiKey(''); setHotwords(''); setEnablePunctuation(true); @@ -368,9 +404,10 @@ const ASRModelModal: React.FC<{

!15Aa{t)jZ6*-xKOsh+N6*<@Q$Bk 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

@@ -388,13 +425,22 @@ const ASRModelModal: React.FC<{
- setModelName(e.target.value)} placeholder="FunAudioLLM/SenseVoiceSmall" /> + setModelName(e.target.value)} + placeholder={vendor === 'DashScope' ? DASHSCOPE_DEFAULT_MODEL : OPENAI_COMPATIBLE_DEFAULT_MODEL} + />
- setBaseUrl(e.target.value)} placeholder="https://api.siliconflow.cn/v1" className="font-mono text-xs" /> + setBaseUrl(e.target.value)} + placeholder={vendor === 'DashScope' ? DASHSCOPE_DEFAULT_BASE_URL : OPENAI_COMPATIBLE_DEFAULT_BASE_URL} + className="font-mono text-xs" + />
@@ -405,6 +451,11 @@ const ASRModelModal: React.FC<{
setHotwords(e.target.value)} placeholder="品牌名, 人名, 专有词" /> + {vendor === 'DashScope' && ( +

+ DashScope 走实时 WebSocket ASR。预览建议使用浏览器录音或上传 WAV 文件。 +

+ )}
diff --git a/web/services/backendApi.ts b/web/services/backendApi.ts index f4b6caa..50aec96 100644 --- a/web/services/backendApi.ts +++ b/web/services/backendApi.ts @@ -3,6 +3,8 @@ import { apiRequest, getApiBaseUrl } from './apiClient'; type AnyRecord = Record; const DEFAULT_LIST_LIMIT = 1000; +const OPENAI_COMPATIBLE_DEFAULT_ASR_BASE_URL = 'https://api.siliconflow.cn/v1'; +const DASHSCOPE_DEFAULT_ASR_BASE_URL = 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime'; const TOOL_ID_ALIASES: Record = { voice_message_prompt: 'voice_msg_prompt', }; @@ -129,7 +131,16 @@ const mapVoice = (raw: AnyRecord): Voice => ({ const mapASRModel = (raw: AnyRecord): ASRModel => ({ id: String(readField(raw, ['id'], '')), name: readField(raw, ['name'], ''), - vendor: readField(raw, ['vendor'], 'OpenAI Compatible'), + vendor: (() => { + const vendor = String(readField(raw, ['vendor'], '')).trim().toLowerCase(); + if (vendor === 'dashscope') { + return 'DashScope'; + } + if (vendor === 'siliconflow' || vendor === 'openai compatible' || vendor === 'openai-compatible' || vendor === '硅基流动') { + return 'OpenAI Compatible'; + } + return String(readField(raw, ['vendor'], 'OpenAI Compatible')) || 'OpenAI Compatible'; + })(), language: readField(raw, ['language'], 'zh'), baseUrl: readField(raw, ['baseUrl', 'base_url'], ''), apiKey: readField(raw, ['apiKey', 'api_key'], ''), @@ -457,11 +468,16 @@ export const fetchASRModels = async (): Promise => { }; export const createASRModel = async (data: Partial): Promise => { + const vendor = data.vendor || 'OpenAI Compatible'; + const normalizedVendor = String(vendor).trim().toLowerCase(); + const defaultBaseUrl = normalizedVendor === 'dashscope' + ? DASHSCOPE_DEFAULT_ASR_BASE_URL + : OPENAI_COMPATIBLE_DEFAULT_ASR_BASE_URL; const payload = { name: data.name || 'New ASR Model', - vendor: data.vendor || 'OpenAI Compatible', + vendor, language: data.language || 'zh', - base_url: data.baseUrl || 'https://api.siliconflow.cn/v1', + base_url: data.baseUrl || defaultBaseUrl, api_key: data.apiKey || '', model_name: data.modelName || undefined, hotwords: data.hotwords || [], From e4ccec6cc1beea34d9d5cd384e367d28857a60ee Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Tue, 10 Mar 2026 02:25:52 +0800 Subject: [PATCH 15/20] feat: Introduce DashScope agent configuration, a WAV client for duplex testing, and new UI components for assistants. --- web/components/UI.tsx | 5 +- web/pages/Assistants.tsx | 267 ++++++++++++++++++++++--------------- web/services/backendApi.ts | 4 +- 3 files changed, 165 insertions(+), 111 deletions(-) diff --git a/web/components/UI.tsx b/web/components/UI.tsx index 98c72c9..787fd2a 100644 --- a/web/components/UI.tsx +++ b/web/components/UI.tsx @@ -206,10 +206,11 @@ interface DrawerProps { isOpen: boolean; onClose: () => void; title: string; + className?: string; children: React.ReactNode; } -export const Drawer: React.FC = ({ isOpen, onClose, title, children }) => { +export const Drawer: React.FC = ({ isOpen, onClose, title, className, children }) => { if (!isOpen) return null; return ( @@ -218,7 +219,7 @@ export const Drawer: React.FC = ({ isOpen, onClose, title, children
{/* Drawer Content */} -
+

{title}

- ))} + { handleHangup(); onClose(); }} title={`调试: ${assistant.name}`} className="w-[90vw] sm:w-[85vw] max-w-none"> +
+ + {/* Left Column: Call Interface */} +
+
+
+ {(['text', 'voice', 'video'] as const).map(m => ( + + ))} +
+
-
-
-
+
+
{mode === 'text' ? ( textSessionStarted ? ( -
-
- +
+
+
+
+ +
+
+
+

通话中

+

文本交互测试进行中

) : wsStatus === 'connecting' ? ( @@ -4468,41 +4487,57 @@ export const DebugDrawer: React.FC<{
) ) : ( -
+
{mode === 'voice' ? ( -
- -
+
+
+
+
+
+
+ +
+
+
+
+

通话进行中

+
+ + + + +

已连接

+
+
+
) : ( -
-
-
- - -
-
-
{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}
-
{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}
- -
+
+
+ + +
+
+
{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}
+
{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}
+
-
)}
)}
-
- {mode === 'voice' && ( -
- 麦克风 + {/* Hangup / Mic Select area (Left Column Bottom) */} +
+ {mode === 'voice' && callStatus === 'active' && ( +
+ 麦克风
)} -
- {mode === 'text' && textSessionStarted && ( - +
+ {mode === 'text' && textSessionStarted && ( + + )} + {mode !== 'text' && callStatus === 'active' && ( + + )} +
+
+
+
+ + {/* Right Column: Transcript */} +
+
+ +

Transcript

+
+ +
+
+ {(messages.length === 0 && !isLoading) ? ( +
+ +

暂无对话记录

+
+ ) : ( + )} - {mode === 'voice' && callStatus === 'active' && ( - - )} - setInputText(e.target.value)} - placeholder={mode === 'text' && !textSessionStarted ? "请先发起呼叫后输入消息..." : (mode === 'text' ? "输入消息..." : "输入文本模拟交互...")} - onKeyDown={e => e.key === 'Enter' && handleSend()} - disabled={mode === 'text' ? !textSessionStarted : (isLoading || callStatus !== 'active')} - className="flex-1 min-w-0" - /> - -
-
-
+
+
+ + {/* Input bar */} +
+ setInputText(e.target.value)} + placeholder={mode === 'text' && !textSessionStarted ? "请先发起呼叫后输入消息..." : (mode === 'text' ? "输入消息..." : "输入文本模拟交互...")} + onKeyDown={e => e.key === 'Enter' && handleSend()} + disabled={mode === 'text' ? !textSessionStarted : (isLoading || callStatus !== 'active')} + className="flex-1 min-w-0 border-0 bg-transparent focus-visible:ring-0 shadow-none px-3" + /> + +
+
+
{textPromptDialog.open && (
@@ -4604,7 +4666,7 @@ export const DebugDrawer: React.FC<{ )}
- {choicePromptDialog.options.map((option) => ( + {choicePromptDialog.options.map((option: any) => (
)} -
- - {isOpen && ( -
- -
-
+
{settingsPanel}
-
-
- )} + + )} ); }; diff --git a/web/services/backendApi.ts b/web/services/backendApi.ts index 50aec96..08c11cf 100644 --- a/web/services/backendApi.ts +++ b/web/services/backendApi.ts @@ -28,7 +28,7 @@ const normalizeToolIdList = (value: unknown): string[] => { return result; }; -const normalizeManualOpenerToolCalls = (value: unknown): AnyRecord[] => { +const normalizeManualOpenerToolCalls = (value: unknown): any[] => { if (!Array.isArray(value)) return []; return value .filter((item) => item && typeof item === 'object') @@ -41,7 +41,7 @@ const normalizeManualOpenerToolCalls = (value: unknown): AnyRecord[] => { toolName, }; }) - .filter(Boolean) as AnyRecord[]; + .filter(Boolean) as any[]; }; const withLimit = (path: string, limit: number = DEFAULT_LIST_LIMIT): string => From 373be4eb97aabc1ce92dffa467e6b826ac32de60 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Tue, 10 Mar 2026 03:13:47 +0800 Subject: [PATCH 16/20] feat: Add DashScope and Volcengine agent configurations, a WAV client for duplex testing, and an Assistants UI page. --- web/pages/Assistants.tsx | 50 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index fc09b04..e116d17 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -2315,7 +2315,6 @@ const TranscriptionLog: React.FC<{
))} - {isLoading &&
Thinking...
}
); @@ -2380,6 +2379,7 @@ export const DebugDrawer: React.FC<{ const [inputText, setInputText] = useState(''); const [isLoading, setIsLoading] = useState(false); const [callStatus, setCallStatus] = useState<'idle' | 'calling' | 'active'>('idle'); + const [agentState, setAgentState] = useState<'waiting' | 'listening' | 'thinking' | 'speaking'>('waiting'); const [textPromptDialog, setTextPromptDialog] = useState({ open: false, message: '', @@ -2562,6 +2562,7 @@ export const DebugDrawer: React.FC<{ clearResponseTracking(); setMessages([]); setCallStatus('idle'); + setAgentState('waiting'); } } else { setMode('text'); @@ -2585,6 +2586,7 @@ export const DebugDrawer: React.FC<{ setSettingsDrawerOpen(false); setIsSwapped(false); setCallStatus('idle'); + setAgentState('waiting'); } }, [isOpen, assistant, mode]); @@ -3110,6 +3112,7 @@ export const DebugDrawer: React.FC<{ console.error(e); stopVoiceCapture(); setCallStatus('idle'); + setAgentState('waiting'); const err = e as Error & { __dynamicVariables?: boolean }; if (err.__dynamicVariables) { setWsStatus('disconnected'); @@ -3135,6 +3138,7 @@ export const DebugDrawer: React.FC<{ stopMedia(); closeWs(); setCallStatus('idle'); + setAgentState('waiting'); clearResponseTracking(); setMessages([]); setTextPromptDialog({ open: false, message: '', promptType: 'text' }); @@ -3500,6 +3504,7 @@ export const DebugDrawer: React.FC<{ setChoicePromptDialog({ open: false, question: '', options: [] }); setTextSessionStarted(false); stopPlaybackImmediately(); + setAgentState('waiting'); if (isOpen) setWsStatus('disconnected'); }; @@ -3580,6 +3585,12 @@ export const DebugDrawer: React.FC<{ if (type === 'output.audio.start') { // New utterance audio starts: cancel old queued/playing audio to avoid overlap. stopPlaybackImmediately(); + setAgentState('speaking'); + return; + } + + if (type === 'output.audio.end') { + setAgentState('waiting'); return; } @@ -3595,6 +3606,7 @@ export const DebugDrawer: React.FC<{ assistantDraftIndexRef.current = null; setIsLoading(false); stopPlaybackImmediately(); + setAgentState('waiting'); return; } @@ -3878,6 +3890,7 @@ export const DebugDrawer: React.FC<{ if (type === 'session.started') { wsReadyRef.current = true; setWsStatus('ready'); + setAgentState('waiting'); pendingResolveRef.current?.(); pendingResolveRef.current = null; pendingRejectRef.current = null; @@ -3899,11 +3912,13 @@ export const DebugDrawer: React.FC<{ if (type === 'input.speech_started') { setIsLoading(true); + setAgentState('listening'); return; } if (type === 'input.speech_stopped') { setIsLoading(false); + setAgentState('thinking'); return; } @@ -4500,13 +4515,38 @@ export const DebugDrawer: React.FC<{
-

通话进行中

+

+ {agentState === 'listening' ? '正在倾听...' : + agentState === 'thinking' ? '思考中...' : + agentState === 'speaking' ? '正在回复...' : + '待机中'} +

- - + + -

已连接

+

+ {agentState === 'listening' ? 'Listening' : + agentState === 'thinking' ? 'Thinking' : + agentState === 'speaking' ? 'Speaking' : + 'Waiting'} +

From 47293ac46da83b0da98673763f068377f37f4b10 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Tue, 10 Mar 2026 03:31:39 +0800 Subject: [PATCH 17/20] feat: Add core UI components, Assistants page, Dashscope and Volcengine agent configurations, and a WAV client example. --- web/components/UI.tsx | 6 +++--- web/pages/Assistants.tsx | 31 +++++++++++++------------------ 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/web/components/UI.tsx b/web/components/UI.tsx index 787fd2a..ad9390d 100644 --- a/web/components/UI.tsx +++ b/web/components/UI.tsx @@ -219,14 +219,14 @@ export const Drawer: React.FC = ({ isOpen, onClose, title, classNam
{/* Drawer Content */} -
-
+
+

{title}

-
+
{children}
diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index e116d17..cee26b1 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -2379,7 +2379,7 @@ export const DebugDrawer: React.FC<{ const [inputText, setInputText] = useState(''); const [isLoading, setIsLoading] = useState(false); const [callStatus, setCallStatus] = useState<'idle' | 'calling' | 'active'>('idle'); - const [agentState, setAgentState] = useState<'waiting' | 'listening' | 'thinking' | 'speaking'>('waiting'); + const [agentState, setAgentState] = useState<'listening' | 'thinking' | 'speaking'>('listening'); const [textPromptDialog, setTextPromptDialog] = useState({ open: false, message: '', @@ -2562,7 +2562,7 @@ export const DebugDrawer: React.FC<{ clearResponseTracking(); setMessages([]); setCallStatus('idle'); - setAgentState('waiting'); + setAgentState('listening'); } } else { setMode('text'); @@ -2586,7 +2586,7 @@ export const DebugDrawer: React.FC<{ setSettingsDrawerOpen(false); setIsSwapped(false); setCallStatus('idle'); - setAgentState('waiting'); + setAgentState('listening'); } }, [isOpen, assistant, mode]); @@ -3112,7 +3112,7 @@ export const DebugDrawer: React.FC<{ console.error(e); stopVoiceCapture(); setCallStatus('idle'); - setAgentState('waiting'); + setAgentState('listening'); const err = e as Error & { __dynamicVariables?: boolean }; if (err.__dynamicVariables) { setWsStatus('disconnected'); @@ -3138,7 +3138,7 @@ export const DebugDrawer: React.FC<{ stopMedia(); closeWs(); setCallStatus('idle'); - setAgentState('waiting'); + setAgentState('listening'); clearResponseTracking(); setMessages([]); setTextPromptDialog({ open: false, message: '', promptType: 'text' }); @@ -3590,7 +3590,7 @@ export const DebugDrawer: React.FC<{ } if (type === 'output.audio.end') { - setAgentState('waiting'); + setAgentState('listening'); return; } @@ -4516,36 +4516,31 @@ export const DebugDrawer: React.FC<{

- {agentState === 'listening' ? '正在倾听...' : - agentState === 'thinking' ? '思考中...' : + {agentState === 'thinking' ? '思考中...' : agentState === 'speaking' ? '正在回复...' : - '待机中'} + '正在倾听...'}

- {agentState === 'listening' ? 'Listening' : - agentState === 'thinking' ? 'Thinking' : + {agentState === 'thinking' ? 'Thinking' : agentState === 'speaking' ? 'Speaking' : - 'Waiting'} + 'Listening'}

From 13684d498bf4c184e6fcf9b694c055e11ef75bc8 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Tue, 10 Mar 2026 16:21:58 +0800 Subject: [PATCH 18/20] feat/fix(frontend): update shadcn compnents, fix debug drawer layout and font sizes --- web/components.json | 25 + web/components/UI.tsx | 329 +- web/components/ui/badge.tsx | 52 + web/components/ui/button.tsx | 58 + web/components/ui/card.tsx | 103 + web/components/ui/dialog.tsx | 157 + web/components/ui/input.tsx | 20 + web/components/ui/select.tsx | 201 ++ web/components/ui/sheet.tsx | 133 + web/components/ui/switch.tsx | 30 + web/components/ui/table.tsx | 116 + web/index.css | 158 + web/index.html | 113 +- web/index.tsx | 1 + web/lib/utils.ts | 6 + web/old_ui.txt | Bin 0 -> 21882 bytes web/package-lock.json | 4398 ++++++++++++++++++++++- web/package.json | 21 +- web/pages/Assistants.tsx | 6431 +++++++++++++++++----------------- web/vite.config.ts | 33 +- 20 files changed, 8700 insertions(+), 3685 deletions(-) create mode 100644 web/components.json create mode 100644 web/components/ui/badge.tsx create mode 100644 web/components/ui/button.tsx create mode 100644 web/components/ui/card.tsx create mode 100644 web/components/ui/dialog.tsx create mode 100644 web/components/ui/input.tsx create mode 100644 web/components/ui/select.tsx create mode 100644 web/components/ui/sheet.tsx create mode 100644 web/components/ui/switch.tsx create mode 100644 web/components/ui/table.tsx create mode 100644 web/index.css create mode 100644 web/lib/utils.ts create mode 100644 web/old_ui.txt diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..46aa2f0 --- /dev/null +++ b/web/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/web/components/UI.tsx b/web/components/UI.tsx index ad9390d..15b77bc 100644 --- a/web/components/UI.tsx +++ b/web/components/UI.tsx @@ -1,63 +1,37 @@ - import React from 'react'; import { X } from 'lucide-react'; -// Button +// Shadcn UI Imports +import { Button as ShadcnButton } from './ui/button'; +import { Input as ShadcnInput } from './ui/input'; +import { Switch as ShadcnSwitch } from './ui/switch'; +import { Card as ShadcnCard } from './ui/card'; +import { Badge as ShadcnBadge } from './ui/badge'; +import { TableHeader as ShadcnTableHeader, TableRow as ShadcnTableRow, TableHead as ShadcnTableHead, TableCell as ShadcnTableCell } from './ui/table'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from './ui/sheet'; +import { Dialog as ShadcnDialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog'; + +// Button Wrapper to match old API interface ButtonProps extends React.ButtonHTMLAttributes { variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive'; size?: 'sm' | 'md' | 'lg' | 'icon'; } +export const Button: React.FC = ({ variant = 'primary', size = 'md', className, ...props }) => { + const vMap: any = { primary: 'default', secondary: 'secondary', outline: 'outline', ghost: 'ghost', destructive: 'destructive' }; + const sMap: any = { sm: 'sm', md: 'default', lg: 'lg', icon: 'icon' }; + return ; +} -export const Button: React.FC = ({ - className = '', - variant = 'primary', - size = 'md', - children, - ...props -}) => { - const baseStyles = "inline-flex items-center justify-center rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 active:scale-95"; - - const variants = { - // Primary: Glow effect - primary: "bg-primary text-primary-foreground shadow-[0_0_10px_rgba(6,182,212,0.5)] hover:bg-primary/90 hover:shadow-[0_0_15px_rgba(6,182,212,0.6)]", - secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", - outline: "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-primary/50", - ghost: "hover:bg-accent hover:text-accent-foreground", - destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", - }; - - const sizes = { - sm: "h-8 px-3 text-xs", - md: "h-9 px-4 py-2", - lg: "h-10 px-8", - icon: "h-9 w-9", - }; - - return ( - - ); -}; - -// Input - Removed border, added subtle background -interface InputProps extends React.InputHTMLAttributes {} - -export const Input: React.FC = ({ className = '', ...props }) => { - return ( - - ); -}; - -interface SelectProps extends React.SelectHTMLAttributes {} +// Input and Switch match seamlessly +export const Input = ShadcnInput; +export const Switch = ShadcnSwitch; +// Native Select Wrapper to avoid breaking consumers expecting +interface SelectProps extends React.SelectHTMLAttributes { } export const Select: React.FC = ({ className = '', children, ...props }) => { return (