Files
xiaozhi-esp32-server/main/xiaozhi-server/core/services/connection_service.py
T

1355 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import copy
import json
import re
import time
from typing import Dict, Any
from urllib.parse import parse_qs, urlparse
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.components.component_registry import ComponentRegistry
from core.components.component_manager import ComponentType
from core.pipeline.message_pipeline import MessagePipeline
from core.processors.message_router import MessageRouter
from config.logger import setup_logging
from config.config_loader import get_private_config_from_api
from config.manage_api_client import (
DeviceNotFoundException,
DeviceBindException,
generate_and_save_chat_title,
)
from core.utils.util import check_vad_update, check_asr_update, filter_sensitive_info
from core.utils.dialogue import Dialogue, Message
from core.utils.voiceprint_provider import VoiceprintProvider
logger = setup_logging()
TAG = __name__
class ConnectionService:
"""连接服务:统一管理连接生命周期,替代ConnectionHandler"""
def __init__(self, config: Dict[str, Any]):
self.config = config
self.logger = setup_logging()
# 创建统一消息路由器
self.message_router = MessageRouter()
# 创建消息处理管道
self.message_pipeline = MessagePipeline()
self._setup_pipeline()
def _setup_pipeline(self):
"""设置消息处理管道"""
# 使用统一的MessageRouter替代单独的processor
self.message_pipeline.add_processor(self.message_router)
async def handle_connection(self, transport: TransportInterface, headers: Dict[str, str]):
"""处理新连接"""
# 创建会话上下文
base_config = dict(self.config)
if "_shared_asr_manager" in base_config:
del base_config["_shared_asr_manager"]
context = SessionContext(config=base_config)
context.headers = headers
# 设置transport接口
context.transport = transport
async def handle_asr_result(text, audio_snapshot):
from core.processors.audio_receive_processor import AudioReceiveProcessor
from core.processors.report_processor import ReportProcessor
ReportProcessor().enqueue_asr_report(context, text, audio_snapshot)
await AudioReceiveProcessor().start_to_chat(
context, transport, text
)
context.asr_result_handler = handle_asr_result
async def end_conversation(session_id=None):
await self._finalize_conversation_session(context, session_id)
context.end_conversation = end_conversation
# 传入共享 ASR 管理器
# 这使得 ASRAdapter 可以使用预加载的模型实例
if '_shared_asr_manager' in self.config:
context.shared_asr_manager = self.config['_shared_asr_manager']
logger.bind(tag=TAG).debug("连接使用共享 ASR 实例")
# 兼容性:设置websocket属性(如果transport是WebSocket
if transport.transport_type in ("websocket", "gateway"):
context.websocket = transport.raw_connection
# 绑定服务器实例(用于管理端下发动作)
context.server = getattr(self, "server", None)
# 初始化 welcome_msg(关键!设备需要这些信息来确定协议配置)
# 从 config.xiaozhi 读取欢迎消息配置
if 'xiaozhi' in self.config:
import copy
context.welcome_msg = copy.deepcopy(self.config['xiaozhi'])
context.welcome_msg['session_id'] = context.session_id
else:
# 默认欢迎消息
context.welcome_msg = {
"type": "hello",
"version": 1,
"transport": "websocket",
"session_id": context.session_id,
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60
}
}
output_audio = context.welcome_msg.get("audio_params", {})
context.output_sample_rate = int(output_audio.get("sample_rate", 24000))
context.output_channels = int(output_audio.get("channels", 1))
context.output_frame_duration = int(output_audio.get("frame_duration", 60))
context.sample_rate = context.output_sample_rate
context.channels = context.output_channels
context.frame_duration = context.output_frame_duration
# 从headers或URL参数中提取设备信息
await self._extract_device_info(context, headers)
if transport.is_protocol_authenticated:
context.is_authenticated = True
management_owner = context.server
management_registered = False
# 识别服务器管理连接(非设备连接)
context.is_server_action_conn = self._is_server_action_connection(context, headers)
# 创建组件管理器(仅在需要时)
component_manager = None
if not context.is_server_action_conn:
component_manager = ComponentRegistry.create_component_manager(self.config)
context.component_manager = component_manager
# 设置绑定检查事件
bind_completed_event = asyncio.Event()
last_bind_prompt_time = 0
bind_prompt_interval = 60 # 绑定提示播放间隔(秒)
# 启动超时检查任务
timeout_task = None
initialization_task = None
try:
logger.bind(tag=TAG).info(f"新连接建立: {context.device_id} from {context.client_ip}")
register_context = getattr(
management_owner, "register_connection_context", None
)
if callable(register_context):
try:
management_registered = bool(
await register_context(context, transport)
)
except Exception as e:
logger.bind(tag=TAG).warning(
"注册连接管理上下文失败: {}", e
)
if not context.is_server_action_conn:
initialization = self._background_initialize(
context, component_manager, bind_completed_event
)
if transport.keeps_connection_between_sessions:
# Native MQTT owns Hello at the protocol layer. Keep its
# readiness barrier until the business runtime can accept
# the negotiated UDP session.
await initialization
else:
# WS and Gateway clients expect the Hello response before
# slow Agent/provider initialization completes.
initialization_task = asyncio.create_task(
initialization,
name="xiaozhi-connection-initialize",
)
# Start the owner before entering receive() so immediate
# disconnect cleanup can still observe partial resources.
await asyncio.sleep(0)
else:
context.need_bind = False
bind_completed_event.set()
# Native MQTT may have received Hello while private configuration
# was loading. Release that handshake only after the runtime can
# consume the session boundary and subsequent audio.
await transport.mark_business_ready()
# 启动超时检查
timeout_task = asyncio.create_task(self._check_timeout(context, transport))
context.timeout_task = timeout_task
# 处理消息流
async for message in transport.receive():
try:
msg_json = self._parse_control_message(message)
is_hello = (
isinstance(msg_json, dict)
and msg_json.get("type") == "hello"
)
if initialization_task is not None:
if is_hello and not initialization_task.done():
# Acknowledge transport negotiation first, then
# join initialization before admitting listen/audio.
await self.message_pipeline.process_message(
context, transport, message
)
await initialization_task
initialization_task = None
if getattr(context, "init_error", None):
await self._send_config_error_audio(
context,
transport,
context.init_error,
send_hello=False,
)
context.init_error_notified = True
break
if context.need_bind:
await self._prompt_bind_if_needed(
context,
transport,
last_bind_prompt_time,
bind_prompt_interval,
)
last_bind_prompt_time = time.time()
continue
await initialization_task
initialization_task = None
is_native_hello = (
transport.keeps_connection_between_sessions
and is_hello
)
if is_native_hello:
try:
# Every Native Hello is a logical-session boundary.
# Always query Agent config here; the refresh helper
# cheaply retains an unchanged healthy runtime.
component_manager = await self._refresh_private_runtime(
context,
component_manager,
bind_completed_event,
)
finally:
await transport.mark_session_ready(transport.session_id)
if getattr(context, "init_error", None):
# 配置错误时,允许hello/listen触发默认语音(节流)
msg_type = msg_json.get("type") if isinstance(msg_json, dict) else None
if msg_type == "hello":
# 配置错误场景:默认语音限流,避免唤醒循环
session_id = msg_json.get("session_id") if isinstance(msg_json, dict) else None
if session_id and session_id != context.session_id:
context.session_id = session_id
now = time.time()
last_audio_ts = getattr(context, "_init_error_last_audio_ts", 0.0)
audio_params = msg_json.get("audio_params")
if audio_params:
context.audio_format = audio_params.get("format", context.audio_format)
if not transport.has_datagram_audio:
hello_resp = {
"type": "hello",
"session_id": context.session_id,
"version": 1,
"transport": "websocket",
}
if audio_params:
hello_resp["audio_params"] = audio_params
await transport.send_json(hello_resp)
# 60秒冷却:期间只发goodbye让设备回Idle,不重复播报
if now - last_audio_ts < 60:
session_id = getattr(transport, "session_id", None) or context.session_id
if transport.keeps_connection_between_sessions:
await transport.end_session(session_id)
else:
await self._send_config_error_audio(
context,
transport,
context.init_error,
send_hello=False,
)
context.init_error_notified = True
context._init_error_last_audio_ts = now
# MQTT/UDP 连接保持,不主动断开
if not transport.keeps_connection_between_sessions:
break
continue
if getattr(context, "init_error_notified", False):
continue
# 检查绑定状态
should_process = await self._check_bind_status(
context, transport, bind_completed_event,
last_bind_prompt_time, bind_prompt_interval
)
if not should_process:
last_bind_prompt_time = time.time()
continue
await self.message_pipeline.process_message(context, transport, message)
except Exception as e:
logger.bind(tag=TAG).error(f"处理消息时出错: {e}")
# 继续处理其他消息,不中断连接
except Exception as e:
logger.bind(tag=TAG).error(f"连接处理出错: {e}")
finally:
# 清理资源
if management_registered:
unregister_context = getattr(
management_owner, "unregister_connection_context", None
)
if callable(unregister_context):
try:
await unregister_context(context, transport)
except Exception as e:
logger.bind(tag=TAG).warning(
"注销连接管理上下文失败: {}", e
)
if initialization_task and not initialization_task.done():
initialization_task.cancel()
await asyncio.gather(
initialization_task, return_exceptions=True
)
if timeout_task and not timeout_task.done():
timeout_task.cancel()
try:
await timeout_task
except asyncio.CancelledError:
pass
# init_task 已移除(同步初始化),这里不再取消
await self._finalize_conversation_session(context)
# MQTT/UDP 连接关闭前通知设备回到Idle
try:
if (
transport.keeps_connection_between_sessions
and transport.is_connected
):
session_id = getattr(transport, "session_id", None) or context.session_id
await transport.end_session(session_id)
except Exception as e:
logger.bind(tag=TAG).warning(f"关闭前发送goodbye失败: {e}")
# 先停止所有生产任务和会话回调,再关闭其依赖的 Provider。
try:
await context.run_cleanup()
except Exception as e:
logger.bind(tag=TAG).error(f"会话清理失败: {e}")
# A cancelled Native runtime refresh may have already installed a
# healthy replacement before this local variable was reassigned.
# Always clean the manager currently owned by the context.
active_component_manager = (
getattr(context, "component_manager", None) or component_manager
)
if active_component_manager:
try:
await active_component_manager.cleanup_all()
except Exception as e:
logger.bind(tag=TAG).error(f"组件清理失败: {e}")
try:
await transport.close()
except Exception as e:
logger.bind(tag=TAG).error(f"关闭传输层失败: {e}")
logger.bind(tag=TAG).info(f"连接已关闭: {context.device_id}")
async def _extract_device_info(self, context: SessionContext, headers: Dict[str, str]):
"""
从headers中提取设备信息,必要时从transport补齐
"""
context.device_id = headers.get("device-id")
context.headers["client-id"] = headers.get("client-id")
context.client_ip = headers.get("x-real-ip") or headers.get("x-forwarded-for", "unknown")
if context.client_ip and "," in context.client_ip:
context.client_ip = context.client_ip.split(",")[0].strip()
# MQTT连接可能在connect后才有device_id/client_id,这里等待短时间补齐
if not context.device_id and hasattr(context.transport, "device_id"):
for _ in range(30):
transport_device_id = getattr(context.transport, "device_id", None)
if transport_device_id:
context.device_id = transport_device_id
break
await asyncio.sleep(0.1)
if not context.device_id and hasattr(context.transport, "username"):
transport_username = getattr(context.transport, "username", None)
if transport_username:
context.device_id = transport_username
if context.device_id and not context.headers.get("device-id"):
context.headers["device-id"] = context.device_id
if not context.headers.get("client-id") and hasattr(context.transport, "client_id"):
transport_client_id = getattr(context.transport, "client_id", None)
if transport_client_id:
context.headers["client-id"] = transport_client_id
if (
not context.transport.is_protocol_authenticated
and not context.headers.get("authorization")
and hasattr(context.transport, "password")
):
transport_password = getattr(context.transport, "password", None)
if transport_password:
context.headers["authorization"] = transport_password
def _is_server_action_connection(self, context: SessionContext, headers: Dict[str, str]) -> bool:
"""判断是否为管理端下发动作的临时连接"""
if not context.read_config_from_api:
return False
device_id = headers.get("device-id") or ""
if not device_id:
return False
# 设备ID是MAC则认为是设备连接
mac_pattern = r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"
if re.match(mac_pattern, device_id):
return False
# 非MAC且带Authorization头,视为管理端连接
return bool(headers.get("authorization"))
async def _initialize_components(self, context: SessionContext, component_manager):
"""初始化必要的组件"""
try:
# 设置组件管理器到上下文
context.component_manager = component_manager
# 根据配置确定需要初始化的组件
required_components = ComponentRegistry.get_required_components(context.config)
if not required_components:
logger.bind(tag=TAG).warning("未配置任何组件,跳过初始化")
return
# 按需初始化组件
for component_type in required_components:
component = await component_manager.get_component(component_type, context)
if component:
logger.bind(tag=TAG).info(f"组件初始化成功: {component_type.value}")
else:
raise RuntimeError(f"组件初始化失败: {component_type.value}")
component_manager.lazy_enabled = False
await self._initialize_voiceprint(context)
# 对齐旧版:初始化意图类型与工具处理器
selected_intent = context.config.get("selected_module", {}).get("Intent")
intent_cfg = context.config.get("Intent", {})
if selected_intent and selected_intent in intent_cfg:
context.intent_type = intent_cfg[selected_intent].get("type", "nointent")
if context.intent_type in ("function_call", "intent_llm"):
context.load_function_plugin = True
try:
from core.providers.tools.unified_tool_handler import UnifiedToolHandler
context.func_handler = UnifiedToolHandler(context)
context.register_cleanup(context.func_handler.cleanup)
await context.func_handler._initialize()
logger.bind(tag=TAG).info(
f"统一工具处理器初始化完成: intent_type={context.intent_type}"
)
except Exception as e:
logger.bind(tag=TAG).error(f"初始化工具处理器失败: {e}")
await self._initialize_prompt(context)
context.inject_tool_call_fewshot = lambda: self._inject_tool_call_fewshot(
context
)
self._inject_tool_call_fewshot(context)
except Exception as e:
logger.bind(tag=TAG).error(f"组件初始化出错: {e}")
raise
async def _initialize_voiceprint(self, context: SessionContext) -> None:
"""Initialize the connection-scoped voiceprint provider after config merge."""
voiceprint_config = context.config.get("voiceprint") or {}
context.voiceprint_provider = None
if not voiceprint_config:
logger.bind(tag=TAG).info("声纹识别功能未启用")
return
try:
provider = await asyncio.to_thread(VoiceprintProvider, voiceprint_config)
if provider.enabled:
context.voiceprint_provider = provider
logger.bind(tag=TAG).info("声纹识别功能已在连接时动态启用")
else:
logger.bind(tag=TAG).warning("声纹识别功能启用但配置不完整")
except Exception as e:
logger.bind(tag=TAG).warning(f"声纹识别初始化失败: {e}")
@staticmethod
def _inject_tool_call_fewshot(context: SessionContext) -> None:
if context.intent_type != "function_call" or not context.func_handler:
return
if any(
getattr(message, "is_temporary", False)
for message in context.dialogue.dialogue
):
return
tools = context.func_handler.get_functions() or []
if not tools:
return
tool_names = {
tool.get("function", {}).get("name") for tool in tools
}
direct_id = "fewshot_da_001"
context.dialogue.put(
Message(role="user", content="给我讲个故事吧", is_temporary=True)
)
context.dialogue.put(
Message(
role="assistant",
tool_calls=[
{
"id": direct_id,
"function": {
"arguments": '{"response":"好呀,你想听什么类型的呀?"}',
"name": "direct_answer",
},
"type": "function",
"index": 0,
}
],
is_temporary=True,
)
)
context.dialogue.put(
Message(
role="tool",
tool_call_id=direct_id,
content="已直接回复",
is_temporary=True,
)
)
if "handle_exit_intent" in tool_names:
exit_id = "fewshot_exit_001"
context.dialogue.put(
Message(role="user", content="拜拜", is_temporary=True)
)
context.dialogue.put(
Message(
role="assistant",
tool_calls=[
{
"id": exit_id,
"function": {
"arguments": '{"say_goodbye":"再见,下次再聊~"}',
"name": "handle_exit_intent",
},
"type": "function",
"index": 0,
}
],
is_temporary=True,
)
)
context.dialogue.put(
Message(
role="tool",
tool_call_id=exit_id,
content="退出意图已处理",
is_temporary=True,
)
)
context.dialogue.put(
Message(
role="assistant",
content="再见,下次再聊~",
is_temporary=True,
)
)
async def _initialize_prompt(self, context: SessionContext):
user_prompt = context.config.get("prompt")
if user_prompt is None or not context.prompt_manager:
return
quick_prompt = context.prompt_manager.get_quick_prompt(
user_prompt, context.device_id
)
context.prompt = quick_prompt
context.dialogue.update_system_message(quick_prompt)
try:
await asyncio.to_thread(
context.prompt_manager.update_context_info,
context,
context.client_ip,
)
enhanced_prompt = await asyncio.to_thread(
context.prompt_manager.build_enhanced_prompt,
user_prompt,
context.device_id,
context.client_ip,
emoji_enabled=(context.features or {}).get("emoji", True),
)
if enhanced_prompt:
context.prompt = enhanced_prompt
context.dialogue.update_system_message(enhanced_prompt)
except Exception as e:
logger.bind(tag=TAG).warning(f"增强系统提示词失败,使用快速提示词: {e}")
def _collect_missing_modules(self, config: Dict[str, Any]) -> list[str]:
selected = config.get("selected_module", {}) or {}
missing = []
module_keys = {
"ASR": "语音识别",
"LLM": "大模型",
"TTS": "语音合成",
"Memory": "记忆功能",
"Intent": "意图识别",
"VAD": "语音活动检测"
}
for key, name in module_keys.items():
module_name = selected.get(key)
if not module_name:
missing.append(name)
continue
section = config.get(key, {})
if module_name not in section:
missing.append(name)
return missing
async def _send_config_error_audio(
self,
context: SessionContext,
transport: TransportInterface,
error: Exception,
send_hello: bool = True,
):
logger.bind(tag=TAG).error("配置错误,发送默认语音")
missing = self._collect_missing_modules(context.config)
if missing:
missing_text = "、".join(missing)
text = f"你好啊,很高兴认识你。在我们开始聊天之前,{missing_text}未配置完成,请前往控制台完成配置"
else:
text = f"配置加载失败,请前往控制台检查配置。"
try:
if send_hello:
# 先发送最小 hello,避免客户端等待超时
await transport.send_json({
"type": "hello",
"session_id": context.session_id,
"version": 1,
"transport": "websocket"
})
# MQTT/UDP 场景需要等待设备先发送UDP以拿到远端地址
if transport.has_datagram_audio:
await transport.prepare_audio_channel(
context.welcome_msg.get("audio_params", {}), 3
)
await transport.wait_audio_ready(timeout=3)
from core.providers.tts.edge import TTSProvider as EdgeTTSProvider
from core.utils.util import audio_bytes_to_data_stream
# 使用 EdgeTTS 兜底提示
tts_cfg = {
"voice": "zh-CN-XiaoxiaoNeural",
"format": "mp3",
"output_dir": "tmp",
}
tts = EdgeTTSProvider(tts_cfg, delete_audio_file=True)
audio_bytes = await tts.text_to_speak(text, None)
if not audio_bytes:
return
# 转成 Opus 帧
opus_frames: list[bytes] = []
loop = asyncio.get_running_loop()
def _convert():
audio_bytes_to_data_stream(audio_bytes, "mp3", True, lambda f: opus_frames.append(f))
await loop.run_in_executor(None, _convert)
session_id = getattr(transport, "session_id", None) or context.session_id
# 发送 TTS 状态与音频
await transport.send_json({
"type": "tts",
"state": "start",
"session_id": session_id,
})
await transport.send_json({
"type": "tts",
"state": "sentence_start",
"text": text,
"session_id": session_id,
})
for frame in opus_frames:
await transport.send_audio(frame)
await asyncio.sleep(0.06)
await transport.send_json({
"type": "tts",
"state": "stop",
"session_id": session_id,
})
# MQTT/UDP:默认语音结束后主动发送goodbye,设备回到Idle但保持MQTT连接
if transport.keeps_connection_between_sessions:
await transport.end_session(session_id)
# MQTT/UDP 需要保持连接,避免发送完默认语音后断链
if not transport.keeps_connection_between_sessions:
await transport.close()
except Exception as e:
logger.bind(tag=TAG).error(f"发送配置错误提示语音失败: {e}")
async def _background_initialize(
self,
context: SessionContext,
component_manager,
bind_completed_event: asyncio.Event
):
"""在后台初始化配置和组件"""
try:
await self._initialize_private_config(context, bind_completed_event)
await self._initialize_components(context, component_manager)
except asyncio.CancelledError:
raise
except Exception as e:
logger.bind(tag=TAG).error(f"后台初始化失败: {e}")
context.init_error = e
# 即使初始化失败,也要设置绑定完成事件,避免消息一直被丢弃
bind_completed_event.set()
async def _initialize_private_config(
self,
context: SessionContext,
bind_completed_event: asyncio.Event
):
"""从API异步获取差异化配置"""
if not context.read_config_from_api:
context.need_bind = False
bind_completed_event.set()
return
try:
begin_time = time.time()
private_config = await get_private_config_from_api(
context.config,
context.device_id,
context.headers.get("client-id", context.device_id),
)
if private_config:
private_config["delete_audio"] = bool(context.config.get("delete_audio", True))
logger.bind(tag=TAG).info(
f"{time.time() - begin_time:.2f}秒,获取差异化配置成功"
)
# 合并私有配置并重算所有派生字段,避免长连接刷新后残留旧值。
self._apply_private_runtime_config(context, private_config)
if context.component_manager is not None:
context.component_manager._config = context.config
selected = context.config.get("selected_module", {})
llm_cfg = context.config.get("LLM", {})
logger.bind(tag=TAG).info(
f"私有配置模块: selected={list(selected.keys())}, LLM_selected={selected.get('LLM')}, "
f"LLM_keys={list(llm_cfg.keys())}"
)
context.need_bind = False
else:
context.need_bind = False
bind_completed_event.set()
except DeviceNotFoundException:
logger.bind(tag=TAG).warning(f"设备 {context.device_id} 未找到,需要绑定")
context.need_bind = True
bind_completed_event.set()
except DeviceBindException as e:
logger.bind(tag=TAG).warning(f"设备绑定异常: {e}")
context.need_bind = True
context.bind_code = getattr(e, 'bind_code', None)
bind_completed_event.set()
except Exception as e:
logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}")
context.need_bind = True
bind_completed_event.set()
@staticmethod
def _parse_control_message(message: Any):
if isinstance(message, dict):
return message
if isinstance(message, str):
try:
return json.loads(message)
except json.JSONDecodeError:
return None
return None
async def _refresh_private_runtime(
self,
context: SessionContext,
component_manager,
bind_completed_event: asyncio.Event,
):
"""Refresh Agent config at a Native MQTT logical-session boundary."""
if not context.read_config_from_api:
return component_manager
try:
private_config = await get_private_config_from_api(
context.common_config,
context.device_id,
context.headers.get("client-id", context.device_id),
)
except DeviceNotFoundException:
context.need_bind = True
return component_manager
except DeviceBindException as exc:
context.need_bind = True
context.bind_code = getattr(exc, "bind_code", None)
return component_manager
except Exception as exc:
logger.bind(tag=TAG).warning(f"刷新差异化配置失败,继续使用当前配置: {exc}")
return component_manager
if not private_config:
return component_manager
private_config["delete_audio"] = bool(
context.common_config.get("delete_audio", True)
)
config_changed = private_config != context.private_config
if not config_changed and getattr(context, "init_error", None) is None:
context.need_bind = False
context.bind_code = None
bind_completed_event.set()
return component_manager
await self._finalize_conversation_session(context, context.session_id)
await context.cancel_conversation_tasks()
try:
staged_context = copy.copy(context)
staged_context.config = copy.deepcopy(context.common_config)
staged_context.private_config = copy.deepcopy(private_config)
self._reset_config_derived_state(staged_context)
staged_context.config.update(copy.deepcopy(private_config))
self._reset_config_derived_state(staged_context)
self._merge_private_modules(
staged_context, copy.deepcopy(private_config)
)
except Exception as exc:
logger.bind(tag=TAG).error(
f"物化刷新配置失败,继续使用旧运行时: {exc}"
)
return component_manager
try:
new_manager = ComponentRegistry.create_component_manager(
staged_context.config
)
except Exception as exc:
logger.bind(tag=TAG).error(f"创建刷新业务组件失败,保留旧运行时: {exc}")
return component_manager
runtime_fields = (
"config",
"private_config",
"component_manager",
"asr",
"tts",
"func_handler",
"voiceprint_provider",
"prompt",
"intent_type",
"load_function_plugin",
"inject_tool_call_fewshot",
"dialogue",
"max_output_size",
"chat_history_conf",
"cmd_exit",
"need_bind",
"bind_code",
"init_error",
"init_error_notified",
"_init_error_last_audio_ts",
)
missing = object()
previous = {
field: getattr(context, field, missing) for field in runtime_fields
}
previous_callbacks = list(context._cleanup_callbacks)
old_func_handler = context.func_handler
context.config = staged_context.config
context.private_config = staged_context.private_config
context.max_output_size = staged_context.max_output_size
context.chat_history_conf = staged_context.chat_history_conf
context.cmd_exit = staged_context.cmd_exit
context.component_manager = new_manager
context.asr = None
context.tts = None
context.func_handler = None
context.voiceprint_provider = None
context.prompt = None
context.intent_type = "nointent"
context.load_function_plugin = False
context.inject_tool_call_fewshot = None
context.dialogue = Dialogue()
context.init_error = None
try:
await self._initialize_components(context, new_manager)
except (asyncio.CancelledError, Exception) as exc:
was_cancelled = isinstance(exc, asyncio.CancelledError)
retry_callbacks = []
new_callbacks = [
callback
for callback in context._cleanup_callbacks
if callback not in previous_callbacks
]
for callback in reversed(new_callbacks):
try:
result = callback()
if asyncio.iscoroutine(result):
await result
except (asyncio.CancelledError, Exception) as cleanup_exc:
retry_callbacks.append(callback)
logger.bind(tag=TAG).warning(
f"回滚刷新工具处理器失败: {cleanup_exc}"
)
try:
await new_manager.cleanup_all()
except (asyncio.CancelledError, Exception) as cleanup_exc:
retry_callbacks.append(new_manager.cleanup_all)
logger.bind(tag=TAG).warning(
f"回滚刷新业务组件失败: {cleanup_exc}"
)
for field, value in previous.items():
if value is missing:
if hasattr(context, field):
delattr(context, field)
else:
setattr(context, field, value)
context._cleanup_callbacks = previous_callbacks + retry_callbacks
if was_cancelled:
logger.bind(tag=TAG).info(
"刷新业务组件被取消,已回滚并保留旧运行时"
)
raise
logger.bind(tag=TAG).error(
f"刷新业务组件失败,继续使用旧运行时: {exc}"
)
return component_manager
# Only retire the previous runtime after the replacement is healthy.
# Register the old manager before the first await so cancellation at
# any point leaves a cleanup owner for handle_connection.finally.
if (
component_manager
and component_manager.cleanup_all not in context._cleanup_callbacks
):
context.register_cleanup(component_manager.cleanup_all)
if old_func_handler and hasattr(old_func_handler, "cleanup"):
try:
await old_func_handler.cleanup()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.bind(tag=TAG).warning(f"清理旧工具处理器失败: {exc}")
else:
context.unregister_cleanup(old_func_handler.cleanup)
if component_manager:
try:
await component_manager.cleanup_all()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.bind(tag=TAG).warning(f"清理旧业务组件失败: {exc}")
else:
context.unregister_cleanup(component_manager.cleanup_all)
context.init_error = None
context.init_error_notified = False
context._init_error_last_audio_ts = 0.0
context.need_bind = False
context.bind_code = None
bind_completed_event.set()
logger.bind(tag=TAG).info(
"Native MQTT逻辑会话已原子刷新私有配置和业务组件"
)
return new_manager
@staticmethod
def _reset_config_derived_state(context: SessionContext) -> None:
"""Reset fields derived from config before applying a replacement."""
context.max_output_size = int(
context.config.get(
"device_max_output_size",
context.config.get("max_output_size", 0),
)
or 0
)
context.chat_history_conf = int(
context.config.get("chat_history_conf", 0) or 0
)
context.cmd_exit = list(context.config.get("exit_commands", []) or [])
context.intent_type = "nointent"
context.load_function_plugin = False
def _apply_private_runtime_config(
self, context: SessionContext, private_config: Dict[str, Any]
) -> None:
"""Replace common/private config and recompute connection derivatives."""
context.config = copy.deepcopy(context.common_config)
context.private_config = copy.deepcopy(private_config)
self._reset_config_derived_state(context)
applied_private_config = copy.deepcopy(private_config)
context.config.update(applied_private_config)
self._reset_config_derived_state(context)
self._merge_private_modules(context, applied_private_config)
def _merge_private_modules(self, context: SessionContext, private_config: Dict[str, Any]) -> None:
"""合并私有模块配置(与旧服务保持一致)"""
selected = context.config.setdefault("selected_module", {})
for module_name in ("VAD", "ASR", "TTS", "LLM", "VLLM", "Memory", "Intent"):
module_cfg = private_config.get(module_name)
module_selected = private_config.get("selected_module", {}).get(module_name)
if module_cfg is not None:
context.config[module_name] = module_cfg
if module_selected:
selected[module_name] = module_selected
# 私有配置未包含VAD/ASR时,保留公共配置的选择与配置
common_selected = context.common_config.get("selected_module", {}) if context.common_config else {}
for module_name in ("VAD", "ASR"):
if not selected.get(module_name) and common_selected.get(module_name):
selected[module_name] = common_selected.get(module_name)
if module_name not in context.config and context.common_config.get(module_name) is not None:
context.config[module_name] = context.common_config.get(module_name)
# Intent插件配置处理
intent_selected = private_config.get("selected_module", {}).get("Intent")
if intent_selected and intent_selected != "Intent_nointent":
plugins = private_config.get("plugins", {})
if plugins:
for plugin, config_str in plugins.items():
if isinstance(config_str, str):
try:
plugins[plugin] = json.loads(config_str)
except json.JSONDecodeError:
pass
context.config["plugins"] = plugins
if "Intent" in context.config and intent_selected in context.config["Intent"]:
context.config["Intent"][intent_selected]["functions"] = plugins.keys()
# 更新会话级配置
if private_config.get("device_max_output_size") is not None:
context.max_output_size = int(private_config["device_max_output_size"])
if private_config.get("chat_history_conf") is not None:
context.chat_history_conf = int(private_config["chat_history_conf"])
# TTS providers consume replacement words from the selected module
# configuration, matching the legacy connection runtime.
correct_words = private_config.get("correct_words")
selected_tts = selected.get("TTS")
if (
correct_words is not None
and selected_tts
and selected_tts in context.config.get("TTS", {})
):
context.config["TTS"][selected_tts]["correct_words"] = correct_words
async def _check_bind_status(
self,
context: SessionContext,
transport: TransportInterface,
bind_completed_event: asyncio.Event,
last_bind_prompt_time: float,
bind_prompt_interval: int
) -> bool:
"""
检查设备绑定状态
Returns:
bool: True 表示可以处理消息,False 表示需要丢弃消息
"""
# 如果还没获取到真实绑定状态,等待一下
if not bind_completed_event.is_set():
try:
await asyncio.wait_for(bind_completed_event.wait(), timeout=1)
except asyncio.TimeoutError:
# 超时仍未获取到真实状态,丢弃消息并提示绑定
await self._prompt_bind_if_needed(
context, transport, last_bind_prompt_time, bind_prompt_interval
)
return False
# 检查是否需要绑定
if context.need_bind:
await self._prompt_bind_if_needed(
context, transport, last_bind_prompt_time, bind_prompt_interval
)
return False
return True
async def _prompt_bind_if_needed(
self,
context: SessionContext,
transport: TransportInterface,
last_prompt_time: float,
prompt_interval: int
):
"""如果需要,播放绑定提示"""
current_time = time.time()
if current_time - last_prompt_time >= prompt_interval:
try:
from core.processors.audio_receive_processor import (
AudioReceiveProcessor,
)
# Binding checks run before the normal Hello pipeline. Give the
# prompt an explicit transport session and turn owner so the
# TTS worker does not classify its queued audio as stale.
transport_session_id = getattr(transport, "session_id", None)
if transport_session_id:
context.session_id = transport_session_id
context.sentence_id = (
f"bind:{context.session_id}:{time.time_ns()}"
)
context.abort_requested = False
context.create_background_task(
AudioReceiveProcessor().prompt_bind_device(context, transport),
conversation_scoped=False,
)
except Exception as e:
logger.bind(tag=TAG).error(f"播放绑定提示失败: {e}")
async def _save_memory_async(
self,
context: SessionContext,
session_id: str = None,
dialogue_snapshot=None,
):
"""Persist title and memory before releasing session-owned resources."""
try:
session_id = session_id or context.session_id
dialogue_snapshot = (
list(dialogue_snapshot)
if dialogue_snapshot is not None
else list(context.dialogue.dialogue)
)
tasks = []
if session_id:
tasks.append(generate_and_save_chat_title(session_id))
memory_instance = None
if context.component_manager:
memory_component = await context.component_manager.get_component(
ComponentType.MEMORY, context
)
memory_instance = getattr(memory_component, "memory_instance", None)
if (
memory_instance
and context.dialogue
and hasattr(memory_instance, "save_memory")
):
tasks.append(
memory_instance.save_memory(
dialogue_snapshot, session_id
)
)
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
logger.bind(tag=TAG).error(f"保存会话数据失败: {result}")
except Exception as e:
logger.bind(tag=TAG).error(f"保存会话数据失败: {e}")
async def _finalize_conversation_session(
self, context: SessionContext, session_id: str = None
):
session_id = session_id or context.session_id
if not session_id:
return
if session_id != context.session_id:
if context.last_finalized_session_id != session_id:
logger.bind(tag=TAG).warning(
"忽略非当前会话的结束请求: requested={}, current={}",
session_id,
context.session_id,
)
return
if not hasattr(context, "_conversation_finalize_lock"):
context._conversation_finalize_lock = asyncio.Lock()
async with context._conversation_finalize_lock:
context.accepting_input_audio = False
if context.last_finalized_session_id == session_id:
return
if not context.conversation_active and not context.dialogue.dialogue:
return
# Publish the terminal state before the first await. MQTT control
# frames can be dispatched while persistence/cleanup is running;
# delayed start/detect must not revive this logical session.
context.conversation_active = False
context.listen_stop_pending = False
context.abort_requested = True
await context.cancel_conversation_tasks()
context.listen_stop_task = None
context.listen_start_task = None
context.listen_stop_deadline = 0.0
if context.component_manager and hasattr(context.component_manager, "get"):
vad_component = context.component_manager.get(
ComponentType.VAD.value
)
vad_instance = getattr(vad_component, "vad_instance", None)
if vad_instance and hasattr(vad_instance, "release_conn_resources"):
vad_instance.release_conn_resources(context)
dialogue_snapshot = list(context.dialogue.dialogue)
try:
persistence_timeout = max(
0.01,
float(
context.config.get(
"session_persistence_timeout", 5
)
or 5
),
)
except (TypeError, ValueError):
persistence_timeout = 5.0
try:
await asyncio.wait_for(
self._save_memory_async(
context, session_id, dialogue_snapshot
),
timeout=persistence_timeout,
)
except asyncio.TimeoutError:
logger.bind(tag=TAG).warning(
"会话持久化超时,继续释放会话资源: session_id={}, timeout={}s",
session_id,
persistence_timeout,
)
tts_component = None
if context.component_manager:
tts_component = await context.component_manager.get_component(
ComponentType.TTS, context
)
tts_instance = getattr(tts_component, "tts_instance", None)
if tts_instance:
for queue_name in ("tts_text_queue", "tts_audio_queue"):
pending_queue = getattr(tts_instance, queue_name, None)
if pending_queue:
while True:
try:
pending_queue.get_nowait()
except Exception:
break
tts_instance.tts_audio_first_sentence = True
keeps_connection = bool(
getattr(
getattr(context, "transport", None),
"keeps_connection_between_sessions",
False,
)
)
mcp_client = context.mcp_client
if mcp_client and not keeps_connection:
mcp_cleanup = getattr(
context, "_mcp_cleanup_callback", None
)
if mcp_cleanup:
context.unregister_cleanup(mcp_cleanup)
context._mcp_cleanup_callback = None
if hasattr(mcp_client, "close"):
await mcp_client.close()
if context.func_handler:
iot_executor = getattr(
context.func_handler, "device_iot_executor", None
)
if iot_executor:
iot_executor.iot_tools.clear()
tool_manager = getattr(context.func_handler, "tool_manager", None)
if tool_manager:
tool_manager.refresh_tools()
context.clear_audio_buffer()
context.reset_voice_state()
context.dialogue = Dialogue()
context.current_speaker = None
context.introduced_speakers.clear()
context.system_introduced_speakers.clear()
context.iot_descriptors.clear()
context.aec_audio_cache.clear()
context.aec_audio_cache_time.clear()
context.audio_flow_control.clear()
if not keeps_connection:
context.mcp_client = None
context.sentence_id = f"ended:{session_id}:{time.time_ns()}"
if tts_instance:
tts_instance.current_sentence_id = context.sentence_id
context.just_woken_up = False
context.is_speaking = False
context.close_after_chat = False
context.llm_finish_task = True
context.abort_requested = False
context.accepting_input_audio = False
context.last_finalized_session_id = session_id
logger.bind(tag=TAG).info(f"会话资源已结束: {session_id}")
async def _check_timeout(self, context: SessionContext, transport: TransportInterface):
"""定期检查连接超时"""
timeout_seconds = context.config.get("close_connection_no_voice_time", 120)
check_interval = max(1, min(30, timeout_seconds // 4))
try:
while transport.is_connected:
await asyncio.sleep(check_interval)
timed_out = await self.message_router.timeout_processor.handle_timeout(
context,
transport,
)
if timed_out and not transport.keeps_connection_between_sessions:
break
except asyncio.CancelledError:
pass
except Exception as e:
logger.bind(tag=TAG).error(f"超时检查出错: {e}")