diff --git a/README.md b/README.md
index 27c8c759..a94c89f2 100644
--- a/README.md
+++ b/README.md
@@ -141,11 +141,10 @@
本项目提供两种部署方式,请根据您的具体需求选择:
#### 🚀 部署方式选择
-
-| 部署方式 | 特点 | 适用场景 | Docker部署文档 | 源码部署文档 | 视频教程 |
+| 部署方式 | 特点 | 适用场景 | 部署文档 | 配置要求 | 视频教程 |
|---------|------|---------|---------|---------|---------|
-| **最简化安装** | 智能对话、IOT功能,数据存储在配置文件 | 低配置环境,无需数据库 | [Docker只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) | [本地源码只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| - |
-| **全模块安装** | 智能对话、IOT、OTA、智控台,数据存储在数据库 | 完整功能体验 |[Docker运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | [本地源码运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | [本地源码视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
+| **最简化安装** | 智能对话、IOT功能,数据存储在配置文件 | 低配置环境,无需数据库 | [Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [源码部署](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - |
+| **全模块安装** | 智能对话、IOT、OTA、智控台,数据存储在数据库 | 完整功能体验 |[Docker版](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [源码部署](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据
@@ -158,9 +157,23 @@ OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/
Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
```
+#### 🚩 配置说明和推荐
+> [!Note]
+> 本项目默认的配置是`入门全免费`设置,如果想效果更优,推荐使用`全流式配置`。
+>
+> 本项目自`0.5.2`版本,已支持整个生命周期全流式,相比`0.5`版本以前,响应速度提升约`2.5秒`
+
+
+| 模块名称 | 入门全免费设置 | 全流式配置 |
+|---------|---------|------|
+| ASR(语音识别) | FunASR(本地) | ✅DoubaoASR(火山流式语音识别) |
+| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | ✅DoubaoLLM(火山doubao-1-5-pro-32k-250115) |
+| TTS(语音合成) | EdgeTTS(微软语音) | ✅HuoshanDoubleStreamTTS(火山双流式语音合成) |
+| Intent(意图识别) | function_call(函数调用) | ✅function_call(函数调用) |
+| Memory(记忆功能) | mem_local_short(本地短期记忆) | ✅mem_local_short(本地短期记忆) |
+
---
## 功能清单 ✨
-
### 已实现 ✅
| 功能模块 | 描述 |
diff --git a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java
index abac385b..2e3174af 100644
--- a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java
+++ b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java
@@ -227,7 +227,7 @@ public interface Constant {
/**
* 版本号
*/
- public static final String VERSION = "0.5.1";
+ public static final String VERSION = "0.5.2";
/**
* 无效固件URL
diff --git a/main/xiaozhi-server/config/logger.py b/main/xiaozhi-server/config/logger.py
index e5fec630..5f107f5d 100644
--- a/main/xiaozhi-server/config/logger.py
+++ b/main/xiaozhi-server/config/logger.py
@@ -4,7 +4,7 @@ from loguru import logger
from config.config_loader import load_config
from config.settings import check_config_file
-SERVER_VERSION = "0.5.1"
+SERVER_VERSION = "0.5.2"
_logger_initialized = False
@@ -48,7 +48,11 @@ def setup_logging():
# 第一次初始化时配置日志
if not _logger_initialized:
- logger.configure(extra={"selected_module": log_config.get("selected_module", "00000000000000")}) # 新增配置
+ logger.configure(
+ extra={
+ "selected_module": log_config.get("selected_module", "00000000000000")
+ }
+ ) # 新增配置
log_format = log_config.get(
"log_format",
"{time:YYMMDD HH:mm:ss}[{version}_{extra[selected_module]}][{extra[tag]}]-{level}-{message}",
@@ -62,7 +66,9 @@ def setup_logging():
log_format = log_format.replace("{version}", SERVER_VERSION)
log_format = log_format.replace("{selected_module}", selected_module_str)
log_format_file = log_format_file.replace("{version}", SERVER_VERSION)
- log_format_file = log_format_file.replace("{selected_module}", selected_module_str)
+ log_format_file = log_format_file.replace(
+ "{selected_module}", selected_module_str
+ )
log_level = log_config.get("log_level", "INFO")
log_dir = log_config.get("log_dir", "tmp")
@@ -116,15 +122,25 @@ def update_module_string(selected_module_str):
log_format = log_format.replace("{version}", SERVER_VERSION)
log_format = log_format.replace("{selected_module}", selected_module_str)
log_format_file = log_format_file.replace("{version}", SERVER_VERSION)
- log_format_file = log_format_file.replace("{selected_module}", selected_module_str)
+ log_format_file = log_format_file.replace(
+ "{selected_module}", selected_module_str
+ )
logger.remove()
- logger.add(sys.stdout, format=log_format, level=log_config.get("log_level", "INFO"), filter=formatter)
logger.add(
- os.path.join(log_config.get("log_dir", "tmp"), log_config.get("log_file", "server.log")),
+ sys.stdout,
+ format=log_format,
+ level=log_config.get("log_level", "INFO"),
+ filter=formatter,
+ )
+ logger.add(
+ os.path.join(
+ log_config.get("log_dir", "tmp"),
+ log_config.get("log_file", "server.log"),
+ ),
format=log_format_file,
level=log_config.get("log_level", "INFO"),
- filter=formatter
+ filter=formatter,
)
except Exception as e:
diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py
index da0e6b46..e5e47ccc 100644
--- a/main/xiaozhi-server/core/connection.py
+++ b/main/xiaozhi-server/core/connection.py
@@ -84,6 +84,7 @@ class ConnectionHandler:
# 客户端状态相关
self.client_abort = False
+ self.client_is_speaking = False
self.client_listen_mode = "auto"
# 线程任务相关
@@ -624,7 +625,7 @@ class ConnectionHandler:
function_arguments = ""
content_arguments = ""
text_index = 0
-
+ self.client_abort = False
for response in llm_responses:
if self.client_abort:
break
@@ -704,17 +705,23 @@ class ConnectionHandler:
"arguments": function_arguments,
}
- # 处理MCP工具调用
+ # 处理Server端MCP工具调用
if self.mcp_manager.is_mcp_tool(function_name):
result = self._handle_mcp_tool_call(function_call_data)
elif hasattr(self, "mcp_client") and self.mcp_client.has_tool(function_name):
- # 如果是MCP工具调用
+ # 如果是小智端MCP工具调用
self.logger.bind(tag=TAG).debug(
- f"调用MCP工具: {function_name}, 参数: {function_arguments}"
+ f"调用小智端MCP工具: {function_name}, 参数: {function_arguments}"
)
- result = asyncio.run_coroutine_threadsafe(call_mcp_tool(self, self.mcp_client, function_name, function_arguments), self.loop).result()
- self.logger.bind(tag=TAG).debug(f"MCP工具调用结果: {result}")
- result = ActionResponse(action=Action.REQLLM, result=result, response="")
+ try:
+ result = asyncio.run_coroutine_threadsafe(call_mcp_tool(self, self.mcp_client, function_name, function_arguments), self.loop).result()
+ self.logger.bind(tag=TAG).debug(f"MCP工具调用结果: {result}")
+ result = ActionResponse(action=Action.REQLLM, result=result, response="")
+ except Exception as e:
+ self.logger.bind(tag=TAG).error(f"MCP工具调用失败: {e}")
+ result = ActionResponse(
+ action=Action.REQLLM, result="MCP工具调用失败", response=""
+ )
else:
# 处理系统函数
result = self.func_handler.handle_llm_function_call(
@@ -867,6 +874,7 @@ class ConnectionHandler:
self.report_queue.task_done()
def clearSpeakStatus(self):
+ self.client_is_speaking = False
self.logger.bind(tag=TAG).debug(f"清除服务端讲话状态")
async def close(self, ws=None):
diff --git a/main/xiaozhi-server/core/handle/functionHandler.py b/main/xiaozhi-server/core/handle/functionHandler.py
index 5f620880..c5e438ad 100644
--- a/main/xiaozhi-server/core/handle/functionHandler.py
+++ b/main/xiaozhi-server/core/handle/functionHandler.py
@@ -61,7 +61,7 @@ class FunctionHandler:
self.function_registry.register_function("plugin_loader")
self.function_registry.register_function("get_time")
self.function_registry.register_function("get_lunar")
- self.function_registry.register_function("handle_speaker_volume_or_screen_brightness")
+ # self.function_registry.register_function("handle_speaker_volume_or_screen_brightness")
def register_config_functions(self):
"""注册配置中的函数,可以不同客户端使用不同的配置"""
diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py
index a38a33ce..e0c0f4e3 100644
--- a/main/xiaozhi-server/core/handle/helloHandle.py
+++ b/main/xiaozhi-server/core/handle/helloHandle.py
@@ -104,7 +104,13 @@ async def wakeupWordsResponse(conn):
"""唤醒词响应"""
wakeup_word = random.choice(WAKEUP_CONFIG["words"])
- result = conn.llm.response_no_stream(conn.config["prompt"], wakeup_word)
+ question = (
+ "此刻用户正在和你说```"
+ + wakeup_word
+ + "```。\n请你根据以上用户的内容,进行简短回复,文字内容控制在15个字以内。\n"
+ + "请勿对这条内容本身进行任何解释和回应,仅返回对用户的内容的回复。"
+ )
+ result = conn.llm.response_no_stream(conn.config["prompt"], question)
if result is None or result == "":
return
tts_file = await asyncio.to_thread(conn.tts.to_tts, result)
diff --git a/main/xiaozhi-server/core/handle/mcpHandle.py b/main/xiaozhi-server/core/handle/mcpHandle.py
index e03fe72f..4b0e2884 100644
--- a/main/xiaozhi-server/core/handle/mcpHandle.py
+++ b/main/xiaozhi-server/core/handle/mcpHandle.py
@@ -7,33 +7,36 @@ TAG = __name__
class MCPClient:
"""MCPClient,用于管理MCP状态和工具"""
def __init__(self):
- self.tools = []
+ self.tools = {} # Dictionary for O(1) lookup
self.ready = False
self.call_results = {} # To store Futures for tool call responses
self.next_id = 1
self.lock = asyncio.Lock()
+ self._cached_available_tools = None # Cache for get_available_tools
- async def has_tool(self, name: str) -> bool:
- async with self.lock:
- for tool in self.tools:
- if tool["name"] == name:
- return True
- return False
+ def has_tool(self, name: str) -> bool:
+ return name in self.tools
def get_available_tools(self) -> list:
- # async with self.lock:
+ # Check if the cache is valid
+ if self._cached_available_tools is not None:
+ return self._cached_available_tools
+
+ # If cache is not valid, regenerate the list
result = []
- for tool in self.tools:
+ for tool_name, tool_data in self.tools.items():
function_def = {
- "name": tool["name"],
- "description": tool["description"],
+ "name": tool_data["name"],
+ "description": tool_data["description"],
"parameters": {
- "type": tool["inputSchema"].get("type", "object"),
- "properties": tool["inputSchema"].get("properties", {}),
- "required": tool["inputSchema"].get("required", [])
+ "type": tool_data["inputSchema"].get("type", "object"),
+ "properties": tool_data["inputSchema"].get("properties", {}),
+ "required": tool_data["inputSchema"].get("required", [])
}
}
result.append({"type": "function", "function": function_def})
+
+ self._cached_available_tools = result # Store the generated list in cache
return result
async def is_ready(self) -> bool:
@@ -46,8 +49,9 @@ class MCPClient:
async def add_tool(self, tool_data: dict):
async with self.lock:
- self.tools.append(tool_data)
-
+ self.tools[tool_data["name"]] = tool_data
+ self._cached_available_tools = None # Invalidate the cache when a tool is added
+
async def get_next_id(self) -> int:
async with self.lock:
current_id = self.next_id
@@ -232,7 +236,7 @@ async def call_mcp_tool(conn, mcp_client: MCPClient, tool_name: str, args: str =
if not await mcp_client.is_ready():
raise RuntimeError("MCP客户端尚未准备就绪")
- if not await mcp_client.has_tool(tool_name):
+ if not mcp_client.has_tool(tool_name):
raise ValueError(f"工具 {tool_name} 不存在")
tool_call_id = await mcp_client.get_next_id()
@@ -272,4 +276,4 @@ async def call_mcp_tool(conn, mcp_client: MCPClient, tool_name: str, args: str =
raise TimeoutError("工具调用请求超时")
except Exception as e:
await mcp_client.cleanup_call_result(tool_call_id)
- raise e
+ raise e
\ No newline at end of file
diff --git a/main/xiaozhi-server/core/handle/receiveAudioHandle.py b/main/xiaozhi-server/core/handle/receiveAudioHandle.py
index 6776dd0f..b829afb5 100644
--- a/main/xiaozhi-server/core/handle/receiveAudioHandle.py
+++ b/main/xiaozhi-server/core/handle/receiveAudioHandle.py
@@ -1,6 +1,7 @@
from core.handle.sendAudioHandle import send_stt_message
from core.handle.intentHandler import handle_user_intent
from core.utils.output_counter import check_device_output_limit
+from core.handle.abortHandle import handleAbortMessage
import time
from core.handle.sendAudioHandle import SentenceType
from core.utils.util import audio_to_data
@@ -12,11 +13,14 @@ async def handleAudioMessage(conn, audio):
if conn.vad is None:
conn.logger.bind(tag=TAG).warning("VAD模块未初始化,继续等待")
return
- if conn.asr is None:
- conn.logger.bind(tag=TAG).warning("ASR模块未初始化,继续等待")
+ if conn.asr is None or not hasattr(conn.asr, "conn") or conn.asr.conn is None:
+ conn.logger.bind(tag=TAG).warning("ASR模块未初始化或通道未就绪,继续等待")
return
# 当前片段是否有人说话
have_voice = conn.vad.is_vad(conn, audio)
+ if have_voice:
+ if conn.client_is_speaking:
+ await handleAbortMessage(conn)
# 设备长时间空闲检测,用于say goodbye
await no_voice_close_connect(conn, have_voice)
# 接收音频
@@ -35,6 +39,8 @@ async def startToChat(conn, text):
):
await max_out_size(conn)
return
+ if conn.client_is_speaking:
+ await handleAbortMessage(conn)
# 首先进行意图分析
intent_handled = await handle_user_intent(conn, text)
@@ -72,7 +78,7 @@ async def no_voice_close_connect(conn, have_voice):
return
prompt = end_prompt.get("prompt")
if not prompt:
- prompt = "请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。!"
+ prompt = "请你以```时间过得真快```未来头,用富有感情、依依不舍的话来结束这场对话吧。!"
await startToChat(conn, prompt)
diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py
index 6625838c..85a71c8e 100644
--- a/main/xiaozhi-server/core/handle/sendAudioHandle.py
+++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py
@@ -62,6 +62,7 @@ async def sendAudioMessage(conn, sentenceType, audios, text):
# 发送结束消息(如果是最后一个文本)
if conn.llm_finish_task and sentenceType == SentenceType.LAST:
await send_tts_message(conn, "stop", None)
+ conn.client_is_speaking = False
if conn.close_after_chat:
await conn.close()
@@ -88,6 +89,7 @@ async def sendAudio(conn, audios, pre_buffer=True):
# 播放剩余音频帧
for opus_packet in remaining_audios:
if conn.client_abort:
+ conn.client_abort = False
return
# 每分钟重置一次计时器
@@ -141,4 +143,5 @@ async def send_stt_message(conn, text):
await conn.websocket.send(
json.dumps({"type": "stt", "text": stt_text, "session_id": conn.session_id})
)
+ conn.client_is_speaking = True
await send_tts_message(conn, "start")
diff --git a/main/xiaozhi-server/core/handle/textHandle.py b/main/xiaozhi-server/core/handle/textHandle.py
index ace995e4..0fbae11e 100644
--- a/main/xiaozhi-server/core/handle/textHandle.py
+++ b/main/xiaozhi-server/core/handle/textHandle.py
@@ -59,6 +59,7 @@ async def handleTextMessage(conn, message):
# 如果是唤醒词,且关闭了唤醒词回复,就不用回答
await send_stt_message(conn, original_text)
await send_tts_message(conn, "stop", None)
+ conn.client_is_speaking = False
elif is_wakeup_words:
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_asr_report(conn, "嘿,你好呀", [])
diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py
index 1a3a384d..707f80bb 100644
--- a/main/xiaozhi-server/core/providers/asr/base.py
+++ b/main/xiaozhi-server/core/providers/asr/base.py
@@ -47,7 +47,7 @@ class ASRProviderBase(ABC):
if self.conn.client_voice_stop:
asr_audio_task = copy.deepcopy(self.conn.asr_audio)
self.conn.asr_audio.clear()
- self.conn.client_abort = False
+
# 音频太短了,无法识别
self.conn.reset_vad_states()
if len(asr_audio_task) > 15:
diff --git a/main/xiaozhi-server/core/providers/asr/doubao.py b/main/xiaozhi-server/core/providers/asr/doubao.py
index 9ef1d411..da4098ad 100644
--- a/main/xiaozhi-server/core/providers/asr/doubao.py
+++ b/main/xiaozhi-server/core/providers/asr/doubao.py
@@ -33,6 +33,21 @@ class ASRProvider(ASRProviderBase):
self.max_retries = 3
self.retry_delay = 2 # 重试延迟秒数
self.recv_lock = asyncio.Lock() # 添加接收锁
+ self.reconnect_lock = asyncio.Lock() # 添加重连锁
+ self.last_reconnect_time = 0 # 上次重连时间
+ self.reconnect_cooldown = 1 # 增加重连冷却时间到10秒
+ self.reconnect_count = 0 # 当前重连次数
+ self.max_reconnect_count = 3 # 减少最大重连次数到3次
+ self.asr_thread = None # ASR监听线程
+ self.thread_lock = threading.Lock() # 线程管理锁
+ self.is_reconnecting = False # 添加重连状态标志
+
+ # 添加会话管理相关属性
+ self._session_lock = asyncio.Lock() # 会话操作的并发锁
+ self._current_session_id = None # 当前会话ID
+ self._session_started = False # 会话是否已开始
+ self._session_finished = False # 会话是否已结束
+ self._session_close_event = asyncio.Event() # 添加会话关闭事件
self.appid = str(config.get("appid"))
self.cluster = config.get("cluster")
@@ -60,7 +75,6 @@ class ASRProvider(ASRProviderBase):
self.asr_ws = None
self.forward_task = None
self.conn = None
- self.asr_thread = None
###################################################################################
# 豆包流式ASR重写父类的方法--开始
@@ -68,10 +82,18 @@ class ASRProvider(ASRProviderBase):
async def open_audio_channels(self, conn):
await super().open_audio_channels(conn)
- retry_count = 0
- while retry_count < self.max_retries:
- try:
- # 确保关闭旧的连接
+ async with self._session_lock:
+ # 如果正在重连,等待重连完成
+ if self.is_reconnecting:
+ logger.bind(tag=TAG).info("等待当前重连完成...")
+ await self._session_close_event.wait()
+ self._session_close_event.clear()
+
+ # 如果已有会话未结束,先关闭它
+ if self._session_started and not self._session_finished:
+ logger.bind(tag=TAG).warning(
+ f"发现未关闭的会话 {self._current_session_id},正在关闭..."
+ )
if self.asr_ws is not None:
try:
await self.asr_ws.close()
@@ -79,62 +101,94 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).warning(f"关闭旧连接时发生错误: {e}")
finally:
self.asr_ws = None
+ self._session_finished = True
+ self._session_close_event.set()
- headers = self.token_auth() if self.auth_method == "token" else None
- self.asr_ws = await websockets.connect(
- self.ws_url,
- additional_headers=headers,
- max_size=1000000000,
- ping_interval=None, # 禁用ping,因为服务器可能不支持
- ping_timeout=None,
- close_timeout=10,
- )
+ # 重置会话状态
+ self._current_session_id = str(uuid.uuid4())
+ self._session_started = True
+ self._session_finished = False
+ self.is_reconnecting = True
- # 发送初始化请求
- request_params = self.construct_request(str(uuid.uuid4()))
- try:
- payload_bytes = str.encode(json.dumps(request_params))
- payload_bytes = gzip.compress(payload_bytes)
- full_client_request = self.generate_header()
- full_client_request.extend((len(payload_bytes)).to_bytes(4, "big"))
- full_client_request.extend(payload_bytes)
- await self.asr_ws.send(full_client_request)
- logger.bind(tag=TAG).debug(f"发送初始化请求: {request_params}")
- except Exception as e:
- logger.bind(tag=TAG).error(f"发送初始化请求失败: {e}")
- raise e
+ try:
+ retry_count = 0
+ while retry_count < self.max_retries:
+ try:
+ headers = (
+ self.token_auth() if self.auth_method == "token" else None
+ )
+ self.asr_ws = await websockets.connect(
+ self.ws_url,
+ additional_headers=headers,
+ max_size=1000000000,
+ ping_interval=None,
+ ping_timeout=None,
+ close_timeout=10,
+ )
- # 等待初始化响应
- try:
- init_res = await self.asr_ws.recv()
- result = self.parse_response(init_res)
- logger.bind(tag=TAG).info(f"ASR服务初始化响应: {result}")
- except Exception as e:
- logger.bind(tag=TAG).error(f"ASR服务初始化失败: {e}")
- raise e
+ # 发送初始化请求
+ request_params = self.construct_request(
+ self._current_session_id
+ )
+ try:
+ payload_bytes = str.encode(json.dumps(request_params))
+ payload_bytes = gzip.compress(payload_bytes)
+ full_client_request = self.generate_header()
+ full_client_request.extend(
+ (len(payload_bytes)).to_bytes(4, "big")
+ )
+ full_client_request.extend(payload_bytes)
+ await self.asr_ws.send(full_client_request)
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"发送初始化请求失败: {e}")
+ raise e
- # 启动接收ASR结果的异步任务
- asr_priority = threading.Thread(
- target=self._start_monitor_asr_response_thread, daemon=True
- )
- asr_priority.start()
- return
+ # 等待初始化响应
+ try:
+ init_res = await self.asr_ws.recv()
+ self.parse_response(init_res)
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"ASR服务初始化失败: {e}")
+ raise e
- except websockets.exceptions.WebSocketException as e:
- retry_count += 1
- if retry_count < self.max_retries:
- logger.bind(tag=TAG).warning(
- f"WebSocket连接失败,正在进行第{retry_count}次重试: {e}"
- )
- await asyncio.sleep(self.retry_delay)
- else:
- logger.bind(tag=TAG).error(
- f"WebSocket连接失败,已达到最大重试次数: {e}"
- )
- raise
- except Exception as e:
- logger.bind(tag=TAG).error(f"WebSocket连接发生未知错误: {e}")
- raise
+ # 启动接收ASR结果的异步任务
+ with self.thread_lock:
+ if (
+ self.asr_thread is None
+ or not self.asr_thread.is_alive()
+ ):
+ logger.bind(tag=TAG).info("创建新的ASR监听线程...")
+ self.asr_thread = threading.Thread(
+ target=self._start_monitor_asr_response_thread,
+ daemon=True,
+ )
+ self.asr_thread.start()
+ # 等待一小段时间确保线程启动
+ await asyncio.sleep(0.1)
+ if not self.asr_thread.is_alive():
+ logger.bind(tag=TAG).error("ASR监听线程启动失败")
+ raise Exception("ASR监听线程启动失败")
+ logger.bind(tag=TAG).info("ASR监听线程已启动")
+ return
+
+ except websockets.exceptions.WebSocketException as e:
+ retry_count += 1
+ if retry_count < self.max_retries:
+ logger.bind(tag=TAG).warning(
+ f"WebSocket连接失败,正在进行第{retry_count}次重试: {e}"
+ )
+ await asyncio.sleep(self.retry_delay)
+ else:
+ logger.bind(tag=TAG).warning(
+ f"WebSocket连接失败,已达到最大重试次数: {e}"
+ )
+ raise
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"WebSocket连接发生未知错误: {e}")
+ raise
+ finally:
+ self.is_reconnecting = False
+ self._session_close_event.set()
async def receive_audio(self, audio, _):
if not isinstance(audio, bytes):
@@ -150,7 +204,7 @@ class ASRProvider(ASRProviderBase):
if self.asr_ws:
await self.asr_ws.send(audio_request)
except Exception as e:
- logger.bind(tag=TAG).error(f"发送音频数据时发生错误: {e}")
+ logger.bind(tag=TAG).debug(f"发送音频数据时发生错误: {e}")
###################################################################################
# 豆包流式ASR重写父类的方法--结束
@@ -246,23 +300,61 @@ class ASRProvider(ASRProviderBase):
def _start_monitor_asr_response_thread(self):
# 初始化链接
- asyncio.run_coroutine_threadsafe(
- self._forward_asr_results(), loop=self.conn.loop
- )
+ try:
+ with self.thread_lock:
+ if self.conn is None or self.conn.loop is None:
+ logger.bind(tag=TAG).error(
+ "无法启动ASR监听线程:conn或loop未初始化"
+ )
+ return
+
+ try:
+ logger.bind(tag=TAG).info("开始启动ASR监听...")
+ asyncio.run_coroutine_threadsafe(
+ self._forward_asr_results(), loop=self.conn.loop
+ )
+ logger.bind(tag=TAG).info("ASR监听已启动")
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"启动ASR监听线程失败: {e}")
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"ASR监听线程发生未预期的错误: {e}")
async def _forward_asr_results(self):
try:
while not self.conn.stop_event.is_set():
try:
if self.asr_ws is None:
- logger.bind(tag=TAG).info("尝试重新连接ASR服务...")
- await self.open_audio_channels(self.conn)
- continue
+ # 检查是否需要重连
+ async with self.reconnect_lock:
+ current_time = asyncio.get_event_loop().time()
+ if (
+ current_time - self.last_reconnect_time
+ < self.reconnect_cooldown
+ ):
+ await asyncio.sleep(1)
+ continue
+
+ if self.reconnect_count >= self.max_reconnect_count:
+ logger.bind(tag=TAG).error(
+ "达到最大重连次数限制,停止重连"
+ )
+ await asyncio.sleep(self.reconnect_cooldown)
+ self.reconnect_count = 0
+ continue
+
+ self.last_reconnect_time = current_time
+ self.reconnect_count += 1
+ logger.bind(tag=TAG).info(
+ f"尝试重新连接ASR服务... (第{self.reconnect_count}次)"
+ )
+ await self.open_audio_channels(self.conn)
+ continue
# 使用锁来确保同一时间只有一个协程在接收数据
async with self.recv_lock:
response = await self.asr_ws.recv()
result = self.parse_response(response)
+
# 检查是否需要重连
if result.get("need_reconnect", False):
logger.bind(tag=TAG).info(
@@ -290,6 +382,7 @@ class ASRProvider(ASRProviderBase):
self.text = utterance["text"]
await self.handle_voice_stop(None)
break
+
except websockets.ConnectionClosed:
logger.bind(tag=TAG).debug("ASR服务连接已关闭,准备重连...")
# 确保关闭旧连接
@@ -301,34 +394,16 @@ class ASRProvider(ASRProviderBase):
finally:
self.asr_ws = None
- retry_count = 0
- while (
- retry_count < self.max_retries
- and not self.conn.stop_event.is_set()
- ):
- try:
- logger.bind(tag=TAG).info(
- f"正在进行第{retry_count + 1}次重连尝试..."
- )
- await self.open_audio_channels(self.conn)
- break
- except Exception as e:
- retry_count += 1
- if retry_count < self.max_retries:
- logger.bind(tag=TAG).warning(
- f"重连失败,等待{self.retry_delay}秒后重试: {e}"
- )
- await asyncio.sleep(self.retry_delay)
- else:
- logger.bind(tag=TAG).error(
- f"重连失败,已达到最大重试次数: {e}"
- )
- await asyncio.sleep(
- self.retry_delay
- ) # 继续等待,以便后续重试
+ # 等待冷却时间
+ await asyncio.sleep(self.reconnect_cooldown)
+ continue
+
except Exception as e:
if not self.conn.stop_event.is_set():
- await asyncio.sleep(2) # 增加重试延迟
+ logger.bind(tag=TAG).error(f"ASR监听发生错误: {e}")
+ await asyncio.sleep(self.retry_delay)
+ continue
+
except Exception as e:
logger.bind(tag=TAG).error(f"ASR监听线程发生错误: {e}")
# 确保在发生严重错误时也能继续尝试重连
@@ -422,8 +497,38 @@ class ASRProvider(ASRProviderBase):
f"ASR错误: {error_message} (错误码: {error_code})"
)
- # 如果是识别相关错误(>=1020),标记需要重连
- if error_code >= 1020:
+ # 如果是识别相关错误,标记需要重连
+ if error_code >= 1020 or error_code == 1001:
result["need_reconnect"] = True
return result
+
+ async def close_session(self):
+ """关闭当前会话"""
+ async with self._session_lock:
+ if not self._session_started:
+ logger.bind(tag=TAG).warning("尝试关闭未开始的会话")
+ return
+
+ if self._session_finished:
+ logger.bind(tag=TAG).warning(
+ f"会话 {self._current_session_id} 已经关闭"
+ )
+ return
+
+ try:
+ if self.asr_ws is not None:
+ await self.asr_ws.close()
+ except Exception as e:
+ logger.bind(tag=TAG).warning(f"关闭WebSocket连接时发生错误: {e}")
+ finally:
+ self.asr_ws = None
+ self._session_finished = True
+ self._session_started = False
+ self._current_session_id = None
+ # 重置重连计数
+ self.reconnect_count = 0
+
+ async def close(self):
+ """资源清理方法"""
+ await self.close_session()
diff --git a/main/xiaozhi-server/core/providers/tts/base.py b/main/xiaozhi-server/core/providers/tts/base.py
index 0b17eef5..36b6ecbf 100644
--- a/main/xiaozhi-server/core/providers/tts/base.py
+++ b/main/xiaozhi-server/core/providers/tts/base.py
@@ -216,6 +216,7 @@ class TTSProviderBase(ABC):
logger.bind(tag=TAG).error(
f"处理TTS文本失败: {str(e)}, 类型: {type(e).__name__}, 堆栈: {traceback.format_exc()}"
)
+ continue
def _audio_play_priority_thread(self):
while not self.conn.stop_event.is_set():
diff --git a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py
index 25548958..de50c18c 100644
--- a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py
+++ b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py
@@ -6,6 +6,7 @@ import asyncio
import threading
import traceback
import websockets
+import time
from config.logger import setup_logging
from core.utils import opus_encoder_utils
from core.utils.util import check_model_key
@@ -138,6 +139,7 @@ class Response:
class TTSProvider(TTSProviderBase):
def __init__(self, config, delete_audio_file):
super().__init__(config, delete_audio_file)
+ self.ws = None # 初始化ws属性
self.interface_type = InterfaceType.DUAL_STREAM
self.appId = config.get("appid")
self.access_token = config.get("access_token")
@@ -166,59 +168,133 @@ class TTSProvider(TTSProviderBase):
self._current_session_id = None # 当前会话ID
self._session_started = False # 会话是否已开始
self._session_finished = False # 会话是否已结束
+ self._connection_ready = False # 连接是否就绪
+ self._reconnect_attempts = 0 # 重连尝试次数
+ self._max_reconnect_attempts = 3 # 最大重连次数
###################################################################################
# 火山双流式TTS重写父类的方法--开始
###################################################################################
async def open_audio_channels(self, conn):
- await super().open_audio_channels(conn)
- ws_header = {
- "X-Api-App-Key": self.appId,
- "X-Api-Access-Key": self.access_token,
- "X-Api-Resource-Id": self.resource_id,
- "X-Api-Connect-Id": uuid.uuid4(),
- }
- self.ws = await websockets.connect(
- self.ws_url, additional_headers=ws_header, max_size=1000000000
- )
- tts_priority = threading.Thread(
- target=self._start_monitor_tts_response_thread, daemon=True
- )
- tts_priority.start()
+ try:
+ await super().open_audio_channels(conn)
+ await self._ensure_connection()
+ tts_priority = threading.Thread(
+ target=self._start_monitor_tts_response_thread, daemon=True
+ )
+ tts_priority.start()
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"Failed to open audio channels: {str(e)}")
+ self.ws = None
+ raise
+
+ async def _ensure_connection(self):
+ """确保WebSocket连接可用"""
+ try:
+ if self.ws is None:
+ logger.bind(tag=TAG).info("WebSocket连接不存在,开始建立新连接...")
+ ws_header = {
+ "X-Api-App-Key": self.appId,
+ "X-Api-Access-Key": self.access_token,
+ "X-Api-Resource-Id": self.resource_id,
+ "X-Api-Connect-Id": uuid.uuid4(),
+ }
+ self.ws = await websockets.connect(
+ self.ws_url, additional_headers=ws_header, max_size=1000000000
+ )
+ self._connection_ready = True
+ self._reconnect_attempts = 0
+ logger.bind(tag=TAG).info("WebSocket连接建立成功")
+ else:
+ # 尝试发送ping来检查连接是否还活着
+ try:
+ logger.bind(tag=TAG).debug("检查WebSocket连接状态...")
+ pong_waiter = await self.ws.ping()
+ await asyncio.wait_for(pong_waiter, timeout=1.0)
+ logger.bind(tag=TAG).debug("WebSocket连接状态正常")
+ except (asyncio.TimeoutError, websockets.ConnectionClosed):
+ # 如果ping失败,重新建立连接
+ logger.bind(tag=TAG).warning("WebSocket连接已断开,准备重新连接...")
+ try:
+ await self.ws.close()
+ except:
+ pass
+ self.ws = None
+ self._connection_ready = False
+ # 重新建立连接
+ await self._ensure_connection()
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"确保连接失败: {str(e)}")
+ self._connection_ready = False
+ self.ws = None
+ raise
def tts_text_priority_thread(self):
+ logger.bind(tag=TAG).info("TTS文本处理线程启动")
while not self.conn.stop_event.is_set():
try:
+ logger.bind(tag=TAG).debug("等待TTS文本队列消息...")
message = self.tts_text_queue.get(timeout=1)
- logger.bind(tag=TAG).debug(
- f"TTS任务|{message.sentence_type.name} | {message.content_type.name}"
+ logger.bind(tag=TAG).info(
+ f"收到TTS任务|{message.sentence_type.name} | {message.content_type.name} | 会话ID: {self.conn.sentence_id}"
)
if message.sentence_type == SentenceType.FIRST:
# 初始化参数
- future = asyncio.run_coroutine_threadsafe(
- self.start_session(self.conn.sentence_id), loop=self.conn.loop
- )
- future.result()
- self.tts_audio_first_sentence = True
- self.before_stop_play_files.clear()
- elif ContentType.TEXT == message.content_type:
- if message.content_detail:
+ try:
+ logger.bind(tag=TAG).info("开始启动TTS会话...")
future = asyncio.run_coroutine_threadsafe(
- self.text_to_speak(message.content_detail, None),
+ self.start_session(self.conn.sentence_id),
loop=self.conn.loop,
)
future.result()
+ self.tts_audio_first_sentence = True
+ self.before_stop_play_files.clear()
+ logger.bind(tag=TAG).info("TTS会话启动成功")
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"启动TTS会话失败: {str(e)}")
+ # 直接跳过当前消息,不重新入队
+ time.sleep(1)
+ continue
+ elif ContentType.TEXT == message.content_type:
+ if message.content_detail:
+ try:
+ logger.bind(tag=TAG).info(
+ f"开始发送TTS文本: {message.content_detail}"
+ )
+ future = asyncio.run_coroutine_threadsafe(
+ self.text_to_speak(message.content_detail, None),
+ loop=self.conn.loop,
+ )
+ future.result()
+ logger.bind(tag=TAG).info("TTS文本发送成功")
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"发送TTS文本失败: {str(e)}")
+ # 直接跳过当前消息,不重新入队
+ time.sleep(1)
+ continue
elif ContentType.FILE == message.content_type:
+ logger.bind(tag=TAG).info(
+ f"添加音频文件到待播放列表: {message.content_file}"
+ )
self.before_stop_play_files.append(
(message.content_file, message.content_detail)
)
if message.sentence_type == SentenceType.LAST:
- future = asyncio.run_coroutine_threadsafe(
- self.finish_session(self.conn.sentence_id), loop=self.conn.loop
- )
- future.result()
+ try:
+ logger.bind(tag=TAG).info("开始结束TTS会话...")
+ future = asyncio.run_coroutine_threadsafe(
+ self.finish_session(self.conn.sentence_id),
+ loop=self.conn.loop,
+ )
+ future.result()
+ logger.bind(tag=TAG).info("TTS会话结束成功")
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"结束TTS会话失败: {str(e)}")
+ # 直接跳过当前消息,不重新入队
+ time.sleep(1)
+ continue
except queue.Empty:
continue
@@ -226,11 +302,30 @@ class TTSProvider(TTSProviderBase):
logger.bind(tag=TAG).error(
f"处理TTS文本失败: {str(e)}, 类型: {type(e).__name__}, 堆栈: {traceback.format_exc()}"
)
+ # 如果是WebSocket连接关闭错误,等待一段时间后继续
+ if "non-exist session" in str(e):
+ time.sleep(1)
+ continue
async def text_to_speak(self, text, _):
- # 发送文本
- await self.send_text(self.speaker, text, self.conn.sentence_id)
- return
+ """发送文本到TTS服务"""
+ try:
+ # 确保WebSocket连接可用
+ if not self._connection_ready or self.ws is None:
+ logger.bind(tag=TAG).warning("WebSocket连接不可用,尝试重新连接...")
+ await self._ensure_connection()
+
+ # 发送文本
+ await self.send_text(self.speaker, text, self.conn.sentence_id)
+ return
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"发送TTS文本失败: {str(e)}")
+ # 如果是连接问题,尝试重新连接
+ if isinstance(e, websockets.ConnectionClosed):
+ self._connection_ready = False
+ self.ws = None
+ await self._handle_connection_error()
+ raise
###################################################################################
# 火山双流式TTS重写父类的方法--结束
@@ -308,14 +403,21 @@ class TTSProvider(TTSProviderBase):
async def send_event(
self, header: bytes, optional: bytes | None = None, payload: bytes = None
):
- full_client_request = bytearray(header)
- if optional is not None:
- full_client_request.extend(optional)
- if payload is not None:
- payload_size = len(payload).to_bytes(4, "big", signed=True)
- full_client_request.extend(payload_size)
- full_client_request.extend(payload)
- await self.ws.send(full_client_request)
+ try:
+ full_client_request = bytearray(header)
+ if optional is not None:
+ full_client_request.extend(optional)
+ if payload is not None:
+ payload_size = len(payload).to_bytes(4, "big", signed=True)
+ full_client_request.extend(payload_size)
+ full_client_request.extend(payload)
+ await self.ws.send(full_client_request)
+ except websockets.ConnectionClosed:
+ if await self._handle_connection_error():
+ # 重连成功后重试发送
+ await self.ws.send(full_client_request)
+ else:
+ raise
async def send_text(self, speaker: str, text: str, session_id):
header = Header(
@@ -450,63 +552,154 @@ class TTSProvider(TTSProviderBase):
return
async def start_session(self, session_id):
- logger.bind(tag=TAG).debug(f"开始会话~~{session_id}")
- async with self._session_lock:
- # 如果已有会话未结束,先关闭它
- if self._session_started and not self._session_finished:
- logger.bind(tag=TAG).warning(
- f"发现未关闭的会话 {self._current_session_id},正在关闭..."
- )
- await self.finish_session(self._current_session_id)
+ logger.bind(tag=TAG).info(f"开始会话~~{session_id}")
+ try:
+ async with self._session_lock:
+ try:
+ # 确保连接可用
+ logger.bind(tag=TAG).info("检查WebSocket连接状态...")
+ await asyncio.wait_for(self._ensure_connection(), timeout=5)
- # 重置会话状态
- self._current_session_id = session_id
- self._session_started = True
- self._session_finished = False
+ # 如果已有会话未结束,先关闭它
+ if self._session_started and not self._session_finished:
+ logger.bind(tag=TAG).warning(
+ f"发现未关闭的会话 {self._current_session_id},正在关闭..."
+ )
+ try:
+ await asyncio.wait_for(
+ self.finish_session(self._current_session_id), timeout=5
+ )
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"关闭旧会话失败: {str(e)}")
+ # 强制重置会话状态
+ self._session_started = False
+ self._session_finished = True
+ self._current_session_id = None
- header = Header(
- message_type=FULL_CLIENT_REQUEST,
- message_type_specific_flags=MsgTypeFlagWithEvent,
- serial_method=JSON,
- ).as_bytes()
- optional = Optional(
- event=EVENT_StartSession, sessionId=session_id
- ).as_bytes()
- payload = self.get_payload_bytes(
- event=EVENT_StartSession, speaker=self.speaker
- )
- await self.send_event(header, optional, payload)
+ # 重置会话状态
+ self._current_session_id = session_id
+ self._session_started = True
+ self._session_finished = False
+ logger.bind(tag=TAG).info(
+ f"会话状态已更新 - 开始: {self._session_started}, 结束: {self._session_finished}"
+ )
+
+ header = Header(
+ message_type=FULL_CLIENT_REQUEST,
+ message_type_specific_flags=MsgTypeFlagWithEvent,
+ serial_method=JSON,
+ ).as_bytes()
+ optional = Optional(
+ event=EVENT_StartSession, sessionId=session_id
+ ).as_bytes()
+ payload = self.get_payload_bytes(
+ event=EVENT_StartSession, speaker=self.speaker
+ )
+ await asyncio.wait_for(
+ self.send_event(header, optional, payload), timeout=5
+ )
+ logger.bind(tag=TAG).info("会话启动请求已发送")
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"启动会话失败: {str(e)}")
+ self._session_started = False
+ self._session_finished = True
+ self._current_session_id = None
+ raise
+ except asyncio.TimeoutError:
+ logger.bind(tag=TAG).error(f"启动会话超时: {session_id}")
+ # 超时后强制重置会话状态
+ self._session_started = False
+ self._session_finished = True
+ self._current_session_id = None
+ # 尝试关闭WebSocket连接
+ if self.ws:
+ try:
+ await self.ws.close()
+ except:
+ pass
+ self.ws = None
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"启动会话时发生未知错误: {str(e)}")
+ # 发生未知错误时也重置会话状态
+ self._session_started = False
+ self._session_finished = True
+ self._current_session_id = None
async def finish_session(self, session_id):
- logger.bind(tag=TAG).debug(f"关闭会话~~{session_id}")
- async with self._session_lock:
- # 检查会话状态
- if not self._session_started:
- logger.bind(tag=TAG).warning(f"尝试关闭未开始的会话 {session_id}")
- return
+ logger.bind(tag=TAG).info(f"关闭会话~~{session_id}")
+ try:
+ async with self._session_lock:
+ try:
+ # 检查会话状态
+ if not self._session_started:
+ logger.bind(tag=TAG).warning(
+ f"尝试关闭未开始的会话 {session_id}"
+ )
+ return
- if self._session_finished:
- logger.bind(tag=TAG).warning(f"会话 {session_id} 已经关闭")
- return
+ if self._session_finished:
+ logger.bind(tag=TAG).warning(f"会话 {session_id} 已经关闭")
+ return
- if self._current_session_id != session_id:
- logger.bind(tag=TAG).warning(
- f"尝试关闭错误的会话 {session_id},当前会话为 {self._current_session_id}"
- )
- return
+ if self._current_session_id != session_id:
+ logger.bind(tag=TAG).warning(
+ f"尝试关闭错误的会话 {session_id},当前会话为 {self._current_session_id}"
+ )
+ # 即使会话ID不匹配,也尝试关闭当前会话
+ if self._current_session_id:
+ session_id = self._current_session_id
- header = Header(
- message_type=FULL_CLIENT_REQUEST,
- message_type_specific_flags=MsgTypeFlagWithEvent,
- serial_method=JSON,
- ).as_bytes()
- optional = Optional(
- event=EVENT_FinishSession, sessionId=session_id
- ).as_bytes()
- payload = str.encode("{}")
- await self.send_event(header, optional, payload)
+ # 确保WebSocket连接可用
+ if self.ws is None:
+ logger.bind(tag=TAG).warning(
+ "WebSocket连接不存在,尝试重新连接..."
+ )
+ await asyncio.wait_for(self._ensure_connection(), timeout=5)
- # 更新会话状态
+ header = Header(
+ message_type=FULL_CLIENT_REQUEST,
+ message_type_specific_flags=MsgTypeFlagWithEvent,
+ serial_method=JSON,
+ ).as_bytes()
+ optional = Optional(
+ event=EVENT_FinishSession, sessionId=session_id
+ ).as_bytes()
+ payload = str.encode("{}")
+ await asyncio.wait_for(
+ self.send_event(header, optional, payload), timeout=5
+ )
+ logger.bind(tag=TAG).info("会话结束请求已发送")
+
+ # 更新会话状态
+ self._session_finished = True
+ self._session_started = False
+ self._current_session_id = None
+ logger.bind(tag=TAG).info(
+ "会话状态已更新 - 开始: False, 结束: True"
+ )
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"关闭会话失败: {str(e)}")
+ # 即使发生错误,也要重置会话状态
+ self._session_finished = True
+ self._session_started = False
+ self._current_session_id = None
+ raise
+ except asyncio.TimeoutError:
+ logger.bind(tag=TAG).error(f"关闭会话超时: {session_id}")
+ # 超时后强制重置会话状态
+ self._session_finished = True
+ self._session_started = False
+ self._current_session_id = None
+ # 尝试关闭WebSocket连接
+ if self.ws:
+ try:
+ await self.ws.close()
+ except:
+ pass
+ self.ws = None
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"关闭会话时发生未知错误: {str(e)}")
+ # 发生未知错误时也重置会话状态
self._session_finished = True
self._session_started = False
self._current_session_id = None
@@ -527,3 +720,20 @@ class TTSProvider(TTSProviderBase):
def wav_to_opus_data_audio_raw(self, raw_data_var, is_end=False):
opus_datas = self.opus_encoder.encode_pcm_to_opus(raw_data_var, is_end)
return opus_datas
+
+ async def _handle_connection_error(self):
+ """处理连接错误"""
+ if self._reconnect_attempts < self._max_reconnect_attempts:
+ self._reconnect_attempts += 1
+ logger.bind(tag=TAG).warning(
+ f"尝试重新连接 (第{self._reconnect_attempts}次)"
+ )
+ try:
+ await self._ensure_connection()
+ return True
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"重新连接失败: {str(e)}")
+ return False
+ else:
+ logger.bind(tag=TAG).error("达到最大重连次数,放弃重连")
+ return False
diff --git a/main/xiaozhi-server/test/test_page.html b/main/xiaozhi-server/test/test_page.html
index fbad025d..567332b8 100644
--- a/main/xiaozhi-server/test/test_page.html
+++ b/main/xiaozhi-server/test/test_page.html
@@ -1484,6 +1484,23 @@
if (message.text && message.text !== '😊') {
addMessage(message.text);
}
+ }else if (message.type === 'mcp') {
+ const payload = message.payload || {};
+ log(`服务器下发: ${JSON.stringify(message)}`, 'info');
+ if (payload) {
+ // 模拟小智客户端行为
+ if(payload.method === 'tools/list'){
+ const replay_message = JSON.stringify({"session_id":"","type":"mcp","payload":{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"self.get_device_status","description":"Provides the real-time information of the device, including the current status of the audio speaker, screen, battery, network, etc.\nUse this tool for: \n1. Answering questions about current condition (e.g. what is the current volume of the audio speaker?)\n2. As the first step to control the device (e.g. turn up / down the volume of the audio speaker, etc.)","inputSchema":{"type":"object","properties":{}}},{"name":"self.audio_speaker.set_volume","description":"Set the volume of the audio speaker. If the current volume is unknown, you must call `self.get_device_status` tool first and then call this tool.","inputSchema":{"type":"object","properties":{"volume":{"type":"integer","minimum":0,"maximum":100}},"required":["volume"]}},{"name":"self.screen.set_brightness","description":"Set the brightness of the screen.","inputSchema":{"type":"object","properties":{"brightness":{"type":"integer","minimum":0,"maximum":100}},"required":["brightness"]}},{"name":"self.screen.set_theme","description":"Set the theme of the screen. The theme can be 'light' or 'dark'.","inputSchema":{"type":"object","properties":{"theme":{"type":"string"}},"required":["theme"]}}]}}})
+ websocket.send(replay_message);
+ log(`回复MCP消息: ${replay_message}`, 'info');
+ } else if(payload.method === 'tools/call'){
+ // 模拟回复
+ const replay_message = JSON.stringify({"session_id":"9f261599","type":"mcp","payload":{"jsonrpc":"2.0","id": payload.id,"result":{"content":[{"type":"text","text":"true"}],"isError":false}}})
+ websocket.send(replay_message);
+ log(`回复MCP消息: ${replay_message}`, 'info');
+ }
+ }
+
} else {
// 未知消息类型
log(`未知消息类型: ${message.type}`, 'info');
@@ -1523,7 +1540,10 @@
device_id: config.deviceId,
device_name: config.deviceName,
device_mac: config.deviceMac,
- token: config.token
+ token: config.token,
+ features: {
+ mcp: true
+ }
};
log('发送hello握手消息', 'info');