mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-28 01:53:53 +08:00
1355 lines
56 KiB
Python
1355 lines
56 KiB
Python
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}")
|