Add voice state tags, SuperTTS configs, and demo WS log groups.

Parse leading <state> tags from LLM replies and emit response.state over the product websocket while stripping tags from TTS/text streams. Add FastGPT+Xfyun voice configs (including state-enabled preset), SuperTTS support, and context sync for interrupted turns. Refresh the voice demo with a state indicator and collapsible audio delta websocket log groups.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xin Wang
2026-05-28 11:32:20 +08:00
parent b14ef64665
commit 9e2374f492
18 changed files with 1596 additions and 195 deletions

View File

@@ -26,6 +26,9 @@ def resolve_voice_config_path() -> Path:
DEFAULT_VOICE_CONFIG = resolve_voice_config_path()
SUPPORTED_LLM_PROVIDERS = frozenset({"openai", "fastgpt"})
_LLM_PROVIDER_ALIASES = {"llm": "openai", "openai": "openai", "fastgpt": "fastgpt"}
@dataclass(frozen=True)
class ServerConfig:
@@ -93,11 +96,20 @@ class TurnConfig:
)
@dataclass(frozen=True)
class ResponseStateConfig:
enabled: bool = False
tag: str = "state"
event_type: str = "response.state"
max_prefix_chars: int = 256
@dataclass(frozen=True)
class AgentConfig:
system_prompt: str = "You are a helpful, friendly voice assistant."
greeting: str | None = None
greeting_mode: str = "generated"
response_state: ResponseStateConfig = field(default_factory=ResponseStateConfig)
@dataclass(frozen=True)
@@ -106,6 +118,7 @@ class LLMConfig:
api_key: str = ""
base_url: str | None = None
model: str = "gpt-4o-mini"
app_id: str | None = None
temperature: float | None = 0.7
chat_id: str | None = None
variables: dict[str, str] = field(default_factory=dict)
@@ -113,6 +126,19 @@ class LLMConfig:
timeout_sec: float = 60.0
send_system_prompt: bool = False
@property
def is_fastgpt(self) -> bool:
return self.provider == "fastgpt"
@property
def is_openai(self) -> bool:
return self.provider == "openai"
@property
def uses_local_context_history(self) -> bool:
"""Whether the pipeline should seed and maintain local LLM context history."""
return not self.is_fastgpt or self.send_system_prompt
@dataclass(frozen=True)
class STTConfig:
@@ -147,6 +173,8 @@ class TTSConfig:
pitch: int = 50
timeout_sec: float = 30.0
source_sample_rate_hz: int | None = None
oral_level: str = "mid"
text_aggregation_mode: str | None = None
@dataclass(frozen=True)
@@ -183,14 +211,24 @@ def config_from_dict(data: dict) -> EngineConfig:
agent["greeting"] = None
if agent.get("greeting_mode") not in (None, "generated", "fixed", "off"):
raise ValueError("agent.greeting_mode must be one of: generated, fixed, off")
response_state = ResponseStateConfig(**_dict(agent.pop("response_state")))
if response_state.max_prefix_chars < 1:
raise ValueError("agent.response_state.max_prefix_chars must be greater than 0")
if not response_state.tag:
raise ValueError("agent.response_state.tag must not be empty")
if not response_state.event_type:
raise ValueError("agent.response_state.event_type must not be empty")
stt = _dict(services.get("stt") or services.get("asr"))
if stt.get("language") == "":
stt["language"] = None
llm = _dict(services.get("llm"))
llm["provider"] = _normalize_llm_provider(llm.get("provider", LLMConfig().provider))
if llm.get("chat_id") == "":
llm["chat_id"] = None
if llm.get("app_id") == "":
llm["app_id"] = None
if not isinstance(llm.get("variables"), dict):
llm["variables"] = {}
@@ -219,7 +257,7 @@ def config_from_dict(data: dict) -> EngineConfig:
)
),
),
agent=AgentConfig(**agent),
agent=AgentConfig(**agent, response_state=response_state),
services=ServicesConfig(
llm=LLMConfig(**llm),
stt=STTConfig(**stt),
@@ -230,3 +268,14 @@ def config_from_dict(data: dict) -> EngineConfig:
def _dict(value: object) -> dict:
return dict(value) if isinstance(value, dict) else {}
def _normalize_llm_provider(value: object) -> str:
provider = str(value or LLMConfig().provider).strip().lower()
normalized = _LLM_PROVIDER_ALIASES.get(provider)
if normalized is None:
supported = ", ".join(sorted(SUPPORTED_LLM_PROVIDERS | {"llm"}))
raise ValueError(
f"services.llm.provider must be one of: {supported}; got {value!r}"
)
return normalized