From 5d439a4168c37a0c74c823e2da6f1d2586a57e91 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Sun, 23 Nov 2025 11:55:38 +0800 Subject: [PATCH 01/93] =?UTF-8?q?update:=E5=86=85=E5=AD=98=E6=B3=84?= =?UTF-8?q?=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/handle/reportHandle.py | 68 ++++++++++--------- .../core/providers/asr/aliyun_stream.py | 6 +- .../xiaozhi-server/core/providers/asr/base.py | 6 ++ .../core/providers/asr/doubao_stream.py | 12 ++++ .../core/providers/asr/xunfei_stream.py | 12 ++++ .../core/providers/vad/silero.py | 6 ++ .../core/utils/opus_encoder_utils.py | 10 ++- main/xiaozhi-server/core/utils/util.py | 37 +++++----- 8 files changed, 107 insertions(+), 50 deletions(-) diff --git a/main/xiaozhi-server/core/handle/reportHandle.py b/main/xiaozhi-server/core/handle/reportHandle.py index 7b30f79c..09ad0218 100644 --- a/main/xiaozhi-server/core/handle/reportHandle.py +++ b/main/xiaozhi-server/core/handle/reportHandle.py @@ -10,7 +10,7 @@ TTS上报功能已集成到ConnectionHandler类中。 """ import time - +import gc import opuslib_next from config.manage_api_client import report as manage_report @@ -56,41 +56,47 @@ def opus_to_wav(conn, opus_data): Returns: bytes: WAV格式的音频数据 """ - decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道 - pcm_data = [] + decoder = None + try: + decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道 + pcm_data = [] - for opus_packet in opus_data: - try: - pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms - pcm_data.append(pcm_frame) - except opuslib_next.OpusError as e: - conn.logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True) + for opus_packet in opus_data: + try: + pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms + pcm_data.append(pcm_frame) + except opuslib_next.OpusError as e: + conn.logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True) - if not pcm_data: - raise ValueError("没有有效的PCM数据") + if not pcm_data: + raise ValueError("没有有效的PCM数据") - # 创建WAV文件头 - pcm_data_bytes = b"".join(pcm_data) - num_samples = len(pcm_data_bytes) // 2 # 16-bit samples + # 创建WAV文件头 + pcm_data_bytes = b"".join(pcm_data) + num_samples = len(pcm_data_bytes) // 2 # 16-bit samples - # WAV文件头 - wav_header = bytearray() - wav_header.extend(b"RIFF") # ChunkID - wav_header.extend((36 + len(pcm_data_bytes)).to_bytes(4, "little")) # ChunkSize - wav_header.extend(b"WAVE") # Format - wav_header.extend(b"fmt ") # Subchunk1ID - wav_header.extend((16).to_bytes(4, "little")) # Subchunk1Size - wav_header.extend((1).to_bytes(2, "little")) # AudioFormat (PCM) - wav_header.extend((1).to_bytes(2, "little")) # NumChannels - wav_header.extend((16000).to_bytes(4, "little")) # SampleRate - wav_header.extend((32000).to_bytes(4, "little")) # ByteRate - wav_header.extend((2).to_bytes(2, "little")) # BlockAlign - wav_header.extend((16).to_bytes(2, "little")) # BitsPerSample - wav_header.extend(b"data") # Subchunk2ID - wav_header.extend(len(pcm_data_bytes).to_bytes(4, "little")) # Subchunk2Size + # WAV文件头 + wav_header = bytearray() + wav_header.extend(b"RIFF") # ChunkID + wav_header.extend((36 + len(pcm_data_bytes)).to_bytes(4, "little")) # ChunkSize + wav_header.extend(b"WAVE") # Format + wav_header.extend(b"fmt ") # Subchunk1ID + wav_header.extend((16).to_bytes(4, "little")) # Subchunk1Size + wav_header.extend((1).to_bytes(2, "little")) # AudioFormat (PCM) + wav_header.extend((1).to_bytes(2, "little")) # NumChannels + wav_header.extend((16000).to_bytes(4, "little")) # SampleRate + wav_header.extend((32000).to_bytes(4, "little")) # ByteRate + wav_header.extend((2).to_bytes(2, "little")) # BlockAlign + wav_header.extend((16).to_bytes(2, "little")) # BitsPerSample + wav_header.extend(b"data") # Subchunk2ID + wav_header.extend(len(pcm_data_bytes).to_bytes(4, "little")) # Subchunk2Size - # 返回完整的WAV数据 - return bytes(wav_header) + pcm_data_bytes + # 返回完整的WAV数据 + return bytes(wav_header) + pcm_data_bytes + finally: + if decoder: + del decoder + gc.collect() def enqueue_tts_report(conn, text, opus_data): diff --git a/main/xiaozhi-server/core/providers/asr/aliyun_stream.py b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py index 8acc640a..0728f6c6 100644 --- a/main/xiaozhi-server/core/providers/asr/aliyun_stream.py +++ b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py @@ -5,6 +5,7 @@ import hmac import base64 import hashlib import asyncio +import gc import requests import websockets import opuslib_next @@ -347,4 +348,7 @@ class ASRProvider(ASRProviderBase): async def close(self): """关闭资源""" - await self._cleanup() + await self._cleanup(None) + if hasattr(self, 'decoder'): + del self.decoder + gc.collect() diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index ed69da89..d0a26cf7 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -10,6 +10,7 @@ import traceback import threading import opuslib_next import concurrent.futures +import gc from abc import ABC, abstractmethod from config.logger import setup_logging from typing import Optional, Tuple, List @@ -241,6 +242,7 @@ class ASRProviderBase(ABC): @staticmethod def decode_opus(opus_data: List[bytes]) -> List[bytes]: """将Opus音频数据解码为PCM数据""" + decoder = None try: decoder = opuslib_next.Decoder(16000, 1) pcm_data = [] @@ -265,3 +267,7 @@ class ASRProviderBase(ABC): except Exception as e: logger.bind(tag=TAG).error(f"音频解码过程发生错误: {e}") return [] + finally: + if decoder: + del decoder + gc.collect() diff --git a/main/xiaozhi-server/core/providers/asr/doubao_stream.py b/main/xiaozhi-server/core/providers/asr/doubao_stream.py index 67964075..ed1c0c9d 100644 --- a/main/xiaozhi-server/core/providers/asr/doubao_stream.py +++ b/main/xiaozhi-server/core/providers/asr/doubao_stream.py @@ -4,6 +4,7 @@ import uuid import asyncio import websockets import opuslib_next +import gc from core.providers.asr.base import ASRProviderBase from config.logger import setup_logging from core.providers.asr.dto.dto import InterfaceType @@ -370,6 +371,17 @@ class ASRProvider(ASRProviderBase): pass self.forward_task = None self.is_processing = False + + # 显式释放decoder资源 + if hasattr(self, 'decoder') and self.decoder: + try: + del self.decoder + self.decoder = None + gc.collect() + logger.bind(tag=TAG).info("Doubao decoder resources released") + except Exception as e: + logger.bind(tag=TAG).error(f"Error releasing Doubao decoder: {e}") + # 清理所有连接的音频缓冲区 if hasattr(self, '_connections'): for conn in self._connections.values(): diff --git a/main/xiaozhi-server/core/providers/asr/xunfei_stream.py b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py index e91c8f09..0022d693 100644 --- a/main/xiaozhi-server/core/providers/asr/xunfei_stream.py +++ b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py @@ -5,6 +5,7 @@ import hashlib import asyncio import websockets import opuslib_next +import gc from time import mktime from datetime import datetime from urllib.parse import urlencode @@ -512,6 +513,17 @@ class ASRProvider(ASRProviderBase): pass self.forward_task = None self.is_processing = False + + # 显式释放decoder资源 + if hasattr(self, 'decoder') and self.decoder: + try: + del self.decoder + self.decoder = None + gc.collect() + logger.bind(tag=TAG).info("Xunfei decoder resources released") + except Exception as e: + logger.bind(tag=TAG).error(f"Error releasing Xunfei decoder: {e}") + # 清理所有连接的音频缓冲区 if hasattr(self, "_connections"): for conn in self._connections.values(): diff --git a/main/xiaozhi-server/core/providers/vad/silero.py b/main/xiaozhi-server/core/providers/vad/silero.py index 2263fcb7..738c2646 100644 --- a/main/xiaozhi-server/core/providers/vad/silero.py +++ b/main/xiaozhi-server/core/providers/vad/silero.py @@ -2,6 +2,7 @@ import time import numpy as np import torch import opuslib_next +import gc from config.logger import setup_logging from core.providers.vad.base import VADProviderBase @@ -36,6 +37,11 @@ class VADProvider(VADProviderBase): # 至少要多少帧才算有语音 self.frame_window_threshold = 3 + def __del__(self): + if hasattr(self, 'decoder'): + del self.decoder + gc.collect() + def is_vad(self, conn, opus_packet): try: pcm_frame = self.decoder.decode(opus_packet, 960) diff --git a/main/xiaozhi-server/core/utils/opus_encoder_utils.py b/main/xiaozhi-server/core/utils/opus_encoder_utils.py index ae7066ce..cf76dd6b 100644 --- a/main/xiaozhi-server/core/utils/opus_encoder_utils.py +++ b/main/xiaozhi-server/core/utils/opus_encoder_utils.py @@ -6,6 +6,7 @@ Opus编码工具类 import logging import traceback import numpy as np +import gc from opuslib_next import Encoder from opuslib_next import constants from typing import Optional, Callable, Any @@ -128,5 +129,10 @@ class OpusEncoderUtils: def close(self): """关闭编码器并释放资源""" - # opuslib没有明确的关闭方法,Python的垃圾回收会处理 - pass \ No newline at end of file + if hasattr(self, 'encoder') and self.encoder: + try: + del self.encoder + self.encoder = None + gc.collect() + except Exception as e: + logging.error(f"Error releasing Opus encoder: {e}") \ No newline at end of file diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index f10cc369..190c4d6a 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -8,6 +8,7 @@ import requests import subprocess import numpy as np import opuslib_next +import gc from io import BytesIO from core.utils import p3 from pydub import AudioSegment @@ -372,26 +373,30 @@ def opus_datas_to_wav_bytes(opus_datas, sample_rate=16000, channels=1): 将opus帧列表解码为wav字节流 """ decoder = opuslib_next.Decoder(sample_rate, channels) - pcm_datas = [] + try: + pcm_datas = [] - frame_duration = 60 # ms - frame_size = int(sample_rate * frame_duration / 1000) # 960 + frame_duration = 60 # ms + frame_size = int(sample_rate * frame_duration / 1000) # 960 - for opus_frame in opus_datas: - # 解码为PCM(返回bytes,2字节/采样点) - pcm = decoder.decode(opus_frame, frame_size) - pcm_datas.append(pcm) + for opus_frame in opus_datas: + # 解码为PCM(返回bytes,2字节/采样点) + pcm = decoder.decode(opus_frame, frame_size) + pcm_datas.append(pcm) - pcm_bytes = b"".join(pcm_datas) + pcm_bytes = b"".join(pcm_datas) - # 写入wav字节流 - wav_buffer = BytesIO() - with wave.open(wav_buffer, "wb") as wf: - wf.setnchannels(channels) - wf.setsampwidth(2) # 16bit - wf.setframerate(sample_rate) - wf.writeframes(pcm_bytes) - return wav_buffer.getvalue() + # 写入wav字节流 + wav_buffer = BytesIO() + with wave.open(wav_buffer, "wb") as wf: + wf.setnchannels(channels) + wf.setsampwidth(2) # 16bit + wf.setframerate(sample_rate) + wf.writeframes(pcm_bytes) + return wav_buffer.getvalue() + finally: + del decoder + gc.collect() def check_vad_update(before_config, new_config): From ea50288c5ba2f6058686259109e525b5690006dd Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sun, 23 Nov 2025 12:29:18 +0800 Subject: [PATCH 02/93] =?UTF-8?q?update=EF=BC=9A=E6=9C=AA=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E8=AE=BE=E5=A4=87=E4=BC=9A=E8=AF=9D=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 93 +++++++++++++++++++------- 1 file changed, 69 insertions(+), 24 deletions(-) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index d2891393..d5b9ca4a 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -68,8 +68,11 @@ class ConnectionHandler: self.logger = setup_logging() self.server = server # 保存server实例的引用 - self.need_bind = False - self.bind_code = None + self.need_bind = False # 是否需要绑定设备 + self.bind_code = None # 绑定设备的验证码 + self.last_bind_prompt_time = 0 # 上次播放绑定提示的时间戳(秒) + self.bind_prompt_interval = 30 # 绑定提示播放间隔(秒) + self.read_config_from_api = self.config.get("read_config_from_api", False) self.websocket = None @@ -116,6 +119,7 @@ class ConnectionHandler: self.client_audio_buffer = bytearray() self.client_have_voice = False self.client_voice_window = deque(maxlen=5) + self.first_activity_time = 0.0 # 记录首次活动的时间(毫秒) self.last_activity_time = 0.0 # 统一的活动时间戳(毫秒) self.client_voice_stop = False self.last_is_voice = False @@ -187,6 +191,7 @@ class ConnectionHandler: self.logger.bind(tag=TAG).info("连接来自:MQTT网关") # 初始化活动时间戳 + self.first_activity_time = time.time() * 1000 self.last_activity_time = time.time() * 1000 # 启动超时检查任务 @@ -268,6 +273,22 @@ class ConnectionHandler: if self.vad is None or self.asr is None: return + # 未绑定设备直接丢弃所有音频,不进行ASR处理 + if self.need_bind: + current_time = time.time() + # 检查是否需要播放绑定提示 + if ( + current_time - self.last_bind_prompt_time + >= self.bind_prompt_interval + ): + self.last_bind_prompt_time = current_time + # 复用现有的绑定提示逻辑 + from core.handle.receiveAudioHandle import check_bind_device + + asyncio.create_task(check_bind_device(self)) + # 直接丢弃音频,不进行ASR处理 + return + # 处理来自MQTT网关的音频包 if self.conn_from_mqtt_gateway and len(message) >= 16: handled = await self._process_mqtt_audio_message(message) @@ -744,18 +765,26 @@ class ConnectionHandler: force_final_answer = False # 标记是否强制最终回答 if depth >= MAX_DEPTH: - self.logger.bind(tag=TAG).debug(f"已达到最大工具调用深度 {MAX_DEPTH},将强制基于现有信息回答") + self.logger.bind(tag=TAG).debug( + f"已达到最大工具调用深度 {MAX_DEPTH},将强制基于现有信息回答" + ) force_final_answer = True # 添加系统指令,要求 LLM 基于现有信息回答 - self.dialogue.put(Message( - role="user", - content="[系统提示] 已达到最大工具调用次数限制,请你基于目前已经获取的所有信息,直接给出最终答案。不要再尝试调用任何工具。" - )) + self.dialogue.put( + Message( + role="user", + content="[系统提示] 已达到最大工具调用次数限制,请你基于目前已经获取的所有信息,直接给出最终答案。不要再尝试调用任何工具。", + ) + ) # Define intent functions functions = None # 达到最大深度时,禁用工具调用,强制 LLM 直接回答 - if self.intent_type == "function_call" and hasattr(self, "func_handler") and not force_final_answer: + if ( + self.intent_type == "function_call" + and hasattr(self, "func_handler") + and not force_final_answer + ): functions = self.func_handler.get_functions() response_message = [] @@ -844,11 +873,16 @@ class ConnectionHandler: if a is not None: try: content_arguments_json = json.loads(a) - tool_calls_list.append({ - "id": str(uuid.uuid4().hex), - "name": content_arguments_json["name"], - "arguments": json.dumps(content_arguments_json["arguments"], ensure_ascii=False) - }) + tool_calls_list.append( + { + "id": str(uuid.uuid4().hex), + "name": content_arguments_json["name"], + "arguments": json.dumps( + content_arguments_json["arguments"], + ensure_ascii=False, + ), + } + ) except Exception as e: bHasError = True response_message.append(a) @@ -880,7 +914,9 @@ class ConnectionHandler: ) future = asyncio.run_coroutine_threadsafe( - self.func_handler.handle_llm_function_call(self, tool_call_data), + self.func_handler.handle_llm_function_call( + self, tool_call_data + ), self.loop, ) futures_with_data.append((future, tool_call_data)) @@ -888,7 +924,7 @@ class ConnectionHandler: # 等待协程结束(实际等待时长为最慢的那个) tool_results = [] for future, tool_call_data in futures_with_data: - result = future.result() + result = future.result() tool_results.append((result, tool_call_data)) # 统一处理所有工具调用结果 @@ -922,7 +958,11 @@ class ConnectionHandler: need_llm_tools = [] for result, tool_call_data in tool_results: - if result.action in [Action.RESPONSE, Action.NOTFOUND, Action.ERROR]: # 直接回复前端 + if result.action in [ + Action.RESPONSE, + Action.NOTFOUND, + Action.ERROR, + ]: # 直接回复前端 text = result.response if result.response else result.result self.tts.tts_one_sentence(self, ContentType.TEXT, content_detail=text) self.dialogue.put(Message(role="assistant", content=text)) @@ -957,7 +997,11 @@ class ConnectionHandler: self.dialogue.put( Message( role="tool", - tool_call_id=str(uuid.uuid4()) if tool_call_data["id"] is None else tool_call_data["id"], + tool_call_id=( + str(uuid.uuid4()) + if tool_call_data["id"] is None + else tool_call_data["id"] + ), content=text, ) ) @@ -1137,13 +1181,14 @@ class ConnectionHandler: """检查连接超时""" try: while not self.stop_event.is_set(): + last_activity_time = self.last_activity_time + if self.need_bind: + last_activity_time = self.first_activity_time + # 检查是否超时(只有在时间戳已初始化的情况下) - if self.last_activity_time > 0.0: + if last_activity_time > 0.0: current_time = time.time() * 1000 - if ( - current_time - self.last_activity_time - > self.timeout_seconds * 1000 - ): + if current_time - last_activity_time > self.timeout_seconds * 1000: if not self.stop_event.is_set(): self.logger.bind(tag=TAG).info("连接超时,准备关闭") # 设置停止事件,防止重复处理 @@ -1171,7 +1216,7 @@ class ConnectionHandler: tools_call: 新的工具调用 """ for tool_call in tools_call: - tool_index = getattr(tool_call, 'index', None) + tool_index = getattr(tool_call, "index", None) if tool_index is None: if tool_call.function.name: # 有 function_name,说明是新的工具调用 @@ -1189,4 +1234,4 @@ class ConnectionHandler: if tool_call.function.name: tool_calls_list[tool_index]["name"] = tool_call.function.name if tool_call.function.arguments: - tool_calls_list[tool_index]["arguments"] += tool_call.function.arguments \ No newline at end of file + tool_calls_list[tool_index]["arguments"] += tool_call.function.arguments From 83a3d0eabedeae1c2ce1143df5bf8f2812b3f5b0 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sun, 23 Nov 2025 15:11:35 +0800 Subject: [PATCH 03/93] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/handle/reportHandle.py | 9 ++++++--- .../core/providers/asr/aliyun_stream.py | 11 ++++++++--- main/xiaozhi-server/core/providers/asr/base.py | 9 ++++++--- .../core/providers/asr/doubao_stream.py | 6 +++--- .../core/providers/asr/xunfei_stream.py | 6 +++--- main/xiaozhi-server/core/providers/vad/silero.py | 9 ++++++--- main/xiaozhi-server/core/utils/util.py | 8 ++++++-- 7 files changed, 38 insertions(+), 20 deletions(-) diff --git a/main/xiaozhi-server/core/handle/reportHandle.py b/main/xiaozhi-server/core/handle/reportHandle.py index 09ad0218..cc78a157 100644 --- a/main/xiaozhi-server/core/handle/reportHandle.py +++ b/main/xiaozhi-server/core/handle/reportHandle.py @@ -94,9 +94,12 @@ def opus_to_wav(conn, opus_data): # 返回完整的WAV数据 return bytes(wav_header) + pcm_data_bytes finally: - if decoder: - del decoder - gc.collect() + if decoder is not None: + try: + del decoder + gc.collect() + except Exception as e: + conn.logger.bind(tag=TAG).debug(f"释放decoder资源时出错: {e}") def enqueue_tts_report(conn, text, opus_data): diff --git a/main/xiaozhi-server/core/providers/asr/aliyun_stream.py b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py index 0728f6c6..23999fca 100644 --- a/main/xiaozhi-server/core/providers/asr/aliyun_stream.py +++ b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py @@ -349,6 +349,11 @@ class ASRProvider(ASRProviderBase): async def close(self): """关闭资源""" await self._cleanup(None) - if hasattr(self, 'decoder'): - del self.decoder - gc.collect() + if hasattr(self, 'decoder') and self.decoder is not None: + try: + del self.decoder + self.decoder = None + gc.collect() + logger.bind(tag=TAG).debug("Aliyun decoder resources released") + except Exception as e: + logger.bind(tag=TAG).debug(f"释放Aliyun decoder资源时出错: {e}") diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index d0a26cf7..2d88ff70 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -268,6 +268,9 @@ class ASRProviderBase(ABC): logger.bind(tag=TAG).error(f"音频解码过程发生错误: {e}") return [] finally: - if decoder: - del decoder - gc.collect() + if decoder is not None: + try: + del decoder + gc.collect() + except Exception as e: + logger.bind(tag=TAG).debug(f"释放decoder资源时出错: {e}") diff --git a/main/xiaozhi-server/core/providers/asr/doubao_stream.py b/main/xiaozhi-server/core/providers/asr/doubao_stream.py index ed1c0c9d..7d0871a6 100644 --- a/main/xiaozhi-server/core/providers/asr/doubao_stream.py +++ b/main/xiaozhi-server/core/providers/asr/doubao_stream.py @@ -373,14 +373,14 @@ class ASRProvider(ASRProviderBase): self.is_processing = False # 显式释放decoder资源 - if hasattr(self, 'decoder') and self.decoder: + if hasattr(self, 'decoder') and self.decoder is not None: try: del self.decoder self.decoder = None gc.collect() - logger.bind(tag=TAG).info("Doubao decoder resources released") + logger.bind(tag=TAG).debug("Doubao decoder resources released") except Exception as e: - logger.bind(tag=TAG).error(f"Error releasing Doubao decoder: {e}") + logger.bind(tag=TAG).debug(f"释放Doubao decoder资源时出错: {e}") # 清理所有连接的音频缓冲区 if hasattr(self, '_connections'): diff --git a/main/xiaozhi-server/core/providers/asr/xunfei_stream.py b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py index 0022d693..7bd992a9 100644 --- a/main/xiaozhi-server/core/providers/asr/xunfei_stream.py +++ b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py @@ -515,14 +515,14 @@ class ASRProvider(ASRProviderBase): self.is_processing = False # 显式释放decoder资源 - if hasattr(self, 'decoder') and self.decoder: + if hasattr(self, 'decoder') and self.decoder is not None: try: del self.decoder self.decoder = None gc.collect() - logger.bind(tag=TAG).info("Xunfei decoder resources released") + logger.bind(tag=TAG).debug("Xunfei decoder resources released") except Exception as e: - logger.bind(tag=TAG).error(f"Error releasing Xunfei decoder: {e}") + logger.bind(tag=TAG).debug(f"释放Xunfei decoder资源时出错: {e}") # 清理所有连接的音频缓冲区 if hasattr(self, "_connections"): diff --git a/main/xiaozhi-server/core/providers/vad/silero.py b/main/xiaozhi-server/core/providers/vad/silero.py index 738c2646..6fc26291 100644 --- a/main/xiaozhi-server/core/providers/vad/silero.py +++ b/main/xiaozhi-server/core/providers/vad/silero.py @@ -38,9 +38,12 @@ class VADProvider(VADProviderBase): self.frame_window_threshold = 3 def __del__(self): - if hasattr(self, 'decoder'): - del self.decoder - gc.collect() + if hasattr(self, 'decoder') and self.decoder is not None: + try: + del self.decoder + gc.collect() + except Exception: + pass def is_vad(self, conn, opus_packet): try: diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index 190c4d6a..9586ace8 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -395,8 +395,12 @@ def opus_datas_to_wav_bytes(opus_datas, sample_rate=16000, channels=1): wf.writeframes(pcm_bytes) return wav_buffer.getvalue() finally: - del decoder - gc.collect() + if decoder is not None: + try: + del decoder + gc.collect() + except Exception: + pass def check_vad_update(before_config, new_config): From 31ada101b0aeff5089132a059afd951988e78101 Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Mon, 24 Nov 2025 10:03:06 +0800 Subject: [PATCH 04/93] =?UTF-8?q?fix:=E7=99=BB=E5=BD=95=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/manager-web/src/i18n/vi.js | 2 +- main/manager-web/src/views/auth.scss | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/main/manager-web/src/i18n/vi.js b/main/manager-web/src/i18n/vi.js index 487a342c..15a35b9f 100644 --- a/main/manager-web/src/i18n/vi.js +++ b/main/manager-web/src/i18n/vi.js @@ -329,7 +329,7 @@ export default { 'paramDialog.jsonType': 'Đối tượng JSON', // Login page text - 'login.title': 'Đăng nhập', + 'login.title': 'DN', 'login.welcome': 'CHÀO MỪNG ĐẾN VỚI ĐĂNG NHẬP', 'login.username': 'Tên đăng nhập', 'login.usernamePlaceholder': 'Vui lòng nhập tên đăng nhập', diff --git a/main/manager-web/src/views/auth.scss b/main/manager-web/src/views/auth.scss index 181d1f6e..2ed9db58 100644 --- a/main/manager-web/src/views/auth.scss +++ b/main/manager-web/src/views/auth.scss @@ -38,11 +38,11 @@ position: absolute; top: 50%; transform: translateY(-50%); - right: 15%; + right: 18%; background-color: #fff; border-radius: 20px; padding: 35px 0; - width: 550px; + width: 450px; box-sizing: border-box; } From 0f249ae1db8dbabb1ca9180b54c33b1b88c4cda1 Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Mon, 24 Nov 2025 10:04:40 +0800 Subject: [PATCH 05/93] =?UTF-8?q?fix:=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/manager-web/src/views/KnowledgeFileUpload.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/manager-web/src/views/KnowledgeFileUpload.vue b/main/manager-web/src/views/KnowledgeFileUpload.vue index 6d58ba2f..37fd7e27 100644 --- a/main/manager-web/src/views/KnowledgeFileUpload.vue +++ b/main/manager-web/src/views/KnowledgeFileUpload.vue @@ -214,7 +214,7 @@
- + Date: Mon, 24 Nov 2025 16:29:46 +0800 Subject: [PATCH 06/93] =?UTF-8?q?=E7=A7=BB=E9=99=A4OpenAI=E4=B8=8D?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=9A=84=E5=8F=82=E6=95=B0top=5Fk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/providers/llm/openai/openai.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/main/xiaozhi-server/core/providers/llm/openai/openai.py b/main/xiaozhi-server/core/providers/llm/openai/openai.py index 6e0cd66d..2863c420 100644 --- a/main/xiaozhi-server/core/providers/llm/openai/openai.py +++ b/main/xiaozhi-server/core/providers/llm/openai/openai.py @@ -24,7 +24,6 @@ class LLMProvider(LLMProviderBase): "max_tokens": int, "temperature": lambda x: round(float(x), 1), "top_p": lambda x: round(float(x), 1), - "top_k": int, "frequency_penalty": lambda x: round(float(x), 1), } @@ -40,7 +39,7 @@ class LLMProvider(LLMProviderBase): setattr(self, param, None) logger.debug( - f"意图识别参数初始化: {self.temperature}, {self.max_tokens}, {self.top_p}, {self.top_k}, {self.frequency_penalty}" + f"意图识别参数初始化: {self.temperature}, {self.max_tokens}, {self.top_p}, {self.frequency_penalty}" ) model_key_msg = check_model_key("LLM", self.api_key) @@ -71,7 +70,6 @@ class LLMProvider(LLMProviderBase): "max_tokens": kwargs.get("max_tokens", self.max_tokens), "temperature": kwargs.get("temperature", self.temperature), "top_p": kwargs.get("top_p", self.top_p), - "top_k": kwargs.get("top_k", self.top_k), "frequency_penalty": kwargs.get("frequency_penalty", self.frequency_penalty), } @@ -116,7 +114,6 @@ class LLMProvider(LLMProviderBase): "max_tokens": kwargs.get("max_tokens", self.max_tokens), "temperature": kwargs.get("temperature", self.temperature), "top_p": kwargs.get("top_p", self.top_p), - "top_k": kwargs.get("top_k", self.top_k), "frequency_penalty": kwargs.get("frequency_penalty", self.frequency_penalty), } From 30e7a714977feb9673067aaf457cd5f14266a034 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:10:14 +0000 Subject: [PATCH 07/93] build(deps): bump dashscope in /main/xiaozhi-server Bumps [dashscope](https://dashscope.aliyun.com/) from 1.24.6 to 1.25.2. --- updated-dependencies: - dependency-name: dashscope dependency-version: 1.25.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- main/xiaozhi-server/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/xiaozhi-server/requirements.txt b/main/xiaozhi-server/requirements.txt index a5b791ff..163313f9 100644 --- a/main/xiaozhi-server/requirements.txt +++ b/main/xiaozhi-server/requirements.txt @@ -28,7 +28,7 @@ sherpa_onnx==1.12.15 mcp==1.20.0 cnlunar==0.2.0 PySocks==1.7.1 -dashscope==1.24.6 +dashscope==1.25.2 baidu-aip==4.16.13 chardet==5.2.0 aioconsole==0.8.2 From 1ebe7b8f71c47b49e3bd18f7d05a79ba264a81fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:10:17 +0000 Subject: [PATCH 08/93] build(deps): bump vosk from 0.3.44 to 0.3.45 in /main/xiaozhi-server Bumps [vosk](https://github.com/alphacep/vosk-api) from 0.3.44 to 0.3.45. - [Release notes](https://github.com/alphacep/vosk-api/releases) - [Commits](https://github.com/alphacep/vosk-api/commits/v0.3.45) --- updated-dependencies: - dependency-name: vosk dependency-version: 0.3.45 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- main/xiaozhi-server/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/xiaozhi-server/requirements.txt b/main/xiaozhi-server/requirements.txt index a5b791ff..32ae8d28 100644 --- a/main/xiaozhi-server/requirements.txt +++ b/main/xiaozhi-server/requirements.txt @@ -38,4 +38,4 @@ PyJWT==2.10.1 psutil==7.0.0 portalocker==3.2.0 Jinja2==3.1.6 -vosk==0.3.44 \ No newline at end of file +vosk==0.3.45 \ No newline at end of file From b4dd78b2ed9d1f0770736f0cdf91a4fec9f71b02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:10:27 +0000 Subject: [PATCH 09/93] build(deps): bump openai from 2.7.1 to 2.8.1 in /main/xiaozhi-server Bumps [openai](https://github.com/openai/openai-python) from 2.7.1 to 2.8.1. - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v2.7.1...v2.8.1) --- updated-dependencies: - dependency-name: openai dependency-version: 2.8.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- main/xiaozhi-server/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/xiaozhi-server/requirements.txt b/main/xiaozhi-server/requirements.txt index a5b791ff..5cfb0c96 100644 --- a/main/xiaozhi-server/requirements.txt +++ b/main/xiaozhi-server/requirements.txt @@ -10,7 +10,7 @@ silero_vad==6.1.0 opuslib_next==1.1.5 pydub==0.25.1 funasr==1.2.7 -openai==2.7.1 +openai==2.8.1 google-generativeai==0.8.5 edge_tts==7.2.3 httpx==0.28.1 From 657d03f49d80a22974c06306165814491a1115b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:10:36 +0000 Subject: [PATCH 10/93] build(deps): bump sherpa-onnx in /main/xiaozhi-server Bumps [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) from 1.12.15 to 1.12.17. - [Release notes](https://github.com/k2-fsa/sherpa-onnx/releases) - [Changelog](https://github.com/k2-fsa/sherpa-onnx/blob/master/CHANGELOG.md) - [Commits](https://github.com/k2-fsa/sherpa-onnx/compare/v1.12.15...v1.12.17) --- updated-dependencies: - dependency-name: sherpa-onnx dependency-version: 1.12.17 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- main/xiaozhi-server/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/xiaozhi-server/requirements.txt b/main/xiaozhi-server/requirements.txt index a5b791ff..a02ffb6e 100644 --- a/main/xiaozhi-server/requirements.txt +++ b/main/xiaozhi-server/requirements.txt @@ -24,7 +24,7 @@ cozepy==0.20.0 mem0ai==1.0.0 bs4==0.0.2 modelscope==1.23.2 -sherpa_onnx==1.12.15 +sherpa_onnx==1.12.17 mcp==1.20.0 cnlunar==0.2.0 PySocks==1.7.1 From 106889b0dd45c41b1f34dae904fd1d8fecdce531 Mon Sep 17 00:00:00 2001 From: Chingfeng Li Date: Tue, 25 Nov 2025 13:52:18 +0800 Subject: [PATCH 11/93] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/main/xiaozhi-server/requirements.txt b/main/xiaozhi-server/requirements.txt index 07dc4504..3f62ef4c 100644 --- a/main/xiaozhi-server/requirements.txt +++ b/main/xiaozhi-server/requirements.txt @@ -1,3 +1,4 @@ +# -*- coding:utf-8 -*- #--------- 本项目推荐环境是python3.10,以下暂时不推荐升级的依赖 torch==2.2.2 torchaudio==2.2.2 From 4645c50f6d7cf410e1eaff8c97a865e8859b4619 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Tue, 25 Nov 2025 16:11:48 +0800 Subject: [PATCH 12/93] =?UTF-8?q?update:=E5=B0=86=E5=9E=83=E5=9C=BE?= =?UTF-8?q?=E5=9B=9E=E6=94=B6=E6=94=BE=E7=BD=AEws=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E5=A4=84=EF=BC=8C=E9=81=BF=E5=85=8D=E9=A2=91=E7=B9=81=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 3 +++ main/xiaozhi-server/core/handle/reportHandle.py | 1 - main/xiaozhi-server/core/providers/asr/aliyun_stream.py | 1 - main/xiaozhi-server/core/providers/asr/base.py | 1 - main/xiaozhi-server/core/providers/asr/doubao_stream.py | 1 - main/xiaozhi-server/core/providers/asr/xunfei_stream.py | 1 - main/xiaozhi-server/core/providers/vad/silero.py | 1 - main/xiaozhi-server/core/utils/opus_encoder_utils.py | 1 - main/xiaozhi-server/core/utils/util.py | 1 - 9 files changed, 3 insertions(+), 8 deletions(-) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index d5b9ca4a..507f3942 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -1127,6 +1127,9 @@ class ConnectionHandler: ) self.executor = None + import gc + gc.collect() + self.logger.bind(tag=TAG).info("连接资源已释放") except Exception as e: self.logger.bind(tag=TAG).error(f"关闭连接时出错: {e}") diff --git a/main/xiaozhi-server/core/handle/reportHandle.py b/main/xiaozhi-server/core/handle/reportHandle.py index cc78a157..679f90e8 100644 --- a/main/xiaozhi-server/core/handle/reportHandle.py +++ b/main/xiaozhi-server/core/handle/reportHandle.py @@ -97,7 +97,6 @@ def opus_to_wav(conn, opus_data): if decoder is not None: try: del decoder - gc.collect() except Exception as e: conn.logger.bind(tag=TAG).debug(f"释放decoder资源时出错: {e}") diff --git a/main/xiaozhi-server/core/providers/asr/aliyun_stream.py b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py index 23999fca..179685bd 100644 --- a/main/xiaozhi-server/core/providers/asr/aliyun_stream.py +++ b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py @@ -353,7 +353,6 @@ class ASRProvider(ASRProviderBase): try: del self.decoder self.decoder = None - gc.collect() logger.bind(tag=TAG).debug("Aliyun decoder resources released") except Exception as e: logger.bind(tag=TAG).debug(f"释放Aliyun decoder资源时出错: {e}") diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index 2d88ff70..7e36de5c 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -271,6 +271,5 @@ class ASRProviderBase(ABC): if decoder is not None: try: del decoder - gc.collect() except Exception as e: logger.bind(tag=TAG).debug(f"释放decoder资源时出错: {e}") diff --git a/main/xiaozhi-server/core/providers/asr/doubao_stream.py b/main/xiaozhi-server/core/providers/asr/doubao_stream.py index 7d0871a6..2d179b86 100644 --- a/main/xiaozhi-server/core/providers/asr/doubao_stream.py +++ b/main/xiaozhi-server/core/providers/asr/doubao_stream.py @@ -377,7 +377,6 @@ class ASRProvider(ASRProviderBase): try: del self.decoder self.decoder = None - gc.collect() logger.bind(tag=TAG).debug("Doubao decoder resources released") except Exception as e: logger.bind(tag=TAG).debug(f"释放Doubao decoder资源时出错: {e}") diff --git a/main/xiaozhi-server/core/providers/asr/xunfei_stream.py b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py index 7bd992a9..6a895ebc 100644 --- a/main/xiaozhi-server/core/providers/asr/xunfei_stream.py +++ b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py @@ -519,7 +519,6 @@ class ASRProvider(ASRProviderBase): try: del self.decoder self.decoder = None - gc.collect() logger.bind(tag=TAG).debug("Xunfei decoder resources released") except Exception as e: logger.bind(tag=TAG).debug(f"释放Xunfei decoder资源时出错: {e}") diff --git a/main/xiaozhi-server/core/providers/vad/silero.py b/main/xiaozhi-server/core/providers/vad/silero.py index 6fc26291..ae51b88f 100644 --- a/main/xiaozhi-server/core/providers/vad/silero.py +++ b/main/xiaozhi-server/core/providers/vad/silero.py @@ -41,7 +41,6 @@ class VADProvider(VADProviderBase): if hasattr(self, 'decoder') and self.decoder is not None: try: del self.decoder - gc.collect() except Exception: pass diff --git a/main/xiaozhi-server/core/utils/opus_encoder_utils.py b/main/xiaozhi-server/core/utils/opus_encoder_utils.py index cf76dd6b..300b2422 100644 --- a/main/xiaozhi-server/core/utils/opus_encoder_utils.py +++ b/main/xiaozhi-server/core/utils/opus_encoder_utils.py @@ -133,6 +133,5 @@ class OpusEncoderUtils: try: del self.encoder self.encoder = None - gc.collect() except Exception as e: logging.error(f"Error releasing Opus encoder: {e}") \ No newline at end of file diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index 9586ace8..37388be8 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -398,7 +398,6 @@ def opus_datas_to_wav_bytes(opus_datas, sample_rate=16000, channels=1): if decoder is not None: try: del decoder - gc.collect() except Exception: pass From 6eddf081de89866f1e872a7ff49a3facdae269ed Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Tue, 25 Nov 2025 17:38:55 +0800 Subject: [PATCH 13/93] =?UTF-8?q?fix:=E5=85=81=E8=AE=B8ota,mcp,=E5=A3=B0?= =?UTF-8?q?=E7=BA=B9=E5=9C=B0=E5=9D=80=E4=B8=BAnull?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xiaozhi/modules/sys/controller/SysParamsController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java index 24b070c0..26e6d245 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java @@ -174,7 +174,7 @@ public class SysParamsController { return; } if (StringUtils.isBlank(url) || url.equals("null")) { - throw new RenException(ErrorCode.OTA_URL_EMPTY); + return; } // 检查是否包含localhost或127.0.0.1 @@ -211,7 +211,7 @@ public class SysParamsController { return; } if (StringUtils.isBlank(url) || url.equals("null")) { - throw new RenException(ErrorCode.MCP_URL_EMPTY); + return; } if (url.contains("localhost") || url.contains("127.0.0.1")) { throw new RenException(ErrorCode.MCP_URL_LOCALHOST); @@ -242,7 +242,7 @@ public class SysParamsController { return; } if (StringUtils.isBlank(url) || url.equals("null")) { - throw new RenException(ErrorCode.VOICEPRINT_URL_EMPTY); + return; } if (url.contains("localhost") || url.contains("127.0.0.1")) { throw new RenException(ErrorCode.VOICEPRINT_URL_LOCALHOST); From 74baa85340ef98560aa2bd31ed3bb038552b7d5c Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Wed, 26 Nov 2025 12:21:12 +0800 Subject: [PATCH 14/93] Update ragflow-integration.md --- docs/ragflow-integration.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/ragflow-integration.md b/docs/ragflow-integration.md index c2cd20f5..36088333 100644 --- a/docs/ragflow-integration.md +++ b/docs/ragflow-integration.md @@ -156,6 +156,14 @@ services: 编辑`ragflow/docker`文件夹下的`.env`文件,找到以下配置,逐个搜索,逐个修改!逐个搜索,逐个修改! +下面对于`.env`文件的修改,60%的人会忽略`MYSQL_USER`配置导致ragflow启动不成功,因此,需要强调三次: + +强调第一次:如果你的`.env`文件如果没有`MYSQL_USER`配置,请在配置文件增加这项! + +强调第二次:如果你的`.env`文件如果没有`MYSQL_USER`配置,请在配置文件增加这项! + +强调第三次:如果你的`.env`文件如果没有`MYSQL_USER`配置,请在配置文件增加这项! + ``` env # 端口设置 SVR_WEB_HTTP_PORT=8008 # HTTP端口 From fb5c35e6c907264d5ae1d6d4b611e3ef70cff27a Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Fri, 28 Nov 2025 16:12:19 +0800 Subject: [PATCH 15/93] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E7=BB=84=E4=BB=B6=EF=BC=8Cgc=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E5=8C=96=E9=81=BF=E5=85=8D=E6=AF=8F=E6=AC=A1=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E8=A7=A6=E5=8F=91GIL=E9=94=81=EF=BC=8C=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/app.py | 8 + main/xiaozhi-server/config/config_loader.py | 21 +- .../config/manage_api_client.py | 94 ++++--- .../xiaozhi-server/core/api/vision_handler.py | 2 +- main/xiaozhi-server/core/connection.py | 46 ++-- .../core/handle/reportHandle.py | 6 +- .../core/handle/sendAudioHandle.py | 254 +++++++++--------- .../memory/mem_local_short/mem_local_short.py | 9 +- .../core/utils/audioRateController.py | 132 +++++++++ main/xiaozhi-server/core/utils/gc_manager.py | 122 +++++++++ main/xiaozhi-server/core/websocket_server.py | 6 +- 11 files changed, 503 insertions(+), 197 deletions(-) create mode 100644 main/xiaozhi-server/core/utils/audioRateController.py create mode 100644 main/xiaozhi-server/core/utils/gc_manager.py diff --git a/main/xiaozhi-server/app.py b/main/xiaozhi-server/app.py index bc6f0b54..ab085464 100644 --- a/main/xiaozhi-server/app.py +++ b/main/xiaozhi-server/app.py @@ -9,6 +9,7 @@ from core.utils.util import get_local_ip, validate_mcp_endpoint from core.http_server import SimpleHttpServer from core.websocket_server import WebSocketServer from core.utils.util import check_ffmpeg_installed +from core.utils.gc_manager import get_gc_manager TAG = __name__ logger = setup_logging() @@ -63,6 +64,10 @@ async def main(): # 添加 stdin 监控任务 stdin_task = asyncio.create_task(monitor_stdin()) + # 启动全局GC管理器(5分钟清理一次) + gc_manager = get_gc_manager(interval_seconds=300) + await gc_manager.start() + # 启动 WebSocket 服务器 ws_server = WebSocketServer(config) ws_task = asyncio.create_task(ws_server.start()) @@ -122,6 +127,9 @@ async def main(): except asyncio.CancelledError: print("任务被取消,清理资源中...") finally: + # 停止全局GC管理器 + await gc_manager.stop() + # 取消所有任务(关键修复点) stdin_task.cancel() ws_task.cancel() diff --git a/main/xiaozhi-server/config/config_loader.py b/main/xiaozhi-server/config/config_loader.py index b70cf961..c85510b8 100644 --- a/main/xiaozhi-server/config/config_loader.py +++ b/main/xiaozhi-server/config/config_loader.py @@ -32,7 +32,16 @@ def load_config(): custom_config = read_config(custom_config_path) if custom_config.get("manager-api", {}).get("url"): - config = get_config_from_api(custom_config) + import asyncio + try: + loop = asyncio.get_running_loop() + # 如果已经在事件循环中,使用异步版本 + config = asyncio.run_coroutine_threadsafe( + get_config_from_api_async(custom_config), loop + ).result() + except RuntimeError: + # 如果不在事件循环中(启动时),创建新的事件循环 + config = asyncio.run(get_config_from_api_async(custom_config)) else: # 合并配置 config = merge_configs(default_config, custom_config) @@ -44,13 +53,13 @@ def load_config(): return config -def get_config_from_api(config): - """从Java API获取配置""" +async def get_config_from_api_async(config): + """从Java API获取配置(异步版本)""" # 初始化API客户端 init_service(config) # 获取服务器配置 - config_data = get_server_config() + config_data = await get_server_config() if config_data is None: raise Exception("Failed to fetch server config from API") @@ -74,9 +83,9 @@ def get_config_from_api(config): return config_data -def get_private_config_from_api(config, device_id, client_id): +async def get_private_config_from_api(config, device_id, client_id): """从Java API获取私有配置""" - return get_agent_models(device_id, client_id, config["selected_module"]) + return await get_agent_models(device_id, client_id, config["selected_module"]) def ensure_directories(config): diff --git a/main/xiaozhi-server/config/manage_api_client.py b/main/xiaozhi-server/config/manage_api_client.py index cd3f86c4..88995359 100644 --- a/main/xiaozhi-server/config/manage_api_client.py +++ b/main/xiaozhi-server/config/manage_api_client.py @@ -1,5 +1,4 @@ import os -import time import base64 from typing import Optional, Dict @@ -20,7 +19,7 @@ class DeviceBindException(Exception): class ManageApiClient: _instance = None - _client = None + _async_clients = {} # 为每个事件循环存储独立的客户端 _secret = None def __new__(cls, config): @@ -32,7 +31,7 @@ class ManageApiClient: @classmethod def _init_client(cls, config): - """初始化持久化连接池""" + """初始化配置(延迟创建客户端)""" cls.config = config.get("manager-api") if not cls.config: @@ -47,23 +46,40 @@ class ManageApiClient: cls._secret = cls.config.get("secret") cls.max_retries = cls.config.get("max_retries", 6) # 最大重试次数 cls.retry_delay = cls.config.get("retry_delay", 10) # 初始重试延迟(秒) - # NOTE(goody): 2025/4/16 http相关资源统一管理,后续可以增加线程池或者超时 - # 后续也可以统一配置apiToken之类的走通用的Auth - cls._client = httpx.Client( - base_url=cls.config.get("url"), - headers={ - "User-Agent": f"PythonClient/2.0 (PID:{os.getpid()})", - "Accept": "application/json", - "Authorization": "Bearer " + cls._secret, - }, - timeout=cls.config.get("timeout", 30), # 默认超时时间30秒 - ) + # 不在这里创建 AsyncClient,延迟到实际使用时创建 + cls._async_clients = {} @classmethod - def _request(cls, method: str, endpoint: str, **kwargs) -> Dict: - """发送单次HTTP请求并处理响应""" + async def _ensure_async_client(cls): + """确保异步客户端已创建(为每个事件循环创建独立的客户端)""" + import asyncio + try: + loop = asyncio.get_running_loop() + loop_id = id(loop) + + # 为每个事件循环创建独立的客户端 + if loop_id not in cls._async_clients: + cls._async_clients[loop_id] = httpx.AsyncClient( + base_url=cls.config.get("url"), + headers={ + "User-Agent": f"PythonClient/2.0 (PID:{os.getpid()})", + "Accept": "application/json", + "Authorization": "Bearer " + cls._secret, + }, + timeout=cls.config.get("timeout", 30), + ) + return cls._async_clients[loop_id] + except RuntimeError: + # 如果没有运行中的事件循环,创建一个临时的 + raise Exception("必须在异步上下文中调用") + + @classmethod + async def _async_request(cls, method: str, endpoint: str, **kwargs) -> Dict: + """发送单次异步HTTP请求并处理响应""" + # 确保客户端已创建 + client = await cls._ensure_async_client() endpoint = endpoint.lstrip("/") - response = cls._client.request(method, endpoint, **kwargs) + response = await client.request(method, endpoint, **kwargs) response.raise_for_status() result = response.json() @@ -96,22 +112,23 @@ class ManageApiClient: return False @classmethod - def _execute_request(cls, method: str, endpoint: str, **kwargs) -> Dict: - """带重试机制的请求执行器""" + async def _execute_async_request(cls, method: str, endpoint: str, **kwargs) -> Dict: + """带重试机制的异步请求执行器""" + import asyncio retry_count = 0 while retry_count <= cls.max_retries: try: - # 执行请求 - return cls._request(method, endpoint, **kwargs) + # 执行异步请求 + return await cls._async_request(method, endpoint, **kwargs) except Exception as e: # 判断是否应该重试 if retry_count < cls.max_retries and cls._should_retry(e): retry_count += 1 print( - f"{method} {endpoint} 请求失败,将在 {cls.retry_delay:.1f} 秒后进行第 {retry_count} 次重试" + f"{method} {endpoint} 异步请求失败,将在 {cls.retry_delay:.1f} 秒后进行第 {retry_count} 次重试" ) - time.sleep(cls.retry_delay) + await asyncio.sleep(cls.retry_delay) continue else: # 不重试,直接抛出异常 @@ -119,22 +136,27 @@ class ManageApiClient: @classmethod def safe_close(cls): - """安全关闭连接池""" - if cls._client: - cls._client.close() - cls._instance = None + """安全关闭所有异步连接池""" + import asyncio + for client in list(cls._async_clients.values()): + try: + asyncio.run(client.aclose()) + except Exception: + pass + cls._async_clients.clear() + cls._instance = None -def get_server_config() -> Optional[Dict]: +async def get_server_config() -> Optional[Dict]: """获取服务器基础配置""" - return ManageApiClient._instance._execute_request("POST", "/config/server-base") + return await ManageApiClient._instance._execute_async_request("POST", "/config/server-base") -def get_agent_models( +async def get_agent_models( mac_address: str, client_id: str, selected_module: Dict ) -> Optional[Dict]: """获取代理模型配置""" - return ManageApiClient._instance._execute_request( + return await ManageApiClient._instance._execute_async_request( "POST", "/config/agent-models", json={ @@ -145,9 +167,9 @@ def get_agent_models( ) -def save_mem_local_short(mac_address: str, short_momery: str) -> Optional[Dict]: +async def save_mem_local_short(mac_address: str, short_momery: str) -> Optional[Dict]: try: - return ManageApiClient._instance._execute_request( + return await ManageApiClient._instance._execute_async_request( "PUT", f"/agent/saveMemory/" + mac_address, json={ @@ -159,14 +181,14 @@ def save_mem_local_short(mac_address: str, short_momery: str) -> Optional[Dict]: return None -def report( +async def report( mac_address: str, session_id: str, chat_type: int, content: str, audio, report_time ) -> Optional[Dict]: - """带熔断的业务方法示例""" + """异步聊天记录上报""" if not content or not ManageApiClient._instance: return None try: - return ManageApiClient._instance._execute_request( + return await ManageApiClient._instance._execute_async_request( "POST", f"/agent/chat-history/report", json={ diff --git a/main/xiaozhi-server/core/api/vision_handler.py b/main/xiaozhi-server/core/api/vision_handler.py index 70be247a..a4817a84 100644 --- a/main/xiaozhi-server/core/api/vision_handler.py +++ b/main/xiaozhi-server/core/api/vision_handler.py @@ -96,7 +96,7 @@ class VisionHandler: current_config = copy.deepcopy(self.config) read_config_from_api = current_config.get("read_config_from_api", False) if read_config_from_api: - current_config = get_private_config_from_api( + current_config = await get_private_config_from_api( current_config, device_id, client_id, diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 507f3942..531e1e0e 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -91,7 +91,7 @@ class ConnectionHandler: self.client_listen_mode = "auto" # 线程任务相关 - self.loop = asyncio.get_event_loop() + self.loop = None # 在 handle_connection 中获取运行中的事件循环 self.stop_event = threading.Event() self.executor = ThreadPoolExecutor(max_workers=5) @@ -166,6 +166,9 @@ class ConnectionHandler: async def handle_connection(self, ws): try: + # 获取运行中的事件循环(必须在异步上下文中) + self.loop = asyncio.get_running_loop() + # 获取并验证headers self.headers = dict(ws.request.headers) real_ip = self.headers.get("x-real-ip") or self.headers.get( @@ -200,10 +203,8 @@ class ConnectionHandler: self.welcome_msg = self.config["xiaozhi"] self.welcome_msg["session_id"] = self.session_id - # 获取差异化配置 - self._initialize_private_config() - # 异步初始化 - self.executor.submit(self._initialize_components) + # 在后台初始化配置和组件(完全不阻塞主循环) + asyncio.create_task(self._background_initialize()) try: async for message in self.websocket: @@ -522,21 +523,30 @@ class ConnectionHandler: except Exception as e: self.logger.bind(tag=TAG).warning(f"声纹识别初始化失败: {str(e)}") - def _initialize_private_config(self): - """如果是从配置文件获取,则进行二次实例化""" + async def _background_initialize(self): + """在后台初始化配置和组件(完全不阻塞主循环)""" + try: + # 异步获取差异化配置 + await self._initialize_private_config_async() + # 在线程池中初始化组件 + self.executor.submit(self._initialize_components) + except Exception as e: + self.logger.bind(tag=TAG).error(f"后台初始化失败: {e}") + + async def _initialize_private_config_async(self): + """从接口异步获取差异化配置(异步版本,不阻塞主循环)""" if not self.read_config_from_api: return - """从接口获取差异化的配置进行二次实例化,非全量重新实例化""" try: begin_time = time.time() - private_config = get_private_config_from_api( + private_config = await get_private_config_from_api( self.config, self.headers.get("device-id"), self.headers.get("client-id", self.headers.get("device-id")), ) private_config["delete_audio"] = bool(self.config.get("delete_audio", True)) self.logger.bind(tag=TAG).info( - f"{time.time() - begin_time} 秒,获取差异化配置成功: {json.dumps(filter_sensitive_info(private_config), ensure_ascii=False)}" + f"{time.time() - begin_time} 秒,异步获取差异化配置成功: {json.dumps(filter_sensitive_info(private_config), ensure_ascii=False)}" ) except DeviceNotFoundException as e: self.need_bind = True @@ -547,7 +557,7 @@ class ConnectionHandler: private_config = {} except Exception as e: self.need_bind = True - self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}") + self.logger.bind(tag=TAG).error(f"异步获取差异化配置失败: {e}") private_config = {} init_llm, init_tts, init_memory, init_intent = ( @@ -620,8 +630,12 @@ class ConnectionHandler: self.chat_history_conf = int(private_config["chat_history_conf"]) if private_config.get("mcp_endpoint", None) is not None: self.config["mcp_endpoint"] = private_config["mcp_endpoint"] + + # 使用 run_in_executor 在线程池中执行 initialize_modules,避免阻塞主循环 try: - modules = initialize_modules( + modules = await self.loop.run_in_executor( + None, # 使用默认线程池 + initialize_modules, self.logger, private_config, init_vad, @@ -1034,8 +1048,8 @@ class ConnectionHandler: def _process_report(self, type, text, audio_data, report_time): """处理上报任务""" try: - # 执行上报(传入二进制数据) - report(self, type, text, audio_data, report_time) + # 执行异步上报(在事件循环中运行) + asyncio.run(report(self, type, text, audio_data, report_time)) except Exception as e: self.logger.bind(tag=TAG).error(f"上报处理异常: {e}") finally: @@ -1126,10 +1140,6 @@ class ConnectionHandler: f"关闭线程池时出错: {executor_error}" ) self.executor = None - - import gc - gc.collect() - self.logger.bind(tag=TAG).info("连接资源已释放") except Exception as e: self.logger.bind(tag=TAG).error(f"关闭连接时出错: {e}") diff --git a/main/xiaozhi-server/core/handle/reportHandle.py b/main/xiaozhi-server/core/handle/reportHandle.py index 679f90e8..973ebb38 100644 --- a/main/xiaozhi-server/core/handle/reportHandle.py +++ b/main/xiaozhi-server/core/handle/reportHandle.py @@ -18,7 +18,7 @@ from config.manage_api_client import report as manage_report TAG = __name__ -def report(conn, type, text, opus_data, report_time): +async def report(conn, type, text, opus_data, report_time): """执行聊天记录上报操作 Args: @@ -33,8 +33,8 @@ def report(conn, type, text, opus_data, report_time): audio_data = opus_to_wav(conn, opus_data) else: audio_data = None - # 执行上报 - manage_report( + # 执行异步上报 + await manage_report( mac_address=conn.device_id, session_id=conn.session_id, chat_type=type, diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py index d517cdb9..14f43232 100644 --- a/main/xiaozhi-server/core/handle/sendAudioHandle.py +++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py @@ -4,6 +4,7 @@ import asyncio from core.utils import textUtils from core.utils.util import audio_to_data from core.providers.tts.dto.dto import SentenceType +from core.utils.audioRateController import AudioRateController TAG = __name__ @@ -30,31 +31,6 @@ async def sendAudioMessage(conn, sentenceType, audios, text): await conn.close() -def calculate_timestamp_and_sequence(conn, start_time, packet_index, frame_duration=60): - """ - 计算音频数据包的时间戳和序列号 - Args: - conn: 连接对象 - start_time: 起始时间(性能计数器值) - packet_index: 数据包索引 - frame_duration: 帧时长(毫秒),匹配 Opus 编码 - Returns: - tuple: (timestamp, sequence) - """ - # 计算时间戳(使用播放位置计算) - timestamp = int((start_time + packet_index * frame_duration / 1000) * 1000) % ( - 2**32 - ) - - # 计算序列号 - if hasattr(conn, "audio_flow_control"): - sequence = conn.audio_flow_control["sequence"] - else: - sequence = packet_index # 如果没有流控状态,直接使用索引 - - return timestamp, sequence - - async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence): """ 发送带16字节头部的opus数据包给mqtt_gateway @@ -77,15 +53,20 @@ async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence): await conn.websocket.send(complete_packet) -# 播放音频 +# 播放音频 - 使用 AudioRateController 进行精确流控 async def sendAudio(conn, audios, frame_duration=60): """ - 发送单个opus包,支持流控 + 发送音频包,使用 AudioRateController 进行精确的流量控制 + Args: conn: 连接对象 - opus_packet: 单个opus数据包 - pre_buffer: 快速发送音频 - frame_duration: 帧时长(毫秒),匹配 Opus 编码 + audios: 单个opus包(bytes) 或 opus包列表 + frame_duration: 帧时长(毫秒),默认60ms + + 改进点: + 1. 使用单一时间基准,避免累积误差 + 2. 每次检查队列时重新计算 elapsed_ms,更精准 + 3. 支持高并发而不产生时间偏差 """ if audios is None or len(audios) == 0: return @@ -94,118 +75,133 @@ async def sendAudio(conn, audios, frame_duration=60): send_delay = conn.config.get("tts_audio_send_delay", -1) / 1000.0 if isinstance(audios, bytes): - # 重置流控状态,第一次读取和会话发生转变时 - if not hasattr(conn, "audio_flow_control") or conn.audio_flow_control.get("sentence_id") != conn.sentence_id: - conn.audio_flow_control = { - "last_send_time": 0, - "packet_count": 0, - "start_time": time.perf_counter(), - "sequence": 0, # 添加序列号 - "sentence_id": conn.sentence_id, - } + # 单个 opus 包处理 + await _sendAudio_single(conn, audios, send_delay, frame_duration) + else: + # 音频列表处理(如文件型音频) + await _sendAudio_list(conn, audios, send_delay, frame_duration) + +async def _sendAudio_single(conn, opus_packet, send_delay, frame_duration=60): + """ + 发送单个 opus 包 + 使用 AudioRateController 进行流控 + """ + # 重置流控状态,第一次读取和会话发生转变时 + if not hasattr(conn, "audio_rate_controller") or conn.audio_flow_control.get("sentence_id") != conn.sentence_id: + if hasattr(conn, "audio_rate_controller"): + conn.audio_rate_controller.reset() + else: + conn.audio_rate_controller = AudioRateController(frame_duration) + conn.audio_rate_controller.reset() + + conn.audio_flow_control = { + "packet_count": 0, + "sequence": 0, + "sentence_id": conn.sentence_id, + } + + if conn.client_abort: + return + + conn.last_activity_time = time.time() * 1000 + + rate_controller = conn.audio_rate_controller + flow_control = conn.audio_flow_control + packet_count = flow_control["packet_count"] + + # 预缓冲:前5个包直接发送,不做延迟 + pre_buffer_count = 5 + + if packet_count < pre_buffer_count or send_delay > 0: + # 预缓冲阶段或固定延迟模式,直接发送 + await _do_send_audio(conn, opus_packet, flow_control, frame_duration) + + if send_delay > 0 and packet_count >= pre_buffer_count: + await asyncio.sleep(send_delay) + else: + # 使用流控器进行精确的速率控制 + rate_controller.add_audio(opus_packet) + + async def send_callback(packet): + await _do_send_audio(conn, packet, flow_control, frame_duration) + + await rate_controller.check_queue(send_callback) + + # 更新流控状态 + flow_control["packet_count"] += 1 + flow_control["sequence"] += 1 + + +async def _sendAudio_list(conn, audios, send_delay, frame_duration=60): + """ + 发送音频列表(如文件型音频) + """ + if not audios: + return + + rate_controller = AudioRateController(frame_duration) + rate_controller.reset() + + flow_control = { + "packet_count": 0, + "sequence": 0, + } + + # 预缓冲:前5个包直接发送 + pre_buffer_frames = min(5, len(audios)) + for i in range(pre_buffer_frames): if conn.client_abort: return + await _do_send_audio(conn, audios[i], flow_control, frame_duration) + conn.client_is_speaking = True + + remaining_audios = audios[pre_buffer_frames:] + + # 处理剩余音频帧 + for i, opus_packet in enumerate(remaining_audios): + if conn.client_abort: + break conn.last_activity_time = time.time() * 1000 - # 预缓冲:前5个包直接发送,不做延迟 - pre_buffer_count = 5 - flow_control = conn.audio_flow_control - current_time = time.perf_counter() - - if flow_control["packet_count"] < pre_buffer_count: - # 预缓冲阶段,直接发送不延迟 - pass - elif send_delay > 0: - # 使用固定延迟 + if send_delay > 0: + # 固定延迟模式 await asyncio.sleep(send_delay) else: - effective_packet = flow_control["packet_count"] - pre_buffer_count - expected_time = flow_control["start_time"] + ( - effective_packet * frame_duration / 1000 - ) - delay = expected_time - current_time - if delay > 0: - await asyncio.sleep(delay) - else: - # 纠正误差 - flow_control["start_time"] += abs(delay) + # 使用流控器进行精确延迟 + rate_controller.add_audio(opus_packet) - if conn.conn_from_mqtt_gateway: - # 计算时间戳和序列号 - timestamp, sequence = calculate_timestamp_and_sequence( - conn, - flow_control["start_time"], - flow_control["packet_count"], - frame_duration, - ) - # 调用通用函数发送带头部的数据包 - await _send_to_mqtt_gateway(conn, audios, timestamp, sequence) - else: - # 直接发送opus数据包,不添加头部 - await conn.websocket.send(audios) + async def send_callback(packet): + await _do_send_audio(conn, packet, flow_control, frame_duration) + + await rate_controller.check_queue(send_callback) + conn.client_is_speaking = True + continue + + await _do_send_audio(conn, opus_packet, flow_control, frame_duration) conn.client_is_speaking = True - # 更新流控状态 - flow_control["packet_count"] += 1 - flow_control["sequence"] += 1 - flow_control["last_send_time"] = time.perf_counter() + +async def _do_send_audio(conn, opus_packet, flow_control, frame_duration=60): + """ + 执行实际的音频发送 + """ + packet_index = flow_control.get("packet_count", 0) + sequence = flow_control.get("sequence", 0) + + if conn.conn_from_mqtt_gateway: + # 计算时间戳(基于播放位置) + start_time = time.time() + timestamp = int(start_time * 1000) % (2**32) + await _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence) else: - # 文件型音频走普通播放 - start_time = time.perf_counter() - play_position = 0 + # 直接发送opus数据包 + await conn.websocket.send(opus_packet) - # 执行预缓冲 - pre_buffer_frames = min(5, len(audios)) - for i in range(pre_buffer_frames): - if conn.conn_from_mqtt_gateway: - # 计算时间戳和序列号 - timestamp, sequence = calculate_timestamp_and_sequence( - conn, start_time, i, frame_duration - ) - # 调用通用函数发送带头部的数据包 - await _send_to_mqtt_gateway(conn, audios[i], timestamp, sequence) - else: - # 直接发送预缓冲包,不添加头部 - await conn.websocket.send(audios[i]) - conn.client_is_speaking = True - remaining_audios = audios[pre_buffer_frames:] - - # 播放剩余音频帧 - for i, opus_packet in enumerate(remaining_audios): - if conn.client_abort: - break - - # 重置没有声音的状态 - conn.last_activity_time = time.time() * 1000 - - if send_delay > 0: - # 固定延迟模式 - await asyncio.sleep(send_delay) - else: - # 计算预期发送时间 - expected_time = start_time + (play_position / 1000) - current_time = time.perf_counter() - delay = expected_time - current_time - if delay > 0: - await asyncio.sleep(delay) - - if conn.conn_from_mqtt_gateway: - # 计算时间戳和序列号(使用当前的数据包索引确保连续性) - packet_index = pre_buffer_frames + i - timestamp, sequence = calculate_timestamp_and_sequence( - conn, start_time, packet_index, frame_duration - ) - # 调用通用函数发送带头部的数据包 - await _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence) - else: - # 直接发送opus数据包,不添加头部 - await conn.websocket.send(opus_packet) - - conn.client_is_speaking = True - - play_position += frame_duration + # 更新流控状态 + flow_control["packet_count"] = packet_index + 1 + flow_control["sequence"] = sequence + 1 async def send_tts_message(conn, state, text=None): diff --git a/main/xiaozhi-server/core/providers/memory/mem_local_short/mem_local_short.py b/main/xiaozhi-server/core/providers/memory/mem_local_short/mem_local_short.py index e4486b82..a6b7dd36 100644 --- a/main/xiaozhi-server/core/providers/memory/mem_local_short/mem_local_short.py +++ b/main/xiaozhi-server/core/providers/memory/mem_local_short/mem_local_short.py @@ -5,6 +5,7 @@ import os import yaml from config.config_loader import get_project_dir from config.manage_api_client import save_mem_local_short +import asyncio from core.utils.util import check_model_key @@ -193,7 +194,13 @@ class MemoryProvider(MemoryProviderBase): max_tokens=2000, temperature=0.2, ) - save_mem_local_short(self.role_id, result) + # 使用异步版本,需要在事件循环中运行 + try: + loop = asyncio.get_running_loop() + loop.create_task(save_mem_local_short(self.role_id, result)) + except RuntimeError: + # 如果没有运行中的事件循环,创建一个新的 + asyncio.run(save_mem_local_short(self.role_id, result)) logger.bind(tag=TAG).info(f"Save memory successful - Role: {self.role_id}") return self.short_memory diff --git a/main/xiaozhi-server/core/utils/audioRateController.py b/main/xiaozhi-server/core/utils/audioRateController.py new file mode 100644 index 00000000..b404e6f1 --- /dev/null +++ b/main/xiaozhi-server/core/utils/audioRateController.py @@ -0,0 +1,132 @@ +import time +import asyncio +from config.logger import setup_logging + +TAG = __name__ +logger = setup_logging() + + +class AudioRateController: + """ + 音频速率控制器 - 按照60ms帧时长精确控制音频发送 + 解决高并发下的时间累积误差问题 + + 关键改进: + 1. 单一时间基准(start_timestamp 只初始化一次) + 2. 每次检查队列时重新计算 elapsed_ms,避免累积误差 + 3. 分离"检查时间"和"发送"两个操作 + 4. 支持高并发而不产生延迟 + """ + + def __init__(self, frame_duration=60): + """ + Args: + frame_duration: 单个音频帧时长(毫秒),默认60ms + """ + self.frame_duration = frame_duration + self.queue = [] + self.play_position = 0 # 虚拟播放位置(毫秒) + self.start_timestamp = None # 开始时间戳(只读,不修改) + self.pending_send_task = None + self.logger = logger + + def reset(self): + """重置控制器状态""" + if self.pending_send_task and not self.pending_send_task.done(): + self.pending_send_task.cancel() + try: + # 等待任务取消完成 + asyncio.get_event_loop().run_until_complete(self.pending_send_task) + except asyncio.CancelledError: + pass + + self.queue.clear() + self.play_position = 0 + self.start_timestamp = time.time() + + def add_audio(self, opus_packet): + """添加音频包到队列""" + self.queue.append(("audio", opus_packet)) + + def _get_elapsed_ms(self): + """获取已经过的时间(毫秒)""" + if self.start_timestamp is None: + return 0 + return (time.time() - self.start_timestamp) * 1000 + + async def check_queue(self, send_audio_callback): + """ + 检查队列并按时发送音频/消息 + + Args: + send_audio_callback: 发送音频的回调函数 async def(opus_packet) + """ + if self.start_timestamp is None: + self.reset() + + while self.queue: + item = self.queue[0] + item_type = item[0] + + if item_type == "audio": + _, opus_packet = item + + # 计算时间差 + elapsed_ms = self._get_elapsed_ms() + output_ms = self.play_position + + if elapsed_ms < output_ms: + # 还不到发送时间,计算等待时长 + wait_ms = output_ms - elapsed_ms + + # 等待后继续检查(允许被中断) + try: + await asyncio.sleep(wait_ms / 1000) + except asyncio.CancelledError: + self.logger.bind(tag=TAG).debug("音频发送任务被取消") + raise + + # 继续循环检查(时间可能已到) + continue + + # 时间已到,发送音频 + self.queue.pop(0) + self.play_position += self.frame_duration + + try: + await send_audio_callback(opus_packet) + except Exception as e: + self.logger.bind(tag=TAG).error(f"发送音频失败: {e}") + raise + + + async def start_sending(self, send_audio_callback, send_message_callback=None): + """ + 启动异步发送任务 + + Args: + send_audio_callback: 发送音频的回调函数 + send_message_callback: 发送消息的回调函数 + + Returns: + asyncio.Task: 发送任务 + """ + async def _send_loop(): + try: + while True: + await self.check_queue(send_audio_callback, send_message_callback) + # 如果队列空了,短暂等待后再检查(避免 busy loop) + await asyncio.sleep(0.01) + except asyncio.CancelledError: + self.logger.bind(tag=TAG).info("音频发送循环已停止") + except Exception as e: + self.logger.bind(tag=TAG).error(f"音频发送循环异常: {e}") + + self.pending_send_task = asyncio.create_task(_send_loop()) + return self.pending_send_task + + def stop_sending(self): + """停止发送任务""" + if self.pending_send_task and not self.pending_send_task.done(): + self.pending_send_task.cancel() + self.logger.bind(tag=TAG).debug("已取消音频发送任务") diff --git a/main/xiaozhi-server/core/utils/gc_manager.py b/main/xiaozhi-server/core/utils/gc_manager.py new file mode 100644 index 00000000..41d442b7 --- /dev/null +++ b/main/xiaozhi-server/core/utils/gc_manager.py @@ -0,0 +1,122 @@ +""" +全局GC管理模块 +定期执行垃圾回收,避免频繁触发GC导致的GIL锁问题 +""" +import gc +import asyncio +import threading +from config.logger import setup_logging + +TAG = __name__ +logger = setup_logging() + + +class GlobalGCManager: + """全局垃圾回收管理器""" + + def __init__(self, interval_seconds=300): + """ + 初始化GC管理器 + + Args: + interval_seconds: GC执行间隔(秒),默认300秒(5分钟) + """ + self.interval_seconds = interval_seconds + self._task = None + self._stop_event = asyncio.Event() + self._lock = threading.Lock() + + async def start(self): + """启动定时GC任务""" + if self._task is not None: + logger.bind(tag=TAG).warning("GC管理器已经在运行") + return + + logger.bind(tag=TAG).info(f"启动全局GC管理器,间隔{self.interval_seconds}秒") + self._stop_event.clear() + self._task = asyncio.create_task(self._gc_loop()) + + async def stop(self): + """停止定时GC任务""" + if self._task is None: + return + + logger.bind(tag=TAG).info("停止全局GC管理器") + self._stop_event.set() + + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + + self._task = None + + async def _gc_loop(self): + """GC循环任务""" + try: + while not self._stop_event.is_set(): + # 等待指定间隔 + try: + await asyncio.wait_for( + self._stop_event.wait(), + timeout=self.interval_seconds + ) + # 如果stop_event被设置,退出循环 + break + except asyncio.TimeoutError: + # 超时表示到了执行GC的时间 + pass + + # 执行GC + await self._run_gc() + + except asyncio.CancelledError: + logger.bind(tag=TAG).info("GC循环任务被取消") + raise + except Exception as e: + logger.bind(tag=TAG).error(f"GC循环任务异常: {e}") + finally: + logger.bind(tag=TAG).info("GC循环任务已退出") + + async def _run_gc(self): + """执行垃圾回收""" + try: + # 在线程池中执行GC,避免阻塞事件循环 + loop = asyncio.get_running_loop() + + def do_gc(): + with self._lock: + before = len(gc.get_objects()) + collected = gc.collect() + after = len(gc.get_objects()) + return before, collected, after + + before, collected, after = await loop.run_in_executor(None, do_gc) + logger.bind(tag=TAG).info( + f"全局GC执行完成 - 回收对象: {collected}, " + f"对象数量: {before} -> {after}" + ) + except Exception as e: + logger.bind(tag=TAG).error(f"执行GC时出错: {e}") + + +# 全局单例 +_gc_manager_instance = None + + +def get_gc_manager(interval_seconds=300): + """ + 获取全局GC管理器实例(单例模式) + + Args: + interval_seconds: GC执行间隔(秒),默认300秒(5分钟) + + Returns: + GlobalGCManager实例 + """ + global _gc_manager_instance + if _gc_manager_instance is None: + _gc_manager_instance = GlobalGCManager(interval_seconds) + return _gc_manager_instance diff --git a/main/xiaozhi-server/core/websocket_server.py b/main/xiaozhi-server/core/websocket_server.py index 46b60a71..9cbbbe19 100644 --- a/main/xiaozhi-server/core/websocket_server.py +++ b/main/xiaozhi-server/core/websocket_server.py @@ -4,7 +4,7 @@ import json import websockets from config.logger import setup_logging from core.connection import ConnectionHandler -from config.config_loader import get_config_from_api +from config.config_loader import get_config_from_api_async from core.auth import AuthManager, AuthenticationError from core.utils.modules_initialize import initialize_modules from core.utils.util import check_vad_update, check_asr_update @@ -133,8 +133,8 @@ class WebSocketServer: """ try: async with self.config_lock: - # 重新获取配置 - new_config = get_config_from_api(self.config) + # 重新获取配置(使用异步版本) + new_config = await get_config_from_api_async(self.config) if new_config is None: self.logger.bind(tag=TAG).error("获取新配置失败") return False From dd1a430c2108025dae683ab13c012931db861303 Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Mon, 1 Dec 2025 10:56:19 +0800 Subject: [PATCH 16/93] =?UTF-8?q?fix:=E6=B7=BB=E5=8A=A0=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=9A=84=E8=BE=93=E5=85=A5=E6=A1=86=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/manager-web/src/components/AddModelDialog.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/manager-web/src/components/AddModelDialog.vue b/main/manager-web/src/components/AddModelDialog.vue index c95e95a9..862d8b29 100644 --- a/main/manager-web/src/components/AddModelDialog.vue +++ b/main/manager-web/src/components/AddModelDialog.vue @@ -404,7 +404,7 @@ export default { .custom-input-bg .el-input__inner, .custom-input-bg .el-textarea__inner { - background-color: #f6f8fc; + background-color: #ffffff; } From f3f0c24e167d1083af5dd357ffcc1003868eb624 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Mon, 1 Dec 2025 16:38:10 +0800 Subject: [PATCH 17/93] =?UTF-8?q?update:=E5=87=8F=E5=B0=91=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=BA=A7=E7=94=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/utils/gc_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/main/xiaozhi-server/core/utils/gc_manager.py b/main/xiaozhi-server/core/utils/gc_manager.py index 41d442b7..e3b958fe 100644 --- a/main/xiaozhi-server/core/utils/gc_manager.py +++ b/main/xiaozhi-server/core/utils/gc_manager.py @@ -2,6 +2,7 @@ 全局GC管理模块 定期执行垃圾回收,避免频繁触发GC导致的GIL锁问题 """ + import gc import asyncio import threading @@ -60,8 +61,7 @@ class GlobalGCManager: # 等待指定间隔 try: await asyncio.wait_for( - self._stop_event.wait(), - timeout=self.interval_seconds + self._stop_event.wait(), timeout=self.interval_seconds ) # 如果stop_event被设置,退出循环 break @@ -94,7 +94,7 @@ class GlobalGCManager: return before, collected, after before, collected, after = await loop.run_in_executor(None, do_gc) - logger.bind(tag=TAG).info( + logger.bind(tag=TAG).debug( f"全局GC执行完成 - 回收对象: {collected}, " f"对象数量: {before} -> {after}" ) From f7bd858fea4fd4b142bb3cef6fec751c5ad3b1f5 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Mon, 1 Dec 2025 18:46:42 +0800 Subject: [PATCH 18/93] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E9=97=B4=E9=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 531e1e0e..31bfa0c6 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -71,7 +71,7 @@ class ConnectionHandler: self.need_bind = False # 是否需要绑定设备 self.bind_code = None # 绑定设备的验证码 self.last_bind_prompt_time = 0 # 上次播放绑定提示的时间戳(秒) - self.bind_prompt_interval = 30 # 绑定提示播放间隔(秒) + self.bind_prompt_interval = 60 # 绑定提示播放间隔(秒) self.read_config_from_api = self.config.get("read_config_from_api", False) From a3e2073dc62caaf90951a4fa2d73b175a7ba5204 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Mon, 1 Dec 2025 18:51:24 +0800 Subject: [PATCH 19/93] Bump to 0.8.9 --- .../src/main/java/xiaozhi/common/constant/Constant.java | 2 +- main/manager-mobile/src/pages/settings/index.vue | 2 +- main/xiaozhi-server/config/logger.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 a7deaba1..1ea19355 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 @@ -299,7 +299,7 @@ public interface Constant { /** * 版本号 */ - public static final String VERSION = "0.8.8"; + public static final String VERSION = "0.8.9"; /** * 无效固件URL diff --git a/main/manager-mobile/src/pages/settings/index.vue b/main/manager-mobile/src/pages/settings/index.vue index 90c4e6cd..2f7be6b4 100644 --- a/main/manager-mobile/src/pages/settings/index.vue +++ b/main/manager-mobile/src/pages/settings/index.vue @@ -235,7 +235,7 @@ function showAbout() { title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }), content: t('settings.aboutContent', { appName: import.meta.env.VITE_APP_TITLE, - version: '0.8.8' + version: '0.8.9' }), showCancel: false, confirmText: t('common.confirm'), diff --git a/main/xiaozhi-server/config/logger.py b/main/xiaozhi-server/config/logger.py index 37e5eabf..a0b64dd7 100644 --- a/main/xiaozhi-server/config/logger.py +++ b/main/xiaozhi-server/config/logger.py @@ -5,7 +5,7 @@ from config.config_loader import load_config from config.settings import check_config_file from datetime import datetime -SERVER_VERSION = "0.8.8" +SERVER_VERSION = "0.8.9" _logger_initialized = False From 6120d49a35ae43b088948ab7f0612ff6e711e190 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Wed, 3 Dec 2025 22:49:32 +0800 Subject: [PATCH 20/93] =?UTF-8?q?fix:=E8=BF=87=E6=BB=A48000=E7=AB=AF?= =?UTF-8?q?=E5=8F=A3=E4=BD=BF=E7=94=A8https=E8=AE=BF=E9=97=AE=E6=97=B6?= =?UTF-8?q?=E6=8A=A5=E9=94=99=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/websocket_server.py | 29 +++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/main/xiaozhi-server/core/websocket_server.py b/main/xiaozhi-server/core/websocket_server.py index 9cbbbe19..168b436e 100644 --- a/main/xiaozhi-server/core/websocket_server.py +++ b/main/xiaozhi-server/core/websocket_server.py @@ -1,8 +1,35 @@ import asyncio -import json +import logging import websockets from config.logger import setup_logging + + +class SuppressInvalidHandshakeFilter(logging.Filter): + """过滤掉无效握手错误日志(如HTTPS访问WS端口)""" + + def filter(self, record): + msg = record.getMessage() + suppress_keywords = [ + "opening handshake failed", + "did not receive a valid HTTP request", + "connection closed while reading HTTP request", + "line without CRLF", + ] + return not any(keyword in msg for keyword in suppress_keywords) + + +def _setup_websockets_logger(): + """配置 websockets 相关的所有 logger,过滤无效握手错误""" + filter_instance = SuppressInvalidHandshakeFilter() + for logger_name in ["websockets", "websockets.server", "websockets.client"]: + logger = logging.getLogger(logger_name) + logger.addFilter(filter_instance) + + +_setup_websockets_logger() + + from core.connection import ConnectionHandler from config.config_loader import get_config_from_api_async from core.auth import AuthManager, AuthenticationError From cd6c3e4f79c9abf5c7aaecc72310189b14350081 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Thu, 4 Dec 2025 11:04:16 +0800 Subject: [PATCH 21/93] =?UTF-8?q?update:=E6=B7=BB=E5=8A=A0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8A=E4=B8=8B=E6=96=87=E5=A1=AB=E5=85=85=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E5=8D=95=E6=A8=A1=E5=9D=97=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=8A=E4=B8=8B=E6=96=87=E5=A1=AB=E5=85=85?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/agent-base-prompt.txt | 1 + main/xiaozhi-server/config.yaml | 8 +++ .../core/utils/context_provider.py | 62 +++++++++++++++++++ .../core/utils/prompt_manager.py | 13 +++- 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 main/xiaozhi-server/core/utils/context_provider.py diff --git a/main/xiaozhi-server/agent-base-prompt.txt b/main/xiaozhi-server/agent-base-prompt.txt index 490b23d3..7fd90d21 100644 --- a/main/xiaozhi-server/agent-base-prompt.txt +++ b/main/xiaozhi-server/agent-base-prompt.txt @@ -74,6 +74,7 @@ - **今天农历:** {{lunar_date}} - **用户所在城市:** {{local_address}} - **当地未来7天天气:** {{weather_info}} +{{ dynamic_context }} diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index a4087395..13d9fcb3 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -113,6 +113,14 @@ wakeup_words: # MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token # 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md mcp_endpoint: 你的接入点 websocket地址 + +# 数据上下文填充配置 +# 用于在系统提示词中注入动态数据,如健康数据、股票信息等 +context_providers: + - url: "" + headers: + Authorization: "" + # 插件的基础配置 plugins: # 获取天气插件的配置,这里填写你的api_key diff --git a/main/xiaozhi-server/core/utils/context_provider.py b/main/xiaozhi-server/core/utils/context_provider.py new file mode 100644 index 00000000..617250d7 --- /dev/null +++ b/main/xiaozhi-server/core/utils/context_provider.py @@ -0,0 +1,62 @@ +import httpx +from typing import Dict, Any, List +from config.logger import setup_logging + +TAG = __name__ + +class ContextDataProvider: + """数据上下文填充,负责从配置的API获取数据""" + + def __init__(self, config: Dict[str, Any], logger=None): + self.config = config + self.logger = logger or setup_logging() + self.context_data = "" + + def fetch_all(self, device_id: str) -> str: + """获取所有配置的上下文数据""" + context_providers = self.config.get("context_providers", []) + if not context_providers: + return "" + + formatted_lines = [] + for provider in context_providers: + url = provider.get("url") + headers = provider.get("headers", {}) + + if not url: + continue + + try: + headers = headers.copy() if isinstance(headers, dict) else {} + # 将 device_id 添加到请求头 + headers["device_id"] = device_id + + # 发送请求 + response = httpx.get(url, headers=headers, timeout=3) + + if response.status_code == 200: + result = response.json() + if isinstance(result, dict): + if result.get("code") == 0: + data = result.get("data") + # 格式化数据 + if isinstance(data, dict): + for k, v in data.items(): + formatted_lines.append(f"- **{k}:** {v}") + elif isinstance(data, list): + for item in data: + formatted_lines.append(f"- {item}") + else: + formatted_lines.append(f"- {data}") + else: + self.logger.bind(tag=TAG).warning(f"API {url} 返回错误码: {result.get('msg')}") + else: + self.logger.bind(tag=TAG).warning(f"API {url} 返回的不是JSON字典") + else: + self.logger.bind(tag=TAG).warning(f"API {url} 请求失败: {response.status_code}") + except Exception as e: + self.logger.bind(tag=TAG).error(f"获取上下文数据 {url} 失败: {e}") + + # 将所有格式化后的行拼接成一个字符串 + self.context_data = "\n".join(formatted_lines) + return self.context_data diff --git a/main/xiaozhi-server/core/utils/prompt_manager.py b/main/xiaozhi-server/core/utils/prompt_manager.py index 444b16ee..4ec952ae 100644 --- a/main/xiaozhi-server/core/utils/prompt_manager.py +++ b/main/xiaozhi-server/core/utils/prompt_manager.py @@ -4,7 +4,6 @@ """ import os -import cnlunar from typing import Dict, Any from config.logger import setup_logging from jinja2 import Template @@ -60,6 +59,11 @@ class PromptManager: self.cache_manager = cache_manager self.CacheType = CacheType + + # 初始化数据上下文填充 + from core.utils.context_provider import ContextDataProvider + self.context_provider = ContextDataProvider(config, self.logger) + self.context_data = {} self._load_base_template() @@ -184,6 +188,11 @@ class PromptManager: local_address = self._get_location_info(client_ip) # 获取天气信息(使用全局缓存) self._get_weather_info(conn, local_address) + + # 获取配置的上下文数据 + if hasattr(conn, "device_id") and conn.device_id: + self.context_data = self.context_provider.fetch_all(conn.device_id) + self.logger.bind(tag=TAG).debug(f"上下文信息更新完成") except Exception as e: @@ -230,6 +239,7 @@ class PromptManager: emojiList=EMOJI_List, device_id=device_id, client_ip=client_ip, + dynamic_context=self.context_data, *args, **kwargs, ) @@ -240,6 +250,7 @@ class PromptManager: self.logger.bind(tag=TAG).info( f"构建增强提示词成功,长度: {len(enhanced_prompt)}" ) + print("enhanced_prompt:", enhanced_prompt) return enhanced_prompt except Exception as e: From f3580119000bf71a9e8c49409ebe0ef6e54044b3 Mon Sep 17 00:00:00 2001 From: shiyin Date: Thu, 4 Dec 2025 18:32:57 +0800 Subject: [PATCH 22/93] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E6=8E=A5=E5=8F=A3,=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E6=AD=BB=E5=BE=AA=E7=8E=AFBUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xiaozhi/modules/device/controller/DeviceController.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java index ff6069bf..78a17b02 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/DeviceController.java @@ -72,10 +72,12 @@ public class DeviceController { return new Result().error(ErrorCode.MCA_NOT_NULL); } // 生成六位验证码 - String code = String.valueOf(Math.random()).substring(2, 8); - String key = RedisKeys.getDeviceCaptchaKey(code); + String code; + String key; String existsMac = null; do { + code = String.valueOf(Math.random()).substring(2, 8); + key = RedisKeys.getDeviceCaptchaKey(code); existsMac = (String) redisUtils.get(key); } while (StringUtils.isNotBlank(existsMac)); From d3d329bd4313b2e26640706fb2adf8c0db2ae62c Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Thu, 4 Dec 2025 18:45:49 +0800 Subject: [PATCH 23/93] =?UTF-8?q?fix:=20=E7=8A=B6=E6=80=81=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/handle/sendAudioHandle.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py index 14f43232..ea952fbe 100644 --- a/main/xiaozhi-server/core/handle/sendAudioHandle.py +++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py @@ -116,6 +116,7 @@ async def _sendAudio_single(conn, opus_packet, send_delay, frame_duration=60): if packet_count < pre_buffer_count or send_delay > 0: # 预缓冲阶段或固定延迟模式,直接发送 await _do_send_audio(conn, opus_packet, flow_control, frame_duration) + conn.client_is_speaking = True if send_delay > 0 and packet_count >= pre_buffer_count: await asyncio.sleep(send_delay) @@ -127,6 +128,7 @@ async def _sendAudio_single(conn, opus_packet, send_delay, frame_duration=60): await _do_send_audio(conn, packet, flow_control, frame_duration) await rate_controller.check_queue(send_callback) + conn.client_is_speaking = True # 更新流控状态 flow_control["packet_count"] += 1 From 6f7e8978cacce7b26d0efaf6fe6bbcfad664d704 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Fri, 5 Dec 2025 10:40:47 +0800 Subject: [PATCH 24/93] =?UTF-8?q?update:=E6=B7=BB=E5=8A=A0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8A=E4=B8=8B=E6=96=87=E5=A1=AB=E5=85=85=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E7=9A=84=E8=AF=B4=E6=98=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FAQ.md | 1 + docs/context-provider-integration.md | 109 +++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 docs/context-provider-integration.md diff --git a/docs/FAQ.md b/docs/FAQ.md index 83df8c2c..c40e7784 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -80,6 +80,7 @@ VAD: 7、[如何开启声纹识别](./voiceprint-integration.md)
8、[新闻插件源配置指南](./newsnow_plugin_config.md)
9、[知识库ragflow集成指南](./ragflow-integration.md)
+10、[如何部署上下文源](./context-provider-integration.md)
### 11、语音克隆、本地语音部署相关教程 1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)
diff --git a/docs/context-provider-integration.md b/docs/context-provider-integration.md new file mode 100644 index 00000000..c2c06615 --- /dev/null +++ b/docs/context-provider-integration.md @@ -0,0 +1,109 @@ +# 数据上下文填充功能使用指南 + +## 概述 + +数据上下文填充功能(Context Data Provider)允许小智在唤醒那一刻,获取外部系统的数据,并将其动态注入到大模型的系统提示词(System Prompt)中。 +让其做到唤醒时感知世界某个事物的状态。 + +通过这个功能,在小智唤醒的一刹那,“感知”到: +- 智能家居的传感器状态(温度、湿度、灯光状态等) +- 业务系统的实时数据(服务器负载、健康数据、股票信息等) +- 任何可以通过 HTTP API 获取的文本信息 + +**注意**:该功能只是方便小智在唤醒的时候感知事物的状态,而如果想要小智唤醒后实时获取事物的状态,建议在此功能上再结合MCP工具的调用。 + +## 工作原理 + +1. **配置源**:用户配置一个或多个 HTTP API 地址。 +2. **触发请求**:当系统构建 Prompt 时,如果发现模板中包含 `{{ dynamic_context }}` 占位符,会请求所有配置的 API。 +3. **自动注入**:系统会自动将 API 返回的数据格式化为 Markdown 列表,替换 `{{ dynamic_context }}` 占位符。 + +## 接口规范 + +为了让小智正确解析数据,您的 API 需要满足以下规范: + +- **请求方式**:`GET` +- **请求头**:系统会自动添加 `device_id` 字段到 Request Header。 +- **响应格式**:必须返回 JSON 格式,且包含 `code` 和 `data` 字段。 + +### 响应示例 + +**情况 1:返回键值对** +```json +{ + "code": 0, + "msg": "success", + "data": { + "客厅温度": "26℃", + "客厅湿度": "45%", + "大门状态": "已关闭" + } +} +``` +*注入效果:* +```markdown + +- **客厅温度:** 26℃ +- **客厅湿度:** 45% +- **大门状态:** 已关闭 + +``` + +**情况 2:返回列表** +```json +{ + "code": 0, + "data": [ + "您有10个待办事项", + "当前汽车的行驶速度是100km每小时" + ] +} +``` +*注入效果:* +```markdown + +- 您有10个待办事项 +- 当前汽车的行驶速度是100km每小时 + +``` + +## 配置指南 + +### 方式 1:智控台配置(全模块部署) + +1. 登录智控台,进入**角色配置**页面。 +2. 找到**上下文源**配置项(点击“编辑源”按钮)。 +3. 点击**添加**,输入您的 API 地址。 +4. 如果 API 需要鉴权,可以在**请求头**部分添加 `Authorization` 或其他 Header。 +5. 保存配置。 + +### 方式 2:配置文件配置(单模块部署) + +编辑 `xiaozhi-server/data/.config.yaml` 文件,添加 `context_providers` 配置段: + +```yaml +# 上下文源配置 +context_providers: + - url: "http://api.example.com/data" + headers: + Authorization: "Bearer your-token" + - url: "http://another-api.com/data" +``` + +## 启用功能 + +默认情况下,系统的提示词模板文件(`data/.agent-base-prompt.txt`)中已经预置了 `{{ dynamic_context }}` 占位符,您无需手动添加。 + +**示例:** + +```markdown + +【重要!以下信息已实时提供,无需调用工具查询,请直接使用:】 +- **设备ID:** {{device_id}} +- **当前时间:** {{current_time}} +... +{{ dynamic_context }} + +``` + +**注意**:如果您不需要使用此功能,可以选择**不配置任何上下文源**,也可以从提示词模板文件中**删除** `{{ dynamic_context }}` 占位符。 From db8d100edb8e7de924d15afb86721ed3169c14c1 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Fri, 5 Dec 2025 10:50:44 +0800 Subject: [PATCH 25/93] =?UTF-8?q?update:=E6=B7=BB=E5=8A=A0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E8=A1=A8=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8A=E4=B8=8B=E6=96=87=E5=A1=AB=E5=85=85=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=90=8E=E7=AB=AF=E9=83=A8=E5=88=86=EF=BC=8C=E5=85=A8?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=AE=9E=E7=8E=B0=E6=95=B0=E6=8D=AE=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E5=A1=AB=E5=85=85=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/controller/AgentController.java | 4 ++ .../agent/dao/AgentContextProviderDao.java | 9 ++++ .../modules/agent/dto/AgentUpdateDTO.java | 3 ++ .../modules/agent/dto/ContextProviderDTO.java | 19 ++++++++ .../entity/AgentContextProviderEntity.java | 43 +++++++++++++++++++ .../service/AgentContextProviderService.java | 25 +++++++++++ .../impl/AgentContextProviderServiceImpl.java | 35 +++++++++++++++ .../agent/service/impl/AgentServiceImpl.java | 18 ++++++++ .../xiaozhi/modules/agent/vo/AgentInfoVO.java | 4 ++ .../service/impl/ConfigServiceImpl.java | 10 +++++ .../resources/db/changelog/202512041515.sql | 14 ++++++ .../db/changelog/db.changelog-master.yaml | 7 +++ main/xiaozhi-server/core/connection.py | 4 +- .../core/utils/prompt_manager.py | 5 ++- 14 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentContextProviderDao.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/dto/ContextProviderDTO.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentContextProviderEntity.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentContextProviderService.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentContextProviderServiceImpl.java create mode 100644 main/manager-api/src/main/resources/db/changelog/202512041515.sql diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java index a4ad0863..c09aa141 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java @@ -44,6 +44,7 @@ import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.service.AgentChatAudioService; import xiaozhi.modules.agent.service.AgentChatHistoryService; +import xiaozhi.modules.agent.service.AgentContextProviderService; import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentTemplateService; @@ -64,6 +65,7 @@ public class AgentController { private final AgentChatHistoryService agentChatHistoryService; private final AgentChatAudioService agentChatAudioService; private final AgentPluginMappingService agentPluginMappingService; + private final AgentContextProviderService agentContextProviderService; private final RedisUtils redisUtils; @GetMapping("/list") @@ -135,6 +137,8 @@ public class AgentController { agentChatHistoryService.deleteByAgentId(id, true, true); // 删除关联的插件 agentPluginMappingService.deleteByAgentId(id); + // 删除关联的上下文源配置 + agentContextProviderService.deleteByAgentId(id); // 再删除智能体 agentService.deleteById(id); return new Result<>(); diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentContextProviderDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentContextProviderDao.java new file mode 100644 index 00000000..d46ad3ab --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentContextProviderDao.java @@ -0,0 +1,9 @@ +package xiaozhi.modules.agent.dao; + +import org.apache.ibatis.annotations.Mapper; +import xiaozhi.common.dao.BaseDao; +import xiaozhi.modules.agent.entity.AgentContextProviderEntity; + +@Mapper +public interface AgentContextProviderDao extends BaseDao { +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java index 0e3d9bc3..ebb29fbd 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java @@ -69,6 +69,9 @@ public class AgentUpdateDTO implements Serializable { @Schema(description = "排序", example = "1", nullable = true) private Integer sort; + @Schema(description = "上下文源配置", nullable = true) + private List contextProviders; + @Data @Schema(description = "插件函数信息") public static class FunctionInfo implements Serializable { diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/ContextProviderDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/ContextProviderDTO.java new file mode 100644 index 00000000..0b8edfd8 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/ContextProviderDTO.java @@ -0,0 +1,19 @@ +package xiaozhi.modules.agent.dto; + +import java.io.Serializable; +import java.util.Map; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "上下文源配置DTO") +public class ContextProviderDTO implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "URL地址") + private String url; + + @Schema(description = "请求头") + private Map headers; +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentContextProviderEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentContextProviderEntity.java new file mode 100644 index 00000000..937556ef --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentContextProviderEntity.java @@ -0,0 +1,43 @@ +package xiaozhi.modules.agent.entity; + +import java.util.Date; +import java.util.List; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import xiaozhi.modules.agent.dto.ContextProviderDTO; + +@Data +@TableName(value = "ai_agent_context_provider", autoResultMap = true) +@Schema(description = "智能体上下文源配置") +public class AgentContextProviderEntity { + + @TableId(type = IdType.ASSIGN_UUID) + @Schema(description = "主键") + private String id; + + @Schema(description = "智能体ID") + private String agentId; + + @Schema(description = "上下文源配置") + @TableField(typeHandler = JacksonTypeHandler.class) + private List contextProviders; + + @Schema(description = "创建者") + private Long creator; + + @Schema(description = "创建时间") + private Date createdAt; + + @Schema(description = "更新者") + private Long updater; + + @Schema(description = "更新时间") + private Date updatedAt; +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentContextProviderService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentContextProviderService.java new file mode 100644 index 00000000..da759971 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentContextProviderService.java @@ -0,0 +1,25 @@ +package xiaozhi.modules.agent.service; + +import xiaozhi.common.service.BaseService; +import xiaozhi.modules.agent.entity.AgentContextProviderEntity; + +public interface AgentContextProviderService extends BaseService { + /** + * 根据智能体ID获取上下文源配置 + * @param agentId 智能体ID + * @return 上下文源配置实体 + */ + AgentContextProviderEntity getByAgentId(String agentId); + + /** + * 保存或更新上下文源配置 + * @param entity 实体 + */ + void saveOrUpdateByAgentId(AgentContextProviderEntity entity); + + /** + * 根据智能体ID删除上下文源配置 + * @param agentId 智能体ID + */ + void deleteByAgentId(String agentId); +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentContextProviderServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentContextProviderServiceImpl.java new file mode 100644 index 00000000..b68ab8c9 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentContextProviderServiceImpl.java @@ -0,0 +1,35 @@ +package xiaozhi.modules.agent.service.impl; + +import org.springframework.stereotype.Service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; + +import xiaozhi.common.service.impl.BaseServiceImpl; +import xiaozhi.modules.agent.dao.AgentContextProviderDao; +import xiaozhi.modules.agent.entity.AgentContextProviderEntity; +import xiaozhi.modules.agent.service.AgentContextProviderService; + +@Service +public class AgentContextProviderServiceImpl extends BaseServiceImpl implements AgentContextProviderService { + + @Override + public AgentContextProviderEntity getByAgentId(String agentId) { + return baseDao.selectOne(new QueryWrapper().eq("agent_id", agentId)); + } + + @Override + public void saveOrUpdateByAgentId(AgentContextProviderEntity entity) { + AgentContextProviderEntity exist = getByAgentId(entity.getAgentId()); + if (exist != null) { + entity.setId(exist.getId()); + updateById(entity); + } else { + insert(entity); + } + } + + @Override + public void deleteByAgentId(String agentId) { + baseDao.delete(new QueryWrapper().eq("agent_id", agentId)); + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java index cb550b3e..0adf0873 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java @@ -32,10 +32,12 @@ import xiaozhi.modules.agent.dao.AgentDao; import xiaozhi.modules.agent.dto.AgentCreateDTO; import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentUpdateDTO; +import xiaozhi.modules.agent.entity.AgentContextProviderEntity; import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.service.AgentChatHistoryService; +import xiaozhi.modules.agent.service.AgentContextProviderService; import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentTemplateService; @@ -62,6 +64,7 @@ public class AgentServiceImpl extends BaseServiceImpl imp private final AgentChatHistoryService agentChatHistoryService; private final AgentTemplateService agentTemplateService; private final ModelProviderService modelProviderService; + private final AgentContextProviderService agentContextProviderService; @Override public PageData adminAgentList(Map params) { @@ -85,6 +88,13 @@ public class AgentServiceImpl extends BaseServiceImpl imp agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode()); } } + + // 查询上下文源配置 + AgentContextProviderEntity contextProviderEntity = agentContextProviderService.getByAgentId(id); + if (contextProviderEntity != null) { + agent.setContextProviders(contextProviderEntity.getContextProviders()); + } + // 无需额外查询插件列表,已通过SQL查询出来 return agent; } @@ -331,6 +341,14 @@ public class AgentServiceImpl extends BaseServiceImpl imp agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, false); } + // 更新上下文源配置 + if (dto.getContextProviders() != null) { + AgentContextProviderEntity contextEntity = new AgentContextProviderEntity(); + contextEntity.setAgentId(agentId); + contextEntity.setContextProviders(dto.getContextProviders()); + agentContextProviderService.saveOrUpdateByAgentId(contextEntity); + } + boolean b = validateLLMIntentParams(dto.getLlmModelId(), dto.getIntentModelId()); if (!b) { throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH); diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java index d56c8bb4..3da8b71c 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; +import xiaozhi.modules.agent.dto.ContextProviderDTO; import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentPluginMapping; @@ -21,4 +22,7 @@ public class AgentInfoVO extends AgentEntity @Schema(description = "插件列表Id") @TableField(typeHandler = JacksonTypeHandler.class) private List functions; + + @Schema(description = "上下文源配置") + private List contextProviders; } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java index 3ab3d977..7ddb4b82 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java @@ -20,10 +20,12 @@ import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.JsonUtils; import xiaozhi.modules.agent.dao.AgentVoicePrintDao; +import xiaozhi.modules.agent.entity.AgentContextProviderEntity; import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentVoicePrintEntity; +import xiaozhi.modules.agent.service.AgentContextProviderService; import xiaozhi.modules.agent.service.AgentMcpAccessPointService; import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentService; @@ -53,6 +55,7 @@ public class ConfigServiceImpl implements ConfigService { private final TimbreService timbreService; private final AgentPluginMappingService agentPluginMappingService; private final AgentMcpAccessPointService agentMcpAccessPointService; + private final AgentContextProviderService agentContextProviderService; private final VoiceCloneService cloneVoiceService; private final AgentVoicePrintDao agentVoicePrintDao; @@ -178,6 +181,13 @@ public class ConfigServiceImpl implements ConfigService { mcpEndpoint = mcpEndpoint.replace("/mcp/", "/call/"); result.put("mcp_endpoint", mcpEndpoint); } + + // 获取上下文源配置 + AgentContextProviderEntity contextProviderEntity = agentContextProviderService.getByAgentId(agent.getId()); + if (contextProviderEntity != null && contextProviderEntity.getContextProviders() != null && !contextProviderEntity.getContextProviders().isEmpty()) { + result.put("context_providers", contextProviderEntity.getContextProviders()); + } + // 获取声纹信息 buildVoiceprintConfig(agent.getId(), result); diff --git a/main/manager-api/src/main/resources/db/changelog/202512041515.sql b/main/manager-api/src/main/resources/db/changelog/202512041515.sql new file mode 100644 index 00000000..5e26b99f --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202512041515.sql @@ -0,0 +1,14 @@ +-- liquibase formatted sql + +-- changeset xiaozhi:202512041515 +CREATE TABLE ai_agent_context_provider ( + id VARCHAR(32) NOT NULL COMMENT '主键', + agent_id VARCHAR(32) NOT NULL COMMENT '智能体ID', + context_providers JSON COMMENT '上下文源配置', + creator BIGINT COMMENT '创建者', + created_at DATETIME COMMENT '创建时间', + updater BIGINT COMMENT '更新者', + updated_at DATETIME COMMENT '更新时间', + PRIMARY KEY (id), + INDEX idx_agent_id (agent_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体上下文源配置表'; diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index 3c14eb7f..e0eb949b 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -423,3 +423,10 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202511131023.sql + - changeSet: + id: 202512041515 + author: cgd + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202512041515.sql diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 31bfa0c6..6b34e32f 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -162,7 +162,7 @@ class ConnectionHandler: self.conn_from_mqtt_gateway = False # 初始化提示词管理器 - self.prompt_manager = PromptManager(config, self.logger) + self.prompt_manager = PromptManager(self.config, self.logger) async def handle_connection(self, ws): try: @@ -630,6 +630,8 @@ class ConnectionHandler: self.chat_history_conf = int(private_config["chat_history_conf"]) if private_config.get("mcp_endpoint", None) is not None: self.config["mcp_endpoint"] = private_config["mcp_endpoint"] + if private_config.get("context_providers", None) is not None: + self.config["context_providers"] = private_config["context_providers"] # 使用 run_in_executor 在线程池中执行 initialize_modules,避免阻塞主循环 try: diff --git a/main/xiaozhi-server/core/utils/prompt_manager.py b/main/xiaozhi-server/core/utils/prompt_manager.py index 4ec952ae..96fbf624 100644 --- a/main/xiaozhi-server/core/utils/prompt_manager.py +++ b/main/xiaozhi-server/core/utils/prompt_manager.py @@ -191,7 +191,10 @@ class PromptManager: # 获取配置的上下文数据 if hasattr(conn, "device_id") and conn.device_id: - self.context_data = self.context_provider.fetch_all(conn.device_id) + if self.base_prompt_template and "dynamic_context" in self.base_prompt_template: + self.context_data = self.context_provider.fetch_all(conn.device_id) + else: + self.context_data = "" self.logger.bind(tag=TAG).debug(f"上下文信息更新完成") From 3c4d702bc1a8418ac6da50a19aa69eb7f2027af6 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Fri, 5 Dec 2025 11:24:57 +0800 Subject: [PATCH 26/93] =?UTF-8?q?update:=E6=B7=BB=E5=8A=A0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8A=E4=B8=8B=E6=96=87=E5=A1=AB=E5=85=85=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ContextProviderDialog.vue | 301 ++++++++++++++++++ main/manager-web/src/views/roleConfig.vue | 52 ++- 2 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 main/manager-web/src/components/ContextProviderDialog.vue diff --git a/main/manager-web/src/components/ContextProviderDialog.vue b/main/manager-web/src/components/ContextProviderDialog.vue new file mode 100644 index 00000000..770c6ef4 --- /dev/null +++ b/main/manager-web/src/components/ContextProviderDialog.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/main/manager-web/src/views/roleConfig.vue b/main/manager-web/src/views/roleConfig.vue index a0baf371..9f1ab92f 100644 --- a/main/manager-web/src/views/roleConfig.vue +++ b/main/manager-web/src/views/roleConfig.vue @@ -55,10 +55,24 @@
+ +
+ + 已成功添加 {{ currentContextProviders.length }} 个源。如何部署上下文源 + + + 编辑源 + +
+
+ @@ -275,14 +294,16 @@ import Api from "@/apis/api"; import { getServiceUrl } from "@/apis/api"; import RequestService from "@/apis/httpRequest"; import FunctionDialog from "@/components/FunctionDialog.vue"; +import ContextProviderDialog from "@/components/ContextProviderDialog.vue"; import HeaderBar from "@/components/HeaderBar.vue"; import i18n from "@/i18n"; export default { name: "RoleConfigPage", - components: { HeaderBar, FunctionDialog }, + components: { HeaderBar, FunctionDialog, ContextProviderDialog }, data() { return { + showContextProviderDialog: false, form: { agentCode: "", agentName: "", @@ -320,6 +341,7 @@ export default { voiceDetails: {}, // 保存完整的音色信息 showFunctionDialog: false, currentFunctions: [], + currentContextProviders: [], allFunctions: [], originalFunctions: [], playingVoice: false, @@ -356,6 +378,7 @@ export default { paramInfo: item.params, }; }), + contextProviders: this.currentContextProviders, }; Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => { if (data.code === 0) { @@ -472,6 +495,9 @@ export default { }; // 后端只给了最小映射:[{ id, agentId, pluginId }, ...] const savedMappings = data.data.functions || []; + + // 加载上下文配置 + this.currentContextProviders = data.data.contextProviders || []; // 先保证 allFunctions 已经加载(如果没有,则先 fetchAllFunctions) const ensureFuncs = this.allFunctions.length @@ -646,6 +672,12 @@ export default { this.showFunctionDialog = true; } }, + openContextProviderDialog() { + this.showContextProviderDialog = true; + }, + handleUpdateContext(providers) { + this.currentContextProviders = providers; + }, handleUpdateFunctions(selected) { this.currentFunctions = selected; }, @@ -1345,4 +1377,18 @@ export default { height: 32px; margin-left: 8px; } + +.context-provider-item ::v-deep .el-form-item__label { + line-height: 42px !important; +} + +.doc-link { + color: #1677ff; + text-decoration: none; + margin-left: 4px; + + &:hover { + text-decoration: underline; + } +} From 33f75d26c12c2266f3a0517c0778a132638a5025 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Fri, 5 Dec 2025 14:02:50 +0800 Subject: [PATCH 27/93] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=A1=B5=E9=9D=A2=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ContextProviderDialog.vue | 72 +++++++++++++------ 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/main/manager-web/src/components/ContextProviderDialog.vue b/main/manager-web/src/components/ContextProviderDialog.vue index 770c6ef4..21d31278 100644 --- a/main/manager-web/src/components/ContextProviderDialog.vue +++ b/main/manager-web/src/components/ContextProviderDialog.vue @@ -205,27 +205,40 @@ export default { .dialog-content { max-height: 60vh; overflow-y: auto; - padding: 10px 20px; + padding: 20px 25px; +} + +.dialog-content::-webkit-scrollbar { + width: 6px; +} +.dialog-content::-webkit-scrollbar-thumb { + background: #dcdfe6; + border-radius: 3px; +} +.dialog-content::-webkit-scrollbar-track { + background: #f5f7fa; } .provider-item { display: flex; - gap: 10px; - margin-bottom: 15px; + gap: 15px; + margin-bottom: 20px; align-items: center; } .provider-card { flex: 1; - border-radius: 15px; - border: 1px solid #ebeef5; - transition: all 0.3s; + border-radius: 12px; + border: 1px solid #e4e7ed; + border-left: 4px solid #409EFF; /* 左侧强调色 */ + background-color: #fff; + transition: all 0.3s ease; + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); } .provider-card:hover { - border-color: #c0c4cc; - transform: translateY(-1px); - box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); } .block-controls { @@ -237,8 +250,8 @@ export default { .input-row { display: flex; align-items: center; - gap: 10px; - margin-bottom: 15px; + gap: 12px; + margin-bottom: 18px; } .label-text { @@ -248,6 +261,7 @@ export default { text-align: right; font-size: 13px; white-space: nowrap; + line-height: 32px; /* 垂直居中对齐 */ } .flex-1 { @@ -256,7 +270,7 @@ export default { .headers-section { display: flex; - gap: 10px; + gap: 12px; align-items: flex-start; } @@ -264,38 +278,52 @@ export default { flex: 1; display: flex; flex-direction: column; - gap: 8px; - background: #f9fafc; - padding: 12px; - border-radius: 6px; + gap: 10px; + background: #fcfcfc; + padding: 15px; + border-radius: 8px; + border: 1px dashed #dcdfe6; + transition: all 0.3s; +} + +.headers-list:hover { + border-color: #c0c4cc; + background: #fff; } .header-row { display: flex; align-items: center; - gap: 8px; + gap: 10px; } .separator { color: #909399; font-weight: bold; + margin: 0 2px; } .row-controls { display: flex; - gap: 5px; - margin-left: 5px; + gap: 6px; + margin-left: 8px; flex-shrink: 0; + opacity: 0.6; + transition: opacity 0.2s; +} + +.header-row:hover .row-controls { + opacity: 1; } .empty-header { justify-content: center; - padding: 5px; + padding: 10px; color: #909399; - font-size: 12px; + font-size: 13px; } .no-header-text { - margin-right: 10px; + margin-right: 8px; } From 82125c4933ce80570e5566bb42e087b6b4487b6f Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Fri, 5 Dec 2025 14:22:07 +0800 Subject: [PATCH 28/93] =?UTF-8?q?update:=E8=AF=B4=E6=98=8E=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=B7=BB=E5=8A=A0=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/context-provider-integration.md | 111 +++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/docs/context-provider-integration.md b/docs/context-provider-integration.md index c2c06615..e49ab0dc 100644 --- a/docs/context-provider-integration.md +++ b/docs/context-provider-integration.md @@ -107,3 +107,114 @@ context_providers: ``` **注意**:如果您不需要使用此功能,可以选择**不配置任何上下文源**,也可以从提示词模板文件中**删除** `{{ dynamic_context }}` 占位符。 + +## 附录:Mock 测试服务示例 + +为了方便您测试和开发,我们提供了一个简单的 Python Mock Server 脚本。您可以运行此脚本在本地模拟 API 接口。 + +**mock_api_server.py** + +```python +import http.server +import socketserver +import json +from urllib.parse import urlparse, parse_qs + +# 设置端口号 +PORT = 8081 + +class MockRequestHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + # 解析路径和参数 + parsed_path = urlparse(self.path) + path = parsed_path.path + query = parse_qs(parsed_path.query) + + response_data = {} + status_code = 200 + + print(f"收到请求: {path}, 参数: {query}") + + # Case 1: 模拟健康数据 (返回字典 Dict) + # 路径参数风格: /health + # device_id 从 Header 获取 + if path == "/health": + device_id = self.headers.get("device_id", "unknown_device") + print(f"device_id: {device_id}") + response_data = { + "code": 0, + "msg": "success", + "data": { + "测试设备ID": device_id, + "心率": "80 bpm", + "血压": "120/80 mmHg", + "状态": "良好" + } + } + + # Case 2: 模拟新闻列表 (返回列表 List) + # 无参数: /news/list + elif path == "/news/list": + response_data = { + "code": 0, + "msg": "success", + "data": [ + "今日头条:Python 3.14 发布", + "科技新闻:AI 助手改变生活", + "本地新闻:明日有大雨,记得带伞" + ] + } + + # Case 3: 模拟天气简报 (返回字符串 String) + # 无参数: /weather/simple + elif path == "/weather/simple": + response_data = { + "code": 0, + "msg": "success", + "data": "今日晴转多云,气温 20-25 度,空气质量优,适合出行。" + } + + # Case 4: 模拟设备详情 (Query参数风格) + # 参数风格: /device/info + # device_id 从 Header 获取 + elif path == "/device/info": + device_id = self.headers.get("device_id", "unknown_device") + response_data = { + "code": 0, + "msg": "success", + "data": { + "查询方式": "Header参数", + "设备ID": device_id, + "电量": "85%", + "固件": "v2.0.1" + } + } + + # Case 5: 404 Not Found + else: + status_code = 404 + response_data = {"error": "接口不存在"} + + # 发送响应 + self.send_response(status_code) + self.send_header('Content-type', 'application/json; charset=utf-8') + self.end_headers() + self.wfile.write(json.dumps(response_data, ensure_ascii=False).encode('utf-8')) + +# 启动服务 +# 允许地址重用,防止快速重启报错 +socketserver.TCPServer.allow_reuse_address = True +with socketserver.TCPServer(("", PORT), MockRequestHandler) as httpd: + print(f"==================================================") + print(f"Mock API Server 已启动: http://localhost:{PORT}") + print(f"可用接口列表:") + print(f"1. [字典] http://localhost:{PORT}/health") + print(f"2. [列表] http://localhost:{PORT}/news/list") + print(f"3. [文本] http://localhost:{PORT}/weather/simple") + print(f"4. [参数] http://localhost:{PORT}/device/info") + print(f"==================================================") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\n服务已停止") +``` From 5f229351c8fae7b1afa558175b0b54b5e17ce34b Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Fri, 5 Dec 2025 14:35:53 +0800 Subject: [PATCH 29/93] =?UTF-8?q?uptate:=E5=A2=9E=E5=8A=A0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E7=AE=A1=E7=90=86=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/controller/LoginController.java | 33 +- .../resources/db/changelog/202512031513.sql | 6 + .../db/changelog/db.changelog-master.yaml | 8 + .../manager-web/src/components/DeviceItem.vue | 12 +- .../src/components/FunctionDialog.vue | 24 +- main/manager-web/src/components/HeaderBar.vue | 39 +- main/manager-web/src/i18n/de.js | 31 + main/manager-web/src/i18n/en.js | 31 + main/manager-web/src/i18n/vi.js | 31 + main/manager-web/src/i18n/zh_CN.js | 31 + main/manager-web/src/i18n/zh_TW.js | 31 + main/manager-web/src/main.js | 1 + main/manager-web/src/router/index.js | 21 +- main/manager-web/src/utils/featureManager.js | 340 ++++++++++ .../src/views/FeatureManagement.vue | 592 ++++++++++++++++++ main/manager-web/src/views/home.vue | 28 +- main/manager-web/src/views/login.vue | 8 + main/manager-web/src/views/roleConfig.vue | 35 +- 18 files changed, 1271 insertions(+), 31 deletions(-) create mode 100644 main/manager-api/src/main/resources/db/changelog/202512031513.sql create mode 100644 main/manager-web/src/utils/featureManager.js create mode 100644 main/manager-web/src/views/FeatureManagement.vue diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java index a008c25b..7262a7c8 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java @@ -41,6 +41,7 @@ import xiaozhi.modules.sys.service.SysDictDataService; import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysUserService; import xiaozhi.modules.sys.vo.SysDictDataItem; +import xiaozhi.common.utils.JsonUtils; /** * 登录控制层 @@ -89,13 +90,13 @@ public class LoginController { @Operation(summary = "登录") public Result login(@RequestBody LoginDTO login) { String password = login.getPassword(); - + // 使用工具类解密并验证验证码 String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( password, login.getCaptchaId(), captchaService, sysParamsService); - + login.setPassword(actualPassword); - + // 按照用户名获取用户 SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername()); // 判断用户是否存在 @@ -108,8 +109,6 @@ public class LoginController { } return sysUserTokenService.createToken(userDTO.getId()); } - - @PostMapping("/register") @Operation(summary = "注册") @@ -117,15 +116,15 @@ public class LoginController { if (!sysUserService.getAllowUserRegister()) { throw new RenException(ErrorCode.USER_REGISTER_DISABLED); } - + String password = login.getPassword(); - + // 使用工具类解密并验证验证码 String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( password, login.getCaptchaId(), captchaService, sysParamsService); - + login.setPassword(actualPassword); - + // 是否开启手机注册 Boolean isMobileRegister = sysParamsService .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class); @@ -204,11 +203,11 @@ public class LoginController { } String password = dto.getPassword(); - + // 使用工具类解密并验证验证码 String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( password, dto.getCaptchaId(), captchaService, sysParamsService); - + dto.setPassword(actualPassword); sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword()); @@ -229,7 +228,7 @@ public class LoginController { config.put("beianIcpNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_ICP_NUM.getValue(), true)); config.put("beianGaNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_GA_NUM.getValue(), true)); config.put("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true)); - + // SM2公钥 String publicKey = sysParamsService.getValue(Constant.SM2_PUBLIC_KEY, true); if (StringUtils.isBlank(publicKey)) { @@ -237,6 +236,16 @@ public class LoginController { } config.put("sm2PublicKey", publicKey); + // 获取system-web.menu参数配置 + try { + String menuConfig = sysParamsService.getValue("system-web.menu", false); + if (StringUtils.isNotBlank(menuConfig)) { + config.put("systemWebMenu", JsonUtils.parseObject(menuConfig, Object.class)); + } + } catch (Exception e) { + log.warn("获取system-web.menu参数配置失败: {}", e.getMessage()); + } + return new Result>().ok(config); } } \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/202512031513.sql b/main/manager-api/src/main/resources/db/changelog/202512031513.sql new file mode 100644 index 00000000..10d93873 --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202512031513.sql @@ -0,0 +1,6 @@ +-- 添加系统功能菜单配置参数 +delete from `sys_params` where param_code = 'system-web.menu'; + +-- 添加系统功能菜单配置参数 +INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES +(600, 'system-web.menu', '{"voiceprintRecognition":{"name":"feature.voiceprintRecognition.name","enabled":true,"description":"feature.voiceprintRecognition.description"},"voiceClone":{"name":"feature.voiceClone.name","enabled":true,"description":"feature.voiceClone.description"},"knowledgeBase":{"name":"feature.knowledgeBase.name","enabled":false,"description":"feature.knowledgeBase.description"},"mcpAccessPoint":{"name":"feature.mcpAccessPoint.name","enabled":true,"description":"feature.mcpAccessPoint.description"},"vad":{"name":"feature.vad.name","enabled":true,"description":"feature.vad.description"},"asr":{"name":"feature.asr.name","enabled":true,"description":"feature.asr.description"}}', 'json', 1, '系统功能菜单配置'); \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index 3c14eb7f..bc6f8526 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -423,3 +423,11 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202511131023.sql + - changeSet: + id: 202512031513 + author: hrz + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202512031513.sql + diff --git a/main/manager-web/src/components/DeviceItem.vue b/main/manager-web/src/components/DeviceItem.vue index bfbbb97f..c29d3243 100644 --- a/main/manager-web/src/components/DeviceItem.vue +++ b/main/manager-web/src/components/DeviceItem.vue @@ -23,7 +23,7 @@
{{ $t('home.configureRole') }}
-
+
{{ $t('home.voiceprintRecognition') }}
@@ -49,7 +49,15 @@ import i18n from '@/i18n'; export default { name: 'DeviceItem', props: { - device: { type: Object, required: true } + device: { type: Object, required: true }, + featureStatus: { + type: Object, + default: () => ({ + voiceprintRecognition: false, + voiceClone: false, + knowledgeBase: false + }) + } }, data() { return { switchValue: false } diff --git a/main/manager-web/src/components/FunctionDialog.vue b/main/manager-web/src/components/FunctionDialog.vue index 23adce78..076cfa9f 100644 --- a/main/manager-web/src/components/FunctionDialog.vue +++ b/main/manager-web/src/components/FunctionDialog.vue @@ -106,7 +106,7 @@
-
+
@@ -171,6 +171,7 @@ + + \ No newline at end of file diff --git a/main/manager-web/src/views/home.vue b/main/manager-web/src/views/home.vue index 01cf74fb..fea5af89 100644 --- a/main/manager-web/src/views/home.vue +++ b/main/manager-web/src/views/home.vue @@ -39,8 +39,9 @@
@@ -61,6 +62,7 @@ import ChatHistoryDialog from '@/components/ChatHistoryDialog.vue'; import DeviceItem from '@/components/DeviceItem.vue'; import HeaderBar from '@/components/HeaderBar.vue'; import VersionFooter from '@/components/VersionFooter.vue'; +import featureManager from '@/utils/featureManager'; export default { name: 'HomePage', @@ -76,15 +78,33 @@ export default { skeletonCount: localStorage.getItem('skeletonCount') || 8, showChatHistory: false, currentAgentId: '', - currentAgentName: '' + currentAgentName: '', + // 功能状态 + featureStatus: { + voiceprintRecognition: false, + voiceClone: false, + knowledgeBase: false + } } }, - mounted() { + async mounted() { this.fetchAgentList(); + await this.loadFeatureStatus(); }, methods: { + // 加载功能状态 + async loadFeatureStatus() { + await featureManager.waitForInitialization(); + const config = featureManager.getConfig(); + this.featureStatus = { + voiceprintRecognition: config.voiceprintRecognition, + voiceClone: config.voiceClone, + knowledgeBase: config.knowledgeBase + }; + }, + showAddDialog() { this.addDeviceDialogVisible = true }, diff --git a/main/manager-web/src/views/login.vue b/main/manager-web/src/views/login.vue index 9d56fdc5..cdcf3754 100644 --- a/main/manager-web/src/views/login.vue +++ b/main/manager-web/src/views/login.vue @@ -156,6 +156,7 @@ import VersionFooter from "@/components/VersionFooter.vue"; import i18n, { changeLanguage } from "@/i18n"; import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from "@/utils"; import { mapState } from "vuex"; +import featureManager from "@/utils/featureManager"; export default { name: "login", @@ -214,6 +215,13 @@ export default { this.$store.dispatch("fetchPubConfig").then(() => { // 根据配置决定默认登录方式 this.isMobileLogin = this.enableMobileRegister; + + // pub-config接口调用完成后,重新初始化featureManager以确保使用最新的配置 + featureManager.waitForInitialization().then(() => { + console.log('featureManager重新初始化完成,使用pub-config配置'); + }).catch(error => { + console.warn('featureManager重新初始化失败:', error); + }); }); }, methods: { diff --git a/main/manager-web/src/views/roleConfig.vue b/main/manager-web/src/views/roleConfig.vue index a0baf371..c9ea518c 100644 --- a/main/manager-web/src/views/roleConfig.vue +++ b/main/manager-web/src/views/roleConfig.vue @@ -107,7 +107,11 @@
- +
- +
From 7a7bfa26f6eae9d6142c79644c10a2c6019dab2a Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Fri, 5 Dec 2025 14:50:26 +0800 Subject: [PATCH 30/93] =?UTF-8?q?update:=E4=BF=AE=E6=94=B9=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/utils/context_provider.py | 2 ++ main/xiaozhi-server/core/utils/prompt_manager.py | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/main/xiaozhi-server/core/utils/context_provider.py b/main/xiaozhi-server/core/utils/context_provider.py index 617250d7..5f317540 100644 --- a/main/xiaozhi-server/core/utils/context_provider.py +++ b/main/xiaozhi-server/core/utils/context_provider.py @@ -59,4 +59,6 @@ class ContextDataProvider: # 将所有格式化后的行拼接成一个字符串 self.context_data = "\n".join(formatted_lines) + if self.context_data: + self.logger.bind(tag=TAG).debug(f"已注入动态上下文数据:\n{self.context_data}") return self.context_data diff --git a/main/xiaozhi-server/core/utils/prompt_manager.py b/main/xiaozhi-server/core/utils/prompt_manager.py index 96fbf624..4d8d56af 100644 --- a/main/xiaozhi-server/core/utils/prompt_manager.py +++ b/main/xiaozhi-server/core/utils/prompt_manager.py @@ -253,7 +253,6 @@ class PromptManager: self.logger.bind(tag=TAG).info( f"构建增强提示词成功,长度: {len(enhanced_prompt)}" ) - print("enhanced_prompt:", enhanced_prompt) return enhanced_prompt except Exception as e: From 68b539db15f5266dadb2d72eb5fcf9e93827ed4f Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Fri, 5 Dec 2025 15:29:28 +0800 Subject: [PATCH 31/93] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/security/controller/LoginController.java | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java index 7262a7c8..19bc2944 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java @@ -237,13 +237,9 @@ public class LoginController { config.put("sm2PublicKey", publicKey); // 获取system-web.menu参数配置 - try { - String menuConfig = sysParamsService.getValue("system-web.menu", false); - if (StringUtils.isNotBlank(menuConfig)) { - config.put("systemWebMenu", JsonUtils.parseObject(menuConfig, Object.class)); - } - } catch (Exception e) { - log.warn("获取system-web.menu参数配置失败: {}", e.getMessage()); + String menuConfig = sysParamsService.getValue("system-web.menu", true); + if (StringUtils.isNotBlank(menuConfig)) { + config.put("systemWebMenu", JsonUtils.parseObject(menuConfig, Object.class)); } return new Result>().ok(config); From 2ece3f399bae820b92178639b4e07b93c88b799d Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Fri, 5 Dec 2025 15:39:05 +0800 Subject: [PATCH 32/93] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E6=94=B9=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E7=8A=B6=E6=80=81=E4=B8=BAfalse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/db/changelog/202512031513.sql | 6 ------ .../src/main/resources/db/changelog/202512031514.sql | 6 ++++++ .../main/resources/db/changelog/db.changelog-master.yaml | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) delete mode 100644 main/manager-api/src/main/resources/db/changelog/202512031513.sql create mode 100644 main/manager-api/src/main/resources/db/changelog/202512031514.sql diff --git a/main/manager-api/src/main/resources/db/changelog/202512031513.sql b/main/manager-api/src/main/resources/db/changelog/202512031513.sql deleted file mode 100644 index 10d93873..00000000 --- a/main/manager-api/src/main/resources/db/changelog/202512031513.sql +++ /dev/null @@ -1,6 +0,0 @@ --- 添加系统功能菜单配置参数 -delete from `sys_params` where param_code = 'system-web.menu'; - --- 添加系统功能菜单配置参数 -INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES -(600, 'system-web.menu', '{"voiceprintRecognition":{"name":"feature.voiceprintRecognition.name","enabled":true,"description":"feature.voiceprintRecognition.description"},"voiceClone":{"name":"feature.voiceClone.name","enabled":true,"description":"feature.voiceClone.description"},"knowledgeBase":{"name":"feature.knowledgeBase.name","enabled":false,"description":"feature.knowledgeBase.description"},"mcpAccessPoint":{"name":"feature.mcpAccessPoint.name","enabled":true,"description":"feature.mcpAccessPoint.description"},"vad":{"name":"feature.vad.name","enabled":true,"description":"feature.vad.description"},"asr":{"name":"feature.asr.name","enabled":true,"description":"feature.asr.description"}}', 'json', 1, '系统功能菜单配置'); \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/202512031514.sql b/main/manager-api/src/main/resources/db/changelog/202512031514.sql new file mode 100644 index 00000000..adfff855 --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202512031514.sql @@ -0,0 +1,6 @@ +-- 添加系统功能菜单配置参数 +delete from `sys_params` where param_code = 'system-web.menu'; + +-- 添加系统功能菜单配置参数 +INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES +(600, 'system-web.menu', '{"voiceprintRecognition":{"name":"feature.voiceprintRecognition.name","enabled":false,"description":"feature.voiceprintRecognition.description"},"voiceClone":{"name":"feature.voiceClone.name","enabled":false,"description":"feature.voiceClone.description"},"knowledgeBase":{"name":"feature.knowledgeBase.name","enabled":false,"description":"feature.knowledgeBase.description"},"mcpAccessPoint":{"name":"feature.mcpAccessPoint.name","enabled":false,"description":"feature.mcpAccessPoint.description"},"vad":{"name":"feature.vad.name","enabled":false,"description":"feature.vad.description"},"asr":{"name":"feature.asr.name","enabled":false,"description":"feature.asr.description"}}', 'json', 1, '系统功能菜单配置'); \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index bc6f8526..c929f760 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -424,10 +424,10 @@ databaseChangeLog: encoding: utf8 path: classpath:db/changelog/202511131023.sql - changeSet: - id: 202512031513 + id: 202512031514 author: hrz changes: - sqlFile: encoding: utf8 - path: classpath:db/changelog/202512031513.sql + path: classpath:db/changelog/202512031514.sql From 3d509d75dc846a9dc6d60f073d5acdf262caf929 Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Fri, 5 Dec 2025 17:01:40 +0800 Subject: [PATCH 33/93] =?UTF-8?q?fix:=E4=BF=AE=E6=94=B9=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/security/controller/LoginController.java | 2 +- .../src/main/resources/db/changelog/202512031514.sql | 6 ------ .../src/main/resources/db/changelog/202512031517.sql | 6 ++++++ .../main/resources/db/changelog/db.changelog-master.yaml | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) delete mode 100644 main/manager-api/src/main/resources/db/changelog/202512031514.sql create mode 100644 main/manager-api/src/main/resources/db/changelog/202512031517.sql diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java index 19bc2944..2c7bab4a 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java @@ -237,7 +237,7 @@ public class LoginController { config.put("sm2PublicKey", publicKey); // 获取system-web.menu参数配置 - String menuConfig = sysParamsService.getValue("system-web.menu", true); + String menuConfig = sysParamsService.getValue("system-web.menu", false); if (StringUtils.isNotBlank(menuConfig)) { config.put("systemWebMenu", JsonUtils.parseObject(menuConfig, Object.class)); } diff --git a/main/manager-api/src/main/resources/db/changelog/202512031514.sql b/main/manager-api/src/main/resources/db/changelog/202512031514.sql deleted file mode 100644 index adfff855..00000000 --- a/main/manager-api/src/main/resources/db/changelog/202512031514.sql +++ /dev/null @@ -1,6 +0,0 @@ --- 添加系统功能菜单配置参数 -delete from `sys_params` where param_code = 'system-web.menu'; - --- 添加系统功能菜单配置参数 -INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES -(600, 'system-web.menu', '{"voiceprintRecognition":{"name":"feature.voiceprintRecognition.name","enabled":false,"description":"feature.voiceprintRecognition.description"},"voiceClone":{"name":"feature.voiceClone.name","enabled":false,"description":"feature.voiceClone.description"},"knowledgeBase":{"name":"feature.knowledgeBase.name","enabled":false,"description":"feature.knowledgeBase.description"},"mcpAccessPoint":{"name":"feature.mcpAccessPoint.name","enabled":false,"description":"feature.mcpAccessPoint.description"},"vad":{"name":"feature.vad.name","enabled":false,"description":"feature.vad.description"},"asr":{"name":"feature.asr.name","enabled":false,"description":"feature.asr.description"}}', 'json', 1, '系统功能菜单配置'); \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/202512031517.sql b/main/manager-api/src/main/resources/db/changelog/202512031517.sql new file mode 100644 index 00000000..2ad5bf22 --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202512031517.sql @@ -0,0 +1,6 @@ +-- 添加系统功能菜单配置参数 +delete from `sys_params` where param_code = 'system-web.menu'; + +-- 添加系统功能菜单配置参数 +INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES +(600, 'system-web.menu', '{"features":{"voiceprintRecognition":{"name":"feature.voiceprintRecognition.name","enabled":false,"description":"feature.voiceprintRecognition.description"},"voiceClone":{"name":"feature.voiceClone.name","enabled":false,"description":"feature.voiceClone.description"},"knowledgeBase":{"name":"feature.knowledgeBase.name","enabled":false,"description":"feature.knowledgeBase.description"},"mcpAccessPoint":{"name":"feature.mcpAccessPoint.name","enabled":false,"description":"feature.mcpAccessPoint.description"},"vad":{"name":"feature.vad.name","enabled":true,"description":"feature.vad.description"},"asr":{"name":"feature.asr.name","enabled":true,"description":"feature.asr.description"}},"groups":{"featureManagement":["voiceprintRecognition","voiceClone","knowledgeBase","mcpAccessPoint"],"voiceManagement":["vad","asr"]}}', 'json', 1, '系统功能菜单配置'); \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index c929f760..993d2058 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -424,10 +424,10 @@ databaseChangeLog: encoding: utf8 path: classpath:db/changelog/202511131023.sql - changeSet: - id: 202512031514 - author: hrz + id: 202512031517 + author: rainv123 changes: - sqlFile: encoding: utf8 - path: classpath:db/changelog/202512031514.sql + path: classpath:db/changelog/202512031517.sql From 8879907e38f9ee0a01353ae4d7ced1132d5b2b50 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Fri, 5 Dec 2025 17:31:37 +0800 Subject: [PATCH 34/93] =?UTF-8?q?update:=E4=BF=AE=E6=94=B9=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/context-provider-integration.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/context-provider-integration.md b/docs/context-provider-integration.md index e49ab0dc..d7b3acd0 100644 --- a/docs/context-provider-integration.md +++ b/docs/context-provider-integration.md @@ -1,7 +1,9 @@ -# 数据上下文填充功能使用指南 +# 上下文源使用教程 ## 概述 +上下文源,就是为小智系统提示词的上下文添加【数据源】,我们也称为【数据上下文填充功能】。 + 数据上下文填充功能(Context Data Provider)允许小智在唤醒那一刻,获取外部系统的数据,并将其动态注入到大模型的系统提示词(System Prompt)中。 让其做到唤醒时感知世界某个事物的状态。 From 11e328ea6a47cb115b5a34523fb19158f6664cd1 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Fri, 5 Dec 2025 17:39:34 +0800 Subject: [PATCH 35/93] =?UTF-8?q?update=EF=BC=9A=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/context-provider-integration.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/context-provider-integration.md b/docs/context-provider-integration.md index d7b3acd0..c0dbdcb4 100644 --- a/docs/context-provider-integration.md +++ b/docs/context-provider-integration.md @@ -2,14 +2,16 @@ ## 概述 -上下文源,就是为小智系统提示词的上下文添加【数据源】,我们也称为【数据上下文填充功能】。 +`上下文源`,就是为小智系统提示词的上下文添加【数据源】。 -数据上下文填充功能(Context Data Provider)允许小智在唤醒那一刻,获取外部系统的数据,并将其动态注入到大模型的系统提示词(System Prompt)中。 +`上下文源` 在小智在唤醒那一刻,获取外部系统的数据,并将其动态注入到大模型的系统提示词(System Prompt)中。 让其做到唤醒时感知世界某个事物的状态。 +它和MCP、记忆有本质的区别:`上下文源`是强制让小智感知世界的数据;`记忆(Mem)`是让他知道之前聊了什么内容;`MCP(functionc all)`是当需要调用某项能力/知识的时候使用调用。 + 通过这个功能,在小智唤醒的一刹那,“感知”到: -- 智能家居的传感器状态(温度、湿度、灯光状态等) -- 业务系统的实时数据(服务器负载、健康数据、股票信息等) +- 人体健康传感器状态(体温、血压、血氧状态等) +- 业务系统的实时数据(服务器负载、待办数据、股票信息等) - 任何可以通过 HTTP API 获取的文本信息 **注意**:该功能只是方便小智在唤醒的时候感知事物的状态,而如果想要小智唤醒后实时获取事物的状态,建议在此功能上再结合MCP工具的调用。 From efbef4ff5be71f5503d558d068fa906e9d5960dc Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Fri, 5 Dec 2025 18:08:26 +0800 Subject: [PATCH 36/93] =?UTF-8?q?fix:=E6=A0=B7=E5=BC=8F=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=E7=BF=BB=E8=AF=91=E8=A1=A5=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/FunctionDialog.vue | 7 ++++++ main/manager-web/src/i18n/de.js | 1 + main/manager-web/src/i18n/en.js | 1 + main/manager-web/src/i18n/vi.js | 1 + main/manager-web/src/i18n/zh_CN.js | 1 + main/manager-web/src/i18n/zh_TW.js | 1 + .../src/views/FeatureManagement.vue | 13 ++++++++-- main/manager-web/src/views/roleConfig.vue | 24 +++++++++++++++++-- 8 files changed, 45 insertions(+), 4 deletions(-) diff --git a/main/manager-web/src/components/FunctionDialog.vue b/main/manager-web/src/components/FunctionDialog.vue index 076cfa9f..23c4d1ad 100644 --- a/main/manager-web/src/components/FunctionDialog.vue +++ b/main/manager-web/src/components/FunctionDialog.vue @@ -478,6 +478,7 @@ export default { .function-column { position: relative; width: auto; + height:700px; padding: 10px; overflow-y: auto; border-right: 1px solid #EBEEF5; @@ -485,6 +486,12 @@ export default { overflow-x: hidden; } +.mcp-access-point { + position: relative; + z-index: 1; + background: white; +} + .function-column::-webkit-scrollbar { display: none; } diff --git a/main/manager-web/src/i18n/de.js b/main/manager-web/src/i18n/de.js index 287c513e..4c1dfe5f 100644 --- a/main/manager-web/src/i18n/de.js +++ b/main/manager-web/src/i18n/de.js @@ -1278,6 +1278,7 @@ export default { 'featureManagement.confirm': 'Bestätigen', 'featureManagement.cancel': 'Abbrechen', 'featureManagement.resetSuccess': 'Funktionskonfiguration erfolgreich zurückgesetzt', + 'featureManagement.noChanges': 'Keine Änderungen zum Speichern', // Feature names and descriptions 'feature.voiceprintRecognition.name': 'Stimmerkennung', diff --git a/main/manager-web/src/i18n/en.js b/main/manager-web/src/i18n/en.js index 52e81e3d..db4b47ba 100644 --- a/main/manager-web/src/i18n/en.js +++ b/main/manager-web/src/i18n/en.js @@ -1278,6 +1278,7 @@ export default { 'featureManagement.confirm': 'Confirm', 'featureManagement.cancel': 'Cancel', 'featureManagement.resetSuccess': 'Feature configuration reset successfully', + 'featureManagement.noChanges': 'No changes to save', // Feature names and descriptions 'feature.voiceprintRecognition.name': 'Voiceprint Recognition', diff --git a/main/manager-web/src/i18n/vi.js b/main/manager-web/src/i18n/vi.js index 00ecf284..f109e1c3 100644 --- a/main/manager-web/src/i18n/vi.js +++ b/main/manager-web/src/i18n/vi.js @@ -1278,6 +1278,7 @@ export default { 'featureManagement.confirm': 'Xác nhận', 'featureManagement.cancel': 'Hủy bỏ', 'featureManagement.resetSuccess': 'Cấu hình tính năng đã được đặt lại thành công', + 'featureManagement.noChanges': 'Không có thay đổi nào để lưu', // Feature names and descriptions 'feature.voiceprintRecognition.name': 'Nhận dạng giọng nói', diff --git a/main/manager-web/src/i18n/zh_CN.js b/main/manager-web/src/i18n/zh_CN.js index 3654f08d..50bc55b9 100644 --- a/main/manager-web/src/i18n/zh_CN.js +++ b/main/manager-web/src/i18n/zh_CN.js @@ -1278,6 +1278,7 @@ export default { 'featureManagement.confirm': '确定', 'featureManagement.cancel': '取消', 'featureManagement.resetSuccess': '功能配置重置成功', + 'featureManagement.noChanges': '没有需要保存的更改', // 功能名称和描述 'feature.voiceprintRecognition.name': '声纹识别', diff --git a/main/manager-web/src/i18n/zh_TW.js b/main/manager-web/src/i18n/zh_TW.js index 09732900..be802783 100644 --- a/main/manager-web/src/i18n/zh_TW.js +++ b/main/manager-web/src/i18n/zh_TW.js @@ -1278,6 +1278,7 @@ export default { 'featureManagement.confirm': '確定', 'featureManagement.cancel': '取消', 'featureManagement.resetSuccess': '功能配置重置成功', + 'featureManagement.noChanges': '沒有需要儲存的更改', // 功能名稱和描述 'feature.voiceprintRecognition.name': '聲紋識別', diff --git a/main/manager-web/src/views/FeatureManagement.vue b/main/manager-web/src/views/FeatureManagement.vue index a60486b5..96c5f3cd 100644 --- a/main/manager-web/src/views/FeatureManagement.vue +++ b/main/manager-web/src/views/FeatureManagement.vue @@ -503,13 +503,13 @@ export default { } .feature-card-item:hover { - border-color: #9cc6ef; + border-color: #869bf0; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); transform: translateY(-2px); } .feature-card-item.feature-enabled { - border-color: #409eff; + border-color:#5778ff; box-shadow: 0 4px 16px rgba(95, 112, 243, 0.2); transform: translateY(-2px); } @@ -525,6 +525,15 @@ export default { transform: scale(1.2); } +.feature-checkbox ::v-deep .el-checkbox__input.is-checked .el-checkbox__inner { + background-color: #5778ff; + border-color: #5778ff; +} + +.feature-checkbox ::v-deep .el-checkbox__input.is-checked + .el-checkbox__label { + color: #5778ff; +} + .feature-name { font-size: 18px; diff --git a/main/manager-web/src/views/roleConfig.vue b/main/manager-web/src/views/roleConfig.vue index 16e0f19e..c20c6b82 100644 --- a/main/manager-web/src/views/roleConfig.vue +++ b/main/manager-web/src/views/roleConfig.vue @@ -58,7 +58,7 @@
- 已成功添加 {{ currentContextProviders.length }} 个源。如何部署上下文源 + 已成功添加 {{ currentContextProviders.length }} 个源。如何部署上下文源 Date: Fri, 5 Dec 2025 18:29:20 +0800 Subject: [PATCH 37/93] =?UTF-8?q?uptate:=E6=9B=B4=E6=96=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=87=E6=A1=A3=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=9C=A8?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B8=8A=E5=BC=80=E5=90=AF=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E7=9A=84=E6=95=99=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mcp-endpoint-enable.md | 1 + docs/ragflow-integration.md | 6 ++++-- docs/voiceprint-integration.md | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/mcp-endpoint-enable.md b/docs/mcp-endpoint-enable.md index 6b64ccac..9cbc6244 100644 --- a/docs/mcp-endpoint-enable.md +++ b/docs/mcp-endpoint-enable.md @@ -71,6 +71,7 @@ docker logs -f mcp-endpoint-server 请你保留好上面两个`接口地址`,下一步要用到。 # 2、全模块部署时,怎么配置MCP接入点 +首先,你要开启MCP接入点功能。在智控台,点击顶部`参数字典`,选择`系统功能配置`功能。在页面上勾选`MCP接入点`,点击`保存配置`。在`角色配置`页面,点击`编辑功能`按钮,即可看到`mcp接入点`功能。 如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 diff --git a/docs/ragflow-integration.md b/docs/ragflow-integration.md index 36088333..e996de02 100644 --- a/docs/ragflow-integration.md +++ b/docs/ragflow-integration.md @@ -238,9 +238,11 @@ docker-compose -f docker-compose.yml up -d 在弹框中,点击"Create new Key"按钮,生成一个API Key。复制这个`API Key`,你稍后会用到。 # 第二步 配置到智控台 -确保你的智控台版本是`0.8.7`或以上。使用超级管理员账号登录到智控台。在顶部导航栏中,点击`模型配置`,在左侧导航栏中,点击`知识库`。 +确保你的智控台版本是`0.8.7`或以上。使用超级管理员账号登录到智控台。 -在列表中找到`RAG_RAGFlow`,点击`编辑`按钮。 +首先,你要先开启知识库功能。在顶部导航栏中,点击`参数字典`,在下拉菜单中,点击`系统功能配置`,在页面上勾选`知识库`,点击`保存配置`。即可在导航栏看到`知识库`功能。 + +在顶部导航栏中,点击`模型配置`,在左侧导航栏中,点击`知识库`。在列表中找到`RAG_RAGFlow`,点击`编辑`按钮。 在`服务地址`中,填写`http://你的ragflow服务的局域网IP:8008`,例如我的ragflow服务的局域网IP是`192.168.1.100`,那么我就填写`http://192.168.1.100:8008`。 diff --git a/docs/voiceprint-integration.md b/docs/voiceprint-integration.md index fbc12b1f..3c9ab2c8 100644 --- a/docs/voiceprint-integration.md +++ b/docs/voiceprint-integration.md @@ -164,6 +164,8 @@ http://192.168.1.25:8005/voiceprint/health?key=abcd # 2、全模块部署时,怎么配置声纹识别 ## 第一步 配置接口 +首先,你要开启声纹识别功能。在智控台,点击顶部`参数字典`,选择`系统功能配置`功能。在页面上勾选`声纹识别`,点击`保存配置`。即可在新建智能体的卡片上看到`声纹识别`按钮。 + 如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 然后搜索参数`server.voice_print`,此时,它的值应该是`null`值。 From 43c9d85b8f7624ad702c3e4856e653f4caefe1a9 Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Fri, 5 Dec 2025 18:33:11 +0800 Subject: [PATCH 38/93] =?UTF-8?q?uptate:=E6=9B=B4=E6=96=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=87=E6=A1=A3=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=9C=A8?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B8=8A=E5=BC=80=E5=90=AF=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E7=9A=84=E6=95=99=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mcp-endpoint-enable.md | 2 +- docs/ragflow-integration.md | 2 +- docs/voiceprint-integration.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/mcp-endpoint-enable.md b/docs/mcp-endpoint-enable.md index 9cbc6244..2d86f802 100644 --- a/docs/mcp-endpoint-enable.md +++ b/docs/mcp-endpoint-enable.md @@ -71,7 +71,7 @@ docker logs -f mcp-endpoint-server 请你保留好上面两个`接口地址`,下一步要用到。 # 2、全模块部署时,怎么配置MCP接入点 -首先,你要开启MCP接入点功能。在智控台,点击顶部`参数字典`,选择`系统功能配置`功能。在页面上勾选`MCP接入点`,点击`保存配置`。在`角色配置`页面,点击`编辑功能`按钮,即可看到`mcp接入点`功能。 +首先,你要开启MCP接入点功能。在智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`MCP接入点`,点击`保存配置`。在`角色配置`页面,点击`编辑功能`按钮,即可看到`mcp接入点`功能。 如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 diff --git a/docs/ragflow-integration.md b/docs/ragflow-integration.md index e996de02..7d2b4eab 100644 --- a/docs/ragflow-integration.md +++ b/docs/ragflow-integration.md @@ -240,7 +240,7 @@ docker-compose -f docker-compose.yml up -d # 第二步 配置到智控台 确保你的智控台版本是`0.8.7`或以上。使用超级管理员账号登录到智控台。 -首先,你要先开启知识库功能。在顶部导航栏中,点击`参数字典`,在下拉菜单中,点击`系统功能配置`,在页面上勾选`知识库`,点击`保存配置`。即可在导航栏看到`知识库`功能。 +首先,你要先开启知识库功能。在顶部导航栏中,点击`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`知识库`,点击`保存配置`。即可在导航栏看到`知识库`功能。 在顶部导航栏中,点击`模型配置`,在左侧导航栏中,点击`知识库`。在列表中找到`RAG_RAGFlow`,点击`编辑`按钮。 diff --git a/docs/voiceprint-integration.md b/docs/voiceprint-integration.md index 3c9ab2c8..bd0731e8 100644 --- a/docs/voiceprint-integration.md +++ b/docs/voiceprint-integration.md @@ -164,7 +164,7 @@ http://192.168.1.25:8005/voiceprint/health?key=abcd # 2、全模块部署时,怎么配置声纹识别 ## 第一步 配置接口 -首先,你要开启声纹识别功能。在智控台,点击顶部`参数字典`,选择`系统功能配置`功能。在页面上勾选`声纹识别`,点击`保存配置`。即可在新建智能体的卡片上看到`声纹识别`按钮。 +首先,你要开启声纹识别功能。在智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`声纹识别`,点击`保存配置`。即可在新建智能体的卡片上看到`声纹识别`按钮。 如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 From aba7172a0390376f7a98d01c8ab62f1ef7271281 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Mon, 8 Dec 2025 14:25:11 +0800 Subject: [PATCH 39/93] =?UTF-8?q?update=EF=BC=9A=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E6=BA=90=E7=9A=84device=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/context-provider-integration.md | 6 +++--- main/xiaozhi-server/config.yaml | 3 ++- main/xiaozhi-server/core/utils/context_provider.py | 2 +- main/xiaozhi-server/core/utils/prompt_manager.py | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/context-provider-integration.md b/docs/context-provider-integration.md index c0dbdcb4..d8a9049c 100644 --- a/docs/context-provider-integration.md +++ b/docs/context-provider-integration.md @@ -27,7 +27,7 @@ 为了让小智正确解析数据,您的 API 需要满足以下规范: - **请求方式**:`GET` -- **请求头**:系统会自动添加 `device_id` 字段到 Request Header。 +- **请求头**:系统会自动添加 `device-id` 字段到 Request Header。 - **响应格式**:必须返回 JSON 格式,且包含 `code` 和 `data` 字段。 ### 响应示例 @@ -143,7 +143,7 @@ class MockRequestHandler(http.server.SimpleHTTPRequestHandler): # 路径参数风格: /health # device_id 从 Header 获取 if path == "/health": - device_id = self.headers.get("device_id", "unknown_device") + device_id = self.headers.get("device-id", "unknown_device") print(f"device_id: {device_id}") response_data = { "code": 0, @@ -182,7 +182,7 @@ class MockRequestHandler(http.server.SimpleHTTPRequestHandler): # 参数风格: /device/info # device_id 从 Header 获取 elif path == "/device/info": - device_id = self.headers.get("device_id", "unknown_device") + device_id = self.headers.get("device-id", "unknown_device") response_data = { "code": 0, "msg": "success", diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index 13d9fcb3..2c005356 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -114,8 +114,9 @@ wakeup_words: # 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md mcp_endpoint: 你的接入点 websocket地址 -# 数据上下文填充配置 +# 上下文源配置 # 用于在系统提示词中注入动态数据,如健康数据、股票信息等 +# 可以添加多个上下文源 context_providers: - url: "" headers: diff --git a/main/xiaozhi-server/core/utils/context_provider.py b/main/xiaozhi-server/core/utils/context_provider.py index 5f317540..14943d98 100644 --- a/main/xiaozhi-server/core/utils/context_provider.py +++ b/main/xiaozhi-server/core/utils/context_provider.py @@ -29,7 +29,7 @@ class ContextDataProvider: try: headers = headers.copy() if isinstance(headers, dict) else {} # 将 device_id 添加到请求头 - headers["device_id"] = device_id + headers["device-id"] = device_id # 发送请求 response = httpx.get(url, headers=headers, timeout=3) diff --git a/main/xiaozhi-server/core/utils/prompt_manager.py b/main/xiaozhi-server/core/utils/prompt_manager.py index 4d8d56af..78c63483 100644 --- a/main/xiaozhi-server/core/utils/prompt_manager.py +++ b/main/xiaozhi-server/core/utils/prompt_manager.py @@ -60,7 +60,7 @@ class PromptManager: self.cache_manager = cache_manager self.CacheType = CacheType - # 初始化数据上下文填充 + # 初始化上下文源 from core.utils.context_provider import ContextDataProvider self.context_provider = ContextDataProvider(config, self.logger) self.context_data = {} From 6ffa325b73e6b5ac5cd773969127c61e6f327dae Mon Sep 17 00:00:00 2001 From: FAN-yeB <1442100690@qq.com> Date: Mon, 8 Dec 2025 15:51:04 +0800 Subject: [PATCH 40/93] =?UTF-8?q?update:=E7=BB=9F=E4=B8=80=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E6=B5=8B=E9=80=9F=E5=B7=A5=E5=85=B7=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E5=8C=BA=E9=97=B4=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=99=BE=E7=82=BC=E5=B9=B3=E5=8F=B0=E6=B5=81=E5=BC=8FTTS?= =?UTF-8?q?=E6=B5=8B=E9=80=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../performance_tester_stream_asr.py | 256 ++++++++++-------- .../performance_tester_stream_tts.py | 235 ++++++++++++---- 2 files changed, 323 insertions(+), 168 deletions(-) diff --git a/main/xiaozhi-server/performance_tester/performance_tester_stream_asr.py b/main/xiaozhi-server/performance_tester/performance_tester_stream_asr.py index f4508ef2..f81f6317 100644 --- a/main/xiaozhi-server/performance_tester/performance_tester_stream_asr.py +++ b/main/xiaozhi-server/performance_tester/performance_tester_stream_asr.py @@ -50,7 +50,9 @@ class BaseASRTester: raise NotImplementedError def _calculate_result(self, service_name, latencies, test_count): - valid_latencies = [l for l in latencies if l > 0] + """计算测试结果(修复:正确处理None值,剔除失败测试)""" + # 剔除None值(失败的测试)和无效延迟,只统计有效延迟 + valid_latencies = [l for l in latencies if l is not None and l > 0] if valid_latencies: avg_latency = sum(valid_latencies) / len(valid_latencies) status = f"成功({len(valid_latencies)}/{test_count}次有效)" @@ -64,16 +66,45 @@ class DoubaoStreamASRTester(BaseASRTester): def __init__(self): super().__init__("DoubaoStreamASR") - def _generate_header(self): + def _generate_header( + self, + version=0x01, + message_type=0x01, + message_type_specific_flags=0x00, + serial_method=0x01, + compression_type=0x01, + reserved_data=0x00, + extension_header: bytes = b"", + ): + """生成协议头(修复:使用正确的Header格式)""" header = bytearray() - header.append((0x01 << 4) | 0x01) - header.append((0x01 << 4) | 0x00) - header.append((0x01 << 4) | 0x01) - header.append(0x00) + header_size = int(len(extension_header) / 4) + 1 + header.append((version << 4) | header_size) + header.append((message_type << 4) | message_type_specific_flags) + header.append((serial_method << 4) | compression_type) + header.append(reserved_data) + header.extend(extension_header) return header def _generate_audio_default_header(self): - return self._generate_header() + """生成音频数据Header""" + return self._generate_header( + version=0x01, + message_type=0x02, + message_type_specific_flags=0x00, # 普通音频帧 + serial_method=0x01, + compression_type=0x01, + ) + + def _generate_last_audio_header(self): + """生成最后一帧音频的Header(标记音频结束)""" + return self._generate_header( + version=0x01, + message_type=0x02, + message_type_specific_flags=0x02, # 0x02表示这是最后一帧 + serial_method=0x01, + compression_type=0x01, + ) def _parse_response(self, res: bytes) -> dict: try: @@ -110,6 +141,7 @@ class DoubaoStreamASRTester(BaseASRTester): ws_url = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel" appid = self.asr_config["appid"] access_token = self.asr_config["access_token"] + cluster = self.asr_config.get("cluster", "volcengine_input_common") uid = self.asr_config.get("uid", "streaming_asr_service") start_time = time.time() @@ -130,7 +162,7 @@ class DoubaoStreamASRTester(BaseASRTester): close_timeout=10 ) as ws: request_params = { - "app": {"appid": appid, "token": access_token}, + "app": {"appid": appid, "cluster": cluster, "token": access_token}, "user": {"uid": uid}, "request": { "reqid": str(uuid.uuid4()), @@ -166,8 +198,9 @@ class DoubaoStreamASRTester(BaseASRTester): if audio_data.startswith(b'RIFF'): audio_data = audio_data[44:] + # 发送音频数据(使用最后一帧标记,告诉服务端音频已结束) payload = gzip.compress(audio_data) - audio_request = bytearray(self._generate_audio_default_header()) + audio_request = bytearray(self._generate_last_audio_header()) # 修复:使用最后一帧Header audio_request.extend(len(payload).to_bytes(4, "big")) audio_request.extend(payload) await ws.send(audio_request) @@ -175,11 +208,12 @@ class DoubaoStreamASRTester(BaseASRTester): first_chunk = await ws.recv() latency = time.time() - start_time latencies.append(latency) + print(f"[豆包ASR] 第{i+1}次 首词延迟: {latency:.3f}s") await ws.close() except Exception as e: print(f"[豆包ASR] 第{i+1}次测试失败: {str(e)}") - latencies.append(0) + latencies.append(None) return self._calculate_result("豆包流式ASR", latencies, test_count) @@ -189,11 +223,12 @@ class QwenASRFlashTester(BaseASRTester): super().__init__("Qwen3ASRFlash") async def _test_single(self, audio_file_info): - start_time = time.time() temp_file_path = None try: audio_data = audio_file_info['data'] + + # 优化:将临时文件准备工作移到计时前,减少磁盘IO对性能测试的影响 with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: temp_file_path = f.name @@ -221,6 +256,9 @@ class QwenASRFlashTester(BaseASRTester): dashscope.api_key = api_key + # 统一计时起点:在API调用前开始计时(但文件准备已完成) + start_time = time.time() + response = dashscope.MultiModalConversation.call( model="qwen3-asr-flash", messages=messages, @@ -257,10 +295,10 @@ class QwenASRFlashTester(BaseASRTester): # print(f"\n[通义ASR] 开始第 {i+1} 次测试...") latency = await self._test_single(self.test_audio_files[0]) latencies.append(latency) - # print(f"[通义ASR] 第{i+1}次成功 延迟: {latency:.3f}s") + print(f"[通义ASR] 第{i+1}次 首词延迟: {latency:.3f}s") except Exception as e: # print(f"[通义ASR] 第{i+1}次测试失败: {str(e)}") - latencies.append(0) + latencies.append(None) return self._calculate_result("通义千问ASR", latencies, test_count) @@ -268,134 +306,115 @@ class QwenASRFlashTester(BaseASRTester): class XunfeiStreamASRTester(BaseASRTester): def __init__(self): super().__init__("XunfeiStreamASR") - + def _create_url(self): - """生成讯飞ASR认证URL""" - url = 'ws://iat.cn-huabei-1.xf-yun.com/v1' - # 生成RFC1123格式的时间戳 + url = "wss://iat-api.xfyun.cn/v2/iat" now = datetime.now() date = format_date_time(mktime(now.timetuple())) - # 拼接字符串 - signature_origin = "host: " + "iat.cn-huabei-1.xf-yun.com" + "\n" - signature_origin += "date: " + date + "\n" - signature_origin += "GET " + "/v1 " + "HTTP/1.1" + signature_origin = f"host: iat-api.xfyun.cn\ndate: {date}\nGET /v2/iat HTTP/1.1" + signature_sha = hmac.new( + self.asr_config["api_secret"].encode('utf-8'), + signature_origin.encode('utf-8'), + hashlib.sha256 + ).digest() + signature_sha = base64.b64encode(signature_sha).decode() - # 进行hmac-sha256进行加密 - signature_sha = hmac.new(self.asr_config["api_secret"].encode('utf-8'), signature_origin.encode('utf-8'), - digestmod=hashlib.sha256).digest() - signature_sha = base64.b64encode(signature_sha).decode(encoding='utf-8') + authorization_origin = f'api_key="{self.asr_config["api_key"]}", algorithm="hmac-sha256", headers="host date request-line", signature="{signature_sha}"' + authorization = base64.b64encode(authorization_origin.encode()).decode() - authorization_origin = "api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"" % ( - self.asr_config["api_key"], "hmac-sha256", "host date request-line", signature_sha) - authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8') + v = {"authorization": authorization, "date": date, "host": "iat-api.xfyun.cn"} + return url + "?" + parse.urlencode(v) - # 将请求的鉴权参数组合为字典 - v = { - "authorization": authorization, - "date": date, - "host": "iat.cn-huabei-1.xf-yun.com" - } - - # 拼接鉴权参数,生成url - url = url + '?' + parse.urlencode(v) - return url - - async def test(self, test_count=5): + async def test(self, test_count: int = 5): if not self.test_audio_files: return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未找到测试音频"} if not self.asr_config: return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未配置"} - - # 检查必要的配置参数 - required_keys = ["app_id", "api_key", "api_secret"] - for key in required_keys: - if key not in self.asr_config: - return {"name": "讯飞流式ASR", "latency": 0, "status": f"失败: 缺少配置项 {key}"} - + + required = ["app_id", "api_key", "api_secret"] + for k in required: + if k not in self.asr_config: + return {"name": "讯飞流式ASR", "latency": 0, "status": f"失败: 缺少配置 {k}"} + latencies = [] - STATUS_FIRST_FRAME = 0 - + frame_size = 1280 + audio_raw = self.test_audio_files[0]['data'] + if audio_raw.startswith(b'RIFF'): + audio_raw = audio_raw[44:] + for i in range(test_count): try: - # 生成认证URL - ws_url = self._create_url() - - # 获取音频数据 - audio_data = self.test_audio_files[0]['data'] - if audio_data.startswith(b'RIFF'): - audio_data = audio_data[44:] # 跳过WAV文件头 - - # 识别参数 - iat_params = { - "domain": self.asr_config.get("domain", "slm"), - "language": self.asr_config.get("language", "zh_cn"), - "accent": self.asr_config.get("accent", "mandarin"), - "dwa": self.asr_config.get("dwa", "wpgs"), - "result": { - "encoding": "utf8", - "compress": "raw", - "format": "plain" - } - } - - # 准备首帧数据 - first_frame_data = { - "header": { - "status": STATUS_FIRST_FRAME, - "app_id": self.asr_config["app_id"] - }, - "parameter": { - "iat": iat_params - }, - "payload": { - "audio": { - "audio": base64.b64encode(audio_data[:960]).decode('utf-8'), - "sample_rate": 16000, - "encoding": "raw" - } - } - } - - # 启动连接并测量时间 start_time = time.time() - + ws_url = self._create_url() + async with websockets.connect( ws_url, - max_size=1000000000, + additional_headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}, + max_size=1 << 30, ping_interval=None, ping_timeout=None, close_timeout=30, ) as ws: - # 发送首帧数据 - await ws.send(json.dumps(first_frame_data, ensure_ascii=False)) - print(f"[讯飞ASR] 第{i+1}次测试:已发送首帧,等待响应...") - - # 直接等待第一个响应并计算延迟 - # 参考豆包和通义千问的实现方式,简化逻辑 - response_received = False - while not response_received: - try: - # 设置较大的超时时间 - response = await asyncio.wait_for(ws.recv(), timeout=30.0) - - # 收到响应立即计算延迟,不管内容是什么 - # 这样可以准确测量首包到达时间 - latency = time.time() - start_time - latencies.append(latency) - response_received = True - - print(f"[讯飞ASR] 第{i+1}次测试:收到首包响应,延迟: {latency:.3f}s") + + # 第一帧:移除 punc 字段,避免未知参数错误 + await ws.send(json.dumps({ + "common": {"app_id": self.asr_config["app_id"]}, + "business": { + "domain": "iat", + "language": "zh_cn", + "accent": "mandarin", + "dwa": "wpgs", + "vad_eos": 5000 + # 已移除 "punc": True + }, + "data": { + "status": 0, + "format": "audio/L16;rate=16000", + "encoding": "raw", + "audio": base64.b64encode(audio_raw[:frame_size]).decode() + } + }, ensure_ascii=False)) + + # 后续所有帧 + pos = frame_size + while pos < len(audio_raw): + chunk = audio_raw[pos:pos + frame_size] + status = 2 if (pos + frame_size >= len(audio_raw)) else 1 + await ws.send(json.dumps({ + "data": { + "status": status, + "format": "audio/L16;rate=16000", + "encoding": "raw", + "audio": base64.b64encode(chunk).decode() + } + }, ensure_ascii=False)) + if status == 2: break - except asyncio.TimeoutError: - print(f"[讯飞ASR] 第{i+1}次测试:响应超时") - raise Exception("获取响应超时") + pos += frame_size + + # 接收首词 + first_token = True + async for message in ws: + data = json.loads(message) + if data.get("code") != 0: + raise Exception(f"讯飞错误: {data.get('message')}") + + ws_result = data.get("data", {}).get("result", {}).get("ws") + if ws_result: + text = "".join(cw.get("w", "") for seg in ws_result for cw in seg.get("cw", [])) + if text.strip() and first_token: + latency = time.time() - start_time + latencies.append(latency) + print(f"[讯飞ASR] 第{i+1}次 首词延迟: {latency:.3f}s") + first_token = False + break + except Exception as e: print(f"[讯飞ASR] 第{i+1}次测试失败: {str(e)}") - latencies.append(0) - - return self._calculate_result("讯飞流式ASR", latencies, test_count) + latencies.append(None) + return self._calculate_result("讯飞流式ASR", latencies, test_count) class ASRPerformanceSuite: def __init__(self): self.testers = [] @@ -438,8 +457,9 @@ class ASRPerformanceSuite: print(tabulate(table_data, headers=["ASR服务", "首词延迟", "状态"], tablefmt="grid")) print("\n测试说明:") - print("- 测量从发送请求到接收第一个有效识别文本的时间") - print("- 超时控制: DashScope 默认超时,豆包 WebSocket 超时10秒") + print("- 计时起点: 建立连接前(包含握手、发送音频、接收首个识别结果全流程)") + print("- 通义千问优化: 临时文件准备在计时前完成,减少磁盘IO对测试的影响") + print("- 错误处理: 失败的测试不计入平均值,只统计成功测试的延迟") print("- 排序规则: 成功的按延迟升序,失败的排在后面") async def run(self, test_count=5): diff --git a/main/xiaozhi-server/performance_tester/performance_tester_stream_tts.py b/main/xiaozhi-server/performance_tester/performance_tester_stream_tts.py index ce53fea5..31d49a3e 100644 --- a/main/xiaozhi-server/performance_tester/performance_tester_stream_tts.py +++ b/main/xiaozhi-server/performance_tester/performance_tester_stream_tts.py @@ -35,11 +35,12 @@ class StreamTTSPerformanceTester: host = tts_config["host"] ws_url = f"wss://{host}/ws/v1" + # 统一计时起点:在建立连接前开始计时 start_time = time.time() async with websockets.connect(ws_url, extra_headers={"X-NLS-Token": token}) as ws: task_id = str(uuid.uuid4()) message_id = str(uuid.uuid4()) - + start_request = { "header": { "message_id": message_id, @@ -55,14 +56,15 @@ class StreamTTSPerformanceTester: "volume": 50, "speech_rate": 0, "pitch_rate": 0, + "enable_subtitle": True, } } await ws.send(json.dumps(start_request)) - + start_response = json.loads(await ws.recv()) if start_response["header"]["name"] != "SynthesisStarted": raise Exception("启动合成失败") - + run_request = { "header": { "message_id": str(uuid.uuid4()), @@ -74,23 +76,142 @@ class StreamTTSPerformanceTester: "payload": {"text": text} } await ws.send(json.dumps(run_request)) - + while True: response = await ws.recv() if isinstance(response, bytes): latency = time.time() - start_time latencies.append(latency) + print(f"[阿里云TTS] 第{i+1}次 首词延迟: {latency:.3f}s") break elif isinstance(response, str): data = json.loads(response) if data["header"]["name"] == "TaskFailed": raise Exception(f"合成失败: {data['payload']['error_info']}") - + except Exception as e: - latencies.append(0) + print(f"[阿里云TTS] 第{i+1}次测试失败: {str(e)}") + latencies.append(None) return self._calculate_result("阿里云TTS", latencies, test_count) + async def test_alibl_tts(self, text=None, test_count=5): + """测试阿里云百炼CosyVoice流式TTS首词延迟""" + text = text or self.test_texts[0] + latencies = [] + + for i in range(test_count): + try: + tts_config = self.config["TTS"]["AliBLTTS"] + api_key = tts_config["api_key"] + model = tts_config.get("model", "cosyvoice-v2") + voice = tts_config.get("voice", "longxiaochun_v2") + format_type = tts_config.get("format", "pcm") + sample_rate = int(tts_config.get("sample_rate", "24000")) + + ws_url = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/" + headers = { + "Authorization": f"Bearer {api_key}", + "X-DashScope-DataInspection": "enable", + } + + start_time = time.time() + + async with websockets.connect( + ws_url, + additional_headers=headers, + ping_interval=30, + ping_timeout=10, + close_timeout=10, + max_size=10 * 1024 * 1024, + ) as ws: + session_id = uuid.uuid4().hex + + # 1. 发送 run-task(启动任务) + run_task_message = { + "header": { + "action": "run-task", + "task_id": session_id, + "streaming": "duplex", + }, + "payload": { + "task_group": "audio", + "task": "tts", + "function": "SpeechSynthesizer", + "model": model, + "parameters": { + "text_type": "PlainText", + "voice": voice, + "format": format_type, + "sample_rate": sample_rate, + "volume": 50, + "rate": 1.0, + "pitch": 1.0, + }, + "input": {} + }, + } + await ws.send(json.dumps(run_task_message)) + + # 2. 等待 task-started 事件(关键!必须等这个再发文本) + task_started = False + while not task_started: + msg = await ws.recv() + if isinstance(msg, str): + data = json.loads(msg) + header = data.get("header", {}) + event = header.get("event") + if event == "task-started": + task_started = True + print(f"[阿里云百炼TTS] 第{i+1}次 任务启动成功") + elif event == "task-failed": + raise Exception(f"启动失败: {header.get('error_message', '未知错误')}") + + # 3. 发送 continue-task(发送文本!这是正确动作) + continue_task_message = { + "header": { + "action": "continue-task", # 改回 continue-task + "task_id": session_id, + "streaming": "duplex", + }, + "payload": {"input": {"text": text}}, + } + await ws.send(json.dumps(continue_task_message)) + + # 4. 发送 finish-task(结束任务) + finish_task_message = { + "header": { + "action": "finish-task", + "task_id": session_id, + "streaming": "duplex", + }, + "payload": {"input": {}} + } + await ws.send(json.dumps(finish_task_message)) + + # 5. 等待第一个音频数据块 + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=15.0) + if isinstance(msg, (bytes, bytearray)) and len(msg) > 0: + latency = time.time() - start_time + print(f"[阿里云百炼TTS] 第{i+1}次 首词延迟: {latency:.3f}s") + latencies.append(latency) + break + elif isinstance(msg, str): + data = json.loads(msg) + event = data.get("header", {}).get("event") + if event == "task-failed": + raise Exception(f"合成失败: {data}") + elif event == "task-finished": + if not latencies or latencies[-1] is None: + raise Exception("任务结束但未收到音频") + + except Exception as e: + print(f"[阿里云百炼TTS] 第{i+1}次失败: {str(e)}") + latencies.append(None) + + return self._calculate_result("阿里云百炼TTS", latencies, test_count) + async def test_doubao_tts(self, text=None, test_count=5): """测试火山引擎流式TTS首词延迟(测试多次取平均)""" text = text or self.test_texts[0] @@ -114,13 +235,12 @@ class StreamTTSPerformanceTester: } async with websockets.connect(ws_url, additional_headers=ws_header, max_size=1000000000) as ws: session_id = uuid.uuid4().hex - + # 发送会话启动请求 header = bytes([ - (0b0001 << 4) | 0b0001, - 0b0001 << 4 | 0b100, - 0b0001 << 4 | 0b0000, - 0 + (0b0001 << 4) | 0b0001, + 0b0001 << 4 | 0b1011, + 0b0001 << 4 | 0b0000, ]) optional = bytearray() optional.extend((1).to_bytes(4, "big", signed=True)) @@ -129,13 +249,13 @@ class StreamTTSPerformanceTester: optional.extend(session_id_bytes) payload = json.dumps({"speaker": speaker}).encode() await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload) - + # 发送文本 header = bytes([ - (0b0001 << 4) | 0b0001, - 0b0001 << 4 | 0b100, - 0b0001 << 4 | 0b0000, - 0 + (0b0001 << 4) | 0b0001, + 0b0001 << 4 | 0b1011, + 0b0001 << 4 | 0b0000, + 0 ]) optional = bytearray() optional.extend((200).to_bytes(4, "big", signed=True)) @@ -144,13 +264,15 @@ class StreamTTSPerformanceTester: optional.extend(session_id_bytes) payload = json.dumps({"text": text, "speaker": speaker}).encode() await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload) - + first_chunk = await ws.recv() latency = time.time() - start_time latencies.append(latency) - + print(f"[火山引擎TTS] 第{i+1}次 首词延迟: {latency:.3f}s") + except Exception as e: - latencies.append(0) + print(f"[火山引擎TTS] 第{i+1}次测试失败: {str(e)}") + latencies.append(None) return self._calculate_result("火山引擎TTS", latencies, test_count) @@ -191,22 +313,24 @@ class StreamTTSPerformanceTester: first_chunk = await ws.recv() latency = time.time() - start_time latencies.append(latency) - + print(f"[PaddleSpeechTTS] 第{i+1}次 首词延迟: {latency:.3f}s") + # 发送结束请求 end_request = { "task": "tts", "signal": "end" } await ws.send(json.dumps(end_request)) - + # 确保连接正常关闭 try: await ws.recv() except websockets.exceptions.ConnectionClosedOK: pass - + except Exception as e: - latencies.append(0) + print(f"[PaddleSpeechTTS] 第{i+1}次测试失败: {str(e)}") + latencies.append(None) return self._calculate_result("PaddleSpeechTTS", latencies, test_count) @@ -220,29 +344,32 @@ class StreamTTSPerformanceTester: tts_config = self.config["TTS"]["IndexStreamTTS"] api_url = tts_config.get("api_url") voice = tts_config.get("voice") - + + # 统一计时起点:在建立连接前开始计时 start_time = time.time() - + async with aiohttp.ClientSession() as session: payload = {"text": text, "character": voice} async with session.post(api_url, json=payload, timeout=10) as resp: if resp.status != 200: raise Exception(f"请求失败: {resp.status}, {await resp.text()}") - + async for chunk in resp.content.iter_any(): data = chunk[0] if isinstance(chunk, (list, tuple)) else chunk if not data: continue - + latency = time.time() - start_time latencies.append(latency) + print(f"[IndexStreamTTS] 第{i+1}次 首词延迟: {latency:.3f}s") resp.close() break else: - latencies.append(0) - + latencies.append(None) + except Exception as e: - latencies.append(0) + print(f"[IndexStreamTTS] 第{i+1}次测试失败: {str(e)}") + latencies.append(None) return self._calculate_result("IndexStreamTTS", latencies, test_count) @@ -257,7 +384,8 @@ class StreamTTSPerformanceTester: api_url = tts_config["api_url"] access_token = tts_config["access_token"] voice = tts_config["voice"] - + + # 统一计时起点:在建立连接前开始计时 start_time = time.time() async with aiohttp.ClientSession() as session: params = { @@ -273,21 +401,23 @@ class StreamTTSPerformanceTester: "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } - + async with session.get(api_url, params=params, headers=headers, timeout=10) as resp: if resp.status != 200: raise Exception(f"请求失败: {resp.status}, {await resp.text()}") - + # 接收第一个数据块 async for _ in resp.content.iter_any(): latency = time.time() - start_time latencies.append(latency) + print(f"[LinkeraiTTS] 第{i+1}次 首词延迟: {latency:.3f}s") break else: - latencies.append(0) - + latencies.append(None) + except Exception as e: - latencies.append(0) + print(f"[LinkeraiTTS] 第{i+1}次测试失败: {str(e)}") + latencies.append(None) return self._calculate_result("LinkeraiTTS", latencies, test_count) @@ -305,10 +435,9 @@ class StreamTTSPerformanceTester: api_secret = tts_config["api_secret"] api_url = tts_config.get("api_url", "wss://cbm01.cn-huabei-1.xf-yun.com/v1/private/mcd9m97e6") voice = tts_config.get("voice", "x5_lingxiaoxuan_flow") - # 生成认证URL auth_url = self._create_xunfei_auth_url(api_key, api_secret, api_url) - + start_time = time.time() async with websockets.connect( auth_url, ping_interval=30, @@ -318,10 +447,7 @@ class StreamTTSPerformanceTester: ) as ws: # 构造请求 request = self._build_xunfei_request(app_id, text, voice) - # 发送请求后立即计时,确保准确测量从发送文本到接收首块的时间 await ws.send(json.dumps(request)) - start_time = time.time() - # 等待第一个音频数据块 first_audio_received = False while not first_audio_received: @@ -329,14 +455,14 @@ class StreamTTSPerformanceTester: data = json.loads(msg) header = data.get("header", {}) code = header.get("code") - + if code != 0: message = header.get("message", "未知错误") raise Exception(f"合成失败: {code} - {message}") - + payload = data.get("payload", {}) audio_payload = payload.get("audio", {}) - + if audio_payload: status = audio_payload.get("status", 0) audio_data = audio_payload.get("audio", "") @@ -344,10 +470,12 @@ class StreamTTSPerformanceTester: # 收到第一个音频数据块 latency = time.time() - start_time latencies.append(latency) + print(f"[讯飞TTS] 第{i+1}次 首词延迟: {latency:.3f}s") first_audio_received = True break except Exception as e: - latencies.append(0) + print(f"[讯飞TTS] 第{i+1}次测试失败: {str(e)}") + latencies.append(None) return self._calculate_result("讯飞TTS", latencies, test_count) @@ -431,8 +559,9 @@ class StreamTTSPerformanceTester: def _calculate_result(self, service_name, latencies, test_count): - """计算测试结果""" - valid_latencies = [l for l in latencies if l > 0] + """计算测试结果(正确处理None值,剔除失败测试)""" + # 剔除失败的测试(None值和<=0延迟),只统计有效延迟 + valid_latencies = [l for l in latencies if l is not None and l > 0] if valid_latencies: avg_latency = sum(valid_latencies) / len(valid_latencies) status = f"成功({len(valid_latencies)}/{test_count}次有效)" @@ -466,9 +595,10 @@ class StreamTTSPerformanceTester: ] print(tabulate(table_data, headers=["TTS服务", "首词延迟(秒)", "状态"], tablefmt="grid")) - print("\n测试说明:测量从发送请求到接收第一个音频数据块的时间,取多次测试平均值") + print("\n测试说明:测量从建立连接到接收第一个音频数据块的时间(包含握手、鉴权、发送文本),取多次测试平均值") + print("- 计时起点: 建立WebSocket/HTTP连接前(统一包含网络建连、握手、发送文本全流程)") print("- 超时控制: 单个请求最大等待时间为10秒") - print("- 错误处理: 无法连接和超时的列为网络错误") + print("- 错误处理: 失败的测试不计入平均值,只统计成功测试的延迟") print("- 排序规则: 按平均耗时从快到慢排序") @@ -494,7 +624,12 @@ class StreamTTSPerformanceTester: # 测试阿里云TTS result = await self.test_aliyun_tts(test_text, test_count) self.results.append(result) - + + # 测试阿里云百炼TTS + if self.config.get("TTS", {}).get("AliBLTTS"): + result = await self.test_alibl_tts(test_text, test_count) + self.results.append(result) + # 测试火山引擎TTS result = await self.test_doubao_tts(test_text, test_count) self.results.append(result) From 60521b0a7ef6fc2d9ec7cdfd96a4d6688fb0f582 Mon Sep 17 00:00:00 2001 From: FAN-yeB <1442100690@qq.com> Date: Tue, 9 Dec 2025 14:42:50 +0800 Subject: [PATCH 41/93] =?UTF-8?q?update:=E9=95=BF=E6=8C=89=E8=AF=B4?= =?UTF-8?q?=E8=AF=9D=E4=B8=8D=E8=B5=B0VAD=E7=9B=B4=E6=8E=A5=E8=A7=A6?= =?UTF-8?q?=E5=8F=91ASR=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../textHandler/listenMessageHandler.py | 8 ++++++- .../xiaozhi-server/core/providers/asr/base.py | 23 ++++++++++++------- .../core/providers/vad/silero.py | 4 ++++ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py b/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py index 97286dfe..65b442ad 100644 --- a/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py +++ b/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py @@ -30,7 +30,13 @@ class ListenTextMessageHandler(TextMessageHandler): conn.client_have_voice = True conn.client_voice_stop = True if len(conn.asr_audio) > 0: - await handleAudioMessage(conn, b"") + # 手动模式下直接触发ASR识别,不需要再调用handleAudioMessage + asr_audio_task = conn.asr_audio.copy() + conn.asr_audio.clear() + conn.reset_vad_states() + + if len(asr_audio_task) > 0: + await conn.asr.handle_voice_stop(conn, asr_audio_task) elif msg_json["state"] == "detect": conn.client_have_voice = False conn.asr_audio.clear() diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index 7e36de5c..6c3c2d25 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -56,21 +56,28 @@ class ASRProviderBase(ABC): async def receive_audio(self, conn, audio, audio_have_voice): if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime": have_voice = audio_have_voice + + conn.asr_audio.append(audio) + if not have_voice and not conn.client_have_voice: + conn.asr_audio = conn.asr_audio[-10:] + return else: - have_voice = conn.client_have_voice - - conn.asr_audio.append(audio) - if not have_voice and not conn.client_have_voice: - conn.asr_audio = conn.asr_audio[-10:] - return + # 手动模式:总是缓存音频,忽略VAD检测结果 + conn.asr_audio.append(audio) if conn.client_voice_stop: asr_audio_task = conn.asr_audio.copy() conn.asr_audio.clear() conn.reset_vad_states() - if len(asr_audio_task) > 15: - await self.handle_voice_stop(conn, asr_audio_task) + # 手动模式下允许短语音识别,自动模式保持原有限制 + if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime": + if len(asr_audio_task) > 15: + await self.handle_voice_stop(conn, asr_audio_task) + else: + # 手动模式:只要有音频就进行识别 + if len(asr_audio_task) > 0: + await self.handle_voice_stop(conn, asr_audio_task) # 处理语音停止 async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]): diff --git a/main/xiaozhi-server/core/providers/vad/silero.py b/main/xiaozhi-server/core/providers/vad/silero.py index ae51b88f..28a3e61e 100644 --- a/main/xiaozhi-server/core/providers/vad/silero.py +++ b/main/xiaozhi-server/core/providers/vad/silero.py @@ -45,6 +45,10 @@ class VADProvider(VADProviderBase): pass def is_vad(self, conn, opus_packet): + # 手动模式:直接返回True,不进行实时VAD检测,所有音频都缓存 + if conn.client_listen_mode not in ["auto", "realtime"]: + return True + try: pcm_frame = self.decoder.decode(opus_packet, 960) conn.client_audio_buffer.extend(pcm_frame) # 将新数据加入缓冲区 From 48094f7e376324d893773f667500b5eb14293404 Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Tue, 9 Dec 2025 18:06:56 +0800 Subject: [PATCH 42/93] =?UTF-8?q?update:=20=E9=9F=B3=E9=A2=91=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E9=98=9F=E5=88=97=E7=A8=B3=E5=AE=9A=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 5 + .../core/handle/sendAudioHandle.py | 199 ++++++++++-------- .../core/utils/audioRateController.py | 84 +++++--- .../core/utils/opus_encoder_utils.py | 3 + 4 files changed, 166 insertions(+), 125 deletions(-) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 6b34e32f..b37745c7 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -1171,6 +1171,11 @@ class ConnectionHandler: except queue.Empty: break + # 重置音频流控器(取消后台任务并清空队列) + if hasattr(self, "audio_rate_controller") and self.audio_rate_controller: + self.audio_rate_controller.reset() + self.logger.bind(tag=TAG).debug("已重置音频流控器") + self.logger.bind(tag=TAG).debug( f"清理结束: TTS队列大小={self.tts.tts_text_queue.qsize()}, 音频队列大小={self.tts.tts_audio_queue.qsize()}" ) diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py index ea952fbe..f662b6c6 100644 --- a/main/xiaozhi-server/core/handle/sendAudioHandle.py +++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py @@ -16,7 +16,14 @@ async def sendAudioMessage(conn, sentenceType, audios, text): await send_tts_message(conn, "start", None) if sentenceType == SentenceType.FIRST: - await send_tts_message(conn, "sentence_start", text) + # 同一句子的后续消息加入流控队列,其他情况立即发送 + if hasattr(conn, "audio_rate_controller") and conn.audio_rate_controller and getattr(conn, "audio_flow_control", {}).get("sentence_id") == conn.sentence_id: + conn.audio_rate_controller.add_message( + lambda: send_tts_message(conn, "sentence_start", text) + ) + else: + # 新句子或流控器未初始化,立即发送 + await send_tts_message(conn, "sentence_start", text) await sendAudio(conn, audios) # 发送句子开始消息 @@ -31,6 +38,22 @@ async def sendAudioMessage(conn, sentenceType, audios, text): await conn.close() +async def _wait_for_audio_completion(conn): + """ + 等待音频队列清空 + + Args: + conn: 连接对象 + """ + if hasattr(conn, "audio_rate_controller") and conn.audio_rate_controller: + rate_controller = conn.audio_rate_controller + conn.logger.bind(tag=TAG).debug( + f"等待音频发送完成,队列中还有 {len(rate_controller.queue)} 个包" + ) + await rate_controller.queue_empty_event.wait() + conn.logger.bind(tag=TAG).debug("音频发送完成") + + async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence): """ 发送带16字节头部的opus数据包给mqtt_gateway @@ -53,7 +76,6 @@ async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence): await conn.websocket.send(complete_packet) -# 播放音频 - 使用 AudioRateController 进行精确流控 async def sendAudio(conn, audios, frame_duration=60): """ 发送音频包,使用 AudioRateController 进行精确的流量控制 @@ -62,130 +84,121 @@ async def sendAudio(conn, audios, frame_duration=60): conn: 连接对象 audios: 单个opus包(bytes) 或 opus包列表 frame_duration: 帧时长(毫秒),默认60ms - - 改进点: - 1. 使用单一时间基准,避免累积误差 - 2. 每次检查队列时重新计算 elapsed_ms,更精准 - 3. 支持高并发而不产生时间偏差 """ if audios is None or len(audios) == 0: return - # 获取发送延迟配置 send_delay = conn.config.get("tts_audio_send_delay", -1) / 1000.0 + is_single_packet = isinstance(audios, bytes) - if isinstance(audios, bytes): - # 单个 opus 包处理 - await _sendAudio_single(conn, audios, send_delay, frame_duration) - else: - # 音频列表处理(如文件型音频) - await _sendAudio_list(conn, audios, send_delay, frame_duration) + # 初始化或获取 RateController + rate_controller, flow_control = _get_or_create_rate_controller( + conn, frame_duration, is_single_packet + ) + + # 统一转换为列表处理 + audio_list = [audios] if is_single_packet else audios + + # 发送音频包 + await _send_audio_with_rate_control( + conn, audio_list, rate_controller, flow_control, send_delay + ) -async def _sendAudio_single(conn, opus_packet, send_delay, frame_duration=60): +def _get_or_create_rate_controller(conn, frame_duration, is_single_packet): """ - 发送单个 opus 包 - 使用 AudioRateController 进行流控 + 获取或创建 RateController 和 flow_control + + Args: + conn: 连接对象 + frame_duration: 帧时长 + is_single_packet: 是否单包模式(True: TTS流式单包, False: 批量包) + + Returns: + (rate_controller, flow_control) """ - # 重置流控状态,第一次读取和会话发生转变时 - if not hasattr(conn, "audio_rate_controller") or conn.audio_flow_control.get("sentence_id") != conn.sentence_id: - if hasattr(conn, "audio_rate_controller"): - conn.audio_rate_controller.reset() - else: + # 判断是否需要重置:单包模式且 sentence_id 变化,或者控制器不存在 + need_reset = ( + is_single_packet + and getattr(conn, "audio_flow_control", {}).get("sentence_id") != conn.sentence_id + ) or not hasattr(conn, "audio_rate_controller") + + if need_reset: + # 创建或获取 rate_controller + if not hasattr(conn, "audio_rate_controller"): conn.audio_rate_controller = AudioRateController(frame_duration) + else: conn.audio_rate_controller.reset() + # 初始化 flow_control conn.audio_flow_control = { "packet_count": 0, "sequence": 0, "sentence_id": conn.sentence_id, } - if conn.client_abort: - return + # 启动后台发送循环 + _start_background_sender(conn, conn.audio_rate_controller, conn.audio_flow_control) - conn.last_activity_time = time.time() * 1000 + return conn.audio_rate_controller, conn.audio_flow_control - rate_controller = conn.audio_rate_controller - flow_control = conn.audio_flow_control - packet_count = flow_control["packet_count"] - # 预缓冲:前5个包直接发送,不做延迟 +def _start_background_sender(conn, rate_controller, flow_control): + """ + 启动后台发送循环任务 + + Args: + conn: 连接对象 + rate_controller: 速率控制器 + flow_control: 流控状态 + """ + async def send_callback(packet): + # 检查是否应该中止 + if conn.client_abort: + raise asyncio.CancelledError("客户端已中止") + + conn.last_activity_time = time.time() * 1000 + await _do_send_audio(conn, packet, flow_control) + conn.client_is_speaking = True + + # 使用 start_sending 启动后台循环 + rate_controller.start_sending(send_callback) + + +async def _send_audio_with_rate_control(conn, audio_list, rate_controller, flow_control, send_delay): + """ + 使用 rate_controller 发送音频包 + + Args: + conn: 连接对象 + audio_list: 音频包列表 + rate_controller: 速率控制器 + flow_control: 流控状态 + send_delay: 固定延迟(秒),-1表示使用动态流控 + """ pre_buffer_count = 5 - if packet_count < pre_buffer_count or send_delay > 0: - # 预缓冲阶段或固定延迟模式,直接发送 - await _do_send_audio(conn, opus_packet, flow_control, frame_duration) - conn.client_is_speaking = True - - if send_delay > 0 and packet_count >= pre_buffer_count: - await asyncio.sleep(send_delay) - else: - # 使用流控器进行精确的速率控制 - rate_controller.add_audio(opus_packet) - - async def send_callback(packet): - await _do_send_audio(conn, packet, flow_control, frame_duration) - - await rate_controller.check_queue(send_callback) - conn.client_is_speaking = True - - # 更新流控状态 - flow_control["packet_count"] += 1 - flow_control["sequence"] += 1 - - -async def _sendAudio_list(conn, audios, send_delay, frame_duration=60): - """ - 发送音频列表(如文件型音频) - """ - if not audios: - return - - rate_controller = AudioRateController(frame_duration) - rate_controller.reset() - - flow_control = { - "packet_count": 0, - "sequence": 0, - } - - # 预缓冲:前5个包直接发送 - pre_buffer_frames = min(5, len(audios)) - for i in range(pre_buffer_frames): + for packet in audio_list: if conn.client_abort: return - await _do_send_audio(conn, audios[i], flow_control, frame_duration) - conn.client_is_speaking = True - - remaining_audios = audios[pre_buffer_frames:] - - # 处理剩余音频帧 - for i, opus_packet in enumerate(remaining_audios): - if conn.client_abort: - break conn.last_activity_time = time.time() * 1000 - if send_delay > 0: + # 预缓冲:前5个包直接发送 + if flow_control["packet_count"] < pre_buffer_count: + await _do_send_audio(conn, packet, flow_control) + conn.client_is_speaking = True + elif send_delay > 0: # 固定延迟模式 await asyncio.sleep(send_delay) - else: - # 使用流控器进行精确延迟 - rate_controller.add_audio(opus_packet) - - async def send_callback(packet): - await _do_send_audio(conn, packet, flow_control, frame_duration) - - await rate_controller.check_queue(send_callback) + await _do_send_audio(conn, packet, flow_control) conn.client_is_speaking = True - continue - - await _do_send_audio(conn, opus_packet, flow_control, frame_duration) - conn.client_is_speaking = True + else: + # 动态流控模式:仅添加到队列,由后台循环负责发送 + rate_controller.add_audio(packet) -async def _do_send_audio(conn, opus_packet, flow_control, frame_duration=60): +async def _do_send_audio(conn, opus_packet, flow_control): """ 执行实际的音频发送 """ @@ -224,6 +237,8 @@ async def send_tts_message(conn, state, text=None): ) audios = audio_to_data(stop_tts_notify_voice, is_opus=True) await sendAudio(conn, audios) + # 等待所有音频包发送完成 + await _wait_for_audio_completion(conn) # 清除服务端讲话状态 conn.clearSpeakStatus() diff --git a/main/xiaozhi-server/core/utils/audioRateController.py b/main/xiaozhi-server/core/utils/audioRateController.py index b404e6f1..e6d883f1 100644 --- a/main/xiaozhi-server/core/utils/audioRateController.py +++ b/main/xiaozhi-server/core/utils/audioRateController.py @@ -10,12 +10,6 @@ class AudioRateController: """ 音频速率控制器 - 按照60ms帧时长精确控制音频发送 解决高并发下的时间累积误差问题 - - 关键改进: - 1. 单一时间基准(start_timestamp 只初始化一次) - 2. 每次检查队列时重新计算 elapsed_ms,避免累积误差 - 3. 分离"检查时间"和"发送"两个操作 - 4. 支持高并发而不产生延迟 """ def __init__(self, frame_duration=60): @@ -29,24 +23,34 @@ class AudioRateController: self.start_timestamp = None # 开始时间戳(只读,不修改) self.pending_send_task = None self.logger = logger + self.queue_empty_event = asyncio.Event() # 队列清空事件 + self.queue_empty_event.set() # 初始为空状态 def reset(self): """重置控制器状态""" if self.pending_send_task and not self.pending_send_task.done(): self.pending_send_task.cancel() - try: - # 等待任务取消完成 - asyncio.get_event_loop().run_until_complete(self.pending_send_task) - except asyncio.CancelledError: - pass + # 取消任务后,任务会在下次事件循环时清理,无需阻塞等待 self.queue.clear() self.play_position = 0 self.start_timestamp = time.time() + self.queue_empty_event.set() # 队列已清空 def add_audio(self, opus_packet): """添加音频包到队列""" self.queue.append(("audio", opus_packet)) + self.queue_empty_event.clear() # 队列非空,清除事件 + + def add_message(self, message_callback): + """ + 添加消息到队列(立即发送,不占用播放时间) + + Args: + message_callback: 消息发送回调函数 async def() + """ + self.queue.append(("message", message_callback)) + self.queue_empty_event.clear() # 队列非空,清除事件 def _get_elapsed_ms(self): """获取已经过的时间(毫秒)""" @@ -62,34 +66,47 @@ class AudioRateController: send_audio_callback: 发送音频的回调函数 async def(opus_packet) """ if self.start_timestamp is None: - self.reset() + self.start_timestamp = time.time() while self.queue: item = self.queue[0] item_type = item[0] - if item_type == "audio": + if item_type == "message": + # 消息类型:立即发送,不占用播放时间 + _, message_callback = item + self.queue.pop(0) + try: + await message_callback() + except Exception as e: + self.logger.bind(tag=TAG).error(f"发送消息失败: {e}") + raise + + elif item_type == "audio": _, opus_packet = item - # 计算时间差 - elapsed_ms = self._get_elapsed_ms() - output_ms = self.play_position + # 循环等待直到时间到达 + while True: + # 计算时间差 + elapsed_ms = self._get_elapsed_ms() + output_ms = self.play_position - if elapsed_ms < output_ms: - # 还不到发送时间,计算等待时长 - wait_ms = output_ms - elapsed_ms + if elapsed_ms < output_ms: + # 还不到发送时间,计算等待时长 + wait_ms = output_ms - elapsed_ms - # 等待后继续检查(允许被中断) - try: - await asyncio.sleep(wait_ms / 1000) - except asyncio.CancelledError: - self.logger.bind(tag=TAG).debug("音频发送任务被取消") - raise + # 等待后继续检查(允许被中断) + try: + await asyncio.sleep(wait_ms / 1000) + except asyncio.CancelledError: + self.logger.bind(tag=TAG).debug("音频发送任务被取消") + raise + # 等待结束后重新检查时间(循环回到 while True) + else: + # 时间已到,跳出等待循环 + break - # 继续循环检查(时间可能已到) - continue - - # 时间已到,发送音频 + # 时间已到,从队列移除并发送 self.queue.pop(0) self.play_position += self.frame_duration @@ -99,14 +116,15 @@ class AudioRateController: self.logger.bind(tag=TAG).error(f"发送音频失败: {e}") raise + self.queue_empty_event.set() - async def start_sending(self, send_audio_callback, send_message_callback=None): + + def start_sending(self, send_audio_callback): """ 启动异步发送任务 Args: send_audio_callback: 发送音频的回调函数 - send_message_callback: 发送消息的回调函数 Returns: asyncio.Task: 发送任务 @@ -114,11 +132,11 @@ class AudioRateController: async def _send_loop(): try: while True: - await self.check_queue(send_audio_callback, send_message_callback) + await self.check_queue(send_audio_callback) # 如果队列空了,短暂等待后再检查(避免 busy loop) await asyncio.sleep(0.01) except asyncio.CancelledError: - self.logger.bind(tag=TAG).info("音频发送循环已停止") + self.logger.bind(tag=TAG).debug("音频发送循环已停止") except Exception as e: self.logger.bind(tag=TAG).error(f"音频发送循环异常: {e}") diff --git a/main/xiaozhi-server/core/utils/opus_encoder_utils.py b/main/xiaozhi-server/core/utils/opus_encoder_utils.py index 300b2422..f2fcc266 100644 --- a/main/xiaozhi-server/core/utils/opus_encoder_utils.py +++ b/main/xiaozhi-server/core/utils/opus_encoder_utils.py @@ -103,6 +103,9 @@ class OpusEncoderUtils: def _encode(self, frame: np.ndarray) -> Optional[bytes]: """编码一帧音频数据""" try: + # 编码器已释放,跳过编码 + if not hasattr(self, 'encoder') or self.encoder is None: + return None # 将numpy数组转换为bytes frame_bytes = frame.tobytes() # opuslib要求输入字节数必须是channels*2的倍数 From bfa04743e2d5272ea68f0b77ac4701a262fb656d Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Wed, 10 Dec 2025 10:26:27 +0800 Subject: [PATCH 43/93] =?UTF-8?q?update:=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E6=BA=90=E5=8A=9F=E8=83=BD=E7=9A=84=E5=9B=BD=E9=99=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ContextProviderDialog.vue | 24 +++++++++---------- main/manager-web/src/i18n/de.js | 20 ++++++++++++++++ main/manager-web/src/i18n/en.js | 20 ++++++++++++++++ main/manager-web/src/i18n/vi.js | 20 ++++++++++++++++ main/manager-web/src/i18n/zh_CN.js | 20 ++++++++++++++++ main/manager-web/src/i18n/zh_TW.js | 20 ++++++++++++++++ main/manager-web/src/views/roleConfig.vue | 6 ++--- 7 files changed, 115 insertions(+), 15 deletions(-) diff --git a/main/manager-web/src/components/ContextProviderDialog.vue b/main/manager-web/src/components/ContextProviderDialog.vue index 21d31278..84eae4a9 100644 --- a/main/manager-web/src/components/ContextProviderDialog.vue +++ b/main/manager-web/src/components/ContextProviderDialog.vue @@ -2,14 +2,14 @@
- - 添加 + + {{ $t('contextProviderDialog.add') }}
- 接口地址 + {{ $t('contextProviderDialog.apiUrl') }} @@ -31,7 +31,7 @@
-
请求头
+
{{ $t('contextProviderDialog.requestHeaders') }}
: @@ -73,13 +73,13 @@
- 暂无 Headers + {{ $t('contextProviderDialog.noHeaders') }} 添加 Header + >{{ $t('contextProviderDialog.addHeader') }}
@@ -106,8 +106,8 @@
- 取消 - 确定 + {{ $t('contextProviderDialog.cancel') }} + {{ $t('contextProviderDialog.confirm') }} diff --git a/main/manager-web/src/i18n/de.js b/main/manager-web/src/i18n/de.js index 4c1dfe5f..4a9ab40c 100644 --- a/main/manager-web/src/i18n/de.js +++ b/main/manager-web/src/i18n/de.js @@ -230,6 +230,26 @@ export default { 'voicePrintDialog.requiredName': 'Bitte Namen eingeben', 'voicePrintDialog.requiredAudioVector': 'Bitte Audio-Vektor auswählen', + // Context provider dialog related + 'contextProviderDialog.title': 'Quelle bearbeiten', + 'contextProviderDialog.noContextApi': 'Keine Kontext-API', + 'contextProviderDialog.add': 'Hinzufügen', + 'contextProviderDialog.apiUrl': 'API-URL', + 'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data', + 'contextProviderDialog.requestHeaders': 'Anfrage-Header', + 'contextProviderDialog.headerKeyPlaceholder': 'Schlüssel', + 'contextProviderDialog.headerValuePlaceholder': 'Wert', + 'contextProviderDialog.noHeaders': 'Keine Headers', + 'contextProviderDialog.addHeader': 'Header hinzufügen', + 'contextProviderDialog.cancel': 'Abbrechen', + 'contextProviderDialog.confirm': 'Bestätigen', + + // Role config page - context provider related + 'roleConfig.contextProvider': 'Kontext', + 'roleConfig.contextProviderSuccess': '{count} Quellen erfolgreich hinzugefügt.', + 'roleConfig.contextProviderDocLink': 'Wie man Kontextquellen bereitstellt', + 'roleConfig.editContextProvider': 'Quelle bearbeiten', + // Voice print page related 'voicePrint.pageTitle': 'Stimmabdruck-Erkennung', 'voicePrint.name': 'Name', diff --git a/main/manager-web/src/i18n/en.js b/main/manager-web/src/i18n/en.js index db4b47ba..298d1e17 100644 --- a/main/manager-web/src/i18n/en.js +++ b/main/manager-web/src/i18n/en.js @@ -230,6 +230,26 @@ export default { 'voicePrintDialog.requiredName': 'Please enter name', 'voicePrintDialog.requiredAudioVector': 'Please select audio vector', + // Context provider dialog related + 'contextProviderDialog.title': 'Edit Source', + 'contextProviderDialog.noContextApi': 'No Context API', + 'contextProviderDialog.add': 'Add', + 'contextProviderDialog.apiUrl': 'API URL', + 'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data', + 'contextProviderDialog.requestHeaders': 'Request Headers', + 'contextProviderDialog.headerKeyPlaceholder': 'Key', + 'contextProviderDialog.headerValuePlaceholder': 'Value', + 'contextProviderDialog.noHeaders': 'No Headers', + 'contextProviderDialog.addHeader': 'Add Header', + 'contextProviderDialog.cancel': 'Cancel', + 'contextProviderDialog.confirm': 'Confirm', + + // Role config page - context provider related + 'roleConfig.contextProvider': 'Context', + 'roleConfig.contextProviderSuccess': 'Successfully added {count} sources.', + 'roleConfig.contextProviderDocLink': 'How to deploy context provider', + 'roleConfig.editContextProvider': 'Edit Source', + // Voice print page related 'voicePrint.pageTitle': 'Voice Print Recognition', 'voicePrint.name': 'Name', diff --git a/main/manager-web/src/i18n/vi.js b/main/manager-web/src/i18n/vi.js index f109e1c3..9ca108ef 100644 --- a/main/manager-web/src/i18n/vi.js +++ b/main/manager-web/src/i18n/vi.js @@ -230,6 +230,26 @@ export default { 'voicePrintDialog.requiredName': 'Vui lòng nhập tên', 'voicePrintDialog.requiredAudioVector': 'Vui lòng chọn vector âm thanh', + // Context provider dialog related + 'contextProviderDialog.title': 'Chỉnh sửa nguồn', + 'contextProviderDialog.noContextApi': 'Không có API ngữ cảnh', + 'contextProviderDialog.add': 'Thêm', + 'contextProviderDialog.apiUrl': 'Địa chỉ API', + 'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data', + 'contextProviderDialog.requestHeaders': 'Header yêu cầu', + 'contextProviderDialog.headerKeyPlaceholder': 'Khóa', + 'contextProviderDialog.headerValuePlaceholder': 'Giá trị', + 'contextProviderDialog.noHeaders': 'Không có Headers', + 'contextProviderDialog.addHeader': 'Thêm Header', + 'contextProviderDialog.cancel': 'Hủy bỏ', + 'contextProviderDialog.confirm': 'Xác nhận', + + // Role config page - context provider related + 'roleConfig.contextProvider': 'Bối cảnh', + 'roleConfig.contextProviderSuccess': 'Đã thêm thành công {count} nguồn.', + 'roleConfig.contextProviderDocLink': 'Cách triển khai nguồn ngữ cảnh', + 'roleConfig.editContextProvider': 'Chỉnh sửa nguồn', + // Voice print page related 'voicePrint.pageTitle': 'Nhận dạng dấu giọng nói', 'voicePrint.name': 'Tên', diff --git a/main/manager-web/src/i18n/zh_CN.js b/main/manager-web/src/i18n/zh_CN.js index 50bc55b9..392b9a8b 100644 --- a/main/manager-web/src/i18n/zh_CN.js +++ b/main/manager-web/src/i18n/zh_CN.js @@ -230,6 +230,26 @@ export default { 'voicePrintDialog.requiredName': '请输入姓名', 'voicePrintDialog.requiredAudioVector': '请选择音频向量', + // 上下文源对话框相关 + 'contextProviderDialog.title': '编辑源', + 'contextProviderDialog.noContextApi': '暂无上下文API', + 'contextProviderDialog.add': '添加', + 'contextProviderDialog.apiUrl': '接口地址', + 'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data', + 'contextProviderDialog.requestHeaders': '请求头', + 'contextProviderDialog.headerKeyPlaceholder': 'Key', + 'contextProviderDialog.headerValuePlaceholder': 'Value', + 'contextProviderDialog.noHeaders': '暂无 Headers', + 'contextProviderDialog.addHeader': '添加 Header', + 'contextProviderDialog.cancel': '取消', + 'contextProviderDialog.confirm': '确定', + + // 角色配置页面-上下文源相关 + 'roleConfig.contextProvider': '上下文源', + 'roleConfig.contextProviderSuccess': '已成功添加 {count} 个源。', + 'roleConfig.contextProviderDocLink': '如何部署上下文源', + 'roleConfig.editContextProvider': '编辑源', + // 声纹页面相关 'voicePrint.pageTitle': '声纹识别', 'voicePrint.name': '姓名', diff --git a/main/manager-web/src/i18n/zh_TW.js b/main/manager-web/src/i18n/zh_TW.js index be802783..69bacd67 100644 --- a/main/manager-web/src/i18n/zh_TW.js +++ b/main/manager-web/src/i18n/zh_TW.js @@ -230,6 +230,26 @@ export default { 'voicePrintDialog.requiredName': '請輸入姓名', 'voicePrintDialog.requiredAudioVector': '請選擇音頻向量', + // 上下文源對話框相關 + 'contextProviderDialog.title': '編輯源', + 'contextProviderDialog.noContextApi': '暫無上下文API', + 'contextProviderDialog.add': '添加', + 'contextProviderDialog.apiUrl': '接口地址', + 'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data', + 'contextProviderDialog.requestHeaders': '請求頭', + 'contextProviderDialog.headerKeyPlaceholder': 'Key', + 'contextProviderDialog.headerValuePlaceholder': 'Value', + 'contextProviderDialog.noHeaders': '暫無 Headers', + 'contextProviderDialog.addHeader': '添加 Header', + 'contextProviderDialog.cancel': '取消', + 'contextProviderDialog.confirm': '確定', + + // 角色配置頁面-上下文源相關 + 'roleConfig.contextProvider': '上下文源', + 'roleConfig.contextProviderSuccess': '已成功添加 {count} 個源。', + 'roleConfig.contextProviderDocLink': '如何部署上下文源', + 'roleConfig.editContextProvider': '編輯源', + // 聲紋頁面相關 'voicePrint.pageTitle': '聲紋識別', 'voicePrint.name': '姓名', diff --git a/main/manager-web/src/views/roleConfig.vue b/main/manager-web/src/views/roleConfig.vue index c20c6b82..4203ceb9 100644 --- a/main/manager-web/src/views/roleConfig.vue +++ b/main/manager-web/src/views/roleConfig.vue @@ -55,17 +55,17 @@
- +
- 已成功添加 {{ currentContextProviders.length }} 个源。如何部署上下文源 + {{ $t('roleConfig.contextProviderSuccess', { count: currentContextProviders.length }) }}{{ $t('roleConfig.contextProviderDocLink') }} - 编辑源 + {{ $t('roleConfig.editContextProvider') }}
From 3a74b30a0e5669e7e1d8e69bdd4ba0a892c54497 Mon Sep 17 00:00:00 2001 From: FAN-yeB <1442100690@qq.com> Date: Wed, 10 Dec 2025 17:57:26 +0800 Subject: [PATCH 44/93] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20qwen3=5Fasr=5Fflash.?= =?UTF-8?q?py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/providers/asr/qwen3_asr_flash.py | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py b/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py index 84fe1979..7101448d 100644 --- a/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py +++ b/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py @@ -1,8 +1,5 @@ import os -import json -import asyncio import tempfile -import difflib from typing import Optional, Tuple, List import dashscope from config.logger import setup_logging @@ -130,27 +127,11 @@ class ASRProvider(ASRProviderBase): # 处理流式响应 full_text = "" - last_text = "" # 用于存储上一个文本片段 for chunk in response: try: text = chunk["output"]["choices"][0]["message"].content[0]["text"] - # 标准化文本片段(去除首尾空格) - normalized_text = text.strip() - # 只有当新文本片段与上一个不同时才处理 - if normalized_text != last_text: - # 提取新增的文本部分 - # 通过比较当前文本和上一个文本,找到新增的部分 - if normalized_text.startswith(last_text): - # 如果当前文本以最后一个文本开头,则新增部分是两者的差集 - new_part = normalized_text[len(last_text):] - else: - # 如果不以最后一个文本开头,说明识别结果发生了较大变化,直接使用当前文本 - new_part = normalized_text - - # 将新增部分添加到完整文本中 - full_text += new_part - last_text = normalized_text - # 这里可以实时处理文本片段,例如通过回调函数 + # 更新为最新的完整文本 + full_text = text.strip() except: pass From 5ac1a1d6a597c6cd02bb460074134c4369e066c8 Mon Sep 17 00:00:00 2001 From: panjingpeng Date: Thu, 11 Dec 2025 16:08:45 +0800 Subject: [PATCH 45/93] =?UTF-8?q?fix:=20=E4=B8=BAJava=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=9A=84OTA=E6=8E=A5=E5=8F=A3=E5=AE=9E=E7=8E=B0WebSocket?= =?UTF-8?q?=E8=AE=A4=E8=AF=81token=E7=94=9F=E6=88=90=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E7=A1=AE=E4=BF=9D=E4=B8=8EPython=E7=AB=AF=E5=AE=8C?= =?UTF-8?q?=E5=85=A8=E5=85=BC=E5=AE=B9=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xiaozhi/common/constant/Constant.java | 5 ++ .../service/impl/DeviceServiceImpl.java | 53 ++++++++++++++++++- main/xiaozhi-server/config/config_loader.py | 2 + 3 files changed, 59 insertions(+), 1 deletion(-) 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 1ea19355..13084f9b 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 @@ -141,6 +141,11 @@ public interface Constant { */ String SERVER_MQTT_SECRET = "server.mqtt_signature_key"; + /** + * WebSocket认证开关 + */ + String SERVER_AUTH_ENABLED = "server.auth.enabled"; + /** * 无记忆 */ diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java index 5c3f854d..b00af2cf 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java @@ -1,6 +1,8 @@ package xiaozhi.modules.device.service.impl; import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.util.Base64; import java.util.Date; @@ -169,7 +171,22 @@ public class DeviceServiceImpl extends BaseServiceImpl DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket(); // 从系统参数获取WebSocket URL,如果未配置则使用默认值 String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true); - websocket.setToken(""); + + // 检查是否启用认证并生成token + String authEnabled = sysParamsService.getValue(Constant.SERVER_AUTH_ENABLED, false); + if ("true".equalsIgnoreCase(authEnabled)) { + try { + // 生成token + String token = generateWebSocketToken(clientId, macAddress); + websocket.setToken(token); + } catch (Exception e) { + log.error("生成WebSocket token失败: {}", e.getMessage()); + websocket.setToken(""); + } + } else { + websocket.setToken(""); + } + if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) { log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置"); wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/"; @@ -494,6 +511,40 @@ public class DeviceServiceImpl extends BaseServiceImpl return Base64.getEncoder().encodeToString(signature); } + /** + * 生成WebSocket认证token 遵循Python端AuthManager的实现逻辑:token = signature.timestamp + * + * @param clientId 客户端ID + * @param username 用户名 (通常为deviceId/macAddress) + * @return 认证token字符串 + */ + private String generateWebSocketToken(String clientId, String username) + throws NoSuchAlgorithmException, InvalidKeyException { + // 从系统参数获取密钥 + String secretKey = sysParamsService.getValue(Constant.SERVER_SECRET, false); + if (StringUtils.isBlank(secretKey)) { + throw new IllegalStateException("WebSocket认证密钥未配置(server.secret)"); + } + + // 获取当前时间戳(秒) + long timestamp = System.currentTimeMillis() / 1000; + + // 构建签名内容: clientId|username|timestamp + String content = String.format("%s|%s|%d", clientId, username, timestamp); + + // 生成HMAC-SHA256签名 + Mac hmac = Mac.getInstance("HmacSHA256"); + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + hmac.init(keySpec); + byte[] signature = hmac.doFinal(content.getBytes(StandardCharsets.UTF_8)); + + // Base64 URL-safe编码签名(去除填充符=) + String signatureBase64 = Base64.getUrlEncoder().withoutPadding().encodeToString(signature); + + // 返回格式: signature.timestamp + return String.format("%s.%d", signatureBase64, timestamp); + } + /** * 构建MQTT配置信息 * diff --git a/main/xiaozhi-server/config/config_loader.py b/main/xiaozhi-server/config/config_loader.py index c85510b8..e0220e33 100644 --- a/main/xiaozhi-server/config/config_loader.py +++ b/main/xiaozhi-server/config/config_loader.py @@ -68,6 +68,7 @@ async def get_config_from_api_async(config): "url": config["manager-api"].get("url", ""), "secret": config["manager-api"].get("secret", ""), } + auth_enabled = config_data.get("server", {}).get("auth", {}).get("enabled", False) # server的配置以本地为准 if config.get("server"): config_data["server"] = { @@ -77,6 +78,7 @@ async def get_config_from_api_async(config): "vision_explain": config["server"].get("vision_explain", ""), "auth_key": config["server"].get("auth_key", ""), } + config_data["server"]["auth"] = {"enabled": auth_enabled} # 如果服务器没有prompt_template,则从本地配置读取 if not config_data.get("prompt_template"): config_data["prompt_template"] = config.get("prompt_template") From fbfc408e94427e70075724ae389fc46e7c6e0cd4 Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Thu, 11 Dec 2025 17:12:56 +0800 Subject: [PATCH 46/93] =?UTF-8?q?update:=20=E9=95=BF=E6=8C=89=E8=AE=BE?= =?UTF-8?q?=E5=A4=87ASR=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/config.yaml | 1 - .../core/handle/reportHandle.py | 1 - .../textHandler/listenMessageHandler.py | 24 +- .../core/providers/asr/aliyun_stream.py | 141 +++++----- .../xiaozhi-server/core/providers/asr/base.py | 27 +- .../core/providers/asr/doubao_stream.py | 66 +++-- .../core/providers/asr/qwen3_asr_flash.py | 3 +- .../core/providers/asr/xunfei_stream.py | 250 +++--------------- .../core/providers/vad/silero.py | 3 +- .../core/utils/opus_encoder_utils.py | 1 - main/xiaozhi-server/core/utils/util.py | 1 - 11 files changed, 195 insertions(+), 323 deletions(-) diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index 2c005356..e7b61dcd 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -471,7 +471,6 @@ ASR: domain: slm # 识别领域,iat:日常用语,medical:医疗,finance:金融等 language: zh_cn # 语言,zh_cn:中文,en_us:英文 accent: mandarin # 方言,mandarin:普通话 - dwa: wpgs # 动态修正,wpgs:实时返回中间结果 # 调整音频处理参数以提高长语音识别质量 output_dir: tmp/ diff --git a/main/xiaozhi-server/core/handle/reportHandle.py b/main/xiaozhi-server/core/handle/reportHandle.py index 973ebb38..053e8f2e 100644 --- a/main/xiaozhi-server/core/handle/reportHandle.py +++ b/main/xiaozhi-server/core/handle/reportHandle.py @@ -10,7 +10,6 @@ TTS上报功能已集成到ConnectionHandler类中。 """ import time -import gc import opuslib_next from config.manage_api_client import report as manage_report diff --git a/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py b/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py index 65b442ad..a2c96836 100644 --- a/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py +++ b/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py @@ -1,12 +1,14 @@ import time +import asyncio from typing import Dict, Any -from core.handle.receiveAudioHandle import handleAudioMessage, startToChat +from core.handle.receiveAudioHandle import startToChat from core.handle.reportHandle import enqueue_asr_report from core.handle.sendAudioHandle import send_stt_message, send_tts_message from core.handle.textMessageHandler import TextMessageHandler from core.handle.textMessageType import TextMessageType from core.utils.util import remove_punctuation_and_length +from core.providers.asr.dto.dto import InterfaceType TAG = __name__ @@ -29,14 +31,18 @@ class ListenTextMessageHandler(TextMessageHandler): elif msg_json["state"] == "stop": conn.client_have_voice = True conn.client_voice_stop = True - if len(conn.asr_audio) > 0: - # 手动模式下直接触发ASR识别,不需要再调用handleAudioMessage - asr_audio_task = conn.asr_audio.copy() - conn.asr_audio.clear() - conn.reset_vad_states() - - if len(asr_audio_task) > 0: - await conn.asr.handle_voice_stop(conn, asr_audio_task) + if conn.asr.interface_type == InterfaceType.STREAM: + # 流式模式下,发送结束请求 + asyncio.create_task(conn.asr._send_stop_request()) + else: + # 非流式模式:直接触发ASR识别 + if len(conn.asr_audio) > 0: + asr_audio_task = conn.asr_audio.copy() + conn.asr_audio.clear() + conn.reset_vad_states() + + if len(asr_audio_task) > 0: + await conn.asr.handle_voice_stop(conn, asr_audio_task) elif msg_json["state"] == "detect": conn.client_have_voice = False conn.asr_audio.clear() diff --git a/main/xiaozhi-server/core/providers/asr/aliyun_stream.py b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py index 179685bd..4ce588a7 100644 --- a/main/xiaozhi-server/core/providers/asr/aliyun_stream.py +++ b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py @@ -5,12 +5,9 @@ import hmac import base64 import hashlib import asyncio -import gc import requests import websockets import opuslib_next -import random -from typing import Optional, Tuple, List from urllib import parse from datetime import datetime from config.logger import setup_logging @@ -140,13 +137,13 @@ class ASRProvider(ASRProviderBase): conn.asr_audio.append(audio) conn.asr_audio = conn.asr_audio[-10:] - # 只在有声音且没有连接时建立连接 - if audio_have_voice and not self.is_processing: + # 只在有声音且没有连接时建立连接(排除正在停止的情况) + if audio_have_voice and not self.is_processing and not self.asr_ws: try: await self._start_recognition(conn) except Exception as e: logger.bind(tag=TAG).error(f"开始识别失败: {str(e)}") - await self._cleanup(conn) + await self._cleanup() return if self.asr_ws and self.is_processing and self.server_ready: @@ -186,10 +183,8 @@ class ASRProvider(ASRProviderBase): "header": { "namespace": "SpeechTranscriber", "name": "StartTranscription", - "status": 20000000, "message_id": uuid.uuid4().hex, "task_id": self.task_id, - "status_text": "Gateway:SUCCESS:Success.", "appkey": self.appkey }, "payload": { @@ -208,18 +203,21 @@ class ASRProvider(ASRProviderBase): async def _forward_results(self, conn): """转发识别结果""" try: - while self.asr_ws and not conn.stop_event.is_set(): + while not conn.stop_event.is_set(): try: response = await asyncio.wait_for(self.asr_ws.recv(), timeout=1.0) result = json.loads(response) - + header = result.get("header", {}) payload = result.get("payload", {}) message_name = header.get("name", "") status = header.get("status", 0) - + if status != 20000000: - if status in [40000004, 40010004]: # 连接超时或客户端断开 + if status == 40010004: + logger.bind(tag=TAG).warning(f"请在服务端响应完成后再关闭链接,状态码: {status}") + break + if status in [40000004, 40010003]: # 连接超时或客户端断开 logger.bind(tag=TAG).warning(f"连接问题,状态码: {status}") break elif status in [40270002, 40270003]: # 音频问题 @@ -228,12 +226,12 @@ class ASRProvider(ASRProviderBase): else: logger.bind(tag=TAG).error(f"识别错误,状态码: {status}, 消息: {header.get('status_text', '')}") continue - + # 收到TranscriptionStarted表示服务器准备好接收音频数据 if message_name == "TranscriptionStarted": self.server_ready = True logger.bind(tag=TAG).debug("服务器已准备,开始发送缓存音频...") - + # 发送缓存音频 if conn.asr_audio: for cached_audio in conn.asr_audio[-10:]: @@ -244,89 +242,89 @@ class ASRProvider(ASRProviderBase): logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}") break continue - - if message_name == "TranscriptionResultChanged": - # 中间结果 - text = payload.get("result", "") - if text: - self.text = text elif message_name == "SentenceEnd": - # 最终结果 + # 句子结束(每个句子都会触发) text = payload.get("result", "") if text: - self.text = text - conn.reset_vad_states() - # 传递缓存的音频数据 - audio_data = getattr(conn, 'asr_audio_for_voiceprint', []) - await self.handle_voice_stop(conn, audio_data) - # 清空缓存 - conn.asr_audio_for_voiceprint = [] - break - elif message_name == "TranscriptionCompleted": - # 识别完成 - self.is_processing = False - break - + logger.bind(tag=TAG).info(f"识别到文本: {text}") + + # 手动模式下累积识别结果 + if conn.client_listen_mode == "manual": + if self.text: + self.text += text + else: + self.text = text + + # 手动模式下,只有在收到stop信号后才触发处理(仅处理一次) + if conn.client_voice_stop: + audio_data = getattr(conn, 'asr_audio_for_voiceprint', []) + if len(audio_data) > 0: + logger.bind(tag=TAG).debug("收到最终识别结果,触发处理") + await self.handle_voice_stop(conn, audio_data) + # 清理音频缓存 + conn.asr_audio.clear() + conn.reset_vad_states() + break + else: + # 自动模式下直接覆盖 + self.text = text + conn.reset_vad_states() + audio_data = getattr(conn, 'asr_audio_for_voiceprint', []) + await self.handle_voice_stop(conn, audio_data) + break + except asyncio.TimeoutError: - continue - except websockets.exceptions.ConnectionClosed: + logger.bind(tag=TAG).error("接收结果超时") + break + except websockets.ConnectionClosed: + logger.bind(tag=TAG).info("ASR服务连接已关闭") + self.is_processing = False break except Exception as e: logger.bind(tag=TAG).error(f"处理结果失败: {str(e)}") break - + except Exception as e: logger.bind(tag=TAG).error(f"结果转发失败: {str(e)}") finally: - await self._cleanup(conn) + # 清理连接的音频缓存 + await self._cleanup() + if conn: + if hasattr(conn, 'asr_audio_for_voiceprint'): + conn.asr_audio_for_voiceprint = [] + if hasattr(conn, 'asr_audio'): + conn.asr_audio = [] - async def _cleanup(self, conn): - """清理资源""" - logger.bind(tag=TAG).debug(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}") - - # 清理连接的音频缓存 - if conn and hasattr(conn, 'asr_audio_for_voiceprint'): - conn.asr_audio_for_voiceprint = [] - - # 判断是否需要发送终止请求 - should_stop = self.is_processing or self.server_ready - - # 发送停止识别请求 - if self.asr_ws and should_stop: + async def _send_stop_request(self): + """发送停止识别请求(不关闭连接)""" + if self.asr_ws: try: + # 先停止音频发送 + self.is_processing = False + stop_msg = { "header": { "namespace": "SpeechTranscriber", "name": "StopTranscription", - "status": 20000000, "message_id": uuid.uuid4().hex, "task_id": self.task_id, - "status_text": "Client:Stop", "appkey": self.appkey } } - logger.bind(tag=TAG).debug("正在发送ASR终止请求") + logger.bind(tag=TAG).debug("停止识别请求已发送") await self.asr_ws.send(json.dumps(stop_msg, ensure_ascii=False)) - await asyncio.sleep(0.1) - logger.bind(tag=TAG).debug("ASR终止请求已发送") except Exception as e: - logger.bind(tag=TAG).error(f"ASR终止请求发送失败: {e}") - - # 状态重置(在终止请求发送后) + logger.bind(tag=TAG).error(f"发送停止识别请求失败: {e}") + + async def _cleanup(self): + """清理资源(关闭连接)""" + logger.bind(tag=TAG).debug(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}") + + # 状态重置 self.is_processing = False self.server_ready = False logger.bind(tag=TAG).debug("ASR状态已重置") - # 清理任务 - if self.forward_task and not self.forward_task.done(): - self.forward_task.cancel() - try: - await asyncio.wait_for(self.forward_task, timeout=1.0) - except Exception as e: - logger.bind(tag=TAG).debug(f"forward_task取消异常: {e}") - finally: - self.forward_task = None - # 关闭连接 if self.asr_ws: try: @@ -337,7 +335,10 @@ class ASRProvider(ASRProviderBase): logger.bind(tag=TAG).error(f"关闭WebSocket连接失败: {e}") finally: self.asr_ws = None - + + # 清理任务引用 + self.forward_task = None + logger.bind(tag=TAG).debug("ASR会话清理完成") async def speech_to_text(self, opus_data, session_id, audio_format): diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index 6c3c2d25..671484e9 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -10,7 +10,6 @@ import traceback import threading import opuslib_next import concurrent.futures -import gc from abc import ABC, abstractmethod from config.logger import setup_logging from typing import Optional, Tuple, List @@ -54,30 +53,26 @@ class ASRProviderBase(ABC): # 接收音频 async def receive_audio(self, conn, audio, audio_have_voice): - if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime": + if conn.client_listen_mode == "manual": + # 手动模式:缓存音频用于ASR识别 + conn.asr_audio.append(audio) + else: + # 自动/实时模式:使用VAD检测 have_voice = audio_have_voice - + conn.asr_audio.append(audio) if not have_voice and not conn.client_have_voice: conn.asr_audio = conn.asr_audio[-10:] return - else: - # 手动模式:总是缓存音频,忽略VAD检测结果 - conn.asr_audio.append(audio) - if conn.client_voice_stop: - asr_audio_task = conn.asr_audio.copy() - conn.asr_audio.clear() - conn.reset_vad_states() + # 自动模式下通过VAD检测到语音停止时触发识别 + if conn.client_voice_stop: + asr_audio_task = conn.asr_audio.copy() + conn.asr_audio.clear() + conn.reset_vad_states() - # 手动模式下允许短语音识别,自动模式保持原有限制 - if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime": if len(asr_audio_task) > 15: await self.handle_voice_stop(conn, asr_audio_task) - else: - # 手动模式:只要有音频就进行识别 - if len(asr_audio_task) > 0: - await self.handle_voice_stop(conn, asr_audio_task) # 处理语音停止 async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]): diff --git a/main/xiaozhi-server/core/providers/asr/doubao_stream.py b/main/xiaozhi-server/core/providers/asr/doubao_stream.py index 2d179b86..e141e25d 100644 --- a/main/xiaozhi-server/core/providers/asr/doubao_stream.py +++ b/main/xiaozhi-server/core/providers/asr/doubao_stream.py @@ -4,7 +4,6 @@ import uuid import asyncio import websockets import opuslib_next -import gc from core.providers.asr.base import ASRProviderBase from config.logger import setup_logging from core.providers.asr.dto.dto import InterfaceType @@ -19,8 +18,6 @@ class ASRProvider(ASRProviderBase): self.interface_type = InterfaceType.STREAM self.config = config self.text = "" - self.max_retries = 3 - self.retry_delay = 2 self.decoder = opuslib_next.Decoder(16000, 1) self.asr_ws = None self.forward_task = None @@ -57,14 +54,13 @@ class ASRProvider(ASRProviderBase): async def receive_audio(self, conn, audio, audio_have_voice): conn.asr_audio.append(audio) conn.asr_audio = conn.asr_audio[-10:] - # 存储音频数据 if not hasattr(conn, 'asr_audio_for_voiceprint'): conn.asr_audio_for_voiceprint = [] conn.asr_audio_for_voiceprint.append(audio) - + # 当没有音频数据时处理完整语音片段 - if not audio and len(conn.asr_audio_for_voiceprint) > 0: + if conn.client_listen_mode != "manual" and not audio and len(conn.asr_audio_for_voiceprint) > 0: await self.handle_voice_stop(conn, conn.asr_audio_for_voiceprint) conn.asr_audio_for_voiceprint = [] @@ -180,6 +176,7 @@ class ASRProvider(ASRProviderBase): payload.get("audio_info", {}).get("duration", 0) > 2000 and not utterances and not payload["result"].get("text") + and conn.client_listen_mode != "manual" ): logger.bind(tag=TAG).error(f"识别文本:空") self.text = "" @@ -188,15 +185,44 @@ class ASRProvider(ASRProviderBase): await self.handle_voice_stop(conn, audio_data) break + # 专门处理没有文本的识别结果(手动模式下可能已经识别完成但是没松按键) + elif not payload["result"].get("text") and not utterances: + if conn.client_voice_stop and len(audio_data) > 0: + logger.bind(tag=TAG).debug("消息结束收到停止信号,触发处理") + await self.handle_voice_stop(conn, audio_data) + # 清理音频缓存 + conn.asr_audio.clear() + conn.reset_vad_states() + break + for utterance in utterances: if utterance.get("definite", False): - self.text = utterance["text"] + current_text = utterance["text"] logger.bind(tag=TAG).info( - f"识别到文本: {self.text}" + f"识别到文本: {current_text}" ) - conn.reset_vad_states() - if len(audio_data) > 15: # 确保有足够音频数据 - await self.handle_voice_stop(conn, audio_data) + + # 手动模式下累积识别结果 + if conn.client_listen_mode == "manual": + if self.text: + self.text += current_text + else: + self.text = current_text + + # 在接收消息中途时收到停止信号 + if conn.client_voice_stop and len(audio_data) > 0: + logger.bind(tag=TAG).debug("消息中途收到停止信号,触发处理") + await self.handle_voice_stop(conn, audio_data) + # 清理音频缓存 + conn.asr_audio.clear() + conn.reset_vad_states() + break + else: + # 自动模式下直接覆盖 + self.text = current_text + conn.reset_vad_states() + if len(audio_data) > 15: # 确保有足够音频数据 + await self.handle_voice_stop(conn, audio_data) break elif "error" in payload: error_msg = payload.get("error", "未知错误") @@ -228,8 +254,6 @@ class ASRProvider(ASRProviderBase): conn.asr_audio_for_voiceprint = [] if hasattr(conn, 'asr_audio'): conn.asr_audio = [] - if hasattr(conn, 'has_valid_voice'): - conn.has_valid_voice = False def stop_ws_connection(self): if self.asr_ws: @@ -237,6 +261,20 @@ class ASRProvider(ASRProviderBase): self.asr_ws = None self.is_processing = False + async def _send_stop_request(self): + """发送最后一个音频帧以通知服务器结束""" + if self.asr_ws: + try: + # 发送结束标记的音频帧(gzip压缩的空数据) + empty_payload = gzip.compress(b"") + last_audio_request = bytearray(self.generate_last_audio_default_header()) + last_audio_request.extend(len(empty_payload).to_bytes(4, "big")) + last_audio_request.extend(empty_payload) + await self.asr_ws.send(last_audio_request) + logger.bind(tag=TAG).debug("已发送结束音频帧") + except Exception as e: + logger.bind(tag=TAG).debug(f"发送结束音频帧时出错: {e}") + def construct_request(self, reqid): req = { "app": { @@ -388,5 +426,3 @@ class ASRProvider(ASRProviderBase): conn.asr_audio_for_voiceprint = [] if hasattr(conn, 'asr_audio'): conn.asr_audio = [] - if hasattr(conn, 'has_valid_voice'): - conn.has_valid_voice = False diff --git a/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py b/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py index 7101448d..51c84a37 100644 --- a/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py +++ b/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py @@ -13,7 +13,8 @@ logger = setup_logging() class ASRProvider(ASRProviderBase): def __init__(self, config: dict, delete_audio_file: bool): super().__init__() - self.interface_type = InterfaceType.STREAM + # 音频文件上传类型,流式文本识别输出 + self.interface_type = InterfaceType.NON_STREAM """Qwen3-ASR-Flash ASR初始化""" # 配置参数 diff --git a/main/xiaozhi-server/core/providers/asr/xunfei_stream.py b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py index 6a895ebc..b7e7886e 100644 --- a/main/xiaozhi-server/core/providers/asr/xunfei_stream.py +++ b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py @@ -35,9 +35,6 @@ class ASRProvider(ASRProviderBase): self.forward_task = None self.is_processing = False self.server_ready = False - self.last_frame_sent = False # 标记是否已发送最终帧 - self.best_text = "" # 保存最佳识别结果 - self.has_final_result = False # 标记是否收到最终识别结果 # 讯飞配置 self.app_id = config.get("app_id") @@ -52,7 +49,6 @@ class ASRProvider(ASRProviderBase): "domain": config.get("domain", "slm"), "language": config.get("language", "zh_cn"), "accent": config.get("accent", "mandarin"), - "dwa": config.get("dwa", "wpgs"), "result": {"encoding": "utf8", "compress": "raw", "format": "plain"}, } @@ -116,7 +112,7 @@ class ASRProvider(ASRProviderBase): await self._start_recognition(conn) except Exception as e: logger.bind(tag=TAG).error(f"建立ASR连接失败: {str(e)}") - await self._cleanup(conn) + await self._cleanup() return # 发送当前音频数据 @@ -126,7 +122,7 @@ class ASRProvider(ASRProviderBase): await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME) except Exception as e: logger.bind(tag=TAG).warning(f"发送音频数据时发生错误: {e}") - await self._cleanup(conn) + await self._cleanup() async def _start_recognition(self, conn): """开始识别会话""" @@ -136,6 +132,10 @@ class ASRProvider(ASRProviderBase): ws_url = self.create_url() logger.bind(tag=TAG).info(f"正在连接ASR服务: {ws_url[:50]}...") + # 如果为手动模式,设置超时时长为一分钟 + if conn.client_listen_mode == "manual": + self.iat_params["eos"] = 60000 + self.asr_ws = await websockets.connect( ws_url, max_size=1000000000, @@ -146,8 +146,6 @@ class ASRProvider(ASRProviderBase): logger.bind(tag=TAG).info("ASR WebSocket连接已建立") self.server_ready = False - self.last_frame_sent = False - self.best_text = "" self.forward_task = asyncio.create_task(self._forward_results(conn)) # 发送首帧音频 @@ -196,23 +194,12 @@ class ASRProvider(ASRProviderBase): await self.asr_ws.send(json.dumps(frame_data, ensure_ascii=False)) - # 标记是否发送了最终帧 - if status == STATUS_LAST_FRAME: - self.last_frame_sent = True - logger.bind(tag=TAG).info("标记最终帧已发送") - async def _forward_results(self, conn): """转发识别结果""" try: - while self.asr_ws and not conn.stop_event.is_set(): - # 获取当前连接的音频数据 - audio_data = getattr(conn, "asr_audio_for_voiceprint", []) + while not conn.stop_event.is_set(): try: - # 如果已发送最终帧,增加超时时间等待完整结果 - timeout = 3.0 if self.last_frame_sent else 30.0 - response = await asyncio.wait_for( - self.asr_ws.recv(), timeout=timeout - ) + response = await asyncio.wait_for(self.asr_ws.recv(), timeout=60) result = json.loads(response) logger.bind(tag=TAG).debug(f"收到ASR结果: {result}") @@ -236,144 +223,27 @@ class ASRProvider(ASRProviderBase): # 解码base64文本 decoded_text = base64.b64decode(text_data).decode("utf-8") text_json = json.loads(decoded_text) - # 提取文本内容 text_ws = text_json.get("ws", []) - result_text = "" for i in text_ws: for j in i.get("cw", []): w = j.get("w", "") - result_text += w + self.text += w - # 更新识别文本 - 实时更新策略 - # 只检查是否为空字符串,不再过滤任何标点符号 - # 这样可以确保所有识别到的内容,包括标点符号都能被实时更新 - if result_text and result_text.strip(): - # 实时更新:正常情况下都更新,提高响应速度 - should_update = True - - # 保存最佳文本 - # 1. 如果是识别完成状态或最终帧后收到的结果,优先保存 - # 2. 否则保存最长的有意义文本 - # 取消对标点符号的过滤,只检查是否为空 - # 这样可以保留所有识别到的内容,包括各种标点符号 - is_valid_text = len(result_text.strip()) > 0 - - if ( - self.last_frame_sent or status == 2 - ) and is_valid_text: - self.best_text = result_text - self.has_final_result = True # 标记已收到最终结果 - logger.bind(tag=TAG).debug( - f"保存最终识别结果: {self.best_text}" - ) - elif ( - len(result_text) > len(self.best_text) - and is_valid_text - and not self.has_final_result - ): - self.best_text = result_text - logger.bind(tag=TAG).debug( - f"保存中间最佳文本: {self.best_text}" - ) - - # 如果已发送最终帧,只过滤空文本 - if self.last_frame_sent: - # 只拒绝完全空的结果 - if not result_text.strip(): - should_update = False - logger.bind(tag=TAG).warning( - f"最终帧后拒绝空文本" - ) - - if should_update: - # 处理流式识别结果,避免简单替换导致内容丢失 - # 1. 如果是中间状态(非最终帧后),可能需要替换为更完整的识别 - # 2. 如果是最终帧后收到的结果,可能是对前面文本的补充 - if self.last_frame_sent: - # 最终帧后收到的结果可能是标点符号等补充内容 - # 检查是否需要合并文本而不是替换 - # 如果当前文本是纯标点而前面已有内容,应该追加而不是替换 - if len( - self.text - ) > 0 and result_text.strip() in [ - "。", - ".", - "?", - "?", - "!", - "!", - ",", - ",", - ";", - ";", - ]: - # 对于标点符号,追加到现有文本后 - self.text = ( - self.text.rstrip().rstrip("。.") - + result_text - ) - else: - # 其他情况保持替换逻辑 - self.text = result_text - else: - # 中间状态替换为新的识别结果 - self.text = result_text - - logger.bind(tag=TAG).info( - f"实时更新识别文本: {self.text} (最终帧已发送: {self.last_frame_sent})" - ) - - # 识别完成,但如果还没发送最终帧,继续等待 if status == 2: - logger.bind(tag=TAG).info( - f"识别完成状态已到达,当前识别文本: {self.text}" - ) - - # 如果还没发送最终帧,继续等待 - if not self.last_frame_sent: - logger.bind(tag=TAG).info( - "识别完成但最终帧未发送,继续等待..." - ) - continue - - # 已发送最终帧且收到完成状态,使用最佳策略选择最终结果 - # 优先使用识别完成状态下的最新结果,而不是仅仅基于长度 - if self.best_text: - # 如果当前文本是在最终帧发送后或识别完成状态下收到的,优先使用 - if ( - self.last_frame_sent or status == 2 - ) and self.text.strip(): - logger.bind(tag=TAG).info( - f"使用完成状态下的最新识别结果: {self.text}" - ) - elif len(self.best_text) > len(self.text): - logger.bind(tag=TAG).info( - f"使用更长的最佳文本作为最终结果: {self.text} -> {self.best_text}" - ) - self.text = self.best_text - - logger.bind(tag=TAG).info(f"获取到最终完整文本: {self.text}") + if conn.client_listen_mode == "manual": + audio_data = getattr(conn, 'asr_audio_for_voiceprint', []) + if len(audio_data) > 0: + logger.bind(tag=TAG).debug("收到最终识别结果,触发处理") + await self.handle_voice_stop(conn, audio_data) + # 清理音频缓存 + conn.asr_audio.clear() conn.reset_vad_states() - if len(audio_data) > 15: # 确保有足够音频数据 - # 准备处理结果 - pass break except asyncio.TimeoutError: - if self.last_frame_sent: - # 超时时也使用最佳文本 - if self.best_text and len(self.best_text) > len(self.text): - logger.bind(tag=TAG).info( - f"超时,使用最佳文本: {self.text} -> {self.best_text}" - ) - self.text = self.best_text - logger.bind(tag=TAG).info( - f"最终帧后超时,使用结果: {self.text}" - ) - break - # 如果还没发送最终帧,继续等待 - continue + logger.bind(tag=TAG).error("接收结果超时") + break except websockets.ConnectionClosed: logger.bind(tag=TAG).info("ASR服务连接已关闭") self.is_processing = False @@ -390,17 +260,15 @@ class ASRProvider(ASRProviderBase): if hasattr(e, "__cause__") and e.__cause__: logger.bind(tag=TAG).error(f"错误原因: {str(e.__cause__)}") finally: - if self.asr_ws: - await self.asr_ws.close() - self.asr_ws = None - self.is_processing = False + # 清理连接资源 + await self._cleanup() + + # 清理连接的音频缓存 if conn: if hasattr(conn, "asr_audio_for_voiceprint"): conn.asr_audio_for_voiceprint = [] if hasattr(conn, "asr_audio"): conn.asr_audio = [] - if hasattr(conn, "has_valid_voice"): - conn.has_valid_voice = False async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]): """处理语音停止,发送最后一帧并处理识别结果""" @@ -408,22 +276,13 @@ class ASRProvider(ASRProviderBase): # 先发送最后一帧表示音频结束 if self.asr_ws and self.is_processing: try: - # 取最后一个有效的音频帧作为最后一帧数据 - last_frame = b"" - if asr_audio_task: - last_audio = asr_audio_task[-1] - last_frame = self.decoder.decode(last_audio, 960) - await self._send_audio_frame(last_frame, STATUS_LAST_FRAME) - logger.bind(tag=TAG).info("已发送最后一帧") + await self._send_audio_frame(b"", STATUS_LAST_FRAME) + logger.bind(tag=TAG).debug(f"已发送停止请求") - # 发送最终帧后,给_forward_results适当时间处理最终结果 await asyncio.sleep(0.25) - - logger.bind(tag=TAG).info(f"准备处理最终识别结果: {self.text}") except Exception as e: - logger.bind(tag=TAG).error(f"发送最后一帧失败: {e}") + logger.bind(tag=TAG).error(f"发送停止请求失败: {e}") - # 调用父类的handle_voice_stop方法处理识别结果 await super().handle_voice_stop(conn, asr_audio_task) except Exception as e: logger.bind(tag=TAG).error(f"处理语音停止失败: {e}") @@ -437,40 +296,27 @@ class ASRProvider(ASRProviderBase): self.asr_ws = None self.is_processing = False - async def _cleanup(self, conn): - """清理资源""" - logger.bind(tag=TAG).info( + async def _send_stop_request(self): + """发送停止识别请求(不关闭连接)""" + if self.asr_ws: + try: + # 先停止音频发送 + self.is_processing = False + await self._send_audio_frame(b"", STATUS_LAST_FRAME) + logger.bind(tag=TAG).debug("已发送停止请求") + except Exception as e: + logger.bind(tag=TAG).error(f"发送停止请求失败: {e}") + + async def _cleanup(self): + """清理资源(关闭连接)""" + logger.bind(tag=TAG).debug( f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}" ) - # 发送最后一帧 - if self.asr_ws and self.is_processing: - try: - await self._send_audio_frame(b"", STATUS_LAST_FRAME) - await asyncio.sleep(0.1) - logger.bind(tag=TAG).info("已发送最后一帧") - except Exception as e: - logger.bind(tag=TAG).error(f"发送最后一帧失败: {e}") - # 状态重置 self.is_processing = False self.server_ready = False - self.last_frame_sent = False - self.best_text = "" - self.has_final_result = False - logger.bind(tag=TAG).info("ASR状态已重置") - - # 清理任务 - if self.forward_task and not self.forward_task.done(): - self.forward_task.cancel() - try: - await asyncio.wait_for(self.forward_task, timeout=1.0) - except asyncio.CancelledError: - pass - except Exception as e: - logger.bind(tag=TAG).debug(f"forward_task取消异常: {e}") - finally: - self.forward_task = None + logger.bind(tag=TAG).debug("ASR状态已重置") # 关闭连接 if self.asr_ws: @@ -483,16 +329,10 @@ class ASRProvider(ASRProviderBase): finally: self.asr_ws = None - # 清理连接的音频缓存 - if conn: - if hasattr(conn, "asr_audio_for_voiceprint"): - conn.asr_audio_for_voiceprint = [] - if hasattr(conn, "asr_audio"): - conn.asr_audio = [] - if hasattr(conn, "has_valid_voice"): - conn.has_valid_voice = False + # 清理任务引用 + self.forward_task = None - logger.bind(tag=TAG).info("ASR会话清理完成") + logger.bind(tag=TAG).debug("ASR会话清理完成") async def speech_to_text(self, opus_data, session_id, audio_format): """获取识别结果""" @@ -513,7 +353,7 @@ class ASRProvider(ASRProviderBase): pass self.forward_task = None self.is_processing = False - + # 显式释放decoder资源 if hasattr(self, 'decoder') and self.decoder is not None: try: @@ -530,5 +370,3 @@ class ASRProvider(ASRProviderBase): conn.asr_audio_for_voiceprint = [] if hasattr(conn, "asr_audio"): conn.asr_audio = [] - if hasattr(conn, "has_valid_voice"): - conn.has_valid_voice = False diff --git a/main/xiaozhi-server/core/providers/vad/silero.py b/main/xiaozhi-server/core/providers/vad/silero.py index 28a3e61e..81215681 100644 --- a/main/xiaozhi-server/core/providers/vad/silero.py +++ b/main/xiaozhi-server/core/providers/vad/silero.py @@ -2,7 +2,6 @@ import time import numpy as np import torch import opuslib_next -import gc from config.logger import setup_logging from core.providers.vad.base import VADProviderBase @@ -46,7 +45,7 @@ class VADProvider(VADProviderBase): def is_vad(self, conn, opus_packet): # 手动模式:直接返回True,不进行实时VAD检测,所有音频都缓存 - if conn.client_listen_mode not in ["auto", "realtime"]: + if conn.client_listen_mode == "manual": return True try: diff --git a/main/xiaozhi-server/core/utils/opus_encoder_utils.py b/main/xiaozhi-server/core/utils/opus_encoder_utils.py index f2fcc266..8d603e22 100644 --- a/main/xiaozhi-server/core/utils/opus_encoder_utils.py +++ b/main/xiaozhi-server/core/utils/opus_encoder_utils.py @@ -6,7 +6,6 @@ Opus编码工具类 import logging import traceback import numpy as np -import gc from opuslib_next import Encoder from opuslib_next import constants from typing import Optional, Callable, Any diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index 37388be8..f72dda2c 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -8,7 +8,6 @@ import requests import subprocess import numpy as np import opuslib_next -import gc from io import BytesIO from core.utils import p3 from pydub import AudioSegment From f88abd7638acb6724531fe4b9f09f4afc53194aa Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Fri, 12 Dec 2025 09:46:15 +0800 Subject: [PATCH 47/93] =?UTF-8?q?fix:=20=E8=81=86=E5=90=AC=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E8=AF=AF=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/providers/asr/doubao_stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/xiaozhi-server/core/providers/asr/doubao_stream.py b/main/xiaozhi-server/core/providers/asr/doubao_stream.py index e141e25d..0c1cf727 100644 --- a/main/xiaozhi-server/core/providers/asr/doubao_stream.py +++ b/main/xiaozhi-server/core/providers/asr/doubao_stream.py @@ -187,7 +187,7 @@ class ASRProvider(ASRProviderBase): # 专门处理没有文本的识别结果(手动模式下可能已经识别完成但是没松按键) elif not payload["result"].get("text") and not utterances: - if conn.client_voice_stop and len(audio_data) > 0: + if conn.client_listen_mode == "manual" and conn.client_voice_stop and len(audio_data) > 0: logger.bind(tag=TAG).debug("消息结束收到停止信号,触发处理") await self.handle_voice_stop(conn, audio_data) # 清理音频缓存 From d6697948c2f1c0b0a6a86be3428003bae6a6c624 Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Fri, 12 Dec 2025 12:53:59 +0800 Subject: [PATCH 48/93] =?UTF-8?q?fix=EF=BC=9A=E4=BD=BF=E7=94=A8java?= =?UTF-8?q?=E7=AB=AF=E5=81=9A=E8=81=8A=E5=A4=A9=E8=AE=B0=E5=BD=95=E6=80=BB?= =?UTF-8?q?=E7=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/controller/AgentController.java | 33 ++ .../agent/dto/AgentChatSummaryDTO.java | 48 ++ .../service/AgentChatSummaryService.java | 25 ++ .../impl/AgentChatHistoryBizServiceImpl.java | 34 +- .../impl/AgentChatSummaryServiceImpl.java | 424 ++++++++++++++++++ .../modules/llm/service/LLMService.java | 70 +++ .../impl/OpenAIStyleLLMServiceImpl.java | 306 +++++++++++++ .../model/service/ModelConfigService.java | 8 + .../service/impl/ModelConfigServiceImpl.java | 18 + .../config/manage_api_client.py | 31 +- main/xiaozhi-server/core/connection.py | 4 +- .../core/providers/memory/base.py | 2 +- .../core/providers/memory/mem0ai/mem0ai.py | 6 +- .../memory/mem_local_short/mem_local_short.py | 23 +- .../core/providers/memory/nomem/nomem.py | 2 +- 15 files changed, 1013 insertions(+), 21 deletions(-) create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatSummaryDTO.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatSummaryService.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/llm/service/LLMService.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/llm/service/impl/OpenAIStyleLLMServiceImpl.java diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java index c09aa141..ac1852a5 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java @@ -36,6 +36,7 @@ import xiaozhi.common.utils.Result; import xiaozhi.common.utils.ResultUtils; import xiaozhi.modules.agent.dto.AgentChatHistoryDTO; import xiaozhi.modules.agent.dto.AgentChatSessionDTO; +import xiaozhi.modules.agent.dto.AgentChatSummaryDTO; import xiaozhi.modules.agent.dto.AgentCreateDTO; import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentMemoryDTO; @@ -44,6 +45,7 @@ import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.service.AgentChatAudioService; import xiaozhi.modules.agent.service.AgentChatHistoryService; +import xiaozhi.modules.agent.service.AgentChatSummaryService; import xiaozhi.modules.agent.service.AgentContextProviderService; import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentService; @@ -66,6 +68,7 @@ public class AgentController { private final AgentChatAudioService agentChatAudioService; private final AgentPluginMappingService agentPluginMappingService; private final AgentContextProviderService agentContextProviderService; + private final AgentChatSummaryService agentChatSummaryService; private final RedisUtils redisUtils; @GetMapping("/list") @@ -119,6 +122,36 @@ public class AgentController { return new Result<>(); } + @PostMapping("/chat-summary/{sessionId}") + @Operation(summary = "根据会话ID生成聊天记录总结") + public Result generateChatSummary(@PathVariable String sessionId) { + try { + AgentChatSummaryDTO summary = agentChatSummaryService.generateChatSummary(sessionId); + if (summary.isSuccess()) { + return new Result().ok(summary); + } else { + return new Result().error(summary.getErrorMessage()); + } + } catch (Exception e) { + return new Result().error("生成聊天记录总结失败: " + e.getMessage()); + } + } + + @PostMapping("/chat-summary/{sessionId}/save") + @Operation(summary = "根据会话ID生成聊天记录总结并保存") + public Result generateAndSaveChatSummary(@PathVariable String sessionId) { + try { + boolean success = agentChatSummaryService.generateAndSaveChatSummary(sessionId); + if (success) { + return new Result().ok(null); + } else { + return new Result().error("保存聊天记录总结失败"); + } + } catch (Exception e) { + return new Result().error("生成并保存聊天记录总结失败: " + e.getMessage()); + } + } + @PutMapping("/{id}") @Operation(summary = "更新智能体") @RequiresPermissions("sys:role:normal") diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatSummaryDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatSummaryDTO.java new file mode 100644 index 00000000..e0bf12a7 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatSummaryDTO.java @@ -0,0 +1,48 @@ +package xiaozhi.modules.agent.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 智能体聊天记录总结DTO + */ +@Data +@Schema(description = "智能体聊天记录总结对象") +public class AgentChatSummaryDTO implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "会话ID") + private String sessionId; + + @Schema(description = "智能体ID") + private String agentId; + + @Schema(description = "总结内容") + private String summary; + + @Schema(description = "总结状态") + private boolean success; + + @Schema(description = "错误信息") + private String errorMessage; + + public AgentChatSummaryDTO() { + this.success = true; + } + + public AgentChatSummaryDTO(String sessionId, String agentId, String summary) { + this.sessionId = sessionId; + this.agentId = agentId; + this.summary = summary; + this.success = true; + } + + public AgentChatSummaryDTO(String sessionId, String errorMessage) { + this.sessionId = sessionId; + this.errorMessage = errorMessage; + this.success = false; + } + +} \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatSummaryService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatSummaryService.java new file mode 100644 index 00000000..c442deb8 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatSummaryService.java @@ -0,0 +1,25 @@ +package xiaozhi.modules.agent.service; + +import xiaozhi.modules.agent.dto.AgentChatSummaryDTO; + +/** + * 智能体聊天记录总结服务接口 + */ +public interface AgentChatSummaryService { + + /** + * 根据会话ID生成聊天记录总结 + * + * @param sessionId 会话ID + * @return 总结结果 + */ + AgentChatSummaryDTO generateChatSummary(String sessionId); + + /** + * 根据会话ID生成聊天记录总结并保存到智能体记忆 + * + * @param sessionId 会话ID + * @return 保存结果 + */ + boolean generateAndSaveChatSummary(String sessionId); +} \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/impl/AgentChatHistoryBizServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/impl/AgentChatHistoryBizServiceImpl.java index 36b59993..16bcce50 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/impl/AgentChatHistoryBizServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/biz/impl/AgentChatHistoryBizServiceImpl.java @@ -17,6 +17,7 @@ import xiaozhi.modules.agent.entity.AgentChatHistoryEntity; import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.service.AgentChatAudioService; import xiaozhi.modules.agent.service.AgentChatHistoryService; +import xiaozhi.modules.agent.service.AgentChatSummaryService; import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService; import xiaozhi.modules.device.entity.DeviceEntity; @@ -36,6 +37,7 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic private final AgentService agentService; private final AgentChatHistoryService agentChatHistoryService; private final AgentChatAudioService agentChatAudioService; + private final AgentChatSummaryService agentChatSummaryService; private final RedisUtils redisUtils; private final DeviceService deviceService; @@ -50,7 +52,8 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic public Boolean report(AgentChatHistoryReportDTO report) { String macAddress = report.getMacAddress(); Byte chatType = report.getChatType(); - Long reportTimeMillis = null != report.getReportTime() ? report.getReportTime() * 1000 : System.currentTimeMillis(); + Long reportTimeMillis = null != report.getReportTime() ? report.getReportTime() * 1000 + : System.currentTimeMillis(); log.info("小智设备聊天上报请求: macAddress={}, type={} reportTime={}", macAddress, chatType, reportTimeMillis); // 根据设备MAC地址查询对应的默认智能体,判断是否需要上报 @@ -80,6 +83,9 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic log.warn("聊天记录上报时,未找到mac地址为 {} 的设备", macAddress); } + // 异步触发聊天记录总结(仅在对话结束时触发) + triggerChatSummaryAsync(report.getSessionId(), chatType); + return Boolean.TRUE; } @@ -105,7 +111,8 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic /** * 组装上报数据 */ - private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId, Long reportTime) { + private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId, + Long reportTime) { // 构建聊天记录实体 AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder() .macAddress(macAddress) @@ -123,4 +130,27 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic log.info("设备 {} 对应智能体 {} 上报成功", macAddress, agentId); } + + /** + * 异步触发聊天记录总结 + * 仅在对话结束时(chatType=2)触发总结,避免频繁总结 + */ + private void triggerChatSummaryAsync(String sessionId, Byte chatType) { + // 仅在对话结束时触发总结(chatType=2表示对话结束) + if (chatType != null && chatType == 2) { + new Thread(() -> { + try { + log.info("开始为会话 {} 生成聊天记录总结", sessionId); + boolean success = agentChatSummaryService.generateAndSaveChatSummary(sessionId); + if (success) { + log.info("会话 {} 的聊天记录总结生成并保存成功", sessionId); + } else { + log.warn("会话 {} 的聊天记录总结生成失败", sessionId); + } + } catch (Exception e) { + log.error("触发会话 {} 的聊天记录总结时发生异常", sessionId, e); + } + }).start(); + } + } } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java new file mode 100644 index 00000000..80ad03d6 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java @@ -0,0 +1,424 @@ +package xiaozhi.modules.agent.service.impl; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import xiaozhi.modules.agent.dto.AgentChatHistoryDTO; +import xiaozhi.modules.agent.dto.AgentChatSummaryDTO; +import xiaozhi.modules.agent.dto.AgentMemoryDTO; +import xiaozhi.modules.agent.dto.AgentUpdateDTO; +import xiaozhi.modules.agent.entity.AgentChatHistoryEntity; +import xiaozhi.modules.agent.service.AgentChatHistoryService; +import xiaozhi.modules.agent.service.AgentChatSummaryService; +import xiaozhi.modules.agent.service.AgentService; +import xiaozhi.modules.config.service.ConfigService; +import xiaozhi.modules.device.entity.DeviceEntity; +import xiaozhi.modules.device.service.DeviceService; +import xiaozhi.modules.llm.service.LLMService; +import xiaozhi.modules.agent.vo.AgentInfoVO; +import xiaozhi.modules.model.entity.ModelConfigEntity; +import xiaozhi.modules.model.service.ModelConfigService; +import java.util.Map; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 智能体聊天记录总结服务实现类 + * 实现Python端mem_local_short.py中的总结逻辑 + */ +@Service +@RequiredArgsConstructor +public class AgentChatSummaryServiceImpl implements AgentChatSummaryService { + + private static final Logger log = LoggerFactory.getLogger(AgentChatSummaryServiceImpl.class); + + private final AgentChatHistoryService agentChatHistoryService; + private final AgentService agentService; + private final DeviceService deviceService; + private final LLMService llmService; + private final ConfigService configService; + private final ModelConfigService modelConfigService; + + // 总结规则常量 + private static final int MAX_SUMMARY_LENGTH = 1800; // 最大总结长度 + private static final Pattern JSON_PATTERN = Pattern.compile("\\{.*?\\}", Pattern.DOTALL); + private static final Pattern DEVICE_CONTROL_PATTERN = Pattern.compile("设备控制|设备操作|控制设备|设备状态", + Pattern.CASE_INSENSITIVE); + private static final Pattern WEATHER_PATTERN = Pattern.compile("天气|温度|湿度|降雨|气象", Pattern.CASE_INSENSITIVE); + private static final Pattern DATE_PATTERN = Pattern.compile("日期|时间|星期|月份|年份", Pattern.CASE_INSENSITIVE); + + @Override + public AgentChatSummaryDTO generateChatSummary(String sessionId) { + try { + System.out.println("开始生成会话 " + sessionId + " 的聊天记录总结"); + + // 1. 根据sessionId获取聊天记录 + List chatHistory = getChatHistoryBySessionId(sessionId); + if (chatHistory == null || chatHistory.isEmpty()) { + return new AgentChatSummaryDTO(sessionId, "未找到该会话的聊天记录"); + } + + // 2. 获取智能体信息 + String agentId = getAgentIdFromSession(sessionId, chatHistory); + if (StringUtils.isBlank(agentId)) { + return new AgentChatSummaryDTO(sessionId, "无法获取智能体信息"); + } + + // 3. 提取关键对话内容 + List meaningfulMessages = extractMeaningfulMessages(chatHistory); + if (meaningfulMessages.isEmpty()) { + return new AgentChatSummaryDTO(sessionId, "没有有效的对话内容可总结"); + } + + // 4. 生成总结(generateSummaryFromMessages方法已包含长度限制逻辑) + String summary = generateSummaryFromMessages(meaningfulMessages, agentId); + + System.out.println("成功生成会话 " + sessionId + " 的聊天记录总结,长度: " + summary.length() + " 字符"); + return new AgentChatSummaryDTO(sessionId, agentId, summary); + + } catch (Exception e) { + System.err.println("生成会话 " + sessionId + " 的聊天记录总结时发生错误: " + e.getMessage()); + return new AgentChatSummaryDTO(sessionId, "生成总结时发生错误: " + e.getMessage()); + } + } + + @Override + public boolean generateAndSaveChatSummary(String sessionId) { + try { + // 1. 生成总结 + AgentChatSummaryDTO summaryDTO = generateChatSummary(sessionId); + if (!summaryDTO.isSuccess()) { + System.err.println("生成总结失败: " + summaryDTO.getErrorMessage()); + return false; + } + + // 2. 获取设备信息(通过会话关联的设备) + DeviceEntity device = getDeviceBySessionId(sessionId); + if (device == null) { + System.err.println("未找到与会话 " + sessionId + " 关联的设备"); + return false; + } + + // 3. 更新智能体记忆 + AgentMemoryDTO memoryDTO = new AgentMemoryDTO(); + memoryDTO.setSummaryMemory(summaryDTO.getSummary()); + + // 调用现有接口更新记忆 + agentService.updateAgentById(device.getAgentId(), + new AgentUpdateDTO() { + { + setSummaryMemory(summaryDTO.getSummary()); + } + }); + + System.out.println("成功保存会话 " + sessionId + " 的聊天记录总结到智能体 " + device.getAgentId()); + return true; + + } catch (Exception e) { + System.err.println("保存会话 " + sessionId + " 的聊天记录总结时发生错误: " + e.getMessage()); + return false; + } + } + + /** + * 根据会话ID获取聊天记录 + */ + private List getChatHistoryBySessionId(String sessionId) { + try { + // 这里需要根据sessionId获取聊天记录 + // 由于现有接口需要agentId,我们需要先找到关联的agentId + String agentId = findAgentIdBySessionId(sessionId); + if (StringUtils.isBlank(agentId)) { + return null; + } + return agentChatHistoryService.getChatHistoryBySessionId(agentId, sessionId); + } catch (Exception e) { + System.err.println("获取会话 " + sessionId + " 的聊天记录失败: " + e.getMessage()); + return null; + } + } + + /** + * 根据会话ID查找关联的智能体ID + */ + private String findAgentIdBySessionId(String sessionId) { + try { + // 查询该会话的第一条记录获取agentId + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.select("agent_id") + .eq("session_id", sessionId) + .last("LIMIT 1"); + + AgentChatHistoryEntity entity = agentChatHistoryService.getOne(wrapper); + return entity != null ? entity.getAgentId() : null; + } catch (Exception e) { + System.err.println("根据会话ID " + sessionId + " 查找智能体ID失败: " + e.getMessage()); + return null; + } + } + + /** + * 从会话中获取智能体ID + */ + private String getAgentIdFromSession(String sessionId, List chatHistory) { + // 直接从数据库查询智能体ID + return findAgentIdBySessionId(sessionId); + } + + /** + * 提取有意义的对话内容(只提取用户消息,排除AI回复) + */ + private List extractMeaningfulMessages(List chatHistory) { + List meaningfulMessages = new ArrayList<>(); + + for (AgentChatHistoryDTO message : chatHistory) { + // 只处理用户消息(chatType = 1) + if (message.getChatType() != null && message.getChatType() == 1) { + String content = extractContentFromMessage(message); + if (isMeaningfulMessage(content)) { + meaningfulMessages.add(content); + } + } + } + + return meaningfulMessages; + } + + /** + * 从消息中提取内容(处理JSON格式) + */ + private String extractContentFromMessage(AgentChatHistoryDTO message) { + String content = message.getContent(); + if (StringUtils.isBlank(content)) { + return ""; + } + + // 处理JSON格式内容(与前端ChatHistoryDialog.vue逻辑一致) + Matcher matcher = JSON_PATTERN.matcher(content); + if (matcher.find()) { + String jsonContent = matcher.group(); + // 简化处理:提取JSON中的文本内容 + return extractTextFromJson(jsonContent); + } + + return content; + } + + /** + * 从JSON中提取文本内容 + */ + private String extractTextFromJson(String jsonContent) { + // 简化处理:提取"content"字段的值 + Pattern contentPattern = Pattern.compile("\"content\"\s*:\s*\"([^\"]*)\""); + Matcher matcher = contentPattern.matcher(jsonContent); + if (matcher.find()) { + return matcher.group(1); + } + return jsonContent; + } + + /** + * 判断是否为有意义的消息 + */ + private boolean isMeaningfulMessage(String content) { + if (StringUtils.isBlank(content)) { + return false; + } + + // 排除设备控制信息 + if (DEVICE_CONTROL_PATTERN.matcher(content).find()) { + return false; + } + + // 排除日期天气等无关内容 + if (WEATHER_PATTERN.matcher(content).find() || DATE_PATTERN.matcher(content).find()) { + return false; + } + + // 排除过短的消息 + return content.length() >= 5; + } + + /** + * 从消息生成总结 + */ + private String generateSummaryFromMessages(List messages, String agentId) { + if (messages.isEmpty()) { + return "本次对话内容较少,没有需要总结的重要信息。"; + } + + // 构建完整的对话内容 + StringBuilder conversation = new StringBuilder(); + for (int i = 0; i < messages.size(); i++) { + conversation.append("消息").append(i + 1).append(": ").append(messages.get(i)).append("\n"); + } + + try { + // 获取当前智能体的历史记忆 + String historyMemory = getCurrentAgentMemory(agentId); + + // 调用LLM服务进行智能总结,传递agentId以获取正确的模型配置 + String summary = callJavaLLMForSummaryWithHistory(conversation.toString(), historyMemory, agentId); + + // 应用总结规则:限制最大长度 + if (summary.length() > MAX_SUMMARY_LENGTH) { + summary = summary.substring(0, MAX_SUMMARY_LENGTH) + "..."; + } + + return summary; + } catch (Exception e) { + System.err.println("调用Java端LLM服务失败: " + e.getMessage()); + throw new RuntimeException("LLM服务不可用,无法生成聊天总结"); + } + } + + /** + * 获取当前智能体的历史记忆 + */ + private String getCurrentAgentMemory(String agentId) { + try { + if (StringUtils.isBlank(agentId)) { + return null; + } + + // 获取智能体信息 + AgentInfoVO agentInfo = agentService.getAgentById(agentId); + if (agentInfo == null) { + return null; + } + + // 返回智能体的当前总结记忆 + return agentInfo.getSummaryMemory(); + } catch (Exception e) { + System.err.println("获取智能体历史记忆失败,agentId: " + agentId + ", 错误: " + e.getMessage()); + return null; + } + } + + /** + * 调用Java端LLM服务进行智能总结(支持历史记忆合并) + */ + private String callJavaLLMForSummaryWithHistory(String conversation, String historyMemory, String agentId) { + try { + // 获取智能体配置,从中提取记忆总结的模型ID + String modelId = getMemorySummaryModelId(agentId); + + if (StringUtils.isBlank(modelId)) { + System.out.println("未找到记忆总结的LLM模型配置,使用默认LLM服务"); + return llmService.generateSummaryWithHistory(conversation, historyMemory, null, null); + } + + // 使用指定的模型ID调用LLM服务(支持历史记忆合并) + String summary = llmService.generateSummaryWithHistory(conversation, historyMemory, null, modelId); + + if (StringUtils.isNotBlank(summary) && !summary.equals("服务暂不可用") && !summary.equals("总结生成失败")) { + return summary; + } + + throw new RuntimeException("Java端LLM服务返回异常: " + summary); + + } catch (Exception e) { + System.err.println("调用Java端LLM服务异常,agentId: " + agentId + ", 错误: " + e.getMessage()); + throw e; + } + } + + /** + * 调用Java端LLM服务进行智能总结 + */ + private String callJavaLLMForSummary(String conversation, String agentId) { + try { + // 获取智能体配置,从中提取记忆总结的模型ID + String modelId = getMemorySummaryModelId(agentId); + + if (StringUtils.isBlank(modelId)) { + System.out.println("未找到记忆总结的LLM模型配置,使用默认LLM服务"); + return llmService.generateSummary(conversation); + } + + // 使用指定的模型ID调用LLM服务 + String summary = llmService.generateSummaryWithModel(conversation, modelId); + + if (StringUtils.isNotBlank(summary) && !summary.equals("服务暂不可用") && !summary.equals("总结生成失败")) { + return summary; + } + + throw new RuntimeException("Java端LLM服务返回异常: " + summary); + + } catch (Exception e) { + System.err.println("调用Java端LLM服务异常,agentId: " + agentId + ", 错误: " + e.getMessage()); + throw e; + } + } + + /** + * 获取记忆总结的LLM模型ID + */ + private String getMemorySummaryModelId(String agentId) { + try { + if (StringUtils.isBlank(agentId)) { + return null; + } + + // 获取智能体信息 + AgentInfoVO agentInfo = agentService.getAgentById(agentId); + if (agentInfo == null) { + return null; + } + + // 获取智能体的记忆模型ID + String memModelId = agentInfo.getMemModelId(); + if (StringUtils.isBlank(memModelId)) { + return null; + } + + // 获取记忆模型配置 + ModelConfigEntity memModelConfig = modelConfigService.getModelByIdFromCache(memModelId); + if (memModelConfig == null || memModelConfig.getConfigJson() == null) { + return null; + } + + // 从记忆模型配置中提取对应的LLM模型ID + Map configMap = memModelConfig.getConfigJson(); + String llmModelId = (String) configMap.get("llm"); + + if (StringUtils.isBlank(llmModelId)) { + // 如果记忆模型没有配置独立的LLM,则使用智能体的默认LLM模型 + return agentInfo.getLlmModelId(); + } + + return llmModelId; + } catch (Exception e) { + System.err.println("获取记忆总结LLM模型ID失败,agentId: " + agentId + ", 错误: " + e.getMessage()); + return null; + } + } + + /** + * 根据会话ID获取设备信息 + */ + private DeviceEntity getDeviceBySessionId(String sessionId) { + try { + // 查询该会话的第一条记录获取macAddress + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.select("mac_address") + .eq("session_id", sessionId) + .last("LIMIT 1"); + + AgentChatHistoryEntity entity = agentChatHistoryService.getOne(wrapper); + if (entity != null && StringUtils.isNotBlank(entity.getMacAddress())) { + return deviceService.getDeviceByMacAddress(entity.getMacAddress()); + } + return null; + } catch (Exception e) { + System.err.println("根据会话ID " + sessionId + " 查找设备信息失败: " + e.getMessage()); + return null; + } + } +} \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/llm/service/LLMService.java b/main/manager-api/src/main/java/xiaozhi/modules/llm/service/LLMService.java new file mode 100644 index 00000000..a3f10e9f --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/llm/service/LLMService.java @@ -0,0 +1,70 @@ +package xiaozhi.modules.llm.service; + +/** + * LLM服务接口 + * 支持多种大模型调用 + */ +public interface LLMService { + + /** + * 生成聊天记录总结 + * + * @param conversation 对话内容 + * @param promptTemplate 提示词模板 + * @return 总结结果 + */ + String generateSummary(String conversation, String promptTemplate); + + /** + * 生成聊天记录总结(使用默认提示词) + * + * @param conversation 对话内容 + * @return 总结结果 + */ + String generateSummary(String conversation); + + /** + * 生成聊天记录总结(指定模型ID) + * + * @param conversation 对话内容 + * @param modelId 模型ID + * @return 总结结果 + */ + String generateSummaryWithModel(String conversation, String modelId); + + /** + * 生成聊天记录总结(指定模型ID和提示词模板) + * + * @param conversation 对话内容 + * @param promptTemplate 提示词模板 + * @param modelId 模型ID + * @return 总结结果 + */ + String generateSummary(String conversation, String promptTemplate, String modelId); + + /** + * 生成聊天记录总结(包含历史记忆合并) + * + * @param conversation 对话内容 + * @param historyMemory 历史记忆 + * @param promptTemplate 提示词模板 + * @param modelId 模型ID + * @return 总结结果 + */ + String generateSummaryWithHistory(String conversation, String historyMemory, String promptTemplate, String modelId); + + /** + * 检查服务是否可用 + * + * @return 是否可用 + */ + boolean isAvailable(); + + /** + * 检查指定模型的服务是否可用 + * + * @param modelId 模型ID + * @return 是否可用 + */ + boolean isAvailable(String modelId); +} \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/llm/service/impl/OpenAIStyleLLMServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/llm/service/impl/OpenAIStyleLLMServiceImpl.java new file mode 100644 index 00000000..e0b8a3b2 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/llm/service/impl/OpenAIStyleLLMServiceImpl.java @@ -0,0 +1,306 @@ +package xiaozhi.modules.llm.service.impl; + +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import xiaozhi.modules.llm.service.LLMService; +import xiaozhi.modules.model.entity.ModelConfigEntity; +import xiaozhi.modules.model.service.ModelConfigService; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * OpenAI风格API的LLM服务实现 + * 支持阿里云、DeepSeek、ChatGLM等兼容OpenAI API的模型 + */ +@Slf4j +@Service +public class OpenAIStyleLLMServiceImpl implements LLMService { + + @Autowired + private ModelConfigService modelConfigService; + + private final RestTemplate restTemplate = new RestTemplate(); + + private static final String DEFAULT_SUMMARY_PROMPT = "你是一个经验丰富的记忆总结者,擅长将对话内容进行总结摘要,遵循以下规则:\n1、总结用户的重要信息,以便在未来的对话中提供更个性化的服务\n2、不要重复总结,不要遗忘之前记忆,除非原来的记忆超过了1800字,否则不要遗忘、不要压缩用户的历史记忆\n3、用户操控的设备音量、播放音乐、天气、退出、不想对话等和用户本身无关的内容,这些信息不需要加入到总结中\n4、聊天内容中的今天的日期时间、今天的天气情况与用户事件无关的数据,这些信息如果当成记忆存储会影响后续对话,这些信息不需要加入到总结中\n5、不要把设备操控的成果结果和失败结果加入到总结中,也不要把用户的一些废话加入到总结中\n6、不要为了总结而总结,如果用户的聊天没有意义,请返回原来的历史记录也是可以的\n7、只需要返回总结摘要,严格控制在1800字内\n8、不要包含代码、xml,不需要解释、注释和说明,保存记忆时仅从对话提取信息,不要混入示例内容\n9、如果提供了历史记忆,请将新对话内容与历史记忆进行智能合并,保留有价值的历史信息,同时添加新的重要信息\n\n历史记忆:\n{history_memory}\n\n新对话内容:\n{conversation}"; + + @Override + public String generateSummary(String conversation) { + return generateSummary(conversation, null, null); + } + + @Override + public String generateSummaryWithModel(String conversation, String modelId) { + return generateSummary(conversation, null, modelId); + } + + @Override + public String generateSummary(String conversation, String promptTemplate, String modelId) { + if (!isAvailable()) { + log.warn("LLM服务不可用,无法生成总结"); + return "LLM服务不可用,无法生成总结"; + } + + try { + // 从智控台获取LLM模型配置 + ModelConfigEntity llmConfig; + if (modelId != null && !modelId.trim().isEmpty()) { + // 通过具体模型ID获取配置 + llmConfig = modelConfigService.getModelByIdFromCache(modelId); + } else { + // 保持向后兼容,使用默认配置 + llmConfig = getDefaultLLMConfig(); + } + + if (llmConfig == null || llmConfig.getConfigJson() == null) { + log.error("未找到可用的LLM模型配置,modelId: {}", modelId); + return "未找到可用的LLM模型配置"; + } + + JSONObject configJson = llmConfig.getConfigJson(); + String baseUrl = configJson.getStr("base_url"); + String model = configJson.getStr("model_name"); + String apiKey = configJson.getStr("api_key"); + Double temperature = configJson.getDouble("temperature"); + Integer maxTokens = configJson.getInt("max_tokens"); + + if (StringUtils.isBlank(baseUrl) || StringUtils.isBlank(apiKey)) { + log.error("LLM配置不完整,baseUrl或apiKey为空"); + return "LLM配置不完整,无法生成总结"; + } + + // 构建提示词 + String prompt = (promptTemplate != null ? promptTemplate : DEFAULT_SUMMARY_PROMPT).replace("{conversation}", + conversation); + + // 构建请求体 + Map requestBody = new HashMap<>(); + requestBody.put("model", model != null ? model : "gpt-3.5-turbo"); + + Map[] messages = new Map[1]; + Map message = new HashMap<>(); + message.put("role", "user"); + message.put("content", prompt); + messages[0] = message; + + requestBody.put("messages", messages); + requestBody.put("temperature", temperature != null ? temperature : 0.7); + requestBody.put("max_tokens", maxTokens != null ? maxTokens : 2000); + + // 发送HTTP请求 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + apiKey); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + // 构建完整的API URL + String apiUrl = baseUrl; + if (!apiUrl.endsWith("/chat/completions")) { + if (!apiUrl.endsWith("/")) { + apiUrl += "/"; + } + apiUrl += "chat/completions"; + } + + ResponseEntity response = restTemplate.exchange( + apiUrl, HttpMethod.POST, entity, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + JSONObject responseJson = JSONUtil.parseObj(response.getBody()); + JSONArray choices = responseJson.getJSONArray("choices"); + if (choices != null && choices.size() > 0) { + JSONObject choice = choices.getJSONObject(0); + JSONObject messageObj = choice.getJSONObject("message"); + return messageObj.getStr("content"); + } + } else { + log.error("LLM API调用失败,状态码:{},响应:{}", response.getStatusCode(), response.getBody()); + } + } catch (Exception e) { + log.error("调用LLM服务生成总结时发生异常,modelId: {}", modelId, e); + } + + return "生成总结失败,请稍后重试"; + } + + @Override + public String generateSummary(String conversation, String promptTemplate) { + return generateSummary(conversation, promptTemplate, null); + } + + @Override + public String generateSummaryWithHistory(String conversation, String historyMemory, String promptTemplate, + String modelId) { + if (!isAvailable()) { + log.warn("LLM服务不可用,无法生成总结"); + return "LLM服务不可用,无法生成总结"; + } + + try { + // 从智控台获取LLM模型配置 + ModelConfigEntity llmConfig; + if (modelId != null && !modelId.trim().isEmpty()) { + // 通过具体模型ID获取配置 + llmConfig = modelConfigService.getModelByIdFromCache(modelId); + } else { + // 保持向后兼容,使用默认配置 + llmConfig = getDefaultLLMConfig(); + } + + if (llmConfig == null || llmConfig.getConfigJson() == null) { + log.error("未找到可用的LLM模型配置,modelId: {}", modelId); + return "未找到可用的LLM模型配置"; + } + + JSONObject configJson = llmConfig.getConfigJson(); + String baseUrl = configJson.getStr("base_url"); + String model = configJson.getStr("model_name"); + String apiKey = configJson.getStr("api_key"); + Double temperature = configJson.getDouble("temperature"); + Integer maxTokens = configJson.getInt("max_tokens"); + + if (StringUtils.isBlank(baseUrl) || StringUtils.isBlank(apiKey)) { + log.error("LLM配置不完整,baseUrl或apiKey为空"); + return "LLM配置不完整,无法生成总结"; + } + + // 构建提示词,包含历史记忆 + String prompt = (promptTemplate != null ? promptTemplate : DEFAULT_SUMMARY_PROMPT) + .replace("{history_memory}", historyMemory != null ? historyMemory : "无历史记忆") + .replace("{conversation}", conversation); + + // 构建请求体 + Map requestBody = new HashMap<>(); + requestBody.put("model", model != null ? model : "gpt-3.5-turbo"); + + Map[] messages = new Map[1]; + Map message = new HashMap<>(); + message.put("role", "user"); + message.put("content", prompt); + messages[0] = message; + + requestBody.put("messages", messages); + requestBody.put("temperature", temperature != null ? temperature : 0.7); + requestBody.put("max_tokens", maxTokens != null ? maxTokens : 2000); + + // 发送HTTP请求 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + apiKey); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + // 构建完整的API URL + String apiUrl = baseUrl; + if (!apiUrl.endsWith("/chat/completions")) { + if (!apiUrl.endsWith("/")) { + apiUrl += "/"; + } + apiUrl += "chat/completions"; + } + + ResponseEntity response = restTemplate.exchange( + apiUrl, HttpMethod.POST, entity, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + JSONObject responseJson = JSONUtil.parseObj(response.getBody()); + JSONArray choices = responseJson.getJSONArray("choices"); + if (choices != null && choices.size() > 0) { + JSONObject choice = choices.getJSONObject(0); + JSONObject messageObj = choice.getJSONObject("message"); + return messageObj.getStr("content"); + } + } else { + log.error("LLM API调用失败,状态码:{},响应:{}", response.getStatusCode(), response.getBody()); + } + } catch (Exception e) { + log.error("调用LLM服务生成总结时发生异常,modelId: {}", modelId, e); + } + + return "生成总结失败,请稍后重试"; + } + + @Override + public boolean isAvailable() { + try { + ModelConfigEntity defaultLLMConfig = getDefaultLLMConfig(); + if (defaultLLMConfig == null || defaultLLMConfig.getConfigJson() == null) { + return false; + } + + JSONObject configJson = defaultLLMConfig.getConfigJson(); + String baseUrl = configJson.getStr("base_url"); + String apiKey = configJson.getStr("api_key"); + + return baseUrl != null && !baseUrl.trim().isEmpty() && + apiKey != null && !apiKey.trim().isEmpty(); + } catch (Exception e) { + log.error("检查LLM服务可用性时发生异常:", e); + return false; + } + } + + @Override + public boolean isAvailable(String modelId) { + try { + if (modelId == null || modelId.trim().isEmpty()) { + return isAvailable(); + } + + // 通过具体模型ID获取配置 + ModelConfigEntity modelConfig = modelConfigService.getModelByIdFromCache(modelId); + if (modelConfig == null || modelConfig.getConfigJson() == null) { + log.warn("未找到指定的LLM模型配置,modelId: {}", modelId); + return false; + } + + JSONObject configJson = modelConfig.getConfigJson(); + String baseUrl = configJson.getStr("base_url"); + String apiKey = configJson.getStr("api_key"); + + return baseUrl != null && !baseUrl.trim().isEmpty() && + apiKey != null && !apiKey.trim().isEmpty(); + } catch (Exception e) { + log.error("检查LLM服务可用性时发生异常,modelId: {}", modelId, e); + return false; + } + } + + /** + * 从智控台获取默认的LLM模型配置 + */ + private ModelConfigEntity getDefaultLLMConfig() { + try { + // 获取所有启用的LLM模型配置 + List llmConfigs = modelConfigService.getEnabledModelsByType("LLM"); + if (llmConfigs == null || llmConfigs.isEmpty()) { + return null; + } + + // 优先返回默认配置,如果没有默认配置则返回第一个启用的配置 + for (ModelConfigEntity config : llmConfigs) { + if (config.getIsDefault() != null && config.getIsDefault() == 1) { + return config; + } + } + + return llmConfigs.get(0); + } catch (Exception e) { + log.error("获取LLM模型配置时发生异常:", e); + return null; + } + } +} \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelConfigService.java b/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelConfigService.java index 95b64b20..9ae10761 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelConfigService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/model/service/ModelConfigService.java @@ -55,4 +55,12 @@ public interface ModelConfigService extends BaseService { * @return TTS平台列表(id和modelName) */ List> getTtsPlatformList(); + + /** + * 根据模型类型获取所有启用的模型配置 + * + * @param modelType 模型类型(如:LLM, TTS, ASR等) + * @return 启用的模型配置列表 + */ + List getEnabledModelsByType(String modelType); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelConfigServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelConfigServiceImpl.java index fbfc7723..2a291cf9 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelConfigServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/model/service/impl/ModelConfigServiceImpl.java @@ -502,4 +502,22 @@ public class ModelConfigServiceImpl extends BaseServiceImpl> getTtsPlatformList() { return modelConfigDao.getTtsPlatformList(); } + + /** + * 根据模型类型获取所有启用的模型配置 + */ + @Override + public List getEnabledModelsByType(String modelType) { + if (StringUtils.isBlank(modelType)) { + return null; + } + + List entities = modelConfigDao.selectList( + new QueryWrapper() + .eq("model_type", modelType) + .eq("is_enabled", 1) + .orderByAsc("sort")); + + return entities; + } } diff --git a/main/xiaozhi-server/config/manage_api_client.py b/main/xiaozhi-server/config/manage_api_client.py index 88995359..ae703815 100644 --- a/main/xiaozhi-server/config/manage_api_client.py +++ b/main/xiaozhi-server/config/manage_api_client.py @@ -53,6 +53,7 @@ class ManageApiClient: async def _ensure_async_client(cls): """确保异步客户端已创建(为每个事件循环创建独立的客户端)""" import asyncio + try: loop = asyncio.get_running_loop() loop_id = id(loop) @@ -115,6 +116,7 @@ class ManageApiClient: async def _execute_async_request(cls, method: str, endpoint: str, **kwargs) -> Dict: """带重试机制的异步请求执行器""" import asyncio + retry_count = 0 while retry_count <= cls.max_retries: @@ -138,6 +140,7 @@ class ManageApiClient: def safe_close(cls): """安全关闭所有异步连接池""" import asyncio + for client in list(cls._async_clients.values()): try: asyncio.run(client.aclose()) @@ -149,7 +152,9 @@ class ManageApiClient: async def get_server_config() -> Optional[Dict]: """获取服务器基础配置""" - return await ManageApiClient._instance._execute_async_request("POST", "/config/server-base") + return await ManageApiClient._instance._execute_async_request( + "POST", "/config/server-base" + ) async def get_agent_models( @@ -181,6 +186,30 @@ async def save_mem_local_short(mac_address: str, short_momery: str) -> Optional[ return None +async def generate_chat_summary(session_id: str) -> Optional[Dict]: + """生成聊天记录总结""" + try: + return await ManageApiClient._instance._execute_async_request( + "POST", + f"/agent/chat-summary/{session_id}", + ) + except Exception as e: + print(f"生成聊天记录总结失败: {e}") + return None + + +async def generate_and_save_chat_summary(session_id: str) -> Optional[Dict]: + """生成并保存聊天记录总结""" + try: + return await ManageApiClient._instance._execute_async_request( + "POST", + f"/agent/chat-summary/{session_id}/save", + ) + except Exception as e: + print(f"生成并保存聊天记录总结失败: {e}") + return None + + async def report( mac_address: str, session_id: str, chat_type: int, content: str, audio, report_time ) -> Optional[Dict]: diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 6b34e32f..43c77f93 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -243,7 +243,9 @@ class ConnectionHandler: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete( - self.memory.save_memory(self.dialogue.dialogue) + self.memory.save_memory( + self.dialogue.dialogue, self.session_id + ) ) except Exception as e: self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}") diff --git a/main/xiaozhi-server/core/providers/memory/base.py b/main/xiaozhi-server/core/providers/memory/base.py index 2ced898d..70c4b409 100644 --- a/main/xiaozhi-server/core/providers/memory/base.py +++ b/main/xiaozhi-server/core/providers/memory/base.py @@ -14,7 +14,7 @@ class MemoryProviderBase(ABC): self.llm = llm @abstractmethod - async def save_memory(self, msgs): + async def save_memory(self, msgs, session_id=None): """Save a new memory for specific role and return memory ID""" print("this is base func", msgs) diff --git a/main/xiaozhi-server/core/providers/memory/mem0ai/mem0ai.py b/main/xiaozhi-server/core/providers/memory/mem0ai/mem0ai.py index 530aa317..7156ab72 100644 --- a/main/xiaozhi-server/core/providers/memory/mem0ai/mem0ai.py +++ b/main/xiaozhi-server/core/providers/memory/mem0ai/mem0ai.py @@ -28,7 +28,7 @@ class MemoryProvider(MemoryProviderBase): logger.bind(tag=TAG).error(f"详细错误: {traceback.format_exc()}") self.use_mem0 = False - async def save_memory(self, msgs): + async def save_memory(self, msgs, session_id=None): if not self.use_mem0: return None if len(msgs) < 2: @@ -41,9 +41,7 @@ class MemoryProvider(MemoryProviderBase): for message in msgs if message.role != "system" ] - result = self.client.add( - messages, user_id=self.role_id - ) + result = self.client.add(messages, user_id=self.role_id) logger.bind(tag=TAG).debug(f"Save memory result: {result}") except Exception as e: logger.bind(tag=TAG).error(f"保存记忆失败: {str(e)}") diff --git a/main/xiaozhi-server/core/providers/memory/mem_local_short/mem_local_short.py b/main/xiaozhi-server/core/providers/memory/mem_local_short/mem_local_short.py index a6b7dd36..f8b81aa8 100644 --- a/main/xiaozhi-server/core/providers/memory/mem_local_short/mem_local_short.py +++ b/main/xiaozhi-server/core/providers/memory/mem_local_short/mem_local_short.py @@ -4,7 +4,10 @@ import json import os import yaml from config.config_loader import get_project_dir -from config.manage_api_client import save_mem_local_short +from config.manage_api_client import ( + save_mem_local_short, + generate_and_save_chat_summary, +) import asyncio from core.utils.util import check_model_key @@ -144,7 +147,7 @@ class MemoryProvider(MemoryProviderBase): with open(self.memory_path, "w", encoding="utf-8") as f: yaml.dump(all_memory, f, allow_unicode=True) - async def save_memory(self, msgs): + async def save_memory(self, msgs, session_id=None): # 打印使用的模型信息 model_info = getattr(self.llm, "model_name", str(self.llm.__class__.__name__)) logger.bind(tag=TAG).debug(f"使用记忆保存模型: {model_info}") @@ -188,20 +191,18 @@ class MemoryProvider(MemoryProviderBase): except Exception as e: print("Error:", e) else: - result = self.llm.response_no_stream( - short_term_memory_prompt_only_content, - msgStr, - max_tokens=2000, - temperature=0.2, - ) + # 当save_to_file为False时,调用Java端的聊天记录总结接口 + summary_id = session_id if session_id else self.role_id # 使用异步版本,需要在事件循环中运行 try: loop = asyncio.get_running_loop() - loop.create_task(save_mem_local_short(self.role_id, result)) + loop.create_task(generate_and_save_chat_summary(summary_id)) except RuntimeError: # 如果没有运行中的事件循环,创建一个新的 - asyncio.run(save_mem_local_short(self.role_id, result)) - logger.bind(tag=TAG).info(f"Save memory successful - Role: {self.role_id}") + asyncio.run(generate_and_save_chat_summary(summary_id)) + logger.bind(tag=TAG).info( + f"Save memory successful - Role: {self.role_id}, Session: {session_id}" + ) return self.short_memory diff --git a/main/xiaozhi-server/core/providers/memory/nomem/nomem.py b/main/xiaozhi-server/core/providers/memory/nomem/nomem.py index 51523be4..6fe0a2be 100644 --- a/main/xiaozhi-server/core/providers/memory/nomem/nomem.py +++ b/main/xiaozhi-server/core/providers/memory/nomem/nomem.py @@ -11,7 +11,7 @@ class MemoryProvider(MemoryProviderBase): def __init__(self, config, summary_memory=None): super().__init__(config) - async def save_memory(self, msgs): + async def save_memory(self, msgs, session_id=None): logger.bind(tag=TAG).debug("nomem mode: No memory saving is performed.") return None From c8d2d2255db5ceb16c8256f16d00d62c41926a09 Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Fri, 12 Dec 2025 15:08:40 +0800 Subject: [PATCH 49/93] =?UTF-8?q?fix:=20=E9=9F=B3=E9=A2=91=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E7=BA=BF=E7=A8=8B=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xiaozhi-server/core/providers/asr/base.py | 102 ++++++------------ 1 file changed, 33 insertions(+), 69 deletions(-) diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index 671484e9..6c6969fd 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -9,7 +9,6 @@ import asyncio import traceback import threading import opuslib_next -import concurrent.futures from abc import ABC, abstractmethod from config.logger import setup_logging from typing import Optional, Tuple, List @@ -79,98 +78,63 @@ class ASRProviderBase(ABC): """并行处理ASR和声纹识别""" try: total_start_time = time.monotonic() - + # 准备音频数据 if conn.audio_format == "pcm": pcm_data = asr_audio_task else: pcm_data = self.decode_opus(asr_audio_task) - + combined_pcm_data = b"".join(pcm_data) - + # 预先准备WAV数据 wav_data = None if conn.voiceprint_provider and combined_pcm_data: wav_data = self._pcm_to_wav(combined_pcm_data) - + # 定义ASR任务 - def run_asr(): - start_time = time.monotonic() - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - result = loop.run_until_complete( - self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format) - ) - end_time = time.monotonic() - logger.bind(tag=TAG).debug(f"ASR耗时: {end_time - start_time:.3f}s") - return result - finally: - loop.close() - except Exception as e: - end_time = time.monotonic() - logger.bind(tag=TAG).error(f"ASR失败: {e}") - return ("", None) - - # 定义声纹识别任务 - def run_voiceprint(): - if not wav_data: - return None - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - # 使用连接的声纹识别提供者 - result = loop.run_until_complete( - conn.voiceprint_provider.identify_speaker(wav_data, conn.session_id) - ) - return result - finally: - loop.close() - except Exception as e: - logger.bind(tag=TAG).error(f"声纹识别失败: {e}") - return None - - # 使用线程池执行器并行运行 - with concurrent.futures.ThreadPoolExecutor(max_workers=2) as thread_executor: - asr_future = thread_executor.submit(run_asr) - - if conn.voiceprint_provider and wav_data: - voiceprint_future = thread_executor.submit(run_voiceprint) - - # 等待两个线程都完成 - asr_result = asr_future.result(timeout=15) - voiceprint_result = voiceprint_future.result(timeout=15) - - results = {"asr": asr_result, "voiceprint": voiceprint_result} - else: - asr_result = asr_future.result(timeout=15) - results = {"asr": asr_result, "voiceprint": None} - - - # 处理结果 - raw_text, _ = results.get("asr", ("", None)) - speaker_name = results.get("voiceprint", None) - - # 记录识别结果 + asr_task = self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format) + + if conn.voiceprint_provider and wav_data: + voiceprint_task = conn.voiceprint_provider.identify_speaker(wav_data, conn.session_id) + # 并发等待两个结果 + asr_result, voiceprint_result = await asyncio.gather( + asr_task, voiceprint_task, return_exceptions=True + ) + else: + asr_result = await asr_task + voiceprint_result = None + + # 记录识别结果 - 检查是否为异常 + if isinstance(asr_result, Exception): + logger.bind(tag=TAG).error(f"ASR识别失败: {asr_result}") + raw_text = "" + else: + raw_text, _ = asr_result + + if isinstance(voiceprint_result, Exception): + logger.bind(tag=TAG).error(f"声纹识别失败: {voiceprint_result}") + speaker_name = "" + else: + speaker_name = voiceprint_result + if raw_text: logger.bind(tag=TAG).info(f"识别文本: {raw_text}") if speaker_name: logger.bind(tag=TAG).info(f"识别说话人: {speaker_name}") - + # 性能监控 total_time = time.monotonic() - total_start_time logger.bind(tag=TAG).debug(f"总处理耗时: {total_time:.3f}s") - + # 检查文本长度 text_len, _ = remove_punctuation_and_length(raw_text) self.stop_ws_connection() - + if text_len > 0: # 构建包含说话人信息的JSON字符串 enhanced_text = self._build_enhanced_text(raw_text, speaker_name) - + # 使用自定义模块进行上报 await startToChat(conn, enhanced_text) enqueue_asr_report(conn, enhanced_text, asr_audio_task) From 20f601e6071641aae574da0ea3d08ea1d93881ff Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Fri, 12 Dec 2025 15:39:30 +0800 Subject: [PATCH 50/93] =?UTF-8?q?fix:=20=E5=90=8C=E6=AD=A5=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E4=BD=BF=E7=94=A8=E7=BA=BF=E7=A8=8B=E6=B1=A0=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/providers/asr/fun_local.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/main/xiaozhi-server/core/providers/asr/fun_local.py b/main/xiaozhi-server/core/providers/asr/fun_local.py index 217f17ff..2fd49c36 100644 --- a/main/xiaozhi-server/core/providers/asr/fun_local.py +++ b/main/xiaozhi-server/core/providers/asr/fun_local.py @@ -1,14 +1,16 @@ -import time import os -import sys import io +import sys +import time +import shutil import psutil +import asyncio + from config.logger import setup_logging from typing import Optional, Tuple, List -from core.providers.asr.base import ASRProviderBase from funasr import AutoModel from funasr.utils.postprocess_utils import rich_transcription_postprocess -import shutil +from core.providers.asr.base import ASRProviderBase from core.providers.asr.dto.dto import InterfaceType TAG = __name__ @@ -90,16 +92,17 @@ class ASRProvider(ASRProviderBase): else: file_path = self.save_audio_to_file(pcm_data, session_id) - # 语音识别 + # 语音识别 - 使用线程池避免阻塞事件循环 start_time = time.time() - result = self.model.generate( + result = await asyncio.to_thread( + self.model.generate, input=combined_pcm_data, cache={}, language="auto", use_itn=True, batch_size_s=60, ) - text = rich_transcription_postprocess(result[0]["text"]) + text = await asyncio.to_thread(rich_transcription_postprocess, result[0]["text"]) logger.bind(tag=TAG).debug( f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}" ) From 5dbf796fa931efe15ec217a7ab170fdda9fde676 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Fri, 12 Dec 2025 16:33:50 +0800 Subject: [PATCH 51/93] =?UTF-8?q?update:=E6=9B=B4=E6=96=B0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/xiaozhi/common/constant/Constant.java | 2 +- main/manager-mobile/src/pages/settings/index.vue | 2 +- main/xiaozhi-server/config/logger.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 1ea19355..917f1e50 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 @@ -299,7 +299,7 @@ public interface Constant { /** * 版本号 */ - public static final String VERSION = "0.8.9"; + public static final String VERSION = "0.8.10"; /** * 无效固件URL diff --git a/main/manager-mobile/src/pages/settings/index.vue b/main/manager-mobile/src/pages/settings/index.vue index 2f7be6b4..b1fa487b 100644 --- a/main/manager-mobile/src/pages/settings/index.vue +++ b/main/manager-mobile/src/pages/settings/index.vue @@ -235,7 +235,7 @@ function showAbout() { title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }), content: t('settings.aboutContent', { appName: import.meta.env.VITE_APP_TITLE, - version: '0.8.9' + version: '0.8.10' }), showCancel: false, confirmText: t('common.confirm'), diff --git a/main/xiaozhi-server/config/logger.py b/main/xiaozhi-server/config/logger.py index a0b64dd7..bf64893c 100644 --- a/main/xiaozhi-server/config/logger.py +++ b/main/xiaozhi-server/config/logger.py @@ -5,7 +5,7 @@ from config.config_loader import load_config from config.settings import check_config_file from datetime import datetime -SERVER_VERSION = "0.8.9" +SERVER_VERSION = "0.8.10" _logger_initialized = False From 85e65cf9e8f9363dac1f244dbef3d21b10766590 Mon Sep 17 00:00:00 2001 From: shiyin Date: Fri, 12 Dec 2025 18:28:46 +0800 Subject: [PATCH 52/93] =?UTF-8?q?fix:=E5=88=9D=E5=A7=8B=E5=8C=96ASR?= =?UTF-8?q?=E6=97=B6,=E5=88=A4=E6=96=ADASR=E7=B1=BB=E5=9E=8B=E5=8F=96?= =?UTF-8?q?=E5=80=BC=E9=94=99=E8=AF=AF=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index b37745c7..9805fb5e 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -496,7 +496,7 @@ class ConnectionHandler: def _initialize_asr(self): """初始化ASR""" - if self._asr.interface_type == InterfaceType.LOCAL: + if self._asr is not None and hasattr(self._asr, "interface_type") and self._asr.interface_type == InterfaceType.LOCAL: # 如果公共ASR是本地服务,则直接返回 # 因为本地一个实例ASR,可以被多个连接共享 asr = self._asr From dc170edbc18e2d0b36dcc130a18b5a2a0544329c Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Fri, 12 Dec 2025 18:58:24 +0800 Subject: [PATCH 53/93] =?UTF-8?q?update:=20=E6=9C=AA=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=AD=96=E7=95=A5=E4=BC=98=E5=8C=96=20fix:?= =?UTF-8?q?=20=E9=9F=B3=E9=A2=91=E9=98=9F=E5=88=97=E7=AB=9E=E6=80=81?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 48 +++++++++++-------- .../core/utils/audioRateController.py | 29 ++++------- 2 files changed, 38 insertions(+), 39 deletions(-) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index b37745c7..2a6a8168 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -68,7 +68,8 @@ class ConnectionHandler: self.logger = setup_logging() self.server = server # 保存server实例的引用 - self.need_bind = False # 是否需要绑定设备 + self.need_bind = True # 是否需要绑定设备 + self.bind_completed_event = asyncio.Event() self.bind_code = None # 绑定设备的验证码 self.last_bind_prompt_time = 0 # 上次播放绑定提示的时间戳(秒) self.bind_prompt_interval = 60 # 绑定提示播放间隔(秒) @@ -268,28 +269,30 @@ class ConnectionHandler: async def _route_message(self, message): """消息路由""" + try: + await asyncio.wait_for(self.bind_completed_event.wait(), timeout=1) + except asyncio.TimeoutError: + # 未绑定设备直接丢弃所有消息 + current_time = time.time() + # 检查是否需要播放绑定提示 + if ( + current_time - self.last_bind_prompt_time + >= self.bind_prompt_interval + ): + self.last_bind_prompt_time = current_time + # 复用现有的绑定提示逻辑 + from core.handle.receiveAudioHandle import check_bind_device + + asyncio.create_task(check_bind_device(self)) + # 直接丢弃音频,不进行ASR处理 + return + if isinstance(message, str): await handleTextMessage(self, message) elif isinstance(message, bytes): if self.vad is None or self.asr is None: return - # 未绑定设备直接丢弃所有音频,不进行ASR处理 - if self.need_bind: - current_time = time.time() - # 检查是否需要播放绑定提示 - if ( - current_time - self.last_bind_prompt_time - >= self.bind_prompt_interval - ): - self.last_bind_prompt_time = current_time - # 复用现有的绑定提示逻辑 - from core.handle.receiveAudioHandle import check_bind_device - - asyncio.create_task(check_bind_device(self)) - # 直接丢弃音频,不进行ASR处理 - return - # 处理来自MQTT网关的音频包 if self.conn_from_mqtt_gateway and len(message) >= 16: handled = await self._process_mqtt_audio_message(message) @@ -461,6 +464,9 @@ class ConnectionHandler: self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}") def _init_prompt_enhancement(self): + if self.need_bind: + return + # 更新上下文信息 self.prompt_manager.update_context_info(self, self.client_ip) enhanced_prompt = self.prompt_manager.build_enhanced_prompt( @@ -509,6 +515,9 @@ class ConnectionHandler: def _initialize_voiceprint(self): """为当前连接初始化声纹识别""" + if self.need_bind: + return + try: voiceprint_config = self.config.get("voiceprint", {}) if voiceprint_config: @@ -548,15 +557,14 @@ class ConnectionHandler: self.logger.bind(tag=TAG).info( f"{time.time() - begin_time} 秒,异步获取差异化配置成功: {json.dumps(filter_sensitive_info(private_config), ensure_ascii=False)}" ) + self.need_bind = False + self.bind_completed_event.set() except DeviceNotFoundException as e: - self.need_bind = True private_config = {} except DeviceBindException as e: - self.need_bind = True self.bind_code = e.bind_code private_config = {} except Exception as e: - self.need_bind = True self.logger.bind(tag=TAG).error(f"异步获取差异化配置失败: {e}") private_config = {} diff --git a/main/xiaozhi-server/core/utils/audioRateController.py b/main/xiaozhi-server/core/utils/audioRateController.py index e6d883f1..612dcba0 100644 --- a/main/xiaozhi-server/core/utils/audioRateController.py +++ b/main/xiaozhi-server/core/utils/audioRateController.py @@ -85,26 +85,13 @@ class AudioRateController: elif item_type == "audio": _, opus_packet = item - # 循环等待直到时间到达 - while True: - # 计算时间差 - elapsed_ms = self._get_elapsed_ms() - output_ms = self.play_position + # 计算时间差 + elapsed_ms = self._get_elapsed_ms() + output_ms = self.play_position - if elapsed_ms < output_ms: - # 还不到发送时间,计算等待时长 - wait_ms = output_ms - elapsed_ms - - # 等待后继续检查(允许被中断) - try: - await asyncio.sleep(wait_ms / 1000) - except asyncio.CancelledError: - self.logger.bind(tag=TAG).debug("音频发送任务被取消") - raise - # 等待结束后重新检查时间(循环回到 while True) - else: - # 时间已到,跳出等待循环 - break + if elapsed_ms < output_ms: + # 还不到发送时间,返回让出控制权(非阻塞) + break # 时间已到,从队列移除并发送 self.queue.pop(0) @@ -132,6 +119,10 @@ class AudioRateController: async def _send_loop(): try: while True: + # 只有当队列非空时才处理,否则等待队列事件 + if not self.queue: + await self.queue_empty_event.wait() + await self.check_queue(send_audio_callback) # 如果队列空了,短暂等待后再检查(避免 busy loop) await asyncio.sleep(0.01) From 7fc2eeaaa5d743c964806bbc768176733dcdc85c Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 13 Dec 2025 00:19:38 +0800 Subject: [PATCH 54/93] =?UTF-8?q?update:=E5=8F=91=E9=80=81=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E6=B6=88=E6=81=AF=E6=97=B6=E6=89=93=E6=96=AD=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E4=BA=BA=E8=AF=B4=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/js/core/audio/recorder.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/main/xiaozhi-server/test/js/core/audio/recorder.js b/main/xiaozhi-server/test/js/core/audio/recorder.js index 9f94f895..3fe785f0 100644 --- a/main/xiaozhi-server/test/js/core/audio/recorder.js +++ b/main/xiaozhi-server/test/js/core/audio/recorder.js @@ -240,6 +240,24 @@ export class AudioRecorder { if (this.isRecording) return false; try { + // 检查是否有WebSocketHandler实例 + const { getWebSocketHandler } = await import('../network/websocket.js'); + const wsHandler = getWebSocketHandler(); + + // 如果机器正在说话,发送打断消息 + if (wsHandler && wsHandler.isRemoteSpeaking && wsHandler.currentSessionId) { + const abortMessage = { + session_id: wsHandler.currentSessionId, + type: 'abort', + reason: 'wake_word_detected' + }; + + if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { + this.websocket.send(JSON.stringify(abortMessage)); + log('发送打断消息', 'info'); + } + } + if (!this.initEncoder()) { log('无法启动录音: Opus编码器初始化失败', 'error'); return false; From d33cdd978d62cfae0392344eff2c4e6e42b96fa1 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 13 Dec 2025 00:25:54 +0800 Subject: [PATCH 55/93] fix:edge_tts bug --- main/xiaozhi-server/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/xiaozhi-server/requirements.txt b/main/xiaozhi-server/requirements.txt index 3f62ef4c..919c0f6d 100644 --- a/main/xiaozhi-server/requirements.txt +++ b/main/xiaozhi-server/requirements.txt @@ -13,7 +13,7 @@ pydub==0.25.1 funasr==1.2.7 openai==2.8.1 google-generativeai==0.8.5 -edge_tts==7.2.3 +edge_tts==7.2.6 httpx==0.28.1 aiohttp==3.13.2 aiohttp_cors==0.8.1 From 7699596597d211fa581d37af137f4ac8d296f62f Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 13 Dec 2025 14:35:55 +0800 Subject: [PATCH 56/93] =?UTF-8?q?update:=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 30 +++++++++++--------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 2a6a8168..dbb2bf7a 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -68,7 +68,7 @@ class ConnectionHandler: self.logger = setup_logging() self.server = server # 保存server实例的引用 - self.need_bind = True # 是否需要绑定设备 + self.need_bind = False # 是否需要绑定设备 self.bind_completed_event = asyncio.Event() self.bind_code = None # 绑定设备的验证码 self.last_bind_prompt_time = 0 # 上次播放绑定提示的时间戳(秒) @@ -270,15 +270,12 @@ class ConnectionHandler: async def _route_message(self, message): """消息路由""" try: - await asyncio.wait_for(self.bind_completed_event.wait(), timeout=1) + await asyncio.wait_for(self.bind_completed_event.wait(), timeout=2) except asyncio.TimeoutError: # 未绑定设备直接丢弃所有消息 current_time = time.time() # 检查是否需要播放绑定提示 - if ( - current_time - self.last_bind_prompt_time - >= self.bind_prompt_interval - ): + if current_time - self.last_bind_prompt_time >= self.bind_prompt_interval: self.last_bind_prompt_time = current_time # 复用现有的绑定提示逻辑 from core.handle.receiveAudioHandle import check_bind_device @@ -416,6 +413,14 @@ class ConnectionHandler: def _initialize_components(self): try: + if self.tts is None: + self.tts = self._initialize_tts() + # 打开语音合成通道 + asyncio.run_coroutine_threadsafe( + self.tts.open_audio_channels(self), self.loop + ) + if self.need_bind: + return self.selected_module_str = build_module_string( self.config.get("selected_module", {}) ) @@ -439,17 +444,10 @@ class ConnectionHandler: # 初始化声纹识别 self._initialize_voiceprint() - # 打开语音识别通道 asyncio.run_coroutine_threadsafe( self.asr.open_audio_channels(self), self.loop ) - if self.tts is None: - self.tts = self._initialize_tts() - # 打开语音合成通道 - asyncio.run_coroutine_threadsafe( - self.tts.open_audio_channels(self), self.loop - ) """加载记忆""" self._initialize_memory() @@ -464,8 +462,6 @@ class ConnectionHandler: self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}") def _init_prompt_enhancement(self): - if self.need_bind: - return # 更新上下文信息 self.prompt_manager.update_context_info(self, self.client_ip) @@ -515,9 +511,6 @@ class ConnectionHandler: def _initialize_voiceprint(self): """为当前连接初始化声纹识别""" - if self.need_bind: - return - try: voiceprint_config = self.config.get("voiceprint", {}) if voiceprint_config: @@ -545,6 +538,7 @@ class ConnectionHandler: async def _initialize_private_config_async(self): """从接口异步获取差异化配置(异步版本,不阻塞主循环)""" if not self.read_config_from_api: + self.bind_completed_event.set() return try: begin_time = time.time() From 2e092a78801f2ee14a5ee2c3b5e9843b2c48098c Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 13 Dec 2025 15:34:11 +0800 Subject: [PATCH 57/93] =?UTF-8?q?update=EF=BC=9A=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=BC=80=E5=90=AFserver.auth.enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/device/service/impl/DeviceServiceImpl.java | 6 +++--- .../modules/security/controller/LoginController.java | 8 ++++---- .../src/main/resources/db/changelog/202512131453.sql | 6 ++++++ .../main/resources/db/changelog/db.changelog-master.yaml | 7 +++++++ main/manager-web/src/i18n/de.js | 4 ++-- main/manager-web/src/i18n/en.js | 4 ++-- main/manager-web/src/i18n/vi.js | 4 ++-- main/manager-web/src/i18n/zh_CN.js | 4 ++-- main/manager-web/src/i18n/zh_TW.js | 4 ++-- 9 files changed, 30 insertions(+), 17 deletions(-) create mode 100644 main/manager-api/src/main/resources/db/changelog/202512131453.sql diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java index b00af2cf..44977730 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java @@ -173,7 +173,7 @@ public class DeviceServiceImpl extends BaseServiceImpl String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true); // 检查是否启用认证并生成token - String authEnabled = sysParamsService.getValue(Constant.SERVER_AUTH_ENABLED, false); + String authEnabled = sysParamsService.getValue(Constant.SERVER_AUTH_ENABLED, true); if ("true".equalsIgnoreCase(authEnabled)) { try { // 生成token @@ -206,7 +206,7 @@ public class DeviceServiceImpl extends BaseServiceImpl // 添加MQTT UDP配置 // 从系统参数获取MQTT Gateway地址,仅在配置有效时使用 - String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, false); + String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true); if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) { try { String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard() @@ -555,7 +555,7 @@ public class DeviceServiceImpl extends BaseServiceImpl private DeviceReportRespDTO.MQTT buildMqttConfig(String macAddress, String groupId) throws Exception { // 从环境变量或系统参数获取签名密钥 - String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", false); + String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", true); if (StringUtils.isBlank(signatureKey)) { log.warn("缺少MQTT_SIGNATURE_KEY,跳过MQTT配置生成"); return null; diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java index 2c7bab4a..1ab9809d 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java @@ -6,6 +6,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.apache.commons.lang3.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -23,7 +24,9 @@ import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.RenException; import xiaozhi.common.page.TokenDTO; import xiaozhi.common.user.UserDetail; +import xiaozhi.common.utils.JsonUtils; import xiaozhi.common.utils.Result; +import xiaozhi.common.utils.Sm2DecryptUtil; import xiaozhi.common.validator.AssertUtils; import xiaozhi.common.validator.ValidatorUtils; import xiaozhi.modules.security.dto.LoginDTO; @@ -32,8 +35,6 @@ import xiaozhi.modules.security.password.PasswordUtils; import xiaozhi.modules.security.service.CaptchaService; import xiaozhi.modules.security.service.SysUserTokenService; import xiaozhi.modules.security.user.SecurityUser; -import xiaozhi.common.utils.Sm2DecryptUtil; -import org.apache.commons.lang3.StringUtils; import xiaozhi.modules.sys.dto.PasswordDTO; import xiaozhi.modules.sys.dto.RetrievePasswordDTO; import xiaozhi.modules.sys.dto.SysUserDTO; @@ -41,7 +42,6 @@ import xiaozhi.modules.sys.service.SysDictDataService; import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysUserService; import xiaozhi.modules.sys.vo.SysDictDataItem; -import xiaozhi.common.utils.JsonUtils; /** * 登录控制层 @@ -237,7 +237,7 @@ public class LoginController { config.put("sm2PublicKey", publicKey); // 获取system-web.menu参数配置 - String menuConfig = sysParamsService.getValue("system-web.menu", false); + String menuConfig = sysParamsService.getValue("system-web.menu", true); if (StringUtils.isNotBlank(menuConfig)) { config.put("systemWebMenu", JsonUtils.parseObject(menuConfig, Object.class)); } diff --git a/main/manager-api/src/main/resources/db/changelog/202512131453.sql b/main/manager-api/src/main/resources/db/changelog/202512131453.sql new file mode 100644 index 00000000..27ea4a1b --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202512131453.sql @@ -0,0 +1,6 @@ +-- 删除server模块是否开启token认证参数 +delete from `sys_params` where param_code = 'server.auth.enabled'; + +-- 添加server模块是否开启token认证参数 +INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES +(122, 'server.auth.enabled', 'true', 'boolean', 1, 'server模块是否开启token认证'); \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index b0cdbc63..dd6dff55 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -438,3 +438,10 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202512041515.sql + - changeSet: + id: 202512131453 + author: hrz + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202512131453.sql diff --git a/main/manager-web/src/i18n/de.js b/main/manager-web/src/i18n/de.js index 4a9ab40c..1d8704be 100644 --- a/main/manager-web/src/i18n/de.js +++ b/main/manager-web/src/i18n/de.js @@ -713,7 +713,7 @@ export default { 'paramManagement.deleteFailed': 'Löschen fehlgeschlagen, bitte versuchen Sie es erneut', 'paramManagement.operationCancelled': 'Löschen abgebrochen', 'paramManagement.operationClosed': 'Operation geschlossen', - 'paramManagement.updateSuccess': 'Aktualisierung erfolgreich', + 'paramManagement.updateSuccess': 'Aktualisierung erfolgreich. Einige Konfigurationen werden erst nach Neustart des xiaozhi-server-Moduls wirksam.', 'paramManagement.addSuccess': 'Hinzufügen erfolgreich', 'paramManagement.updateFailed': 'Aktualisierung fehlgeschlagen', 'paramManagement.addFailed': 'Hinzufügen fehlgeschlagen', @@ -852,7 +852,7 @@ export default { 'modelConfig.enableSuccess': 'Aktivieren erfolgreich', 'modelConfig.disableSuccess': 'Deaktivieren erfolgreich', 'modelConfig.operationFailed': 'Operation fehlgeschlagen', - 'modelConfig.setDefaultSuccess': 'Standardmodell erfolgreich gesetzt', + 'modelConfig.setDefaultSuccess': 'Standardmodell erfolgreich gesetzt, bitte starten Sie das xiaozhi-server-Modul zeitnah manuell neu', 'modelConfig.itemsPerPage': '{items} Einträge/Seite', 'modelConfig.firstPage': 'Erste Seite', 'modelConfig.prevPage': 'Vorherige Seite', diff --git a/main/manager-web/src/i18n/en.js b/main/manager-web/src/i18n/en.js index 298d1e17..f57f8ab3 100644 --- a/main/manager-web/src/i18n/en.js +++ b/main/manager-web/src/i18n/en.js @@ -713,7 +713,7 @@ export default { 'paramManagement.deleteFailed': 'Deletion failed, please try again', 'paramManagement.operationCancelled': 'Deletion cancelled', 'paramManagement.operationClosed': 'Operation closed', - 'paramManagement.updateSuccess': 'Update successful', + 'paramManagement.updateSuccess': 'Update successful. Some configurations will take effect only after restarting the xiaozhi-server module.', 'paramManagement.addSuccess': 'Add successful', 'paramManagement.updateFailed': 'Update failed', 'paramManagement.addFailed': 'Add failed', @@ -852,7 +852,7 @@ export default { 'modelConfig.enableSuccess': 'Enable successful', 'modelConfig.disableSuccess': 'Disable successful', 'modelConfig.operationFailed': 'Operation failed', - 'modelConfig.setDefaultSuccess': 'Set default model successful', + 'modelConfig.setDefaultSuccess': 'Set default model successful, please restart the xiaozhi-server module manually in time', 'modelConfig.itemsPerPage': '{items} items/page', 'modelConfig.firstPage': 'First Page', 'modelConfig.prevPage': 'Previous Page', diff --git a/main/manager-web/src/i18n/vi.js b/main/manager-web/src/i18n/vi.js index 9ca108ef..a96ccfaa 100644 --- a/main/manager-web/src/i18n/vi.js +++ b/main/manager-web/src/i18n/vi.js @@ -713,7 +713,7 @@ export default { 'paramManagement.deleteFailed': 'Xóa thất bại, vui lòng thử lại', 'paramManagement.operationCancelled': 'Đã hủy xóa', 'paramManagement.operationClosed': 'Đã đóng thao tác', - 'paramManagement.updateSuccess': 'Cập nhật thành công', + 'paramManagement.updateSuccess': 'Cập nhật thành công. Một số cấu hình chỉ có hiệu lực sau khi khởi động lại mô-đun xiaozhi-server.', 'paramManagement.addSuccess': 'Thêm thành công', 'paramManagement.updateFailed': 'Cập nhật thất bại', 'paramManagement.addFailed': 'Thêm thất bại', @@ -852,7 +852,7 @@ export default { 'modelConfig.enableSuccess': 'Bật thành công', 'modelConfig.disableSuccess': 'Tắt thành công', 'modelConfig.operationFailed': 'Thao tác thất bại', - 'modelConfig.setDefaultSuccess': 'Đặt mô hình mặc định thành công', + 'modelConfig.setDefaultSuccess': 'Đặt mô hình mặc định thành công, vui lòng khởi động lại module xiaozhi-server thủ công kịp thời', 'modelConfig.itemsPerPage': '{items} mục/trang', 'modelConfig.firstPage': 'Trang đầu', 'modelConfig.prevPage': 'Trang trước', diff --git a/main/manager-web/src/i18n/zh_CN.js b/main/manager-web/src/i18n/zh_CN.js index 392b9a8b..6a3c6d80 100644 --- a/main/manager-web/src/i18n/zh_CN.js +++ b/main/manager-web/src/i18n/zh_CN.js @@ -713,7 +713,7 @@ export default { 'paramManagement.deleteFailed': '删除失败,请重试', 'paramManagement.operationCancelled': '已取消删除操作', 'paramManagement.operationClosed': '操作已关闭', - 'paramManagement.updateSuccess': '修改成功', + 'paramManagement.updateSuccess': '修改成功,部分配置需重启xiaozhi-server模块才生效', 'paramManagement.addSuccess': '新增成功', 'paramManagement.updateFailed': '更新失败', 'paramManagement.addFailed': '新增失败', @@ -852,7 +852,7 @@ export default { 'modelConfig.enableSuccess': '启用成功', 'modelConfig.disableSuccess': '禁用成功', 'modelConfig.operationFailed': '操作失败', - 'modelConfig.setDefaultSuccess': '设置默认模型成功', + 'modelConfig.setDefaultSuccess': '设置默认模型成功,请及时手动重启xiaozhi-server模块', 'modelConfig.itemsPerPage': '{items}条/页', 'modelConfig.firstPage': '首页', 'modelConfig.prevPage': '上一页', diff --git a/main/manager-web/src/i18n/zh_TW.js b/main/manager-web/src/i18n/zh_TW.js index 69bacd67..ff5fdb47 100644 --- a/main/manager-web/src/i18n/zh_TW.js +++ b/main/manager-web/src/i18n/zh_TW.js @@ -713,7 +713,7 @@ export default { 'paramManagement.deleteFailed': '刪除失敗,請重試', 'paramManagement.operationCancelled': '已取消刪除操作', 'paramManagement.operationClosed': '操作已關閉', - 'paramManagement.updateSuccess': '修改成功', + 'paramManagement.updateSuccess': '修改成功,部分配置需重啟xiaozhi-server模組才生效', 'paramManagement.addSuccess': '新增成功', 'paramManagement.updateFailed': '更新失敗', 'paramManagement.addFailed': '新增失敗', @@ -852,7 +852,7 @@ export default { 'modelConfig.enableSuccess': '啟用成功', 'modelConfig.disableSuccess': '禁用成功', 'modelConfig.operationFailed': '操作失敗', - 'modelConfig.setDefaultSuccess': '設置默認模型成功', + 'modelConfig.setDefaultSuccess': '設置默認模型成功,請及時手動重啟xiaozhi-server模組', 'modelConfig.itemsPerPage': '{items}條/頁', 'modelConfig.firstPage': '首頁', 'modelConfig.prevPage': '上一頁', From 06a90d6266354db869bcb37e0240abbe22994f46 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 13 Dec 2025 20:27:11 +0800 Subject: [PATCH 58/93] =?UTF-8?q?update=EF=BC=9A=E6=81=A2=E5=A4=8DaudioRat?= =?UTF-8?q?eController=E6=97=A7=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/utils/audioRateController.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/main/xiaozhi-server/core/utils/audioRateController.py b/main/xiaozhi-server/core/utils/audioRateController.py index 612dcba0..35038d27 100644 --- a/main/xiaozhi-server/core/utils/audioRateController.py +++ b/main/xiaozhi-server/core/utils/audioRateController.py @@ -85,13 +85,26 @@ class AudioRateController: elif item_type == "audio": _, opus_packet = item - # 计算时间差 - elapsed_ms = self._get_elapsed_ms() - output_ms = self.play_position + # 循环等待直到时间到达 + while True: + # 计算时间差 + elapsed_ms = self._get_elapsed_ms() + output_ms = self.play_position - if elapsed_ms < output_ms: - # 还不到发送时间,返回让出控制权(非阻塞) - break + if elapsed_ms < output_ms: + # 还不到发送时间,计算等待时长 + wait_ms = output_ms - elapsed_ms + + # 等待后继续检查(允许被中断) + try: + await asyncio.sleep(wait_ms / 1000) + except asyncio.CancelledError: + self.logger.bind(tag=TAG).debug("音频发送任务被取消") + raise + # 等待结束后重新检查时间(循环回到 while True) + else: + # 时间已到,跳出等待循环 + break # 时间已到,从队列移除并发送 self.queue.pop(0) @@ -105,7 +118,6 @@ class AudioRateController: self.queue_empty_event.set() - def start_sending(self, send_audio_callback): """ 启动异步发送任务 @@ -116,13 +128,10 @@ class AudioRateController: Returns: asyncio.Task: 发送任务 """ + async def _send_loop(): try: while True: - # 只有当队列非空时才处理,否则等待队列事件 - if not self.queue: - await self.queue_empty_event.wait() - await self.check_queue(send_audio_callback) # 如果队列空了,短暂等待后再检查(避免 busy loop) await asyncio.sleep(0.01) From 8b3a4ad1634ad4003cb024a18e182d4979fcfce9 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 13 Dec 2025 21:45:31 +0800 Subject: [PATCH 59/93] =?UTF-8?q?update=EF=BC=9A=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=A2=E5=BC=83=E6=B6=88=E6=81=AF=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 51 +++++++++++++++++++------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 70489036..236d4e51 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -267,23 +267,37 @@ class ConnectionHandler: f"保存记忆后关闭连接失败: {close_error}" ) + async def _discard_message_with_bind_prompt(self): + """丢弃消息并检查是否需要播放绑定提示""" + current_time = time.time() + # 检查是否需要播放绑定提示 + if current_time - self.last_bind_prompt_time >= self.bind_prompt_interval: + self.last_bind_prompt_time = current_time + # 复用现有的绑定提示逻辑 + from core.handle.receiveAudioHandle import check_bind_device + + asyncio.create_task(check_bind_device(self)) + async def _route_message(self, message): """消息路由""" - try: - await asyncio.wait_for(self.bind_completed_event.wait(), timeout=2) - except asyncio.TimeoutError: - # 未绑定设备直接丢弃所有消息 - current_time = time.time() - # 检查是否需要播放绑定提示 - if current_time - self.last_bind_prompt_time >= self.bind_prompt_interval: - self.last_bind_prompt_time = current_time - # 复用现有的绑定提示逻辑 - from core.handle.receiveAudioHandle import check_bind_device + # 检查是否已经获取到真实的绑定状态 + if not self.bind_completed_event.is_set(): + # 还没有获取到真实状态,等待直到获取到真实状态或超时 + try: + await asyncio.wait_for(self.bind_completed_event.wait(), timeout=1) + except asyncio.TimeoutError: + # 超时仍未获取到真实状态,丢弃消息 + await self._discard_message_with_bind_prompt() + return - asyncio.create_task(check_bind_device(self)) - # 直接丢弃音频,不进行ASR处理 + # 已经获取到真实状态,检查是否需要绑定 + if self.need_bind: + # 需要绑定,丢弃消息 + await self._discard_message_with_bind_prompt() return + # 不需要绑定,继续处理消息 + if isinstance(message, str): await handleTextMessage(self, message) elif isinstance(message, bytes): @@ -498,7 +512,11 @@ class ConnectionHandler: def _initialize_asr(self): """初始化ASR""" - if self._asr is not None and hasattr(self._asr, "interface_type") and self._asr.interface_type == InterfaceType.LOCAL: + if ( + self._asr is not None + and hasattr(self._asr, "interface_type") + and self._asr.interface_type == InterfaceType.LOCAL + ): # 如果公共ASR是本地服务,则直接返回 # 因为本地一个实例ASR,可以被多个连接共享 asr = self._asr @@ -538,6 +556,7 @@ class ConnectionHandler: async def _initialize_private_config_async(self): """从接口异步获取差异化配置(异步版本,不阻塞主循环)""" if not self.read_config_from_api: + self.need_bind = False self.bind_completed_event.set() return try: @@ -554,11 +573,17 @@ class ConnectionHandler: self.need_bind = False self.bind_completed_event.set() except DeviceNotFoundException as e: + self.need_bind = True + self.bind_completed_event.set() # 状态已确定,设置事件 private_config = {} except DeviceBindException as e: + self.need_bind = True self.bind_code = e.bind_code + self.bind_completed_event.set() # 状态已确定,设置事件 private_config = {} except Exception as e: + self.need_bind = True + self.bind_completed_event.set() # 状态已确定,设置事件 self.logger.bind(tag=TAG).error(f"异步获取差异化配置失败: {e}") private_config = {} From 8b2bbec0b9bf458e3bebd39b0f6ca76a1418c20b Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 13 Dec 2025 22:29:10 +0800 Subject: [PATCH 60/93] =?UTF-8?q?update:audio=5Fto=5Fdata=E6=94=B9?= =?UTF-8?q?=E6=88=90=E5=BC=82=E6=AD=A5=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xiaozhi-server/core/handle/helloHandle.py | 2 +- .../core/handle/receiveAudioHandle.py | 8 +- .../core/handle/sendAudioHandle.py | 21 +++-- main/xiaozhi-server/core/utils/util.py | 77 ++++++++++--------- 4 files changed, 63 insertions(+), 45 deletions(-) diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py index 248c5f73..efe66b59 100644 --- a/main/xiaozhi-server/core/handle/helloHandle.py +++ b/main/xiaozhi-server/core/handle/helloHandle.py @@ -101,7 +101,7 @@ async def checkWakeupWords(conn, text): } # 获取音频数据 - opus_packets = audio_to_data(response.get("file_path")) + opus_packets = await audio_to_data(response.get("file_path")) # 播放唤醒词回复 conn.client_abort = False diff --git a/main/xiaozhi-server/core/handle/receiveAudioHandle.py b/main/xiaozhi-server/core/handle/receiveAudioHandle.py index 4eaf9ab1..8879564f 100644 --- a/main/xiaozhi-server/core/handle/receiveAudioHandle.py +++ b/main/xiaozhi-server/core/handle/receiveAudioHandle.py @@ -123,7 +123,7 @@ async def max_out_size(conn): text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!" await send_stt_message(conn, text) file_path = "config/assets/max_output_size.wav" - opus_packets = audio_to_data(file_path) + opus_packets = await audio_to_data(file_path) conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text)) conn.close_after_chat = True @@ -142,7 +142,7 @@ async def check_bind_device(conn): # 播放提示音 music_path = "config/assets/bind_code.wav" - opus_packets = audio_to_data(music_path) + opus_packets = await audio_to_data(music_path) conn.tts.tts_audio_queue.put((SentenceType.FIRST, opus_packets, text)) # 逐个播放数字 @@ -150,7 +150,7 @@ async def check_bind_device(conn): try: digit = conn.bind_code[i] num_path = f"config/assets/bind_code/{digit}.wav" - num_packets = audio_to_data(num_path) + num_packets = await audio_to_data(num_path) conn.tts.tts_audio_queue.put((SentenceType.MIDDLE, num_packets, None)) except Exception as e: conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}") @@ -162,5 +162,5 @@ async def check_bind_device(conn): text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。" await send_stt_message(conn, text) music_path = "config/assets/bind_not_found.wav" - opus_packets = audio_to_data(music_path) + opus_packets = await audio_to_data(music_path) conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text)) diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py index f662b6c6..d0167498 100644 --- a/main/xiaozhi-server/core/handle/sendAudioHandle.py +++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py @@ -17,7 +17,12 @@ async def sendAudioMessage(conn, sentenceType, audios, text): if sentenceType == SentenceType.FIRST: # 同一句子的后续消息加入流控队列,其他情况立即发送 - if hasattr(conn, "audio_rate_controller") and conn.audio_rate_controller and getattr(conn, "audio_flow_control", {}).get("sentence_id") == conn.sentence_id: + if ( + hasattr(conn, "audio_rate_controller") + and conn.audio_rate_controller + and getattr(conn, "audio_flow_control", {}).get("sentence_id") + == conn.sentence_id + ): conn.audio_rate_controller.add_message( lambda: send_tts_message(conn, "sentence_start", text) ) @@ -120,7 +125,8 @@ def _get_or_create_rate_controller(conn, frame_duration, is_single_packet): # 判断是否需要重置:单包模式且 sentence_id 变化,或者控制器不存在 need_reset = ( is_single_packet - and getattr(conn, "audio_flow_control", {}).get("sentence_id") != conn.sentence_id + and getattr(conn, "audio_flow_control", {}).get("sentence_id") + != conn.sentence_id ) or not hasattr(conn, "audio_rate_controller") if need_reset: @@ -138,7 +144,9 @@ def _get_or_create_rate_controller(conn, frame_duration, is_single_packet): } # 启动后台发送循环 - _start_background_sender(conn, conn.audio_rate_controller, conn.audio_flow_control) + _start_background_sender( + conn, conn.audio_rate_controller, conn.audio_flow_control + ) return conn.audio_rate_controller, conn.audio_flow_control @@ -152,6 +160,7 @@ def _start_background_sender(conn, rate_controller, flow_control): rate_controller: 速率控制器 flow_control: 流控状态 """ + async def send_callback(packet): # 检查是否应该中止 if conn.client_abort: @@ -165,7 +174,9 @@ def _start_background_sender(conn, rate_controller, flow_control): rate_controller.start_sending(send_callback) -async def _send_audio_with_rate_control(conn, audio_list, rate_controller, flow_control, send_delay): +async def _send_audio_with_rate_control( + conn, audio_list, rate_controller, flow_control, send_delay +): """ 使用 rate_controller 发送音频包 @@ -235,7 +246,7 @@ async def send_tts_message(conn, state, text=None): stop_tts_notify_voice = conn.config.get( "stop_tts_notify_voice", "config/assets/tts_notify.mp3" ) - audios = audio_to_data(stop_tts_notify_voice, is_opus=True) + audios = await audio_to_data(stop_tts_notify_voice, is_opus=True) await sendAudio(conn, audios) # 等待所有音频包发送完成 await _wait_for_audio_completion(conn) diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index f72dda2c..2baedf68 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -4,6 +4,7 @@ import json import copy import wave import socket +import asyncio import requests import subprocess import numpy as np @@ -268,56 +269,62 @@ def audio_to_data_stream( pcm_to_data_stream(raw_data, is_opus, callback) -def audio_to_data(audio_file_path: str, is_opus: bool = True) -> list[bytes]: +async def audio_to_data(audio_file_path: str, is_opus: bool = True) -> list[bytes]: """ 将音频文件转换为Opus/PCM编码的帧列表 Args: audio_file_path: 音频文件路径 is_opus: 是否进行Opus编码 """ - # 获取文件后缀名 - file_type = os.path.splitext(audio_file_path)[1] - if file_type: - file_type = file_type.lstrip(".") - # 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞 - audio = AudioSegment.from_file( - audio_file_path, format=file_type, parameters=["-nostdin"] - ) - # 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配) - audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2) + def _sync_audio_to_data(): + # 获取文件后缀名 + file_type = os.path.splitext(audio_file_path)[1] + if file_type: + file_type = file_type.lstrip(".") + # 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞 + audio = AudioSegment.from_file( + audio_file_path, format=file_type, parameters=["-nostdin"] + ) - # 获取原始PCM数据(16位小端) - raw_data = audio.raw_data + # 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配) + audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2) - # 初始化Opus编码器 - encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO) + # 获取原始PCM数据(16位小端) + raw_data = audio.raw_data - # 编码参数 - frame_duration = 60 # 60ms per frame - frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame + # 初始化Opus编码器 + encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO) - datas = [] - # 按帧处理所有音频数据(包括最后一帧可能补零) - for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample - # 获取当前帧的二进制数据 - chunk = raw_data[i : i + frame_size * 2] + # 编码参数 + frame_duration = 60 # 60ms per frame + frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame - # 如果最后一帧不足,补零 - if len(chunk) < frame_size * 2: - chunk += b"\x00" * (frame_size * 2 - len(chunk)) + datas = [] + # 按帧处理所有音频数据(包括最后一帧可能补零) + for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample + # 获取当前帧的二进制数据 + chunk = raw_data[i : i + frame_size * 2] - if is_opus: - # 转换为numpy数组处理 - np_frame = np.frombuffer(chunk, dtype=np.int16) - # 编码Opus数据 - frame_data = encoder.encode(np_frame.tobytes(), frame_size) - else: - frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk) + # 如果最后一帧不足,补零 + if len(chunk) < frame_size * 2: + chunk += b"\x00" * (frame_size * 2 - len(chunk)) - datas.append(frame_data) + if is_opus: + # 转换为numpy数组处理 + np_frame = np.frombuffer(chunk, dtype=np.int16) + # 编码Opus数据 + frame_data = encoder.encode(np_frame.tobytes(), frame_size) + else: + frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk) - return datas + datas.append(frame_data) + + return datas + + loop = asyncio.get_running_loop() + # 在单独的线程中执行同步的音频处理操作 + return await loop.run_in_executor(None, _sync_audio_to_data) def audio_bytes_to_data_stream( From 5c261528d026cc1489b030c58406ad172c7affd1 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 13 Dec 2025 23:10:40 +0800 Subject: [PATCH 61/93] =?UTF-8?q?update:=E5=B8=B8=E7=94=A8=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E5=A2=9E=E5=8A=A0=E7=BC=93=E5=AD=98=EF=BC=8C=E6=8A=B5?= =?UTF-8?q?=E5=BE=A1=E9=AB=98=E5=B9=B6=E5=8F=91=E6=9C=AA=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xiaozhi-server/core/handle/helloHandle.py | 2 +- .../xiaozhi-server/core/utils/cache/config.py | 4 ++++ main/xiaozhi-server/core/utils/util.py | 24 +++++++++++++++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py index efe66b59..a4220d5a 100644 --- a/main/xiaozhi-server/core/handle/helloHandle.py +++ b/main/xiaozhi-server/core/handle/helloHandle.py @@ -101,7 +101,7 @@ async def checkWakeupWords(conn, text): } # 获取音频数据 - opus_packets = await audio_to_data(response.get("file_path")) + opus_packets = await audio_to_data(response.get("file_path"), use_cache=False) # 播放唤醒词回复 conn.client_abort = False diff --git a/main/xiaozhi-server/core/utils/cache/config.py b/main/xiaozhi-server/core/utils/cache/config.py index 248c2af7..f85e40bb 100644 --- a/main/xiaozhi-server/core/utils/cache/config.py +++ b/main/xiaozhi-server/core/utils/cache/config.py @@ -19,6 +19,7 @@ class CacheType(Enum): CONFIG = "config" DEVICE_PROMPT = "device_prompt" VOICEPRINT_HEALTH = "voiceprint_health" # 声纹识别健康检查 + AUDIO_DATA = "audio_data" # 音频数据缓存 @dataclass @@ -58,5 +59,8 @@ class CacheConfig: CacheType.VOICEPRINT_HEALTH: cls( strategy=CacheStrategy.TTL, ttl=600, max_size=100 # 10分钟过期 ), + CacheType.AUDIO_DATA: cls( + strategy=CacheStrategy.TTL, ttl=600, max_size=100 # 10分钟过期 + ), } return configs.get(cache_type, cls()) diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index 2baedf68..4547ad8f 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -269,13 +269,27 @@ def audio_to_data_stream( pcm_to_data_stream(raw_data, is_opus, callback) -async def audio_to_data(audio_file_path: str, is_opus: bool = True) -> list[bytes]: +async def audio_to_data( + audio_file_path: str, is_opus: bool = True, use_cache: bool = True +) -> list[bytes]: """ 将音频文件转换为Opus/PCM编码的帧列表 Args: audio_file_path: 音频文件路径 is_opus: 是否进行Opus编码 + use_cache: 是否使用缓存 """ + from core.utils.cache.manager import cache_manager + from core.utils.cache.config import CacheType + + # 生成缓存键,包含文件路径和编码类型 + cache_key = f"{audio_file_path}:{is_opus}" + + # 尝试从缓存获取结果 + if use_cache: + cached_result = cache_manager.get(CacheType.AUDIO_DATA, cache_key) + if cached_result is not None: + return cached_result def _sync_audio_to_data(): # 获取文件后缀名 @@ -324,7 +338,13 @@ async def audio_to_data(audio_file_path: str, is_opus: bool = True) -> list[byte loop = asyncio.get_running_loop() # 在单独的线程中执行同步的音频处理操作 - return await loop.run_in_executor(None, _sync_audio_to_data) + result = await loop.run_in_executor(None, _sync_audio_to_data) + + # 将结果存入缓存,使用配置中定义的TTL(10分钟) + if use_cache: + cache_manager.set(CacheType.AUDIO_DATA, cache_key, result) + + return result def audio_bytes_to_data_stream( From d2e3a63418700180b85bde7aafec62c3f17e0ba5 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sun, 14 Dec 2025 14:24:39 +0800 Subject: [PATCH 62/93] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E9=A1=B5=E9=9D=A2=E7=BC=93=E5=86=B2=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/test/css/test_page.css | 3 +- .../test/js/core/audio/player.js | 35 +++++++++ .../test/js/core/audio/stream-context.js | 72 ++++++++++++++--- .../test/js/core/network/websocket.js | 7 +- main/xiaozhi-server/test/js/ui/controller.js | 78 ++++++++++++++++++- .../test/js/utils/blocking-queue.js | 5 ++ main/xiaozhi-server/test/test_page.html | 8 +- 7 files changed, 191 insertions(+), 17 deletions(-) diff --git a/main/xiaozhi-server/test/css/test_page.css b/main/xiaozhi-server/test/css/test_page.css index c0564191..b197c60d 100644 --- a/main/xiaozhi-server/test/css/test_page.css +++ b/main/xiaozhi-server/test/css/test_page.css @@ -267,6 +267,7 @@ span.connection-status.llm-emoji { .llm-emoji .status { font-size: 14px !important; padding: 8px 20px !important; + line-height: 1.2 !important; } .emoji-large { @@ -393,7 +394,7 @@ span.connection-status.llm-emoji { /* ==================== 会话记录和日志 ==================== */ .flex-container { display: flex; - margin-top: 10px; + margin-top: 20px; background-color: #f9fafb; } diff --git a/main/xiaozhi-server/test/js/core/audio/player.js b/main/xiaozhi-server/test/js/core/audio/player.js index 459fbb54..bc89d58a 100644 --- a/main/xiaozhi-server/test/js/core/audio/player.js +++ b/main/xiaozhi-server/test/js/core/audio/player.js @@ -249,6 +249,41 @@ export class AudioPlayer { this.playBufferedAudio(); this.startAudioBuffering(); } + + // 获取音频包统计信息 + getAudioStats() { + if (!this.streamingContext) { + return { + pendingDecode: 0, + pendingPlay: 0, + totalPending: 0 + }; + } + + const pendingDecode = this.streamingContext.getPendingDecodeCount(); + const pendingPlay = this.streamingContext.getPendingPlayCount(); + + return { + pendingDecode, // 待解码包数 + pendingPlay, // 待播放包数 + totalPending: pendingDecode + pendingPlay // 总待处理包数 + }; + } + + // 清空所有音频缓冲并停止播放 + clearAllAudio() { + log('AudioPlayer: 清空所有音频', 'info'); + + // 清空接收队列(使用clear方法保持对象引用) + this.queue.clear(); + + // 清空流上下文的所有缓冲 + if (this.streamingContext) { + this.streamingContext.clearAllBuffers(); + } + + log('AudioPlayer: 音频已清空', 'success'); + } } // 创建单例 diff --git a/main/xiaozhi-server/test/js/core/audio/stream-context.js b/main/xiaozhi-server/test/js/core/audio/stream-context.js index 9c722505..05b8c1db 100644 --- a/main/xiaozhi-server/test/js/core/audio/stream-context.js +++ b/main/xiaozhi-server/test/js/core/audio/stream-context.js @@ -22,6 +22,7 @@ export class StreamingContext { this.source = null; // 当前音频源 this.totalSamples = 0; // 累积的总样本数 this.lastPlayTime = 0; // 上次播放的时间戳 + this.scheduledEndTime = 0; // 已调度音频的结束时间 } // 缓存音频数组 @@ -31,17 +32,19 @@ export class StreamingContext { // 获取需要处理缓存队列,单线程:在audioBufferQueue一直更新的状态下不会出现安全问题 async getPendingAudioBufferQueue() { - // 原子交换 + 清空 - [this.pendingAudioBufferQueue, this.audioBufferQueue] = [await this.audioBufferQueue.dequeue(), new BlockingQueue()]; + // 等待数据到达并获取 + const data = await this.audioBufferQueue.dequeue(); + // 赋值给待处理队列 + this.pendingAudioBufferQueue = data; } // 获取正在播放已解码的PCM队列,单线程:在activeQueue一直更新的状态下不会出现安全问题 async getQueue(minSamples) { - let TepArray = []; const num = minSamples - this.queue.length > 0 ? minSamples - this.queue.length : 1; - // 原子交换 + 清空 - [TepArray, this.activeQueue] = [await this.activeQueue.dequeue(num), new BlockingQueue()]; - this.queue.push(...TepArray); + + // 等待数据并获取 + const tempArray = await this.activeQueue.dequeue(num); + this.queue.push(...tempArray); } // 将Int16音频数据转换为Float32音频数据 @@ -54,6 +57,57 @@ export class StreamingContext { return float32Data; } + // 获取待解码包数 + getPendingDecodeCount() { + return this.audioBufferQueue.length + this.pendingAudioBufferQueue.length; + } + + // 获取待播放样本数(转换为包数,每包960样本) + getPendingPlayCount() { + // 计算已在队列中的样本 + const queuedSamples = this.activeQueue.length + this.queue.length; + + // 计算已调度但未播放的样本(在Web Audio缓冲区中) + let scheduledSamples = 0; + if (this.playing && this.scheduledEndTime) { + const currentTime = this.audioContext.currentTime; + const remainingTime = Math.max(0, this.scheduledEndTime - currentTime); + scheduledSamples = Math.floor(remainingTime * this.sampleRate); + } + + const totalSamples = queuedSamples + scheduledSamples; + return Math.ceil(totalSamples / 960); + } + + // 清空所有音频缓冲 + clearAllBuffers() { + log('清空所有音频缓冲', 'info'); + + // 清空所有队列(使用clear方法保持对象引用) + this.audioBufferQueue.clear(); + this.pendingAudioBufferQueue = []; + this.activeQueue.clear(); + this.queue = []; + + // 停止当前播放的音频源 + if (this.source) { + try { + this.source.stop(); + this.source.disconnect(); + } catch (e) { + // 忽略已经停止的错误 + } + this.source = null; + } + + // 重置状态 + this.playing = false; + this.scheduledEndTime = this.audioContext.currentTime; + this.totalSamples = 0; + + log('音频缓冲已清空', 'success'); + } + // 将Opus数据解码为PCM async decodeOpusFrames() { if (!this.opusDecoder) { @@ -97,7 +151,7 @@ export class StreamingContext { // 开始播放音频 async startPlaying() { - let scheduledEndTime = this.audioContext.currentTime; // 跟踪已调度音频的结束时间 + this.scheduledEndTime = this.audioContext.currentTime; // 跟踪已调度音频的结束时间 while (true) { // 初始缓冲:等待足够的样本再开始播放 @@ -126,7 +180,7 @@ export class StreamingContext { // 精确调度播放时间 const currentTime = this.audioContext.currentTime; - const startTime = Math.max(scheduledEndTime, currentTime); + const startTime = Math.max(this.scheduledEndTime, currentTime); // 直接连接到输出 this.source.connect(this.audioContext.destination); @@ -136,7 +190,7 @@ export class StreamingContext { // 更新下一个音频块的调度时间 const duration = audioBuffer.duration; - scheduledEndTime = startTime + duration; + this.scheduledEndTime = startTime + duration; this.lastPlayTime = startTime; // 如果队列中数据不足,等待新数据 diff --git a/main/xiaozhi-server/test/js/core/network/websocket.js b/main/xiaozhi-server/test/js/core/network/websocket.js index b62c710b..a605e6f1 100644 --- a/main/xiaozhi-server/test/js/core/network/websocket.js +++ b/main/xiaozhi-server/test/js/core/network/websocket.js @@ -123,7 +123,12 @@ export class WebSocketHandler { } else if (message.state === 'sentence_end') { log(`语音段结束: ${message.text}`, 'info'); } else if (message.state === 'stop') { - log('服务器语音传输结束', 'info'); + log('服务器语音传输结束,清空所有音频缓冲', 'info'); + + // 清空所有音频缓冲并停止播放 + const audioPlayer = getAudioPlayer(); + audioPlayer.clearAllAudio(); + this.isRemoteSpeaking = false; if (this.onRecordButtonStateChange) { this.onRecordButtonStateChange(false); diff --git a/main/xiaozhi-server/test/js/ui/controller.js b/main/xiaozhi-server/test/js/ui/controller.js index 8fc9dd31..3e4f6563 100644 --- a/main/xiaozhi-server/test/js/ui/controller.js +++ b/main/xiaozhi-server/test/js/ui/controller.js @@ -2,6 +2,7 @@ import { loadConfig, saveConfig } from '../config/manager.js'; import { getAudioRecorder } from '../core/audio/recorder.js'; import { getWebSocketHandler } from '../core/network/websocket.js'; +import { getAudioPlayer } from '../core/audio/player.js'; // UI控制器类 export class UIController { @@ -9,6 +10,7 @@ export class UIController { this.isEditing = false; this.visualizerCanvas = null; this.visualizerContext = null; + this.audioStatsTimer = null; } // 初始化 @@ -18,6 +20,7 @@ export class UIController { this.initVisualizer(); this.initEventListeners(); + this.startAudioStatsMonitor(); loadConfig(); } @@ -86,17 +89,20 @@ export class UIController { const sessionStatus = document.getElementById('sessionStatus'); if (!sessionStatus) return; + // 保留背景元素 + const bgHtml = ''; + if (isSpeaking === null) { // 离线状态 - sessionStatus.innerHTML = '😶 小智离线中'; + sessionStatus.innerHTML = bgHtml + '😶 小智离线中'; sessionStatus.className = 'status offline'; } else if (isSpeaking) { // 说话中 - sessionStatus.innerHTML = '😶 小智说话中'; + sessionStatus.innerHTML = bgHtml + '😶 小智说话中'; sessionStatus.className = 'status speaking'; } else { // 聆听中 - sessionStatus.innerHTML = '😶 小智聆听中'; + sessionStatus.innerHTML = bgHtml + '😶 小智聆听中'; sessionStatus.className = 'status listening'; } } @@ -110,8 +116,72 @@ export class UIController { let currentText = sessionStatus.textContent; // 移除现有的表情符号 currentText = currentText.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '').trim(); + + // 保留背景元素 + const bgHtml = ''; + // 使用 innerHTML 添加带样式的表情 - sessionStatus.innerHTML = `${emoji} ${currentText}`; + sessionStatus.innerHTML = bgHtml + `${emoji} ${currentText}`; + } + + // 更新音频统计信息 + updateAudioStats() { + const audioPlayer = getAudioPlayer(); + const stats = audioPlayer.getAudioStats(); + + const sessionStatus = document.getElementById('sessionStatus'); + const sessionStatusBg = document.getElementById('sessionStatusBg'); + + // 只在说话状态下显示背景进度 + if (sessionStatus && sessionStatus.classList.contains('speaking') && sessionStatusBg) { + if (stats.pendingPlay > 0) { + // 计算进度:5包=50%,10包及以上=100% + let percentage; + if (stats.pendingPlay >= 10) { + percentage = 100; + } else { + percentage = (stats.pendingPlay / 10) * 100; + } + + sessionStatusBg.style.width = `${percentage}%`; + + // 根据缓冲量改变背景颜色 + if (stats.pendingPlay < 5) { + // 缓冲不足:橙红色半透明 + sessionStatusBg.style.background = 'linear-gradient(90deg, rgba(255, 152, 0, 0.25), rgba(255, 87, 34, 0.25))'; + } else if (stats.pendingPlay < 10) { + // 一般:黄绿色半透明 + sessionStatusBg.style.background = 'linear-gradient(90deg, rgba(205, 220, 57, 0.25), rgba(76, 175, 80, 0.25))'; + } else { + // 充足:绿蓝色半透明 + sessionStatusBg.style.background = 'linear-gradient(90deg, rgba(76, 175, 80, 0.25), rgba(33, 150, 243, 0.25))'; + } + } else { + // 没有缓冲,隐藏背景 + sessionStatusBg.style.width = '0%'; + } + } else { + // 非说话状态,隐藏背景 + if (sessionStatusBg) { + sessionStatusBg.style.width = '0%'; + } + } + } + + // 启动音频统计监控 + startAudioStatsMonitor() { + // 每100ms更新一次音频统计 + this.audioStatsTimer = setInterval(() => { + this.updateAudioStats(); + }, 100); + } + + // 停止音频统计监控 + stopAudioStatsMonitor() { + if (this.audioStatsTimer) { + clearInterval(this.audioStatsTimer); + this.audioStatsTimer = null; + } } // 绘制音频可视化效果 diff --git a/main/xiaozhi-server/test/js/utils/blocking-queue.js b/main/xiaozhi-server/test/js/utils/blocking-queue.js index 31a43872..738a3e75 100644 --- a/main/xiaozhi-server/test/js/utils/blocking-queue.js +++ b/main/xiaozhi-server/test/js/utils/blocking-queue.js @@ -95,4 +95,9 @@ export default class BlockingQueue { get length() { return this.#items.length; } + + /* 清空队列(保持对象引用,不影响等待者) */ + clear() { + this.#items.length = 0; + } } \ No newline at end of file diff --git a/main/xiaozhi-server/test/test_page.html b/main/xiaozhi-server/test/test_page.html index 97af77ac..f3f583c9 100644 --- a/main/xiaozhi-server/test/test_page.html +++ b/main/xiaozhi-server/test/test_page.html @@ -91,6 +91,7 @@
+
@@ -113,9 +114,12 @@
-
+
- 😶 小智离线中 + + + 😶 小智离线中 +
From 4a4dbf123e9a6aace8c871b4373b71bf32cc6fc5 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sun, 14 Dec 2025 15:29:13 +0800 Subject: [PATCH 63/93] =?UTF-8?q?update:mqtt=E9=83=A8=E7=BD=B2=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mqtt-gateway-integration.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/mqtt-gateway-integration.md b/docs/mqtt-gateway-integration.md index b2f3068c..2b96255f 100644 --- a/docs/mqtt-gateway-integration.md +++ b/docs/mqtt-gateway-integration.md @@ -76,6 +76,7 @@ MQTT_PORT=1883 # MQTT服务器端口 UDP_PORT=8884 # UDP服务器端口 API_PORT=8007 # 管理API端口 MQTT_SIGNATURE_KEY=test # MQTT签名密钥 +SERVER_SECRET=Te1st12134 # 服务器密钥,请保持和智控台(server.secret)一致或者和xiaozhi-server里(server.auth_key)保持一致 ``` 请注意`PUBLIC_IP`配置,确保其与实际公网IP一致,如果有域名就填域名。 @@ -85,6 +86,13 @@ MQTT_SIGNATURE_KEY=test # MQTT签名密钥 - 注意不要用简单的密码,比如`123456`、`test`等。 - 注意不要用简单的密码,比如`123456`、`test`等。 +`SERVER_SECRET` 是用生成websocket连接的认证信息。 + +1、如果你是全模块部署,且你的智控台的参数管理里`server.auth.enabled`设置成了`true`,那么,`SERVER_SECRET`需要和智控台(`server.secret`)保持一致。 + +2、如果你是单模块部署,且你在配置文件里把`server.auth.enabled`设置成了`true`,那么,`SERVER_SECRET`需要和配置文件里(`server.auth_key`)保持一致。 + + 6. 启动MQTT网关 ``` # 启动服务 From 33b4794e83fae676a08d27d608804714bed26d39 Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Mon, 15 Dec 2025 16:34:50 +0800 Subject: [PATCH 64/93] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/utils/audioRateController.py | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/main/xiaozhi-server/core/utils/audioRateController.py b/main/xiaozhi-server/core/utils/audioRateController.py index 35038d27..01d71b05 100644 --- a/main/xiaozhi-server/core/utils/audioRateController.py +++ b/main/xiaozhi-server/core/utils/audioRateController.py @@ -1,5 +1,6 @@ import time import asyncio +from collections import deque from config.logger import setup_logging TAG = __name__ @@ -18,13 +19,14 @@ class AudioRateController: frame_duration: 单个音频帧时长(毫秒),默认60ms """ self.frame_duration = frame_duration - self.queue = [] + self.queue = deque() self.play_position = 0 # 虚拟播放位置(毫秒) self.start_timestamp = None # 开始时间戳(只读,不修改) self.pending_send_task = None self.logger = logger self.queue_empty_event = asyncio.Event() # 队列清空事件 self.queue_empty_event.set() # 初始为空状态 + self.queue_has_data_event = asyncio.Event() # 队列数据事件 def reset(self): """重置控制器状态""" @@ -34,13 +36,17 @@ class AudioRateController: self.queue.clear() self.play_position = 0 - self.start_timestamp = time.time() - self.queue_empty_event.set() # 队列已清空 + self.start_timestamp = None # 由首个音频包设置 + # 相关事件处理 + self.queue_empty_event.set() + self.queue_has_data_event.clear() def add_audio(self, opus_packet): """添加音频包到队列""" self.queue.append(("audio", opus_packet)) - self.queue_empty_event.clear() # 队列非空,清除事件 + # 相关事件处理 + self.queue_empty_event.clear() + self.queue_has_data_event.set() def add_message(self, message_callback): """ @@ -50,13 +56,15 @@ class AudioRateController: message_callback: 消息发送回调函数 async def() """ self.queue.append(("message", message_callback)) - self.queue_empty_event.clear() # 队列非空,清除事件 + # 相关事件处理 + self.queue_empty_event.clear() + self.queue_has_data_event.set() def _get_elapsed_ms(self): """获取已经过的时间(毫秒)""" if self.start_timestamp is None: return 0 - return (time.time() - self.start_timestamp) * 1000 + return (time.monotonic() - self.start_timestamp) * 1000 async def check_queue(self, send_audio_callback): """ @@ -65,9 +73,6 @@ class AudioRateController: Args: send_audio_callback: 发送音频的回调函数 async def(opus_packet) """ - if self.start_timestamp is None: - self.start_timestamp = time.time() - while self.queue: item = self.queue[0] item_type = item[0] @@ -75,7 +80,7 @@ class AudioRateController: if item_type == "message": # 消息类型:立即发送,不占用播放时间 _, message_callback = item - self.queue.pop(0) + self.queue.popleft() try: await message_callback() except Exception as e: @@ -83,6 +88,9 @@ class AudioRateController: raise elif item_type == "audio": + if self.start_timestamp is None: + self.start_timestamp = time.monotonic() + _, opus_packet = item # 循环等待直到时间到达 @@ -107,16 +115,17 @@ class AudioRateController: break # 时间已到,从队列移除并发送 - self.queue.pop(0) + self.queue.popleft() self.play_position += self.frame_duration - try: await send_audio_callback(opus_packet) except Exception as e: self.logger.bind(tag=TAG).error(f"发送音频失败: {e}") raise + # 队列处理完后清除事件 self.queue_empty_event.set() + self.queue_has_data_event.clear() def start_sending(self, send_audio_callback): """ @@ -132,9 +141,10 @@ class AudioRateController: async def _send_loop(): try: while True: + # 等待队列数据事件,不轮询等待占用CPU + await self.queue_has_data_event.wait() + await self.check_queue(send_audio_callback) - # 如果队列空了,短暂等待后再检查(避免 busy loop) - await asyncio.sleep(0.01) except asyncio.CancelledError: self.logger.bind(tag=TAG).debug("音频发送循环已停止") except Exception as e: From 43ead841a4714867cc315e865e47661ea18fe96f Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Mon, 15 Dec 2025 17:31:13 +0800 Subject: [PATCH 65/93] =?UTF-8?q?fix:=20=E7=AD=89=E5=BE=85=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E6=88=90=E5=8A=9F=E8=AE=BE=E7=BD=AE=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/connection.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 236d4e51..0d3e4090 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -434,6 +434,7 @@ class ConnectionHandler: self.tts.open_audio_channels(self), self.loop ) if self.need_bind: + self.bind_completed_event.set() return self.selected_module_str = build_module_string( self.config.get("selected_module", {}) @@ -574,16 +575,13 @@ class ConnectionHandler: self.bind_completed_event.set() except DeviceNotFoundException as e: self.need_bind = True - self.bind_completed_event.set() # 状态已确定,设置事件 private_config = {} except DeviceBindException as e: self.need_bind = True self.bind_code = e.bind_code - self.bind_completed_event.set() # 状态已确定,设置事件 private_config = {} except Exception as e: self.need_bind = True - self.bind_completed_event.set() # 状态已确定,设置事件 self.logger.bind(tag=TAG).error(f"异步获取差异化配置失败: {e}") private_config = {} From b2123ff01a6cb02d48504d38fbb389682a82b62b Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Mon, 15 Dec 2025 18:15:35 +0800 Subject: [PATCH 66/93] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +++++----- README_de.md | 10 +++++----- README_en.md | 10 +++++----- README_vi.md | 10 +++++----- docs/FAQ.md | 8 ++++---- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 943501a7..7602db54 100644 --- a/README.md +++ b/README.md @@ -211,10 +211,10 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/ | 模块名称 | 入门全免费设置 | 流式配置 | |:---:|:---:|:---:| -| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) | -| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) 或 👍DoubaoLLM(doubao-1-5-pro-32k-250115) | -| VLLM(视觉大模型) | ChatGLMVLLM(智谱glm-4v-flash) | 👍QwenVLVLLM(千问qwen2.5-vl-3b-instructh) | -| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山双流式语音合成) 或 👍AliyunStreamTTS(阿里云流式语音合成) | +| ASR(语音识别) | FunASR(本地) | 👍XunfeiStreamASR(讯飞流式) | +| LLM(大模型) | glm-4-flash(智谱) | 👍qwen-flash(阿里百炼) | +| VLLM(视觉大模型) | glm-4v-flash(智谱) | 👍qwen2.5-vl-3b-instructh(阿里百炼) | +| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山流式) | | Intent(意图识别) | function_call(函数调用) | function_call(函数调用) | | Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) | @@ -260,7 +260,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/ --- ## 产品生态 👬 -小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的[优秀项目](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE) +小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的[优秀项目](https://github.com/78/xiaozhi-esp32/blob/main/README_zh.md#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE) --- diff --git a/README_de.md b/README_de.md index c3578fec..53609462 100644 --- a/README_de.md +++ b/README_de.md @@ -209,10 +209,10 @@ Websocket-Schnittstellenadresse: wss://2662r3426b.vicp.fun/xiaozhi/v1/ | Modulname | Einstiegslevel Kostenlose Einstellungen | Streaming-Konfiguration | |:---:|:---:|:---:| -| ASR (Spracherkennung) | FunASR (Lokal) | 👍FunASR (Lokaler GPU-Modus) | -| LLM (Großes Modell) | ChatGLMLLM (Zhipu glm-4-flash) | 👍AliLLM (qwen3-235b-a22b-instruct-2507) oder 👍DoubaoLLM (doubao-1-5-pro-32k-250115) | -| VLLM (Vision Large Model) | ChatGLMVLLM (Zhipu glm-4v-flash) | 👍QwenVLVLLM (Qwen qwen2.5-vl-3b-instructh) | -| TTS (Sprachsynthese) | ✅LinkeraiTTS (Lingxi-Streaming) | 👍HuoshanDoubleStreamTTS (Volcano Dual-Stream-Sprachsynthese) oder 👍AliyunStreamTTS (Alibaba Cloud Streaming-Sprachsynthese) | +| ASR (Spracherkennung) | FunASR (Lokal) | 👍XunfeiStreamASR (Xunfei-Streaming) | +| LLM (Großes Modell) | glm-4-flash (Zhipu) | 👍qwen-flash (Alibaba Bailian) | +| VLLM (Vision Large Model) | glm-4v-flash (Zhipu) | 👍qwen2.5-vl-3b-instructh (Alibaba Bailian) | +| TTS (Sprachsynthese) | ✅LinkeraiTTS (Lingxi-Streaming) | 👍HuoshanDoubleStreamTTS (Volcano-Streaming) | | Intent (Absichtserkennung) | function_call (Funktionsaufruf) | function_call (Funktionsaufruf) | | Memory (Gedächtnisfunktion) | mem_local_short (Lokales Kurzzeitgedächtnis) | mem_local_short (Lokales Kurzzeitgedächtnis) | @@ -258,7 +258,7 @@ Wenn Sie ein Softwareentwickler sind, finden Sie hier einen [Offenen Brief an En --- ## Produktökosystem 👬 -Xiaozhi ist ein Ökosystem. Wenn Sie dieses Produkt verwenden, können Sie sich auch andere [hervorragende Projekte](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE) in diesem Ökosystem ansehen +Xiaozhi ist ein Ökosystem. Wenn Sie dieses Produkt verwenden, können Sie sich auch andere [hervorragende Projekte](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#related-open-source-projects) in diesem Ökosystem ansehen --- diff --git a/README_en.md b/README_en.md index e8437832..cc0f170c 100644 --- a/README_en.md +++ b/README_en.md @@ -208,10 +208,10 @@ Websocket Interface Address: wss://2662r3426b.vicp.fun/xiaozhi/v1/ | Module Name | Entry Level Free Settings | Streaming Configuration | |:---:|:---:|:---:| -| ASR(Speech Recognition) | FunASR(Local) | 👍FunASRServer or 👍DoubaoStreamASR | -| LLM(Large Model) | ChatGLMLLM(Zhipu glm-4-flash) | 👍DoubaoLLM(Volcano doubao-1-5-pro-32k-250115) | -| VLLM(Vision Large Model) | ChatGLMVLLM(Zhipu glm-4v-flash) | 👍QwenVLVLLM(Qwen qwen2.5-vl-3b-instructh) | -| TTS(Speech Synthesis) | ✅LinkeraiTTS(Lingxi streaming) | 👍HuoshanDoubleStreamTTS(Volcano dual-stream speech synthesis) | +| ASR(Speech Recognition) | FunASR(Local) | 👍XunfeiStreamASR(Xunfei Streaming) | +| LLM(Large Model) | glm-4-flash(Zhipu) | 👍qwen-flash(Alibaba Bailian) | +| VLLM(Vision Large Model) | glm-4v-flash(Zhipu) | 👍qwen2.5-vl-3b-instructh(Alibaba Bailian) | +| TTS(Speech Synthesis) | ✅LinkeraiTTS(Lingxi streaming) | 👍HuoshanDoubleStreamTTS(Volcano Streaming) | | Intent(Intent Recognition) | function_call(Function calling) | function_call(Function calling) | | Memory(Memory function) | mem_local_short(Local short-term memory) | mem_local_short(Local short-term memory) | @@ -256,7 +256,7 @@ If you are a software developer, here is an [Open Letter to Developers](docs/con --- ## Product Ecosystem 👬 -Xiaozhi is an ecosystem. When using this product, you can also check out other [excellent projects](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE) in this ecosystem +Xiaozhi is an ecosystem. When using this product, you can also check out other [excellent projects](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#related-open-source-projects) in this ecosystem | Project Name | Project Address | Project Description | |:---------------------|:--------|:--------| diff --git a/README_vi.md b/README_vi.md index 92697551..83557ce5 100644 --- a/README_vi.md +++ b/README_vi.md @@ -210,10 +210,10 @@ Công cụ kiểm tra dịch vụ: https://2662r3426b.vicp.fun/test/ | Tên module | Cài đặt miễn phí cho người mới | Cấu hình streaming | |:---:|:---:|:---:| -| ASR(Nhận dạng giọng nói) | FunASR(Local) | 👍FunASR(Chế độ GPU cục bộ) | -| LLM(Mô hình lớn) | ChatGLMLLM(Zhipu glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) hoặc 👍DoubaoLLM(doubao-1-5-pro-32k-250115) | -| VLLM(Mô hình lớn thị giác) | ChatGLMVLLM(Zhipu glm-4v-flash) | 👍QwenVLVLLM(Qwen qwen2.5-vl-3b-instructh) | -| TTS(Tổng hợp giọng nói) | ✅LinkeraiTTS(Lingxi streaming) | 👍HuoshanDoubleStreamTTS(Tổng hợp giọng nói streaming kép Volcano) hoặc 👍AliyunStreamTTS(Tổng hợp giọng nói streaming Alibaba Cloud) | +| ASR(Nhận dạng giọng nói) | FunASR(Local) | 👍XunfeiStreamASR(Xunfei Streaming) | +| LLM(Mô hình lớn) | glm-4-flash(Zhipu) | 👍qwen-flash(Alibaba Bailian) | +| VLLM(Mô hình lớn thị giác) | glm-4v-flash(Zhipu) | 👍qwen2.5-vl-3b-instructh(Alibaba Bailian) | +| TTS(Tổng hợp giọng nói) | ✅LinkeraiTTS(Lingxi streaming) | 👍HuoshanDoubleStreamTTS(Volcano Streaming) | | Intent(Nhận dạng ý định) | function_call(Gọi hàm) | function_call(Gọi hàm) | | Memory(Chức năng bộ nhớ) | mem_local_short(Bộ nhớ ngắn hạn cục bộ) | mem_local_short(Bộ nhớ ngắn hạn cục bộ) | @@ -259,7 +259,7 @@ Nếu bạn là một nhà phát triển phần mềm, đây có một [Lá thư --- ## Hệ sinh thái sản phẩm 👬 -Xiaozhi là một hệ sinh thái, khi bạn sử dụng sản phẩm này, bạn cũng có thể xem các [dự án xuất sắc](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#%E7%9B%B8%E5%85%B3%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE) khác trong hệ sinh thái này +Xiaozhi là một hệ sinh thái, khi bạn sử dụng sản phẩm này, bạn cũng có thể xem các [dự án xuất sắc](https://github.com/78/xiaozhi-esp32?tab=readme-ov-file#related-open-source-projects) khác trong hệ sinh thái này --- diff --git a/docs/FAQ.md b/docs/FAQ.md index c40e7784..f1e12d84 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -38,10 +38,10 @@ conda install conda-forge::ffmpeg | 模块名称 | 入门全免费设置 | 流式配置 | |:---:|:---:|:---:| -| ASR(语音识别) | FunASR(本地) | 👍FunASR(本地GPU模式) | -| LLM(大模型) | ChatGLMLLM(智谱glm-4-flash) | 👍AliLLM(qwen3-235b-a22b-instruct-2507) 或 👍DoubaoLLM(doubao-1-5-pro-32k-250115) | -| VLLM(视觉大模型) | ChatGLMVLLM(智谱glm-4v-flash) | 👍QwenVLVLLM(千问qwen2.5-vl-3b-instructh) | -| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山双流式语音合成) 或 👍AliyunStreamTTS(阿里云流式语音合成) | +| ASR(语音识别) | FunASR(本地) | 👍XunfeiStreamASR(讯飞流式) | +| LLM(大模型) | glm-4-flash(智谱) | 👍qwen-flash(阿里百炼) | +| VLLM(视觉大模型) | glm-4v-flash(智谱) | 👍qwen2.5-vl-3b-instructh(阿里百炼) | +| TTS(语音合成) | ✅LinkeraiTTS(灵犀流式) | 👍HuoshanDoubleStreamTTS(火山流式) | | Intent(意图识别) | function_call(函数调用) | function_call(函数调用) | | Memory(记忆功能) | mem_local_short(本地短期记忆) | mem_local_short(本地短期记忆) | From 1a7c06eb810bc54abd9888c4721ef85b0982e4a8 Mon Sep 17 00:00:00 2001 From: Sakura-RanChen <1908198662@qq.com> Date: Tue, 16 Dec 2025 16:39:13 +0800 Subject: [PATCH 67/93] =?UTF-8?q?update:=20=E5=A2=9E=E5=8A=A0websocket?= =?UTF-8?q?=E5=BF=83=E8=B7=B3=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/db/changelog/202512161529.sql | 1 + .../db/changelog/db.changelog-master.yaml | 7 +++ main/xiaozhi-server/config.yaml | 3 ++ .../handle/textHandler/pingMessageHandler.py | 45 +++++++++++++++++++ .../core/handle/textMessageHandlerRegistry.py | 2 + .../core/handle/textMessageType.py | 1 + 6 files changed, 59 insertions(+) create mode 100644 main/manager-api/src/main/resources/db/changelog/202512161529.sql create mode 100644 main/xiaozhi-server/core/handle/textHandler/pingMessageHandler.py diff --git a/main/manager-api/src/main/resources/db/changelog/202512161529.sql b/main/manager-api/src/main/resources/db/changelog/202512161529.sql new file mode 100644 index 00000000..40f154c7 --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202512161529.sql @@ -0,0 +1 @@ +INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (311, 'enable_websocket_ping', 'false', 'boolean', 1, '是否启用WebSocket心跳保活机制'); \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index dd6dff55..67d912a1 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -445,3 +445,10 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202512131453.sql + - changeSet: + id: 202512161529 + author: RanChen + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202512161529.sql diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index e7b61dcd..f2bbe9be 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -69,6 +69,9 @@ enable_greeting: true enable_stop_tts_notify: false # 说完话是否开启提示音,音效地址 stop_tts_notify_voice: "config/assets/tts_notify.mp3" +# 是否启用WebSocket心跳保活机制 +enable_websocket_ping: false + # TTS音频发送延迟配置 # tts_audio_send_delay: 控制音频包发送间隔 diff --git a/main/xiaozhi-server/core/handle/textHandler/pingMessageHandler.py b/main/xiaozhi-server/core/handle/textHandler/pingMessageHandler.py new file mode 100644 index 00000000..f3af85eb --- /dev/null +++ b/main/xiaozhi-server/core/handle/textHandler/pingMessageHandler.py @@ -0,0 +1,45 @@ +import json +import time +from typing import Dict, Any + +from core.handle.textMessageHandler import TextMessageHandler +from core.handle.textMessageType import TextMessageType + +TAG = __name__ + + +class PingMessageHandler(TextMessageHandler): + """Ping消息处理器,用于保持WebSocket连接""" + + @property + def message_type(self) -> TextMessageType: + return TextMessageType.PING + + async def handle(self, conn, msg_json: Dict[str, Any]) -> None: + """ + 处理PING消息,发送PONG响应 + 消息格式:{"type": "ping"} + Args: + conn: WebSocket连接对象 + msg_json: PING消息的JSON数据 + """ + # 检查是否启用了WebSocket心跳功能 + enable_websocket_ping = conn.config.get("enable_websocket_ping", False) + if not enable_websocket_ping: + conn.logger.debug(f"WebSocket心跳功能未启用,忽略PING消息") + return + + try: + conn.logger.debug(f"收到PING消息,发送PONG响应") + conn.last_activity_time = time.time() * 1000 + # 构造PONG响应消息 + pong_message = { + "type": "pong", + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + } + + # 发送PONG响应 + await conn.websocket.send(json.dumps(pong_message)) + + except Exception as e: + conn.logger.error(f"处理PING消息时发生错误: {e}") diff --git a/main/xiaozhi-server/core/handle/textMessageHandlerRegistry.py b/main/xiaozhi-server/core/handle/textMessageHandlerRegistry.py index e90d7231..65e9474f 100644 --- a/main/xiaozhi-server/core/handle/textMessageHandlerRegistry.py +++ b/main/xiaozhi-server/core/handle/textMessageHandlerRegistry.py @@ -7,6 +7,7 @@ from core.handle.textHandler.listenMessageHandler import ListenTextMessageHandle from core.handle.textHandler.mcpMessageHandler import McpTextMessageHandler from core.handle.textMessageHandler import TextMessageHandler from core.handle.textHandler.serverMessageHandler import ServerTextMessageHandler +from core.handle.textHandler.pingMessageHandler import PingMessageHandler TAG = __name__ @@ -27,6 +28,7 @@ class TextMessageHandlerRegistry: IotTextMessageHandler(), McpTextMessageHandler(), ServerTextMessageHandler(), + PingMessageHandler(), ] for handler in handlers: diff --git a/main/xiaozhi-server/core/handle/textMessageType.py b/main/xiaozhi-server/core/handle/textMessageType.py index 53e71b71..bd04d289 100644 --- a/main/xiaozhi-server/core/handle/textMessageType.py +++ b/main/xiaozhi-server/core/handle/textMessageType.py @@ -9,3 +9,4 @@ class TextMessageType(Enum): IOT = "iot" MCP = "mcp" SERVER = "server" + PING = "ping" From 53e26821ade9adc3bad1482dd7ecdb2b4e80b9a5 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Tue, 16 Dec 2025 22:03:12 +0800 Subject: [PATCH 68/93] =?UTF-8?q?update:=E8=A1=A5=E5=9B=9E=E5=89=8D5?= =?UTF-8?q?=E4=B8=AA=E5=8C=85=E6=8F=90=E5=89=8D=E5=8F=91=E9=80=81=E7=9A=84?= =?UTF-8?q?=E6=97=B6=E9=97=B4=EF=BC=8C=E5=9B=A0=E4=B8=BA=E5=8F=91=E9=80=81?= =?UTF-8?q?=E5=AE=8C=E4=B8=8D=E7=AD=89=E4=BA=8E=E6=92=AD=E6=94=BE=E5=AE=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/handle/sendAudioHandle.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py index d0167498..8198dfb0 100644 --- a/main/xiaozhi-server/core/handle/sendAudioHandle.py +++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py @@ -7,6 +7,10 @@ from core.providers.tts.dto.dto import SentenceType from core.utils.audioRateController import AudioRateController TAG = __name__ +# 音频帧时长(毫秒) +AUDIO_FRAME_DURATION = 60 +# 预缓冲包数量,直接发送以减少延迟 +PRE_BUFFER_COUNT = 5 async def sendAudioMessage(conn, sentenceType, audios, text): @@ -45,7 +49,7 @@ async def sendAudioMessage(conn, sentenceType, audios, text): async def _wait_for_audio_completion(conn): """ - 等待音频队列清空 + 等待音频队列清空并等待预缓冲包播放完成 Args: conn: 连接对象 @@ -56,6 +60,13 @@ async def _wait_for_audio_completion(conn): f"等待音频发送完成,队列中还有 {len(rate_controller.queue)} 个包" ) await rate_controller.queue_empty_event.wait() + + # 等待预缓冲包播放完成 + # 前N个包直接发送,增加2个网络抖动包,需要额外等待它们在客户端播放完成 + frame_duration_ms = rate_controller.frame_duration + pre_buffer_playback_time = (PRE_BUFFER_COUNT + 2) * frame_duration_ms / 1000.0 + await asyncio.sleep(pre_buffer_playback_time) + conn.logger.bind(tag=TAG).debug("音频发送完成") @@ -81,14 +92,14 @@ async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence): await conn.websocket.send(complete_packet) -async def sendAudio(conn, audios, frame_duration=60): +async def sendAudio(conn, audios, frame_duration=AUDIO_FRAME_DURATION): """ 发送音频包,使用 AudioRateController 进行精确的流量控制 Args: conn: 连接对象 audios: 单个opus包(bytes) 或 opus包列表 - frame_duration: 帧时长(毫秒),默认60ms + frame_duration: 帧时长(毫秒),默认使用全局常量AUDIO_FRAME_DURATION """ if audios is None or len(audios) == 0: return @@ -187,16 +198,14 @@ async def _send_audio_with_rate_control( flow_control: 流控状态 send_delay: 固定延迟(秒),-1表示使用动态流控 """ - pre_buffer_count = 5 - for packet in audio_list: if conn.client_abort: return conn.last_activity_time = time.time() * 1000 - # 预缓冲:前5个包直接发送 - if flow_control["packet_count"] < pre_buffer_count: + # 预缓冲:前N个包直接发送 + if flow_control["packet_count"] < PRE_BUFFER_COUNT: await _do_send_audio(conn, packet, flow_control) conn.client_is_speaking = True elif send_delay > 0: From 3fb40677a40a98743ba7a1869a46f410e9cf853f Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Wed, 17 Dec 2025 11:58:40 +0800 Subject: [PATCH 69/93] =?UTF-8?q?update=EF=BC=9A=E7=BA=A0=E6=AD=A3?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/manager-web/src/i18n/de.js | 2 +- main/manager-web/src/i18n/en.js | 2 +- main/manager-web/src/i18n/vi.js | 2 +- main/manager-web/src/i18n/zh_CN.js | 2 +- main/manager-web/src/i18n/zh_TW.js | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/main/manager-web/src/i18n/de.js b/main/manager-web/src/i18n/de.js index 1d8704be..22512e8c 100644 --- a/main/manager-web/src/i18n/de.js +++ b/main/manager-web/src/i18n/de.js @@ -821,7 +821,7 @@ export default { 'modelConfig.rag': 'RAG', 'modelConfig.modelId': 'Modell-ID', 'modelConfig.modelName': 'Modellname', - 'modelConfig.provider': 'Anbieter', + 'modelConfig.provider': 'Schnittstellentyp', 'modelConfig.unknown': 'Unbekannt', 'modelConfig.isEnabled': 'Aktiviert', 'modelConfig.isDefault': 'Standard', diff --git a/main/manager-web/src/i18n/en.js b/main/manager-web/src/i18n/en.js index f57f8ab3..9bcc1f7d 100644 --- a/main/manager-web/src/i18n/en.js +++ b/main/manager-web/src/i18n/en.js @@ -821,7 +821,7 @@ export default { 'modelConfig.rag': 'RAG', 'modelConfig.modelId': 'Model ID', 'modelConfig.modelName': 'Model Name', - 'modelConfig.provider': 'Provider', + 'modelConfig.provider': 'Interface Type', 'modelConfig.unknown': 'Unknown', 'modelConfig.isEnabled': 'Enabled', 'modelConfig.isDefault': 'Default', diff --git a/main/manager-web/src/i18n/vi.js b/main/manager-web/src/i18n/vi.js index a96ccfaa..c9d9e1cf 100644 --- a/main/manager-web/src/i18n/vi.js +++ b/main/manager-web/src/i18n/vi.js @@ -821,7 +821,7 @@ export default { 'modelConfig.rag': 'RAG', 'modelConfig.modelId': 'ID mô hình', 'modelConfig.modelName': 'Tên mô hình', - 'modelConfig.provider': 'Nhà cung cấp', + 'modelConfig.provider': 'Loại giao diện', 'modelConfig.unknown': 'Không xác định', 'modelConfig.isEnabled': 'Đã bật', 'modelConfig.isDefault': 'Mặc định', diff --git a/main/manager-web/src/i18n/zh_CN.js b/main/manager-web/src/i18n/zh_CN.js index 6a3c6d80..44d8e400 100644 --- a/main/manager-web/src/i18n/zh_CN.js +++ b/main/manager-web/src/i18n/zh_CN.js @@ -821,7 +821,7 @@ export default { 'modelConfig.rag': '知识库', 'modelConfig.modelId': '模型ID', 'modelConfig.modelName': '模型名称', - 'modelConfig.provider': '提供商', + 'modelConfig.provider': '接口类型', 'modelConfig.unknown': '未知', 'modelConfig.isEnabled': '是否启用', 'modelConfig.isDefault': '是否默认', diff --git a/main/manager-web/src/i18n/zh_TW.js b/main/manager-web/src/i18n/zh_TW.js index ff5fdb47..ca5a5221 100644 --- a/main/manager-web/src/i18n/zh_TW.js +++ b/main/manager-web/src/i18n/zh_TW.js @@ -821,7 +821,7 @@ export default { 'modelConfig.rag': '知識庫', 'modelConfig.modelId': '模型ID', 'modelConfig.modelName': '模型名稱', - 'modelConfig.provider': '提供商', + 'modelConfig.provider': '接口類型', 'modelConfig.unknown': '未知', 'modelConfig.isEnabled': '是否啟用', 'modelConfig.isDefault': '是否默認', From 33a385cfa8b4f4bae0a7155326d1a2bee8b0a506 Mon Sep 17 00:00:00 2001 From: rui chen Date: Wed, 17 Dec 2025 16:26:35 +0800 Subject: [PATCH 70/93] add basic OTA support for single server deployment Committer: rxchen --- main/xiaozhi-server/core/api/ota_handler.py | 219 ++++++++++++++++++-- main/xiaozhi-server/core/http_server.py | 5 +- 2 files changed, 209 insertions(+), 15 deletions(-) diff --git a/main/xiaozhi-server/core/api/ota_handler.py b/main/xiaozhi-server/core/api/ota_handler.py index b6c88dff..a521332e 100644 --- a/main/xiaozhi-server/core/api/ota_handler.py +++ b/main/xiaozhi-server/core/api/ota_handler.py @@ -3,6 +3,10 @@ import time import base64 import hashlib import hmac +import os +import re +import glob +from typing import Dict, List, Tuple from aiohttp import web from core.auth import AuthManager @@ -12,6 +16,33 @@ from core.api.base_handler import BaseHandler TAG = __name__ +def _safe_basename(filename: str) -> str: + # Prevent directory traversal + return os.path.basename(filename) + + +def _parse_version(ver: str) -> Tuple[int, ...]: + # conservative parser: split by non-digit, keep numeric parts + parts = re.findall(r"\d+", ver) + return tuple(int(p) for p in parts) if parts else (0,) + + +def _is_higher_version(a: str, b: str) -> bool: + """Return True if version string a > b (semver-like numeric compare).""" + ta = _parse_version(a) + tb = _parse_version(b) + # compare tuple lexicographically, but allow different lengths + maxlen = max(len(ta), len(tb)) + for i in range(maxlen): + ai = ta[i] if i < len(ta) else 0 + bi = tb[i] if i < len(tb) else 0 + if ai > bi: + return True + if ai < bi: + return False + return False + + class OTAHandler(BaseHandler): def __init__(self, config: dict): super().__init__(config) @@ -23,6 +54,46 @@ class OTAHandler(BaseHandler): expire_seconds = auth_config.get("expire_seconds") self.auth = AuthManager(secret_key=secret_key, expire_seconds=expire_seconds) + # firmware storage + self.bin_dir = os.path.join(os.getcwd(), "data", "bin") + # cache structure: { 'updated_at': timestamp, 'ttl': seconds, 'files_by_model': { model: [(version, filename), ...] } } + self._bin_cache: Dict = {"updated_at": 0, "ttl": config.get("firmware_cache_ttl", 30), "files_by_model": {}} + + def _refresh_bin_cache_if_needed(self): + now = int(time.time()) + ttl = int(self._bin_cache.get("ttl", 30)) + if now - int(self._bin_cache.get("updated_at", 0)) < ttl and self._bin_cache.get("files_by_model"): + return + + files_by_model: Dict[str, List[Tuple[str, str]]] = {} + try: + if not os.path.isdir(self.bin_dir): + os.makedirs(self.bin_dir, exist_ok=True) + + # match files like model_1.2.3.bin (allow dots, dashes, underscores in model and version) + pattern = os.path.join(self.bin_dir, "*.bin") + for path in glob.glob(pattern): + fname = os.path.basename(path) + # filename format: {model}_{version}.bin + m = re.match(r"^(.+?)_([0-9][A-Za-z0-9\.\-_]*)\.bin$", fname) + if not m: + # skip files not conforming to naming rule + continue + model = m.group(1) + version = m.group(2) + files_by_model.setdefault(model, []).append((version, fname)) + + # sort versions for each model descending + for model, items in files_by_model.items(): + items.sort(key=lambda it: _parse_version(it[0]), reverse=True) + + self._bin_cache["files_by_model"] = files_by_model + self._bin_cache["updated_at"] = now + self.logger.bind(tag=TAG).info(f"Firmware cache refreshed: {len(files_by_model)} models") + except Exception as e: + self.logger.bind(tag=TAG).error(f"刷新固件缓存失败: {e}") + # keep previous cache if any + def generate_password_signature(self, content: str, secret_key: str) -> str: """生成MQTT密码签名 @@ -62,7 +133,14 @@ class OTAHandler(BaseHandler): return f"ws://{local_ip}:{port}/xiaozhi/v1/" async def handle_post(self, request): - """处理 OTA POST 请求""" + """处理 OTA POST 请求 + + This handler will: + - read device id/client id (as before) + - attempt to determine device model and current firmware version (prefer headers, fallback to body) + - check data/bin for newer firmware for that model + - if found a newer firmware, set firmware.url to the download endpoint + """ try: data = await request.text() self.logger.bind(tag=TAG).debug(f"OTA请求方法: {request.method}") @@ -81,11 +159,54 @@ class OTAHandler(BaseHandler): else: raise Exception("OTA请求ClientID为空") - data_json = json.loads(data) + data_json = {} + try: + data_json = json.loads(data) if data else {} + self.logger.bind(tag=TAG).info(f"data json:{data_json}") + except Exception: + data_json = {} server_config = self.config["server"] - port = int(server_config.get("port", 8000)) + # Distinguish ports: + # - websocket_port is used to construct websocket URL (server["port"]) + # - http_port is used to construct OTA download URLs (server["http_port"]) + websocket_port = int(server_config.get("port", 8000)) + http_port = int(server_config.get("http_port", 8003)) local_ip = get_local_ip() + ota_addr = server_config.get("ota_addr", "") + + # Determine device model (prefer headers) + device_model = "" + # header candidates + for h in ("device-model", "device_model", "model"): + if h in request.headers: + device_model = request.headers.get(h, "").strip() + break + # body fallback + if not device_model: + try: + if "board" in data_json and isinstance(data_json["board"], dict): + device_model = data_json["board"].get("type", "") + elif "model" in data_json: + device_model = data_json.get("model", "") + except Exception: + device_model = "" + if not device_model: + device_model = "default" + + # Determine device current version (prefer headers) + device_version = "" + for h in ("device-version", "device_version", "firmware-version", "app-version", "application-version"): + if h in request.headers: + device_version = request.headers.get(h, "").strip() + break + if not device_version: + try: + device_version = data_json.get("application", {}).get("version", "") + except Exception: + device_version = "" + if not device_version: + device_version = "0.0.0" return_json = { "server_time": { @@ -93,21 +214,17 @@ class OTAHandler(BaseHandler): "timezone_offset": server_config.get("timezone_offset", 8) * 60, }, "firmware": { - "version": data_json["application"].get("version", "1.0.0"), + "version": device_version, "url": "", }, } + # existing mqtt/websocket logic (unchanged) mqtt_gateway_endpoint = server_config.get("mqtt_gateway") if mqtt_gateway_endpoint: # 如果配置了非空字符串 - # 尝试从请求数据中获取设备型号 - device_model = "default" + # 尝试从请求数据中获取设备型号(已解析 above) try: - if "device" in data_json and isinstance(data_json["device"], dict): - device_model = data_json["device"].get("model", "default") - elif "model" in data_json: - device_model = data_json["model"] group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_") except Exception as e: self.logger.bind(tag=TAG).error(f"获取设备型号失败: {e}") @@ -159,20 +276,51 @@ class OTAHandler(BaseHandler): token = self.auth.generate_token(client_id, device_id) else: token = self.auth.generate_token(client_id, device_id) + # NOTE: use websocket_port here return_json["websocket"] = { - "url": self._get_websocket_url(local_ip, port), + "url": self._get_websocket_url(local_ip, websocket_port), "token": token, } self.logger.bind(tag=TAG).info( f"未配置MQTT网关,为设备 {device_id} 下发WebSocket配置" ) - self.logger.bind(tag=TAG).info(f"{return_json}") + + # Now check firmware files for updates + try: + self._refresh_bin_cache_if_needed() + files_by_model = self._bin_cache.get("files_by_model", {}) + candidates = files_by_model.get(device_model, []) + + self.logger.bind(tag=TAG).info(f"查找型号 {device_model} 的固件,找到 {len(candidates)} 个候选") + + chosen_url = "" + chosen_version = device_version + + # candidates are sorted descending by version + for ver, fname in candidates: + if _is_higher_version(ver, device_version): + # build download url (only allow download via our download endpoint) + chosen_version = ver + # use local_ip and http_port to construct url + chosen_url = f"http://{ota_addr}:{http_port}/xiaozhi/ota/download/{fname}" + break + + if chosen_url: + return_json["firmware"]["version"] = chosen_version + return_json["firmware"]["url"] = chosen_url + self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发固件 {chosen_version} -> {chosen_url}") + else: + self.logger.bind(tag=TAG).info(f"设备 {device_id} 固件已是最新: {device_version}") + + except Exception as e: + self.logger.bind(tag=TAG).error(f"检查固件版本时出错: {e}") response = web.Response( text=json.dumps(return_json, separators=(",", ":")), content_type="application/json", ) except Exception as e: + self.logger.bind(tag=TAG).error(f"OTA POST处理异常: {e}") return_json = {"success": False, "message": "request error."} response = web.Response( text=json.dumps(return_json, separators=(",", ":")), @@ -187,8 +335,9 @@ class OTAHandler(BaseHandler): try: server_config = self.config["server"] local_ip = get_local_ip() - port = int(server_config.get("port", 8000)) - websocket_url = self._get_websocket_url(local_ip, port) + # use websocket port for websocket URL + websocket_port = int(server_config.get("port", 8000)) + websocket_url = self._get_websocket_url(local_ip, websocket_port) message = f"OTA接口运行正常,向设备发送的websocket地址是:{websocket_url}" response = web.Response(text=message, content_type="text/plain") except Exception as e: @@ -197,3 +346,45 @@ class OTAHandler(BaseHandler): finally: self._add_cors_headers(response) return response + + async def handle_download(self, request): + """ + 下载固件接口 + URL: /xiaozhi/ota/download/{filename} + - 只允许下载 data/bin 目录下的 .bin 文件 + - filename 必须是 basename 且匹配安全的模式 + """ + try: + fname = request.match_info.get("filename", "") + if not fname: + raise web.HTTPBadRequest(text="filename required") + + # sanitize + fname = _safe_basename(fname) + # pattern: allow letters, numbers, dot, underscore, dash + if not re.match(r"^[A-Za-z0-9\.\-_]+\.bin$", fname): + raise web.HTTPBadRequest(text="invalid filename") + + file_path = os.path.join(self.bin_dir, fname) + # ensure realpath is under bin_dir + file_real = os.path.realpath(file_path) + bin_dir_real = os.path.realpath(self.bin_dir) + if not file_real.startswith(bin_dir_real + os.sep) and file_real != bin_dir_real: + raise web.HTTPForbidden(text="forbidden") + + if not os.path.isfile(file_real): + raise web.HTTPNotFound(text="file not found") + + # use FileResponse to stream file + resp = web.FileResponse(path=file_real) + except web.HTTPError as e: + resp = e + except Exception as e: + self.logger.bind(tag=TAG).error(f"固件下载异常: {e}") + resp = web.Response(text="download error", status=500) + finally: + try: + self._add_cors_headers(resp) + except Exception: + pass + return resp diff --git a/main/xiaozhi-server/core/http_server.py b/main/xiaozhi-server/core/http_server.py index edbdf1fe..ecc80efb 100644 --- a/main/xiaozhi-server/core/http_server.py +++ b/main/xiaozhi-server/core/http_server.py @@ -48,6 +48,9 @@ class SimpleHttpServer: web.get("/xiaozhi/ota/", self.ota_handler.handle_get), web.post("/xiaozhi/ota/", self.ota_handler.handle_post), web.options("/xiaozhi/ota/", self.ota_handler.handle_post), + # 下载接口,仅提供 data/bin/*.bin 下载 + web.get("/xiaozhi/ota/download/{filename}", self.ota_handler.handle_download), + web.options("/xiaozhi/ota/download/{filename}", self.ota_handler.handle_download), ] ) # 添加路由 @@ -67,4 +70,4 @@ class SimpleHttpServer: # 保持服务运行 while True: - await asyncio.sleep(3600) # 每隔 1 小时检查一次 + await asyncio.sleep(3600) # 每隔 1 小时检查一次 \ No newline at end of file From d5f804bbb36c736edd5b8e1e7c5d6b13793acb9f Mon Sep 17 00:00:00 2001 From: rui chen Date: Wed, 17 Dec 2025 16:44:54 +0800 Subject: [PATCH 71/93] add basic OTA support for single server deployment, remove debug --- main/xiaozhi-server/core/api/ota_handler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/main/xiaozhi-server/core/api/ota_handler.py b/main/xiaozhi-server/core/api/ota_handler.py index a521332e..b20ba60b 100644 --- a/main/xiaozhi-server/core/api/ota_handler.py +++ b/main/xiaozhi-server/core/api/ota_handler.py @@ -162,7 +162,6 @@ class OTAHandler(BaseHandler): data_json = {} try: data_json = json.loads(data) if data else {} - self.logger.bind(tag=TAG).info(f"data json:{data_json}") except Exception: data_json = {} From 7d9895cf5b3798868cf53e510a4276500e799f99 Mon Sep 17 00:00:00 2001 From: FAN-yeB <1442100690@qq.com> Date: Thu, 18 Dec 2025 10:11:13 +0800 Subject: [PATCH 72/93] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=94=A4=E9=86=92?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/handle/textHandler/listenMessageHandler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py b/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py index a2c96836..c71649ed 100644 --- a/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py +++ b/main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py @@ -69,6 +69,7 @@ class ListenTextMessageHandler(TextMessageHandler): enqueue_asr_report(conn, "嘿,你好呀", []) await startToChat(conn, "嘿,你好呀") else: + conn.just_woken_up = True # 上报纯文字数据(复用ASR上报功能,但不提供音频数据) enqueue_asr_report(conn, original_text, []) # 否则需要LLM对文字内容进行答复 From 19736e66ad46b0542450ebc779596009e2a149d9 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Thu, 18 Dec 2025 11:18:49 +0800 Subject: [PATCH 73/93] =?UTF-8?q?fix=EF=BC=9A=E6=97=A0=E6=84=8F=E5=9B=BE?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E6=97=B6=EF=BC=8C=E6=97=A0"plugins"=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=8A=A5=E9=94=99=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../functions/get_news_from_chinanews.py | 2 +- .../functions/get_news_from_newsnow.py | 8 ++++---- .../plugins_func/functions/get_weather.py | 11 ++++------- .../plugins_func/functions/hass_init.py | 16 +++++++++------- .../plugins_func/functions/play_music.py | 5 +++-- .../functions/search_from_ragflow.py | 7 ++++--- 6 files changed, 25 insertions(+), 24 deletions(-) diff --git a/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py b/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py index e5ca0d1f..2267c83c 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py +++ b/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py @@ -195,7 +195,7 @@ def get_news_from_chinanews( # 否则,获取新闻列表并随机选择一条 # 从配置中获取RSS URL - rss_config = conn.config["plugins"]["get_news_from_chinanews"] + rss_config = conn.config.get("plugins", {}).get("get_news_from_chinanews", {}) default_rss_url = rss_config.get( "default_rss_url", "https://www.chinanews.com.cn/rss/society.xml" ) diff --git a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py index 1d60aefd..2bcd9193 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py +++ b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py @@ -120,10 +120,10 @@ def fetch_news_from_api(conn, source="thepaper"): """从API获取新闻列表""" try: api_url = f"https://newsnow.busiyi.world/api/s?id={source}" - if conn.config["plugins"].get("get_news_from_newsnow") and conn.config[ - "plugins" - ]["get_news_from_newsnow"].get("url"): - api_url = conn.config["plugins"]["get_news_from_newsnow"]["url"] + source + + news_config = conn.config.get("plugins", {}).get("get_news_from_newsnow", {}) + if news_config.get("url"): + api_url = news_config["url"] + source headers = {"User-Agent": "Mozilla/5.0"} response = requests.get(api_url, headers=headers, timeout=10) diff --git a/main/xiaozhi-server/plugins_func/functions/get_weather.py b/main/xiaozhi-server/plugins_func/functions/get_weather.py index 38770a3f..e95a40d8 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_weather.py +++ b/main/xiaozhi-server/plugins_func/functions/get_weather.py @@ -158,13 +158,10 @@ def parse_weather_info(soup): def get_weather(conn, location: str = None, lang: str = "zh_CN"): from core.utils.cache.manager import cache_manager, CacheType - api_host = conn.config["plugins"]["get_weather"].get( - "api_host", "mj7p3y7naa.re.qweatherapi.com" - ) - api_key = conn.config["plugins"]["get_weather"].get( - "api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da" - ) - default_location = conn.config["plugins"]["get_weather"]["default_location"] + weather_config = conn.config.get("plugins", {}).get("get_weather", {}) + api_host = weather_config.get("api_host", "mj7p3y7naa.re.qweatherapi.com") + api_key = weather_config.get("api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da") + default_location = weather_config.get("default_location", "广州") client_ip = conn.client_ip # 优先使用用户提供的location参数 diff --git a/main/xiaozhi-server/plugins_func/functions/hass_init.py b/main/xiaozhi-server/plugins_func/functions/hass_init.py index 11cbb7a0..dadb190b 100644 --- a/main/xiaozhi-server/plugins_func/functions/hass_init.py +++ b/main/xiaozhi-server/plugins_func/functions/hass_init.py @@ -11,15 +11,17 @@ def append_devices_to_prompt(conn): "functions", [] ) + # 安全地获取插件配置 + plugins_config = conn.config.get("plugins", {}) config_source = ( "home_assistant" - if conn.config["plugins"].get("home_assistant") + if plugins_config.get("home_assistant") else "hass_get_state" ) if "hass_get_state" in funcs or "hass_set_state" in funcs: prompt = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n" - deviceStr = conn.config["plugins"].get(config_source, {}).get("devices", "") + deviceStr = plugins_config.get(config_source, {}).get("devices", "") conn.prompt += prompt + deviceStr + "\n" # 更新提示词 conn.dialogue.update_system_message(conn.prompt) @@ -30,17 +32,17 @@ def initialize_hass_handler(conn): if not conn.load_function_plugin: return ha_config + # 安全地获取插件配置 + plugins_config = conn.config.get("plugins", {}) # 确定配置来源 config_source = ( - "home_assistant" - if conn.config["plugins"].get("home_assistant") - else "hass_get_state" + "home_assistant" if plugins_config.get("home_assistant") else "hass_get_state" ) - if not conn.config["plugins"].get(config_source): + if not plugins_config.get(config_source): return ha_config # 统一获取配置 - plugin_config = conn.config["plugins"][config_source] + plugin_config = plugins_config[config_source] ha_config["base_url"] = plugin_config.get("base_url") ha_config["api_key"] = plugin_config.get("api_key") diff --git a/main/xiaozhi-server/plugins_func/functions/play_music.py b/main/xiaozhi-server/plugins_func/functions/play_music.py index 2cbc4018..be4cf618 100644 --- a/main/xiaozhi-server/plugins_func/functions/play_music.py +++ b/main/xiaozhi-server/plugins_func/functions/play_music.py @@ -118,8 +118,9 @@ def get_music_files(music_dir, music_ext): def initialize_music_handler(conn): global MUSIC_CACHE if MUSIC_CACHE == {}: - if "play_music" in conn.config["plugins"]: - MUSIC_CACHE["music_config"] = conn.config["plugins"]["play_music"] + plugins_config = conn.config.get("plugins", {}) + if "play_music" in plugins_config: + MUSIC_CACHE["music_config"] = plugins_config["play_music"] MUSIC_CACHE["music_dir"] = os.path.abspath( MUSIC_CACHE["music_config"].get("music_dir", "./music") # 默认路径修改 ) diff --git a/main/xiaozhi-server/plugins_func/functions/search_from_ragflow.py b/main/xiaozhi-server/plugins_func/functions/search_from_ragflow.py index d585a762..ec6ac426 100644 --- a/main/xiaozhi-server/plugins_func/functions/search_from_ragflow.py +++ b/main/xiaozhi-server/plugins_func/functions/search_from_ragflow.py @@ -32,9 +32,10 @@ def search_from_ragflow(conn, question=None): else: question = str(question) if question is not None else "" - base_url = conn.config["plugins"]["search_from_ragflow"].get("base_url", "") - api_key = conn.config["plugins"]["search_from_ragflow"].get("api_key", "") - dataset_ids = conn.config["plugins"]["search_from_ragflow"].get("dataset_ids", []) + ragflow_config = conn.config.get("plugins", {}).get("search_from_ragflow", {}) + base_url = ragflow_config.get("base_url", "") + api_key = ragflow_config.get("api_key", "") + dataset_ids = ragflow_config.get("dataset_ids", []) url = base_url + "/api/v1/retrieval" headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} From 33d70ccc96c54ac2a6ef044bfc7d3b43be77a427 Mon Sep 17 00:00:00 2001 From: rui chen Date: Thu, 18 Dec 2025 15:48:25 +0800 Subject: [PATCH 74/93] get OTA address from websocket address config, if failed find ota_addr, if failed again, use local address. --- main/xiaozhi-server/core/api/ota_handler.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/main/xiaozhi-server/core/api/ota_handler.py b/main/xiaozhi-server/core/api/ota_handler.py index b20ba60b..1fe339c2 100644 --- a/main/xiaozhi-server/core/api/ota_handler.py +++ b/main/xiaozhi-server/core/api/ota_handler.py @@ -9,6 +9,8 @@ import glob from typing import Dict, List, Tuple from aiohttp import web +from urllib.parse import urlparse + from core.auth import AuthManager from core.utils.util import get_local_ip from core.api.base_handler import BaseHandler @@ -172,7 +174,18 @@ class OTAHandler(BaseHandler): websocket_port = int(server_config.get("port", 8000)) http_port = int(server_config.get("http_port", 8003)) local_ip = get_local_ip() - ota_addr = server_config.get("ota_addr", "") + + ota_addr = "" + websocket_addr = self._get_websocket_url(local_ip, websocket_port) + parsedurl = urlparse(websocket_addr) + netloc = parsedurl.netloc + if netloc: + host_part = netloc.split(":")[0] + ota_addr = host_part if host_part else "" + if ota_addr == "": + ota_addr = server_config.get("ota_addr", "") + if ota_addr == "": + ota_addr = local_ip # Determine device model (prefer headers) device_model = "" From e8d0bb0c5485f63d0d30604c0c028d3956d23d3b Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Thu, 18 Dec 2025 16:34:37 +0800 Subject: [PATCH 75/93] =?UTF-8?q?update=EF=BC=9A=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E4=B8=8A=E4=B8=8B=E6=96=87=E6=8C=89=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=8C=89=E9=9C=80=E8=8E=B7=E5=8F=96=EF=BC=88=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?/=E5=A4=A9=E6=B0=94/=E5=8A=A8=E6=80=81=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/utils/prompt_manager.py | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/main/xiaozhi-server/core/utils/prompt_manager.py b/main/xiaozhi-server/core/utils/prompt_manager.py index 78c63483..70f14d92 100644 --- a/main/xiaozhi-server/core/utils/prompt_manager.py +++ b/main/xiaozhi-server/core/utils/prompt_manager.py @@ -184,10 +184,25 @@ class PromptManager: def update_context_info(self, conn, client_ip: str): """同步更新上下文信息""" try: - # 获取位置信息(使用全局缓存) - local_address = self._get_location_info(client_ip) - # 获取天气信息(使用全局缓存) - self._get_weather_info(conn, local_address) + local_address = "" + if ( + client_ip + and self.base_prompt_template + and ( + "local_address" in self.base_prompt_template + or "weather_info" in self.base_prompt_template + ) + ): + # 获取位置信息(使用全局缓存) + local_address = self._get_location_info(client_ip) + + if ( + self.base_prompt_template + and "weather_info" in self.base_prompt_template + and local_address + ): + # 获取天气信息(使用全局缓存) + self._get_weather_info(conn, local_address) # 获取配置的上下文数据 if hasattr(conn, "device_id") and conn.device_id: From 6e7c86e159ffa8e51fdba92d47f47a036e4f346a Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Thu, 18 Dec 2025 17:37:20 +0800 Subject: [PATCH 76/93] =?UTF-8?q?update:=E4=BB=8Evision=5Furl=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=87=8C=E8=AF=BB=E5=8F=96=E5=9F=9F=E5=90=8D=E5=92=8C?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/core/api/base_handler.py | 10 ++- main/xiaozhi-server/core/api/ota_handler.py | 63 +++++++++------ .../xiaozhi-server/core/api/vision_handler.py | 14 +--- main/xiaozhi-server/core/http_server.py | 79 ++++++++++++------- 4 files changed, 99 insertions(+), 67 deletions(-) diff --git a/main/xiaozhi-server/core/api/base_handler.py b/main/xiaozhi-server/core/api/base_handler.py index 7330185e..db277543 100644 --- a/main/xiaozhi-server/core/api/base_handler.py +++ b/main/xiaozhi-server/core/api/base_handler.py @@ -10,7 +10,15 @@ class BaseHandler: def _add_cors_headers(self, response): """添加CORS头信息""" response.headers["Access-Control-Allow-Headers"] = ( - "client-id, content-type, device-id" + "client-id, content-type, device-id, authorization" ) response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Allow-Origin"] = "*" + + async def handle_options(self, request): + """处理OPTIONS请求,添加CORS头信息""" + response = web.Response(body=b"", content_type="text/plain") + self._add_cors_headers(response) + # 添加允许的方法 + response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + return response diff --git a/main/xiaozhi-server/core/api/ota_handler.py b/main/xiaozhi-server/core/api/ota_handler.py index 1fe339c2..1e3b3bd0 100644 --- a/main/xiaozhi-server/core/api/ota_handler.py +++ b/main/xiaozhi-server/core/api/ota_handler.py @@ -9,10 +9,8 @@ import glob from typing import Dict, List, Tuple from aiohttp import web -from urllib.parse import urlparse - from core.auth import AuthManager -from core.utils.util import get_local_ip +from core.utils.util import get_local_ip, get_vision_url from core.api.base_handler import BaseHandler TAG = __name__ @@ -59,12 +57,18 @@ class OTAHandler(BaseHandler): # firmware storage self.bin_dir = os.path.join(os.getcwd(), "data", "bin") # cache structure: { 'updated_at': timestamp, 'ttl': seconds, 'files_by_model': { model: [(version, filename), ...] } } - self._bin_cache: Dict = {"updated_at": 0, "ttl": config.get("firmware_cache_ttl", 30), "files_by_model": {}} + self._bin_cache: Dict = { + "updated_at": 0, + "ttl": config.get("firmware_cache_ttl", 30), + "files_by_model": {}, + } def _refresh_bin_cache_if_needed(self): now = int(time.time()) ttl = int(self._bin_cache.get("ttl", 30)) - if now - int(self._bin_cache.get("updated_at", 0)) < ttl and self._bin_cache.get("files_by_model"): + if now - int( + self._bin_cache.get("updated_at", 0) + ) < ttl and self._bin_cache.get("files_by_model"): return files_by_model: Dict[str, List[Tuple[str, str]]] = {} @@ -91,7 +95,9 @@ class OTAHandler(BaseHandler): self._bin_cache["files_by_model"] = files_by_model self._bin_cache["updated_at"] = now - self.logger.bind(tag=TAG).info(f"Firmware cache refreshed: {len(files_by_model)} models") + self.logger.bind(tag=TAG).info( + f"Firmware cache refreshed: {len(files_by_model)} models" + ) except Exception as e: self.logger.bind(tag=TAG).error(f"刷新固件缓存失败: {e}") # keep previous cache if any @@ -175,18 +181,6 @@ class OTAHandler(BaseHandler): http_port = int(server_config.get("http_port", 8003)) local_ip = get_local_ip() - ota_addr = "" - websocket_addr = self._get_websocket_url(local_ip, websocket_port) - parsedurl = urlparse(websocket_addr) - netloc = parsedurl.netloc - if netloc: - host_part = netloc.split(":")[0] - ota_addr = host_part if host_part else "" - if ota_addr == "": - ota_addr = server_config.get("ota_addr", "") - if ota_addr == "": - ota_addr = local_ip - # Determine device model (prefer headers) device_model = "" # header candidates @@ -208,7 +202,13 @@ class OTAHandler(BaseHandler): # Determine device current version (prefer headers) device_version = "" - for h in ("device-version", "device_version", "firmware-version", "app-version", "application-version"): + for h in ( + "device-version", + "device_version", + "firmware-version", + "app-version", + "application-version", + ): if h in request.headers: device_version = request.headers.get(h, "").strip() break @@ -303,7 +303,9 @@ class OTAHandler(BaseHandler): files_by_model = self._bin_cache.get("files_by_model", {}) candidates = files_by_model.get(device_model, []) - self.logger.bind(tag=TAG).info(f"查找型号 {device_model} 的固件,找到 {len(candidates)} 个候选") + self.logger.bind(tag=TAG).info( + f"查找型号 {device_model} 的固件,找到 {len(candidates)} 个候选" + ) chosen_url = "" chosen_version = device_version @@ -313,16 +315,24 @@ class OTAHandler(BaseHandler): if _is_higher_version(ver, device_version): # build download url (only allow download via our download endpoint) chosen_version = ver - # use local_ip and http_port to construct url - chosen_url = f"http://{ota_addr}:{http_port}/xiaozhi/ota/download/{fname}" + # Use get_vision_url to get the base URL and replace the path + vision_url = get_vision_url(self.config) + # Replace the path from "/mcp/vision/explain" to "/xiaozhi/ota/download/{fname}" + chosen_url = vision_url.replace( + "/mcp/vision/explain", f"/xiaozhi/ota/download/{fname}" + ) break if chosen_url: return_json["firmware"]["version"] = chosen_version return_json["firmware"]["url"] = chosen_url - self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发固件 {chosen_version} -> {chosen_url}") + self.logger.bind(tag=TAG).info( + f"为设备 {device_id} 下发固件 {chosen_version} [如果地址前缀有误,请检查配置文件中的server.vision_explain]-> {chosen_url} " + ) else: - self.logger.bind(tag=TAG).info(f"设备 {device_id} 固件已是最新: {device_version}") + self.logger.bind(tag=TAG).info( + f"设备 {device_id} 固件已是最新: {device_version}" + ) except Exception as e: self.logger.bind(tag=TAG).error(f"检查固件版本时出错: {e}") @@ -381,7 +391,10 @@ class OTAHandler(BaseHandler): # ensure realpath is under bin_dir file_real = os.path.realpath(file_path) bin_dir_real = os.path.realpath(self.bin_dir) - if not file_real.startswith(bin_dir_real + os.sep) and file_real != bin_dir_real: + if ( + not file_real.startswith(bin_dir_real + os.sep) + and file_real != bin_dir_real + ): raise web.HTTPForbidden(text="forbidden") if not os.path.isfile(file_real): diff --git a/main/xiaozhi-server/core/api/vision_handler.py b/main/xiaozhi-server/core/api/vision_handler.py index a4817a84..28f48753 100644 --- a/main/xiaozhi-server/core/api/vision_handler.py +++ b/main/xiaozhi-server/core/api/vision_handler.py @@ -2,6 +2,7 @@ import json import copy from aiohttp import web from config.logger import setup_logging +from core.api.base_handler import BaseHandler from core.utils.util import get_vision_url, is_valid_image_file from core.utils.vllm import create_instance from config.config_loader import get_private_config_from_api @@ -16,10 +17,9 @@ TAG = __name__ MAX_FILE_SIZE = 5 * 1024 * 1024 -class VisionHandler: +class VisionHandler(BaseHandler): def __init__(self, config: dict): - self.config = config - self.logger = setup_logging() + super().__init__(config) # 初始化认证工具 self.auth = AuthToken(config["server"]["auth_key"]) @@ -172,11 +172,3 @@ class VisionHandler: finally: self._add_cors_headers(response) return response - - def _add_cors_headers(self, response): - """添加CORS头信息""" - response.headers["Access-Control-Allow-Headers"] = ( - "client-id, content-type, device-id" - ) - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Allow-Origin"] = "*" diff --git a/main/xiaozhi-server/core/http_server.py b/main/xiaozhi-server/core/http_server.py index ecc80efb..feb96f3b 100644 --- a/main/xiaozhi-server/core/http_server.py +++ b/main/xiaozhi-server/core/http_server.py @@ -33,41 +33,60 @@ class SimpleHttpServer: return f"ws://{local_ip}:{port}/xiaozhi/v1/" async def start(self): - server_config = self.config["server"] - read_config_from_api = self.config.get("read_config_from_api", False) - host = server_config.get("ip", "0.0.0.0") - port = int(server_config.get("http_port", 8003)) + try: + server_config = self.config["server"] + read_config_from_api = self.config.get("read_config_from_api", False) + host = server_config.get("ip", "0.0.0.0") + port = int(server_config.get("http_port", 8003)) - if port: - app = web.Application() + if port: + app = web.Application() - if not read_config_from_api: - # 如果没有开启智控台,只是单模块运行,就需要再添加简单OTA接口,用于下发websocket接口 + if not read_config_from_api: + # 如果没有开启智控台,只是单模块运行,就需要再添加简单OTA接口,用于下发websocket接口 + app.add_routes( + [ + web.get("/xiaozhi/ota/", self.ota_handler.handle_get), + web.post("/xiaozhi/ota/", self.ota_handler.handle_post), + web.options( + "/xiaozhi/ota/", self.ota_handler.handle_options + ), + # 下载接口,仅提供 data/bin/*.bin 下载 + web.get( + "/xiaozhi/ota/download/{filename}", + self.ota_handler.handle_download, + ), + web.options( + "/xiaozhi/ota/download/{filename}", + self.ota_handler.handle_options, + ), + ] + ) + # 添加路由 app.add_routes( [ - web.get("/xiaozhi/ota/", self.ota_handler.handle_get), - web.post("/xiaozhi/ota/", self.ota_handler.handle_post), - web.options("/xiaozhi/ota/", self.ota_handler.handle_post), - # 下载接口,仅提供 data/bin/*.bin 下载 - web.get("/xiaozhi/ota/download/{filename}", self.ota_handler.handle_download), - web.options("/xiaozhi/ota/download/{filename}", self.ota_handler.handle_download), + web.get("/mcp/vision/explain", self.vision_handler.handle_get), + web.post( + "/mcp/vision/explain", self.vision_handler.handle_post + ), + web.options( + "/mcp/vision/explain", self.vision_handler.handle_options + ), ] ) - # 添加路由 - app.add_routes( - [ - web.get("/mcp/vision/explain", self.vision_handler.handle_get), - web.post("/mcp/vision/explain", self.vision_handler.handle_post), - web.options("/mcp/vision/explain", self.vision_handler.handle_post), - ] - ) - # 运行服务 - runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, host, port) - await site.start() + # 运行服务 + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() - # 保持服务运行 - while True: - await asyncio.sleep(3600) # 每隔 1 小时检查一次 \ No newline at end of file + # 保持服务运行 + while True: + await asyncio.sleep(3600) # 每隔 1 小时检查一次 + except Exception as e: + self.logger.bind(tag=TAG).error(f"HTTP服务器启动失败: {e}") + import traceback + + self.logger.bind(tag=TAG).error(f"错误堆栈: {traceback.format_exc()}") + raise From f3f0d62f1269102c4af5a7bfcf4ffa85355ad0ee Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Thu, 18 Dec 2025 17:38:58 +0800 Subject: [PATCH 77/93] =?UTF-8?q?add:=E6=B7=BB=E5=8A=A0=E5=8D=95=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E9=83=A8=E7=BD=B2=E6=97=B6=EF=BC=8C=E4=BD=BF=E7=94=A8?= =?UTF-8?q?ota=E6=8E=A5=E5=8F=A3=E8=87=AA=E5=8A=A8=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E5=9B=BA=E4=BB=B6=E7=9A=84=E6=95=99=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FAQ.md | 1 + docs/ota-upgrade-guide.md | 264 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 docs/ota-upgrade-guide.md diff --git a/docs/FAQ.md b/docs/FAQ.md index f1e12d84..69494c3d 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -69,6 +69,7 @@ VAD: ### 9、编译固件相关教程 1、[如何自己编译小智固件](./firmware-build.md)
2、[如何基于虾哥编译好的固件修改OTA地址](./firmware-setting.md)
+3、[单模块部署如何配置固件OTA自动升级](./ota-upgrade-guide.md)
### 10、拓展相关教程 1、[如何开启手机号码注册智控台](./ali-sms-integration.md)
diff --git a/docs/ota-upgrade-guide.md b/docs/ota-upgrade-guide.md new file mode 100644 index 00000000..157a80ba --- /dev/null +++ b/docs/ota-upgrade-guide.md @@ -0,0 +1,264 @@ +# 单模块部署固件OTA自动升级配置指南 + +本教程将指导你如何在**单模块部署**场景下配置固件OTA自动升级功能,实现设备固件的自动更新。 + +## 功能介绍 + +在单模块部署中,xiaozhi-server内置了OTA固件管理功能,可以自动检测设备版本并下发升级固件。系统会根据设备型号和当前版本,自动匹配并推送最新的固件版本。 + +## 前提条件 + +- 你已经成功进行**单模块部署**并运行xiaozhi-server +- 设备能够正常连接到服务器 + +## 第一步 准备固件文件 + +### 1. 创建固件存放目录 + +固件文件需要放在`data/bin/`目录下。如果该目录不存在,请手动创建: + +```bash +mkdir -p data/bin +``` + +### 2. 固件文件命名规则 + +固件文件必须遵循以下命名格式: + +``` +{设备型号}_{版本号}.bin +``` + +**命名规则说明:** +- `设备型号`:设备的型号名称,例如 `lichuang-dev`、`bread-compact-wifi` 等 +- `版本号`:固件版本号,必须以数字开头,支持数字、字母、点号、下划线和短横线,例如 `1.6.6`、`2.0.0` 等 +- 文件扩展名必须是 `.bin` + +**命名示例:** +``` +bread-compact-wifi_1.6.6.bin +lichuang-dev_2.0.0.bin +``` + +### 3. 放置固件文件 + +将准备好的固件文件(.bin文件)复制到`data/bin/`目录下: + +```bash +cp your_firmware.bin data/bin/设备型号_版本号.bin +``` + +例如: +```bash +cp xiaozhi_firmware.bin data/bin/esp32s3_1.6.6.bin +``` + +## 第二步 配置公网访问地址(仅公网部署需要) + +**注意:此步骤仅适用于单模块公网部署的场景。** + +如果你的xiaozhi-server是公网部署(使用公网IP或域名),**必须**配置`server.vision_explain`参数,因为OTA固件下载地址会使用该配置的域名和端口。 + +如果你是局域网部署,可以跳过此步骤。 + +### 为什么要配置这个参数? + +在单模块部署中,系统生成固件下载地址时,会使用`vision_explain`配置的域名和端口作为基础地址。如果不配置或配置错误,设备将无法访问固件下载地址。 + +### 配置方法 + +打开`data/.config.yaml`文件,找到`server`配置段,设置`vision_explain`参数: + +```yaml +server: + vision_explain: http://你的域名或IP:端口号/mcp/vision/explain +``` + +**配置示例:** + +局域网部署(默认): +```yaml +server: + vision_explain: http://192.168.1.100:8003/mcp/vision/explain +``` + +公网域名部署: +```yaml +server: + vision_explain: http://yourdomain.com:8003/mcp/vision/explain +``` + +公网IP部署: +```yaml +server: + vision_explain: http://111.111.111.111:8003/mcp/vision/explain +``` + +使用HTTPS(推荐公网部署使用): +```yaml +server: + vision_explain: https://yourdomain.com:8003/mcp/vision/explain +``` + +### 注意事项 + +- 域名或IP必须是设备能够访问的地址 +- 如果使用Docker部署,不能使用Docker内部地址(如127.0.0.1或localhost) +- 端口号默认是8003,如果你修改了`server.http_port`配置,需要同步修改这里的端口号 + +## 第三步 启动或重启服务 + +### 源码运行 + +```bash +python app.py +``` + +### Docker运行 + +```bash +docker restart xiaozhi-esp32-server +``` + +### 验证服务启动 + +启动后,查看日志输出,应该能看到类似以下内容: + +``` +2025-12-18 **** - OTA接口是 http://192.168.1.100:8003/xiaozhi/ota/ +2025-12-18 **** - 视觉分析接口是 http://192.168.1.100:8003/mcp/vision/explain +``` + +使用浏览器访问OTA接口地址,如果显示以下内容说明服务正常: + +``` +OTA接口运行正常,向设备发送的websocket地址是:ws://xxx.xxx.xxx.xxx:8000/xiaozhi/v1/ +``` + +## 第四步 设备自动检测升级 + +### 升级原理 + +当设备连接到服务器时(每次开机或定时检查),会自动发送OTA请求。服务器会: + +1. 读取设备的型号和当前固件版本 +2. 扫描`data/bin/`目录,查找匹配该型号的所有固件文件 +3. 比较版本号,如果有更高版本,则返回固件下载地址 +4. 设备收到下载地址后,会自动下载并安装新固件 + +### 版本比较规则 + +系统使用语义化版本比较方式,按数字段从左到右依次比较: + +- `1.6.6` < `1.6.7` +- `1.6.9` < `1.7.0` +- `2.0.0` > `1.9.9` + +### 查看升级日志 + +在xiaozhi-server的日志中,你可以看到OTA相关的日志输出: + +``` +[ota_handler] - OTA请求设备ID: AA:BB:CC:DD:EE:FF +[ota_handler] - 查找型号 esp32s3 的固件,找到 3 个候选 +[ota_handler] - 为设备 AA:BB:CC:DD:EE:FF 下发固件 1.6.6 [如果地址前缀有误,请检查配置文件中的server.vision_explain]-> http://yourdomain.com:8003/xiaozhi/ota/download/esp32s3_1.6.6.bin +``` + +或者如果设备已是最新版本: + +``` +[ota_handler] - 设备 AA:BB:CC:DD:EE:FF 固件已是最新: 1.6.6 +``` + +## 高级配置 + +### 固件缓存时间(可选) + +系统会缓存`data/bin/`目录的扫描结果以提高性能。默认缓存时间为30秒。你可以在配置文件中调整: + +```yaml +firmware_cache_ttl: 60 # 单位:秒,设置为60秒缓存时间 +``` + +### 多版本固件管理 + +你可以同时放置多个版本的固件,系统会自动选择最新版本: + +``` +data/bin/ + ├── esp32s3_1.6.5.bin + ├── esp32s3_1.6.6.bin + ├── esp32s3_1.7.0-beta.bin + └── xiaozhi-v2_2.0.0.bin +``` + +系统会为`esp32s3`型号的设备推送`1.7.0-beta`版本(最高版本)。 + +### 多型号固件管理 + +不同型号的设备会自动匹配对应型号的固件: + +``` +data/bin/ + ├── esp32s3_1.6.6.bin # 仅供 esp32s3 型号设备使用 + ├── xiaozhi-v2_2.0.0.bin # 仅供 xiaozhi-v2 型号设备使用 + └── default_1.0.0.bin # 供未识别型号的设备使用 +``` + +## 常见问题 + +### 1. 设备收不到固件更新 + +**可能原因和解决方法:** + +- 检查固件文件命名是否符合规则:`{型号}_{版本号}.bin` +- 检查固件文件是否正确放置在`data/bin/`目录 +- 检查设备型号是否与固件文件名中的型号匹配 +- 检查固件版本号是否高于设备当前版本 +- 查看服务器日志,确认OTA请求是否正常处理 + +### 2. 设备报告下载地址无法访问 + +**可能原因和解决方法:** + +- 检查`server.vision_explain`配置的域名或IP是否正确 +- 确认端口号配置正确(默认8003) +- 如果是公网部署,确保设备能够访问该公网地址 +- 如果是Docker部署,确保不是使用了内部地址(127.0.0.1) +- 检查防火墙是否开放了对应端口 + +### 3. 如何确认设备当前版本 + +查看OTA请求日志,日志中会显示设备上报的版本号: + +``` +[ota_handler] - 设备 AA:BB:CC:DD:EE:FF 固件已是最新: 1.6.6 +``` + +### 4. 固件文件放置后没有生效 + +系统有30秒的缓存时间(默认),可以: +- 等待30秒后再让设备发起OTA请求 +- 重启xiaozhi-server服务 +- 调整`firmware_cache_ttl`配置为更短的时间 + +### 5. 如何回滚到旧版本 + +系统只会推送更高版本的固件。如果需要回滚: +1. 删除或重命名`data/bin/`目录中高于目标版本的固件文件 +2. 等待缓存过期或重启服务 +3. 设备下次检查时会收到目标版本 + +## 安全说明 + +- 系统会验证固件文件路径,防止目录穿越攻击 +- 固件下载接口只允许访问`data/bin/`目录下的`.bin`文件 +- 建议在生产环境使用HTTPS协议传输固件 + +## 相关教程 + +如需了解更多,请参考以下教程: + +1. [如何自己编译小智固件](./firmware-build.md) +2. [如何基于虾哥编译好的固件修改OTA地址](./firmware-setting.md) +3. [如何进行全模块部署](./Deployment_all.md) From 7222f68d4d994c06acdb9443ba1fb7ee4c82f7b9 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Thu, 18 Dec 2025 17:57:05 +0800 Subject: [PATCH 78/93] =?UTF-8?q?update:=E6=B7=BB=E5=8A=A0=E9=87=8D?= =?UTF-8?q?=E8=A6=81=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ota-upgrade-guide.md | 146 ++++---------------------------------- 1 file changed, 12 insertions(+), 134 deletions(-) diff --git a/docs/ota-upgrade-guide.md b/docs/ota-upgrade-guide.md index 157a80ba..0df4a2ff 100644 --- a/docs/ota-upgrade-guide.md +++ b/docs/ota-upgrade-guide.md @@ -2,6 +2,8 @@ 本教程将指导你如何在**单模块部署**场景下配置固件OTA自动升级功能,实现设备固件的自动更新。 +如果你已经使用**全模块部署**,请忽略本教程。 + ## 功能介绍 在单模块部署中,xiaozhi-server内置了OTA固件管理功能,可以自动检测设备版本并下发升级固件。系统会根据设备型号和当前版本,自动匹配并推送最新的固件版本。 @@ -44,13 +46,19 @@ lichuang-dev_2.0.0.bin 将准备好的固件文件(.bin文件)复制到`data/bin/`目录下: +重要的事情说三遍:升级的bin文件是`xiaozhi.bin`,不是全量固件文件`merged-binary.bin`! + +重要的事情说三遍:升级的bin文件是`xiaozhi.bin`,不是全量固件文件`merged-binary.bin`! + +重要的事情说三遍:升级的bin文件是`xiaozhi.bin`,不是全量固件文件`merged-binary.bin`! + ```bash -cp your_firmware.bin data/bin/设备型号_版本号.bin +cp xiaozhi.bin data/bin/设备型号_版本号.bin ``` 例如: ```bash -cp xiaozhi_firmware.bin data/bin/esp32s3_1.6.6.bin +cp xiaozhi.bin data/bin/bread-compact-wifi_1.6.6.bin ``` ## 第二步 配置公网访问地址(仅公网部署需要) @@ -88,122 +96,12 @@ server: vision_explain: http://yourdomain.com:8003/mcp/vision/explain ``` -公网IP部署: -```yaml -server: - vision_explain: http://111.111.111.111:8003/mcp/vision/explain -``` - -使用HTTPS(推荐公网部署使用): -```yaml -server: - vision_explain: https://yourdomain.com:8003/mcp/vision/explain -``` - ### 注意事项 - 域名或IP必须是设备能够访问的地址 - 如果使用Docker部署,不能使用Docker内部地址(如127.0.0.1或localhost) -- 端口号默认是8003,如果你修改了`server.http_port`配置,需要同步修改这里的端口号 +- 如果你使用了nginx反向代理,请填写对外的地址和端口号,不是本项目运行的端口号 -## 第三步 启动或重启服务 - -### 源码运行 - -```bash -python app.py -``` - -### Docker运行 - -```bash -docker restart xiaozhi-esp32-server -``` - -### 验证服务启动 - -启动后,查看日志输出,应该能看到类似以下内容: - -``` -2025-12-18 **** - OTA接口是 http://192.168.1.100:8003/xiaozhi/ota/ -2025-12-18 **** - 视觉分析接口是 http://192.168.1.100:8003/mcp/vision/explain -``` - -使用浏览器访问OTA接口地址,如果显示以下内容说明服务正常: - -``` -OTA接口运行正常,向设备发送的websocket地址是:ws://xxx.xxx.xxx.xxx:8000/xiaozhi/v1/ -``` - -## 第四步 设备自动检测升级 - -### 升级原理 - -当设备连接到服务器时(每次开机或定时检查),会自动发送OTA请求。服务器会: - -1. 读取设备的型号和当前固件版本 -2. 扫描`data/bin/`目录,查找匹配该型号的所有固件文件 -3. 比较版本号,如果有更高版本,则返回固件下载地址 -4. 设备收到下载地址后,会自动下载并安装新固件 - -### 版本比较规则 - -系统使用语义化版本比较方式,按数字段从左到右依次比较: - -- `1.6.6` < `1.6.7` -- `1.6.9` < `1.7.0` -- `2.0.0` > `1.9.9` - -### 查看升级日志 - -在xiaozhi-server的日志中,你可以看到OTA相关的日志输出: - -``` -[ota_handler] - OTA请求设备ID: AA:BB:CC:DD:EE:FF -[ota_handler] - 查找型号 esp32s3 的固件,找到 3 个候选 -[ota_handler] - 为设备 AA:BB:CC:DD:EE:FF 下发固件 1.6.6 [如果地址前缀有误,请检查配置文件中的server.vision_explain]-> http://yourdomain.com:8003/xiaozhi/ota/download/esp32s3_1.6.6.bin -``` - -或者如果设备已是最新版本: - -``` -[ota_handler] - 设备 AA:BB:CC:DD:EE:FF 固件已是最新: 1.6.6 -``` - -## 高级配置 - -### 固件缓存时间(可选) - -系统会缓存`data/bin/`目录的扫描结果以提高性能。默认缓存时间为30秒。你可以在配置文件中调整: - -```yaml -firmware_cache_ttl: 60 # 单位:秒,设置为60秒缓存时间 -``` - -### 多版本固件管理 - -你可以同时放置多个版本的固件,系统会自动选择最新版本: - -``` -data/bin/ - ├── esp32s3_1.6.5.bin - ├── esp32s3_1.6.6.bin - ├── esp32s3_1.7.0-beta.bin - └── xiaozhi-v2_2.0.0.bin -``` - -系统会为`esp32s3`型号的设备推送`1.7.0-beta`版本(最高版本)。 - -### 多型号固件管理 - -不同型号的设备会自动匹配对应型号的固件: - -``` -data/bin/ - ├── esp32s3_1.6.6.bin # 仅供 esp32s3 型号设备使用 - ├── xiaozhi-v2_2.0.0.bin # 仅供 xiaozhi-v2 型号设备使用 - └── default_1.0.0.bin # 供未识别型号的设备使用 -``` ## 常见问题 @@ -226,6 +124,7 @@ data/bin/ - 如果是公网部署,确保设备能够访问该公网地址 - 如果是Docker部署,确保不是使用了内部地址(127.0.0.1) - 检查防火墙是否开放了对应端口 +- 如果你使用了nginx反向代理,请填写对外的地址和端口号,不是本项目运行的端口号 ### 3. 如何确认设备当前版本 @@ -241,24 +140,3 @@ data/bin/ - 等待30秒后再让设备发起OTA请求 - 重启xiaozhi-server服务 - 调整`firmware_cache_ttl`配置为更短的时间 - -### 5. 如何回滚到旧版本 - -系统只会推送更高版本的固件。如果需要回滚: -1. 删除或重命名`data/bin/`目录中高于目标版本的固件文件 -2. 等待缓存过期或重启服务 -3. 设备下次检查时会收到目标版本 - -## 安全说明 - -- 系统会验证固件文件路径,防止目录穿越攻击 -- 固件下载接口只允许访问`data/bin/`目录下的`.bin`文件 -- 建议在生产环境使用HTTPS协议传输固件 - -## 相关教程 - -如需了解更多,请参考以下教程: - -1. [如何自己编译小智固件](./firmware-build.md) -2. [如何基于虾哥编译好的固件修改OTA地址](./firmware-setting.md) -3. [如何进行全模块部署](./Deployment_all.md) From ce49b409ac5520d38eb146cd5b25309536a76537 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Thu, 18 Dec 2025 18:11:53 +0800 Subject: [PATCH 79/93] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- README_de.md | 4 ++-- README_en.md | 4 ++-- README_vi.md | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7602db54..eb718c24 100644 --- a/README.md +++ b/README.md @@ -183,8 +183,8 @@ Spearheaded by Professor Siyuan Liu's Team (South China University of Technology #### 🚀 部署方式选择 | 部署方式 | 特点 | 适用场景 | 部署文档 | 配置要求 | 视频教程 | |---------|------|---------|---------|---------|---------| -| **最简化安装** | 智能对话、IOT、MCP、视觉感知 | 低配置环境,数据存储在配置文件,无需数据库 | [①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、MCP接入点、声纹识别、视觉感知、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) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) | +| **最简化安装** | 智能对话、单智能体管理 | 低配置环境,数据存储在配置文件,无需数据库 | [①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 | - | +| **全模块安装** | 智能对话、多用户管理、多智能体管理、智控台界面操作 | 完整功能体验,数据存储在数据库 |[①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) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) | 常见问题及相关教程,可参考[这个链接](./docs/FAQ.md) diff --git a/README_de.md b/README_de.md index 53609462..0e4c74d5 100644 --- a/README_de.md +++ b/README_de.md @@ -181,8 +181,8 @@ Dieses Projekt bietet zwei Bereitstellungsmethoden. Bitte wählen Sie basierend #### 🚀 Auswahl der Bereitstellungsmethode | Bereitstellungsmethode | Funktionen | Anwendungsszenarien | Deployment-Dokumente | Konfigurationsanforderungen | Video-Tutorials | |---------|------|---------|---------|---------|---------| -| **Vereinfachte Installation** | Intelligenter Dialog, IOT, MCP, visuelle Wahrnehmung | Umgebungen mit geringer Konfiguration, Daten in Konfigurationsdateien gespeichert, keine Datenbank erforderlich | [①Docker-Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Quellcode-Deployment](./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)| 2 Kerne 4GB bei Verwendung von `FunASR`, 2 Kerne 2GB bei allen APIs | - | -| **Vollständige Modulinstallation** | Intelligenter Dialog, IOT, MCP-Endpunkte, Stimmabdruckerkennung, visuelle Wahrnehmung, OTA, intelligente Steuerkonsole | Vollständige Funktionserfahrung, Daten in Datenbank gespeichert |[①Docker-Version](./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) / [②Quellcode-Deployment](./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) / [③Quellcode-Deployment Auto-Update-Tutorial](./docs/dev-ops-integration.md) | 4 Kerne 8GB bei Verwendung von `FunASR`, 2 Kerne 4GB bei allen APIs| [Video-Tutorial für lokalen Quellcode-Start](https://www.bilibili.com/video/BV1wBJhz4Ewe) | +| **Vereinfachte Installation** | Intelligenter Dialog, Einzel-Agenten-Verwaltung | Umgebungen mit geringer Konfiguration, Daten in Konfigurationsdateien gespeichert, keine Datenbank erforderlich | [①Docker-Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Quellcode-Deployment](./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)| 2 Kerne 4GB bei Verwendung von `FunASR`, 2 Kerne 2GB bei allen APIs | - | +| **Vollständige Modulinstallation** | Intelligenter Dialog, Mehrbenutzerverwaltung, Mehr-Agenten-Verwaltung, Intelligente Steuerkonsole-Bedienung | Vollständige Funktionserfahrung, Daten in Datenbank gespeichert |[①Docker-Version](./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) / [②Quellcode-Deployment](./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) / [③Quellcode-Deployment Auto-Update-Tutorial](./docs/dev-ops-integration.md) | 4 Kerne 8GB bei Verwendung von `FunASR`, 2 Kerne 4GB bei allen APIs| [Video-Tutorial für lokalen Quellcode-Start](https://www.bilibili.com/video/BV1wBJhz4Ewe) | Häufige Fragen und entsprechende Tutorials finden Sie unter [diesem Link](./docs/FAQ.md) diff --git a/README_en.md b/README_en.md index cc0f170c..196cf16f 100644 --- a/README_en.md +++ b/README_en.md @@ -181,8 +181,8 @@ This project provides two deployment methods. Please choose based on your specif #### 🚀 Deployment Method Selection | Deployment Method | Features | Applicable Scenarios | Deployment Docs | Configuration Requirements | Video Tutorials | |---------|------|---------|---------|---------|---------| -| **Simplified Installation** | Intelligent dialogue, IOT, MCP, visual perception | Low-configuration environments, data stored in config files, no database required | [①Docker Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Source Code Deployment](./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)| 2 cores 4GB if using `FunASR`, 2 cores 2GB if all APIs | - | -| **Full Module Installation** | Intelligent dialogue, IOT, MCP endpoints, voiceprint recognition, visual perception, OTA, intelligent control console | Complete functionality experience, data stored in database |[①Docker Version](./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) / [②Source Code Deployment](./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) / [③Source Code Deployment Auto-Update Tutorial](./docs/dev-ops-integration.md) | 4 cores 8GB if using `FunASR`, 2 cores 4GB if all APIs| [Local Source Code Startup Video Tutorial](https://www.bilibili.com/video/BV1wBJhz4Ewe) | +| **Simplified Installation** | Intelligent dialogue, single agent management | Low-configuration environments, data stored in config files, no database required | [①Docker Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Source Code Deployment](./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)| 2 cores 4GB if using `FunASR`, 2 cores 2GB if all APIs | - | +| **Full Module Installation** | Intelligent dialogue, multi-user management, multi-agent management, intelligent console interface operation | Complete functionality experience, data stored in database |[①Docker Version](./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) / [②Source Code Deployment](./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) / [③Source Code Deployment Auto-Update Tutorial](./docs/dev-ops-integration.md) | 4 cores 8GB if using `FunASR`, 2 cores 4GB if all APIs| [Local Source Code Startup Video Tutorial](https://www.bilibili.com/video/BV1wBJhz4Ewe) | > 💡 Note: Below is a test platform deployed with the latest code. You can burn and test if needed. Concurrent users: 6, data will be cleared daily. diff --git a/README_vi.md b/README_vi.md index 83557ce5..e6d5f489 100644 --- a/README_vi.md +++ b/README_vi.md @@ -182,8 +182,8 @@ Dự án này cung cấp hai phương pháp triển khai, vui lòng chọn theo #### 🚀 Lựa chọn phương pháp triển khai | Phương pháp triển khai | Đặc điểm | Tình huống áp dụng | Tài liệu triển khai | Yêu cầu cấu hình | Video hướng dẫn | |---------|------|---------|---------|---------|---------| -| **Cài đặt tối giản** | Đối thoại thông minh, IOT, MCP, cảm nhận thị giác | Môi trường cấu hình thấp, dữ liệu lưu trong tệp cấu hình, không cần cơ sở dữ liệu | [①Phiên bản Docker](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Triển khai mã nguồn](./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)| 2 nhân 4GB nếu dùng `FunASR`, 2 nhân 2GB nếu toàn API | - | -| **Cài đặt toàn bộ module** | Đối thoại thông minh, IOT, điểm truy cập MCP, nhận dạng giọng nói, cảm nhận thị giác, OTA, bảng điều khiển thông minh | Trải nghiệm đầy đủ tính năng, dữ liệu lưu trong cơ sở dữ liệu |[①Phiên bản 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) / [②Triển khai mã nguồn](./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) / [③Hướng dẫn tự động cập nhật triển khai mã nguồn](./docs/dev-ops-integration.md) | 4 nhân 8GB nếu dùng `FunASR`, 2 nhân 4GB nếu toàn API| [Video hướng dẫn khởi động mã nguồn cục bộ](https://www.bilibili.com/video/BV1wBJhz4Ewe) | +| **Cài đặt tối giản** | Đối thoại thông minh, quản lý đơn tác nhân | Môi trường cấu hình thấp, dữ liệu lưu trong tệp cấu hình, không cần cơ sở dữ liệu | [①Phiên bản Docker](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Triển khai mã nguồn](./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)| 2 nhân 4GB nếu dùng `FunASR`, 2 nhân 2GB nếu toàn API | - | +| **Cài đặt toàn bộ module** | Đối thoại thông minh, quản lý đa người dùng, quản lý đa tác nhân, bảng điều khiển thông minh | Trải nghiệm đầy đủ tính năng, dữ liệu lưu trong cơ sở dữ liệu |[①Phiên bản 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) / [②Triển khai mã nguồn](./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) / [③Hướng dẫn tự động cập nhật triển khai mã nguồn](./docs/dev-ops-integration.md) | 4 nhân 8GB nếu dùng `FunASR`, 2 nhân 4GB nếu toàn API| [Video hướng dẫn khởi động mã nguồn cục bộ](https://www.bilibili.com/video/BV1wBJhz4Ewe) | Câu hỏi thường gặp và hướng dẫn liên quan, vui lòng tham khảo [liên kết này](./docs/FAQ.md) From 6ac67a7e4187a3d04e62842b8335e7ae4a8262a8 Mon Sep 17 00:00:00 2001 From: rainv123 <2148537152@qq.com> Date: Fri, 19 Dec 2025 09:55:18 +0800 Subject: [PATCH 80/93] =?UTF-8?q?fix:=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/controller/AgentController.java | 39 +++++++------------ .../agent/dto/AgentChatSummaryDTO.java | 5 +-- .../service/AgentChatSummaryService.java | 10 ----- .../impl/AgentChatSummaryServiceImpl.java | 3 +- .../config/manage_api_client.py | 12 ------ 5 files changed, 17 insertions(+), 52 deletions(-) diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java index ac1852a5..0052c7cc 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java @@ -36,7 +36,6 @@ import xiaozhi.common.utils.Result; import xiaozhi.common.utils.ResultUtils; import xiaozhi.modules.agent.dto.AgentChatHistoryDTO; import xiaozhi.modules.agent.dto.AgentChatSessionDTO; -import xiaozhi.modules.agent.dto.AgentChatSummaryDTO; import xiaozhi.modules.agent.dto.AgentCreateDTO; import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentMemoryDTO; @@ -122,33 +121,24 @@ public class AgentController { return new Result<>(); } - @PostMapping("/chat-summary/{sessionId}") - @Operation(summary = "根据会话ID生成聊天记录总结") - public Result generateChatSummary(@PathVariable String sessionId) { - try { - AgentChatSummaryDTO summary = agentChatSummaryService.generateChatSummary(sessionId); - if (summary.isSuccess()) { - return new Result().ok(summary); - } else { - return new Result().error(summary.getErrorMessage()); - } - } catch (Exception e) { - return new Result().error("生成聊天记录总结失败: " + e.getMessage()); - } - } - @PostMapping("/chat-summary/{sessionId}/save") - @Operation(summary = "根据会话ID生成聊天记录总结并保存") + @Operation(summary = "根据会话ID生成聊天记录总结并保存(异步执行)") public Result generateAndSaveChatSummary(@PathVariable String sessionId) { try { - boolean success = agentChatSummaryService.generateAndSaveChatSummary(sessionId); - if (success) { - return new Result().ok(null); - } else { - return new Result().error("保存聊天记录总结失败"); - } + // 异步执行总结生成任务,立即返回成功响应 + new Thread(() -> { + try { + agentChatSummaryService.generateAndSaveChatSummary(sessionId); + System.out.println("异步执行会话 " + sessionId + " 的聊天记录总结完成"); + } catch (Exception e) { + System.err.println("异步执行会话 " + sessionId + " 的聊天记录总结失败: " + e.getMessage()); + } + }).start(); + + // 立即返回成功响应,不等待总结生成完成 + return new Result().ok(null); } catch (Exception e) { - return new Result().error("生成并保存聊天记录总结失败: " + e.getMessage()); + return new Result().error("启动异步总结生成任务失败: " + e.getMessage()); } } @@ -219,6 +209,7 @@ public class AgentController { List result = agentChatHistoryService.getChatHistoryBySessionId(id, sessionId); return new Result>().ok(result); } + @GetMapping("/{id}/chat-history/user") @Operation(summary = "获取智能体聊天记录(用户)") @RequiresPermissions("sys:role:normal") diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatSummaryDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatSummaryDTO.java index e0bf12a7..f6fbe660 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatSummaryDTO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentChatSummaryDTO.java @@ -3,15 +3,12 @@ package xiaozhi.modules.agent.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; -import java.io.Serializable; - /** * 智能体聊天记录总结DTO */ @Data @Schema(description = "智能体聊天记录总结对象") -public class AgentChatSummaryDTO implements Serializable { - private static final long serialVersionUID = 1L; +public class AgentChatSummaryDTO { @Schema(description = "会话ID") private String sessionId; diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatSummaryService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatSummaryService.java index c442deb8..418ee9a0 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatSummaryService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentChatSummaryService.java @@ -1,20 +1,10 @@ package xiaozhi.modules.agent.service; -import xiaozhi.modules.agent.dto.AgentChatSummaryDTO; - /** * 智能体聊天记录总结服务接口 */ public interface AgentChatSummaryService { - /** - * 根据会话ID生成聊天记录总结 - * - * @param sessionId 会话ID - * @return 总结结果 - */ - AgentChatSummaryDTO generateChatSummary(String sessionId); - /** * 根据会话ID生成聊天记录总结并保存到智能体记忆 * diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java index 80ad03d6..5e855be6 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java @@ -53,8 +53,7 @@ public class AgentChatSummaryServiceImpl implements AgentChatSummaryService { private static final Pattern WEATHER_PATTERN = Pattern.compile("天气|温度|湿度|降雨|气象", Pattern.CASE_INSENSITIVE); private static final Pattern DATE_PATTERN = Pattern.compile("日期|时间|星期|月份|年份", Pattern.CASE_INSENSITIVE); - @Override - public AgentChatSummaryDTO generateChatSummary(String sessionId) { + private AgentChatSummaryDTO generateChatSummary(String sessionId) { try { System.out.println("开始生成会话 " + sessionId + " 的聊天记录总结"); diff --git a/main/xiaozhi-server/config/manage_api_client.py b/main/xiaozhi-server/config/manage_api_client.py index ae703815..70d9dbf0 100644 --- a/main/xiaozhi-server/config/manage_api_client.py +++ b/main/xiaozhi-server/config/manage_api_client.py @@ -186,18 +186,6 @@ async def save_mem_local_short(mac_address: str, short_momery: str) -> Optional[ return None -async def generate_chat_summary(session_id: str) -> Optional[Dict]: - """生成聊天记录总结""" - try: - return await ManageApiClient._instance._execute_async_request( - "POST", - f"/agent/chat-summary/{session_id}", - ) - except Exception as e: - print(f"生成聊天记录总结失败: {e}") - return None - - async def generate_and_save_chat_summary(session_id: str) -> Optional[Dict]: """生成并保存聊天记录总结""" try: From 6c57ce9dd2ba7e036c4529b340730e358a3b8614 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Fri, 19 Dec 2025 11:49:42 +0800 Subject: [PATCH 81/93] =?UTF-8?q?update=EF=BC=9A=E5=B0=86=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AFMCP=E7=9A=84=E5=88=9D=E5=A7=8B=E5=8C=96MCP?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E4=BB=8E=E4=B8=B2=E8=A1=8C=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E6=89=A7=E8=A1=8C=EF=BC=8C=E5=B9=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E8=B6=85=E6=97=B6=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../providers/tools/server_mcp/mcp_manager.py | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/main/xiaozhi-server/core/providers/tools/server_mcp/mcp_manager.py b/main/xiaozhi-server/core/providers/tools/server_mcp/mcp_manager.py index 6edc44d1..1d0d9cb6 100644 --- a/main/xiaozhi-server/core/providers/tools/server_mcp/mcp_manager.py +++ b/main/xiaozhi-server/core/providers/tools/server_mcp/mcp_manager.py @@ -49,29 +49,47 @@ class ServerMCPManager: ) return {} + async def _init_server(self, name: str, srv_config: Dict[str, Any]): + """初始化单个MCP服务""" + client = None + try: + # 初始化服务端MCP客户端 + logger.bind(tag=TAG).info(f"初始化服务端MCP客户端: {name}") + client = ServerMCPClient(srv_config) + # 设置超时时间5秒 + await asyncio.wait_for(client.initialize(logging_callback=self.logging_callback), timeout=5) + self.clients[name] = client + client_tools = client.get_available_tools() + self.tools.extend(client_tools) + + except asyncio.TimeoutError: + logger.bind(tag=TAG).error( + f"Failed to initialize MCP server {name}: Timeout" + ) + if client: + await client.cleanup() + except Exception as e: + logger.bind(tag=TAG).error( + f"Failed to initialize MCP server {name}: {e}" + ) + if client: + await client.cleanup() + async def initialize_servers(self) -> None: """初始化所有MCP服务""" config = self.load_config() + tasks = [] for name, srv_config in config.items(): if not srv_config.get("command") and not srv_config.get("url"): logger.bind(tag=TAG).warning( f"Skipping server {name}: neither command nor url specified" ) continue - - try: - # 初始化服务端MCP客户端 - logger.bind(tag=TAG).info(f"初始化服务端MCP客户端: {name}") - client = ServerMCPClient(srv_config) - await client.initialize(logging_callback=self.logging_callback) - self.clients[name] = client - client_tools = client.get_available_tools() - self.tools.extend(client_tools) - - except Exception as e: - logger.bind(tag=TAG).error( - f"Failed to initialize MCP server {name}: {e}" - ) + + tasks.append(self._init_server(name, srv_config)) + + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) # 输出当前支持的服务端MCP工具列表 if hasattr(self.conn, "func_handler") and self.conn.func_handler: From 0dda4f56461a9ed6fd561cca1f382dca7b3e29e2 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Fri, 19 Dec 2025 12:12:10 +0800 Subject: [PATCH 82/93] Update huoshan-streamTTS-voice-cloning.md --- docs/huoshan-streamTTS-voice-cloning.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/huoshan-streamTTS-voice-cloning.md b/docs/huoshan-streamTTS-voice-cloning.md index c2d261d9..304a8c90 100644 --- a/docs/huoshan-streamTTS-voice-cloning.md +++ b/docs/huoshan-streamTTS-voice-cloning.md @@ -23,6 +23,8 @@ ### 2.将音色资源ID分配给系统账号 +使用超级管理员账号登录智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`音色克隆`,点击保存配置。即可在顶部菜单看到`音色克隆`按钮。 + 使用超级管理员账号登录智控台,点击顶部【音色克隆】、【音色资源】。 点击新增按钮,在【平台名称】选择“火山双流式语音合成”; From 96991ae5ef3153f6bb10d59fc3c3a58cd46671c8 Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Fri, 19 Dec 2025 13:29:06 +0800 Subject: [PATCH 83/93] =?UTF-8?q?add:=E5=A4=9A=E8=AF=AD=E8=A8=80logo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/manager-web/src/assets/xiaozhi-ai.png | Bin 1946 -> 1941 bytes main/manager-web/src/assets/xiaozhi-ai_de.png | Bin 0 -> 6957 bytes main/manager-web/src/assets/xiaozhi-ai_en.png | Bin 0 -> 7354 bytes main/manager-web/src/assets/xiaozhi-ai_vi.png | Bin 0 -> 7171 bytes .../src/assets/xiaozhi-ai_zh_CN.png | Bin 0 -> 1946 bytes .../src/assets/xiaozhi-ai_zh_TW.png | Bin 0 -> 1828 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 main/manager-web/src/assets/xiaozhi-ai_de.png create mode 100644 main/manager-web/src/assets/xiaozhi-ai_en.png create mode 100644 main/manager-web/src/assets/xiaozhi-ai_vi.png create mode 100644 main/manager-web/src/assets/xiaozhi-ai_zh_CN.png create mode 100644 main/manager-web/src/assets/xiaozhi-ai_zh_TW.png diff --git a/main/manager-web/src/assets/xiaozhi-ai.png b/main/manager-web/src/assets/xiaozhi-ai.png index ef3834afc08b6e3c1ffa652275e4a2d3d455facc..8a59549341b7c1ed522b30c6611849681efb6ab9 100644 GIT binary patch delta 1905 zcmV-%2afof50wv)Ie$k>dEz`2g-^ccYalH!B`{m>Pzgy1rUX&~lz>Y} zN-#TbLkZ&&h7x8Lx}G>-z_PAnW1V-G0LFlHbansq^ykkX5JJ-Bzp-zC5HcdxdV{*=HvIS(U{}>3*DX5doJ$v&~FC zbda}MS7lLTCVyW6C>5^fL{%2uX3+%5Ox{=h-n9gvZd<%( z7TyMS^50_tD@w0Q_vB135ooCynGQKTOnMJ|tQY_|!des25mVepz|PspHMjUO@rUq| znS3+WHmk2=Q1go;WGuGOTg;ACSrlT`Xk2qEzMAOV6@LM-0>)Tz2vI{OnC2vF`@L@Z z1svFm(=sZ3wga>RJn4uA_^ceO%;dh;NDBG>HGEVJhqgz*DvLTZd4b{1)`n*Q@0rO> zOuxxZ5ug>IhUOr==4B?=*+6$3ckSKZU@Z+_ab5r@V@3?FZU;m?X$6pnM$Bu?aa?r8 z^<~0pIe+#VA&6q_-SP=&t0y62uvfm`)rl_N{iD+RED=cvI=2JjL~&Km#!>Qu{&CnN zfR8(m6EX@Cc+AKd$86h zL#7C%#U8g;?;*}K*Z8=`1ZLFeE0^HS*SS7t5mz!;Gx-=N*rdKCc+=|RIoJ{qGgE7&X}r@-mR~Vu&wsV4 zBa>b`C;rh$d3+R_nYPX?$1{^}GL!#C7*n&^$N$bu{z=DPH?jg~HL{3})(W1gC2*|D zLiM-r+hgzrejRFuu!K!3-hv^a3#0+7c78lyzw2ge5dy6hJVg;WAwWaYYgL2`*h#P5 zm~jZr79n->x1rt1xrVg}w7#1fc7KqC?Cd-@OW?dv?dtQ+^ZrG|Kha-s*CDD2uUP^o zSP{^QD>t58*DzJ1d{fR|yNP(}wMt7b5Q_lt7lI>fDrzm6gP~9T_~>2jiYwaP@AXo}bboNp7$k(u3yMG- zQqFTH6Ne%YhpA+@5pn@VfRMzA=@|SGWq)p-+I2#Gwd8CQu^~LI%U_Kw!l5zf~qW zm_E&5uiVoL!yAXprhmHgjJ+JmmK+p;z(~Eg>nYDSQ7E1DaVV}l9lUHJJ24m{jDw~K zoG@=_Q+3J`T%^8|cWqA2O2o+-7V6MFi7{`QG8A2Bj5NxG%P7l)4!LehfU0YVaoB0xyu rPy`4`9Et!Ti9-<}BylJLge1;iVFt3b!d7pB00000NkvXXu0mjfX9#%K delta 1910 zcmV-+2Z{KV51J2Y} zN-#TbLkZ&&#wE-wBo7|OfF4(}LFd0q0Go$&bah{P`St4;2qF3M-_&n_5Hc+k0YWl| zB0xyyPy`6cod0c}^z!mD`u{?eO8_g&dv#4+RhG}qf z`lhb-A@@&><9NM`%jYjtxd5;>f9?RN(BBULBpmxzB5;E>fUV_o-vjUUjJ!e*2p`@7 zgbtz={6(k*@K7QE-~eDLbgj*;2Mbk}(7*2rvD72z>LCd6yty+50}Y6D*Hgn6%00?-bv+6#+r z3qASov49q(H=+0BjkgF4l#WbGi2##6fDkJN08Y@>M0AWP9wJzm?9DZIIGgxBtP53c zr`}eDI0hxZR6=61gWQ63YU-*oR*b|ocg8m}8D1h_tbc$hRv$u~FcVC2k_~-ZH~j(* z9E{U)5`4DFJ^D>uwS_7x_&Zm7UIBa*s#Gcc zBt6a|Kr0{!$w7I|>rBpUAv;#b_U`Y{mPV);F96h2L=2p^14dbC1(4Ze%xlT9T8+i^ z&4iV5?0@w_kkqwAd9%#pq;;urKHPS>XT(*_Z#7CjZeN$Ia>Tg182grkByyyM$wl;#W#~>E& zP=6bc%2|9wo+1DMTl1T}NrwY!~xh+)r6LHMWejop{P~{gLduPmwAl29+HNGo)u9m>5sjDcz zbx4oFHNv{p`mlsfE8cOM^peMa_(?%cq zEkbUl@Q`8m<`&u_korDa=s_2=n}748TLPEGXjh+infGr*`wR90yNOv%=wb|oe zWA44Yt|6;l73Y-6b{FHRmnt>=2V)Tc;X-hNPEBo1=3p$m3St7ngto~{(GSTnpFu&3 z?7h#ik6AnfIEa98%Do9W|5^YFCi@9)6Iz+F%}0w4#9YBzKxAw13BBrcgMYtc!J`)xEph#~s&HOBMvtAF>=-h$5`Tv_ zJzWM{KLGx$iso82rcspeNPl8lq#3ju*wxjy(B|f-`D-kKZ-Uu|H*-HcSYjTDP_Y;~ z-6f8l8^y~K7*eS62cA2FOU(N4SfOqCCvEsn4#8g~!;gw^Udr4V9fxx`kd(L)%P(;4 zT)+;0uii(TSCi#L2VMl9rc7j^2&5&Jx82qmIES+eqM5bC50Q*PLVw8PLJ>$y&Ux=- zrceaZGM8*OLjFJzAS839b_{+?al*=sLP?U_f!@=9g0?Vxo0E{tnOX#|p-+I2%%KQOM5IO_ggC?PKq8Fkf2(ZN8=D5O zSKiYK{*6N(x}tNy!G9dVmMs*4M40+<*OQ%ZB2Y>j;!s=#I(6ANb|y?M0=b0S5L5lD>jh)YahiU1LTA}|f16ZpjN8j*q`Fb$z15cMz$l5ZCjfoTX40o&uR2(f}9 z@K_w7&u!pVfQTWHClInKB}p9(Os|cR+++KhhbPc{rpT_$knBr|tqva{ggh_Y4iJ(# w6ahjqhax~o=1>F($sCFRA(=xFAS82s1LfGVwb@@<$p8QV07*qoM6N<$g8H(4pa1{> diff --git a/main/manager-web/src/assets/xiaozhi-ai_de.png b/main/manager-web/src/assets/xiaozhi-ai_de.png new file mode 100644 index 0000000000000000000000000000000000000000..f2c54d80c00fe5bf9e577af81f358fb56b1d32ac GIT binary patch literal 6957 zcmXYW1yoes`}NQ;v~&!J)DR*glEWY^-8E7}BOp?e5|V;+gOoIaFn|&cDZ)?!3epWj z=O7(^^Zvg7TIb$-?pk-9b05Lk;c?0FcxDci{nY^Oyku)+Kdi1tb5w{d{e&xo-{Ve*vc(B(I2K;N1?4T|lvA zsX?Hyt`zJH=vLP?c;s@=fyktfHB#W=eOV#WLm<^55KHU~{~~-$uy7xY3N;p)OC1BK z;5!3uk2lT_m-OgW^l>BIdpT55QvSawZd?@)Ru_ZgaKlUDrYtkb1s7R=UIHF;4VS2W5dChaEQj{W$V$b{D&v_uvjg{Z ztRL4e8kKEz!bNXX2~F}!eAvAZg8iVl;+Dq7dcQINGS#`?RlbC>|FKo-@m~iZ zv|MpG8$&4_fZH#a?pcN@SE_K*uTh2SgzNAiY0yd&>@Y7va{;(M1% z=_Wa3KE1qTFq|sf>Ge2P|=Upzq}{QfI?7Lm_s$D^@6{W%-{~O zdVvgRj|5SawNxks(Ig-+1{2N@yCN+ZLt~vPRBrKYm@d{)P?XQ4?kpYqXivw2H0b`lBf z(BX-D(1pWTq$Q~dOwOw7tUUwGC!J;=5QoRF0~7eTmQW0K8e!hHd1RAxYsVEpt)AHl zgIt9#xkf#Q|4dQy!Z$$$y(|;+wR=< zgE+96x}eq-+5fjErf@&ZYZQD^J<}Thf{I0VhSWqo*lYNpd*o|Ugo)4-^%@>l9D+qF z|79w!3id;_K)eH3Qm-{k;{S|}Rb=Lc32Q%?3V%>I-5H4V-rp@M0}t*Nv6kG^<*L{C zpRLGk=`jp|{#rLKFjj5=I~|el`ikTs&fNu^g|De*ChB;NJdNowQIP1LyWDd>vAMqL zhFmGs$Ovy9o;rMPdn(HhD^I$zro=!heSaGpJBe;zv2s4FPs-H$KD)^X7!=S7d(w?Y zj_RGr_|{1Zu!jB%Yp*r9g1UcsF=&*oX6Lgt^8xSTUB!77;Q}4tsZ<3>@%;X}pZyU& z!&HRlruB4^IBh}VocI|Zhj`6PFQPeXO;W{|ae#?nd+J-+jGXRSINCq(GtuoG9!meW zW&vTsuG)6lc|tQ~!P6k#Gw<%Ti&(N@q9;>*$f@zRL}uO1M&Pw!8n7!A*%=Ld#O`$! zZ_}B!ah}fc=F5-qAj@v0r8IjZOvVM_GQU0jh?*J|;?+FAV@3Kq^hGrMj&^66X&Rbv z%H@8O|ND?F)$}``ty$8{F{qBfKkyO9Kb;14q`qhfz>JLI2eHX<_G3SQJGv&Bc!VwQ zfDUr2gJ;1Dw<5TpK&*J^+8!NtZ!PIZ1hvuhX3#e$A#eZu6?Q!OE1e_0{-@|pkeyxDD&^j}SVbhI5TKC7PIuc( zMDA3z=gmV9SPcIN2Uc=3{*}ScFLY;9zl)r0TEHLv`0K6my4D6g_SK%ZG=8CL^s6#* zzw?30nV7wVm}&)D^{o)`=%$9w5}n@6+AktQ7(VH8fO!n$d~|H#-9xXvsV z*t_a@h7Lgn=S_-J$K+Un5M2n#5o`cU!Ya)0j1|3PN&%tWyUnHs8$x!-`^fbz>Hs(4 z`LYR{I~043r$Pf*b98-$@8`Vq$etD|z?MSua|>D+3zUy6M|y8Wf!^qr*Bm)kUBYtxbttmTBI*N5Rau@+OIdG zFaHpk*1=LTLg0?Lc9pTs_dF@=v;9fhFM+gsF@N0Zr1x}1aO|Ek@*I=3E2S~@j}DuI zxk4b&`GT>;eTpqrfA|B;T@XDX>`2_HVoL-LEs$9*mX+*B@+uITvTg7%%v6oeK`;7sCpWDjEe&Lnl z@7g@xe2hl!R<(Uyr*NJwSGN;wkIjjSacY-m?iJ@K=_r^VKi~P_e@>!3Ta_f^%r#9W zu)0rNw+s)9V% zUKNhsSX*+ct*4z_6rWtuOLQi03>o!t&XN_Ru70!gA(Cjp0tr2rQ@zH3-w92vv}>c; zPbAuSDh6u~Mf!y{bT6#m_~>jnb-~sdOgCF5MEoC9rmT1DR%W4IC{UGoU%-wZ!_wb+ z>sefv!wY9shq-J+K(#zrvYb*DaW9BRcE%gayOE-21jV+?wQbIK0C>D2={y3{**dQXrw@t<_`IOudO5q6La6g7lM! zl)Gz695Xr7WaRTVi(2rnFY-Gs-gyr5IX*lr3qPGe25%l!*!J(0wcOjpTKrU$_V{J% zAOda(-@9F;O~S~sJm@iDN@Z!uoyhKpm|S}K%3X5k@c?%8h>p}Em&Hx=SYpIs48LZ< z;p#d5rfxdv0p12wT8!z_u2K}(YJ`ks)^Y4Fr3_~~(iy+|PFk^*W@yF6(h>rE zFXu|~8H7t<5(8MG`Nzy4gKj~B7>yH7=7PA>uh{6_6iU7+LoQu zTPK(PZ)x)kSwc0D8W&g82|haGf(u$32KvxC;eTaejFQ|U6zvP4dYApWOg-}%-eaww z*%(u#jNZZ-*G^htc8IusDn@ME%{4|#CxCZ2r=J2J7B+QM9TNJwU)=4ssTe;b z_2Ka6jQp41Jkn7m(ID_tg`7O7o)B4_&t04KmqLCn%zEu&WI2huF2nihRffi?X?KWj zR9H<%4Wh0!ygiycZ|Y#M6@j?kVy^$xdizy5=0wL({eep6?vL?rVT`5afgRmV=deAI zuPh(k`K8W&m*ySxO*rmMD#Qs1WQtAs(zt$VmOJBXw;CH22fgst?s8;?bltft2C%@11%9|yTjatN$mb!abH)TZI|wgG%ji7WGR;fY+s$e& zg{jMM*k$@i$YBW|mtr&;ZV3F31%3*QzukAd?AG6)Bc30UtNHa98C2AFa%});$w#m+ zWqlVOz~WrE^~hMk?tC!g9>Lle4r#@|tP||o#n4_#;NzRw$uAe@Eac6Eb`<-}AA%G< zaAx^@uJ6$?AFSeZ*O+-QfMu0kN=agtgo2&2+I=i5$NWxd-Opb{pCxce;P$>EIXQPu zn>3>uVWRGuw3zK1v%GvZQ`%G zP_!*6=2__B3UAGi`HUFp4yFxAW7{}>oZHRQ?h!H_|0csa4@H4j@#1nfl7J9FEDm0r z+S|@d;x284XUY1%bho7@e^5E(zJ{t$7GFLNM}c1Kn2ls)>o?g(2iAawSgsOc%G(Cm+fx7~X3F6Qb zp%;_OpwJ5Bwyp{*uKFcvfP#=_%X{DuN4Z^cQTD<#;iPA>z1l1ePNb3>sz;S=I($Zq zpsbN+m4Y~u!W#qogn$7*+~By2WLPL7|HWSiI6C{$rE)2GydwXZ^&Q zJO0Nq@=ax+_qEhx5W8&@S6w>kIK}3fwtSzdDx6#IALLWPS-vZ0hM~3Kn|@!O@O;S* ze)!#c3H>(>8}#LnRPG*+9n%Xf#Q@6WjRO~#%Q#E*hIt;ioNmJn*8nyYkyMq7isRE7 zdcG@n8pzp#c0j#d%E&fb$N)4>ai>j?d(dzFRU zN;jpEX6KhQr|*9ovX$q$@rT36wlo-jommMfF5cy}@-S9*rj5bdvrFZoNKCERsGd8n ze>xnMb~Q-F(B{BzBHBM!lg*Ul{fKY)s>h~*_KG%#$2Zu}uyAtG-ELchVrkg`Ep{4fe;)*Y~9&l6@ zG;-rIQ+10f-j4WDq3Rg9&%_Pg-A`e`QLK`!qLBul&^?`4TpjJ|wAVpPYWYv|BV)3S zNBP=way}n|M7

<)eghB1R)i@w*P(HJ%N>`u%@Y4j^P^{yKJD7*Zo0H&HuJ-I6qp zGwwV6Ci=>M%o+Tx5g$R*&GwJ6Oy$kyeu8swyyne8(YZ!$+w_M~vtvMM;ctv}U5!ke z2AA|4S^>Nf2Lh!e`MmHD)PnFUCCO6qSmMsNhntNZvZ4*sI}f=^C<#{Foc?FjQpZUcvA%!%#?m4WiM|yZO3!d5dX-*l{^oqHaQoJ(RiJx1` zi*U~br(#BbRwS=YiAi#i;u=&4)x+%EA}~L-7>8HdPeWavWnTk zawpCL$Oo4z?82WUFPUpgHJFH3l40Q3dRrhk0@+3=f!len`*lW-`e1ROT~`&v)b{|I zvZ(PqeZNQ4#xmcp$yj<%aZI|yu=lk0SXhbXRJ&>|gn;FGDo$TwBX4=EO65mor)@e+ zh;@bU5LZYK#8(9O$HC=fu&n60(2=}4_^$-j#Q+=>XV~=USvH+gG0lbzWz;Lk*VNKw zmEjibFoNnXR<3~GrHPn9u4)g1gN~k1#9Fxiq`(ARNnjIsP|1x8>QH+|X9hRY8uM~? zhrm3om+JiTt{($!MGFJo(p5bYDVH&d7Q37@l+#XIS8Ewyme|58Lh`1g4y7-h%XW}((yc{Dp-fdT=eWVuVQ%Xym2C4)b7U7K;Me|bCw@({-7 zt*B#Fmm?=VQ^zG)`xB>_*mCgJt;nsgnW^l28>MwDdF1Jr0Cc?cUY)R&CQ44lZ;UpM zoK)(=!%O>Gk}PIGdF~|Jch@TTO-^gdllrcY;#rQ*M?_|)Mgu!8H|kj|bI-^qgpS(y zb&vzBm2!CF%i99gls%e*5UrPW)Y)+*J>0{_I$GUFPfKieHJ)h@2ie_Z>&KA09u>y0 zndwf7*OVv(9EA|Nraz5{r(F4lX}7GgBy*X*K9(;irzM=NgSMm2%!%v( zUEkX>yUSP0uq9&O?f`vzXle5xXh`NieXCLjrdPe@tzO1R^nr`AVgK~eCXIV zA7z~dp}fGXYs;;+k@h=1^;7`&pkqQsN;Cka2JyJ2 zqC7*@Gwi`m?_dcICxW!;!Os%7J>lv(47H|C*j&aF=hk**0t{Jyz%K(W$xSoX+8F!l#Qm6L+y0E z(LX@1l~#(H2hf;>Bh=gZZC7P1kM2y;s%5}_Kq(|r_aT%dE4jT}iR9`X+-X?&kvd*S z#X-%!whfxTa6;2_g}+KKeeT%4QCYfqndQ&sWVF(|BMurJW4?<#L#m% zaIUewiQ?C2XcHpLQHFK2LQgEGM1Irm#=!;?We2HZeK=b^$*fiAwxq$VhTbg+Wkfpn zp%|LyetQG5uOJ$l^HpP?W&7mVae068sjX>*TYA?O$@vY`41uzb z*2$~`-QNp|&+5KGuhW&c&or_QL8jkhHJa?GkK8r`fz+Utf*b>*aE87BXHD6Bu;voO z??@>XBH!eQgyM^EM!m8kH3Bpt@so~P!np^%m>lNFrp^nBOAey zxxbbnT~8RM^*1%Gp>@{xgChO5k(3Wx6(Pw3Mr*@rRCMA6)Dj{(d}#38VyU{5MqIIA z?Jb1+&q+vWV0ql%lwb<J4+f^{7 zDhOBaYm%={F2KF5ld=}vL8QbKQrl$5>@UVrgt~O&8CEN#k6RA6ywWZ9zejWit?6&BHwl;f0fQ?@haxv4tis&0W$gDj1}y;48>%x z3o^`H-O6*#6#` z0fJY?H!AoUOyK?AexDJ-^;=*5J@cTN+H#picS%jAlXUNuPPA2?VxkLKWG%NG7nC9S zAwH~7Za5~7y#;_GS4L0U?PVKJ>*h8m0#$itimK!{2(tip)V`9wFoQn~7w79NB+#L< z$YDl}>DHtD)|XaeH#dwXQ&qC2rrka5z6v}&nIt3+0WFz&JWU@Q^6f8q!wPTsU|3v# zqn`Ur4Vh@-{I#(>_LTdIgryeYr`|ychrE;=n&TNVMM8JQC{R7v z1A`e=5iD!^L^`zv%a>OZ2UG@5jFJ%}=(2 zxFqLLEEVd1^k&eeAU+ld{6kf}}j={0xD zfr>t6B}t#8PajHUZc$}^2FHme)ITW8y6ZYtKYg}#h~LzixyX8^@nSxSd4J6*A^7wKK*R*G&zYG24`2S=diO1tTw4#{p9qecnQiB1p(QKEOE zci-fG_m8{QdD~gd8vsX4_E)4chkMtzrcN z{qyV`yqDcEXhg@yRb4yH>ah*jcpf6jMLh~W78s9vn?b7XfnB_JZ&;SCri4uj&j0z= zBlz;}Mx0z(@84J_zih9e%jb?M{5Jyi)(!t>T3M3jzL(>jjMU9grX<; zv5o-DdkA09vR?}n_4})u-Xd=O+W@(n(3<#Ouew&RX=Y`;6z>GyDlPyIs{LqgZnC$8 zw>rj>81n^C!j45XWUZI@y!$7vp@nXU#7OSXv6Z>LS|zEQvusA4 zA16zU9KavUQH@#a3MjFd!dBH4(X4I@TmbU8lBU*-r^+$UxR?28d=YTTKS8a$t%`T@ z8!-T~e$AF2wgh;sR{u|!kpg$YzyV+dJMHl_Mkfin!^d?JBUsWMC6|^h&<#Xp{K8|f z?eH;10d@Pf&6ICr#n!yYJ;Q)n%pB1zIWGt zEt8MPTnG(jE*ZG4lr-RrVb$#>z96us8Z&1<{T4gP|Km?@Lm~Iz0BaiDp#{3A;5F|= zSvs5Q1h0Lh$vX)`TCsR)bbcdO-qw}$!u}6~ffdmf?4!pb_hD?$xPY!qrL!Uo+=iHq z$<#_CzlZ4^7dNqScsz-Rt?R$q^0Ml0_WV4SS@`-dH?!3Ug!tXeJC0^ugw}9=UH!4c(DNVnb^vCvYH6j3rKZj-WPZ7-#oknk$S5s{Op`xg1Dt z@)fVu%Y+4hVExL3o&8;5$dy>4PKo(E`lOtA@`4~B{ZRrP!wMkKyHxHPI$KT z<3sto&$tf9>-(|s^Gcv)1uJph80i!i2OGW9iD~o54@qA5N`PFa-?bP>_Vq;{<4Z_( zcuqb13dwT_38Bo`w}m>14~bCse&5lv25A$NbF(^%4{4_3aoD^ca!*%Y9sGnMGd6R; zq81N*yXJ*4yvJRhNuiqQP7i8oi;CH{cDNNONkJ$IYJ{yiqP^rYfdm;mxkTC#yyb#I zP*W&V&YeM%6`mC}j=`@_WlzPaUPBgF>nUb30UMBy2lw1DZo(3V78rA&D4peq3uk@u zgr-Z2R%oSN>d1ZHU9LRYk9B5Xt4SJ@D`$wJ0xce@7x=3NG&1p`1CXox0?nZK+3=9=c(LES1*~fhOw2v5&wT(Acda ztc`ne0Hb6JLw!`dl;CdR-m$z(ae$|K^f|U&^s1l30n46DwfOyQ{6ad}%6d8!zPY2< zdCC0|64-!bj{p13c!}CiIyIy7(p1&w%G~|>2G0tz&7m9GccHdbdD8a-XmL@5)q*%B zY4I1m&X&7Y!Z``dTw?1Q@~!Ma>83(9b@0;C=ip znPI~9O@as>?hyyF>-k$jSztw2TdarvV|zEBb369%`$v(fsHl5R2tt()umt4izlP(i z8jt;nFkv{pGNr zQq_4p693^EI^u{FK0r1@3sV0tqb6at=>1}ky-`!J0Ff2D?O)ZOvMC?9i zZ$J>%m!_JonE306^1Sq~`OfASAr_(lm+So*$emE5^Y5^?!*}3_kqq_KrMie#(hV8! z1U;F*Us@-CsIkQ79UQ%9reI@b>`mkTys(5VPpZ;ceA{Y)@ZC#A;`K;HrK&865jTKr zI+<#;Jzv#gK6v9%w?XmrdRvdZErb6riqr2KU6$=|WUbC!Wmtw|mAZTd)n_x_q?-jV z{^F%AeA`esdN~g1H68*`_3p6%L7tCz@VQ`RjX7`XB9--g!P3*$qO+uyGZz|KYvRW% zCv!cWKtVx*0ty!%=I%_1kt9qkTILEHqv!0n%JE3cl^E%g7zyg+cO_My#Z`lGCX|w) z@AE|C(WS3;4dd7tLjLBsNRoAU)tR}74~;?6I)3YG0apdj5G_y> zRcL`?{n*he8GXkKt1*llH%X9Bby{~?#eufucGA_60(7&RTKrcxvwu?3*+TyCOJ1xK zr+8fvZFuLLWX-7KxnJJ+E?JvF6!^e(MA_AV16dubDr15b^tb=y9^``&xytvqPbQK2 z|FzVO|M1*L8wUtVd$i~ucc|kAItlFhFptn5XU@cn{hl=*m<}EwiDp@Nq^u$7wN)Rg zXX3!;Fj`hsP|raxi4_J3JRdX&3_iSj0M@P=0YoUzId$r83T$3H={Wr(A4`xq)AzFf zTzxqhK1KI!(<*Xbv*WiwwTQ>gEUMBu6wn>q~y|cmAGQc3ji5q3W(p60am2wyW zFGJ4lk>D3pp~*9IXCaZ;EhZFkh7Cq0n$FUHMJHDme})}oe!js5=$u6C@t8DAW4ulY1rQc z#;3xdx4I;lcUNUj)pM-wXZABA=r#IIA@c?trwF*;a~n~0?vnw&;H$PWwfG^?!Qmb{ZfSbQ48NHA;PZKv`U5g94@=ljF|vu*zM05eskqtuMYPIM-DfG; zQ{X~uk0PCf+4)mSNssnK_KwSwL-d(9N!R2h1iVgh&7<0LhzJZF(pX`?GOvX z8umj`v6iy4F~v4qXVTLy}{0IFuIt2PNxF9o{y(4&{@5|Ne8aK%qj zQ5x#2a`A>k(N?H8RU&oszh~1xLEN-^tg1tA4(5x&$Q$J0V^ecA+vWfC;n%qJF;+~@ z;zO)Db%(BG0JBV*;j#^@>_Hf4Z$)DdLr9EB_S~@XlGNj9rW=$Gsr1cZQ5wzOo!0t{F;! zel?eS-YUKv@Baqz;uxHz&Qlft*i5&pQ1=>XG=j;Y<0l<~Ess@hQXqpJvi^q;bhlH< zdE=F>qtwR^8ZZBo-CLha@GcXL{Pg)n4^=km*aVXj0a?@ir5^!}J6@fc-tOPw{TO-eCNi@{BS$iOopdTgdy#y}6Q3SN%aglI*Bq zz5K{OQaW`^5SgL@cg9$j2)*Dxye$Gj3>ONYMKh{tcFOcZMN+Ur2SX6 z-W%#t=u{2fpy?tX;MV2v`!}b^OPmaWG5ig06TJSNA$-mmU+@WiK&L&_Fr_;Ho+dPo z@F^{0|G+vV-7{&&A|S7Pb{l4YC^_sy=JRoY#)aLZ$UqnU!Se3OO>aczx=ZJ^iQ>|F zs8L5Ck&FP0JmX8`UQEhrhRsQWlP54BwlhCLZNDjQ^6@fHwMf0Ac`|9>P$I$d*v>Uj zc+dhE3H)pN`AEwB716SnAXbf*B@+hj>SkRCP5>#3GJ>z36i3D-S%lkQCG;0+)j5}M$&0!~R9~=Z z^AF9EBJeM?eGtfr+Mq1q?-_B&8q*p5a0=isz-Oa6LGN?2w5WnT=u{7+&+BKR!$UJl_fIwq;tU~*h4&)96d*x18`_t3^fPKI% zMrUhQJSFia(zg+W1)6`7@lusdm)waBl@Xaa3NP(UH2_N&i~cc_d*aB4F7Cr07TyqI zKko4~HMu?+4)5xG`LFf-jIKM2k&jBa`nf&%0CsH6ef-}9^Y?qZJQeBn!2O95DHE9F zrw|;X?+D*anIpy`KQa7qv0Wf^y{ToE4bn??)4hJ2$et@Jfmd+ji z`=6;ZBop)I+5);Y0*FiyB6DO_)E`p-wnJiGy3?^!m)b<`+Uk7fD5{8}FT3=tk3S)} z4OakJIenc=qhMxw>2grbAXtWDt)y5x26n|>mei>;W#KqqimNS=yQ8NGCYW04`!=RL zC5x}>l>XmD&XYcY0zPO@%~%5Kn&K$pdlb&jq2h8GBf_k&hr!U-iGCdag>j&G>WkiI zJ@%AIS-rtE^KL|aj*|dZm069?grFkFjdd4hU<7sdQ{?SDT*AmJGtrbFj6GiwY&WU%-JJob(06^f83?AX*Q?YGza3V z%*uTxG*`;qk>u%XXw}H6VAKFyY-8uh;riz;FOC#?@CR#!KIP?zeIT=bROB*0a%_pk zg)aZFdm+!lZg;@*eC%Gw2;i$(;esiz#}&l9u#vLx@=?gb)903q)Nbk!FYHBOB4UjM zZ&)njhIPd?oIJ{3LW_nkJ~B}euVA4J~a9tdkb|_rp|#pd0#dZjHJ@(N#FEu=Ux*`}h%low|9FE_}iW zev~^4fWeigy|V6JEp_3=tr|z8%9gv~wCqPB9Uv2L!=2 z>7hUaW9I=Iun|MWXbPtxMv}|>QIetxGVfIIo?ok_kZ;H1z9n&`#?bW1CH#G7;Z134 zdN!o%xL?8KJ$>+2478C-N4~wLv*lQ$?UP_v1lmmz{P?(r&JEA@h19Yd?Jw*z(D3zj zJy$(9Rge9%)k~iw8`f%m3!=|lNG;ko9jr!GtfRDUEOuDbAS3o08iB+ESn2Wz{5Opo zHHTN=^0x^EuMw&v>~HQXIv>EfI8+_L3B-+O6Fo^(lC@KtdrE>b}%@(4joX-Rt1 z#?p%hv)5haV-8j~qDN2{V#XJFIE*o#{8H%weN32!(3w}=-V2(=Ce;r%fq~pKHkG)# zz>?NIs4mv38>07fxh>&x5ihE!8~kfZ1>o*_im`|Cf>m9%2{ncdLCXfN5E|S(VtQKv znp(C|9in}y@VIMDmD%J%60d(nFc+L@7n5J0j~O@XAF?G~>I&(v^zq7aV^~6+q6)|T z#|&bo*5;4@b6|K|6bAJi!LbIRuL=#Zj*Hd#hbR<{mI%vztf6n-NZh;YW!qk)`8FSR zWZ}H;EBAmoeYY(>Gr%cOVgjw*sNkKLEq-uN2wD9}xZ?!d_L{wu6*pelDRS z60e>;(3p9Q%48RSbFonq1QRVscDyEiI@I`~`~Ln>b#|jVI14&WAQ7|RA4Y$^pYy~1 zd~zC~FlLx@cPgV(N0;6kkVRZ4-pW(eC6k2&6YF=#93IG!X#L2uM1SZnIf^jt`fVdk zIK8Z`=B`g(byE?PG5-s-*T z3?h18?TY%`4M=??W{XpP1Z`FEP(kQTAH65geo z7sxA%Jm!--B9r*HaL$~!tGWEhsp6_ucHS~fL8zb0n0oFO*S0UNn_MXN~8ZF+a=yp?Lj19}w~UU)?f-wFb*Ui})^`d~KB0##%j@cbCcY-Y^L zulr$@mPnyrJ8YN8;Ww=<7Ge5X*io{}cbDtw`pv+eGtZaSXqBkWGK=N8;5Ys|c>C>!XA`s*|6-E4Y-XHoVbLds)CC z$j4LH{E`6)?FEG%UyqwCMctb$WvsVMs0+HX7N{IOS~>F9uUTr< z_NBSh0Jz5XOQ#zN1B-3IP-=d^%MZpNzkGIL(_cgffnd7lYd>uN0w>4n5K|SVUS8$=mULv*gG!(jNjBhGxx@i>6;>2RGGlyN-S~1Q>~0x z`_y;qS7y~x)CLWxf<{YmRYFp2aUKnS1T>v!p_>jc*g)?;b(=@x?nq z@Nh5>O6)XSS3Bfv?m;Ql__#9z9Ak#Qq)V#h+x|!aUn9#|dsgGd-lpJ>Yc4NWuqUGa zj2wOU?9vva$gx+5eQi-M>`#ERF%hj9TBA;GbYWS9dI*ClLI{|BWn{)x%@&F2>n}Q| zF~P|*+}1WkdEmafwD@}_^~&7?@N3PdZqH2$mb~y)^03%WWuLC6T8^k+FO%1c*?{g6 z`D&)qk(Z63#}+?r7Cf!D`{9hOg(YqHj9i?@Un?tLr&jqz)2oEYhIex-^7J`(fSLwX!rbGKCAr~mv7 zm|N%BpIkGNgz-yz{vK5ML|ex1637E1^7YdMn;mA4%NJI;X00^f$Ts?M|4Qfi>2r>P zA9QkEs*9U-$+!8>alD@Fw4a&X$4_sDtydEvcJP=HnSqg4@f)r!)x409rRd6kxCfmM zLzgh5Pz8o-z&Yx1nP6gE5p|fiBnItDXcQ@OLEagJ2IYJU(@O{@nw4U=aRU`~j6(J- zHnCAo(@$5(sTphI!BOO+jWj*p;tcq3%Y74PIWS5b!SWrBI;n3?5X zy^Ss+4?g39{b`H7MaC3j17yt0cuE;9imKc)ROM2u_P@unrJ(SNE&hGRTDGXpVH{86 zHAWGMFO*lF#N+ac_8@6S)NcBld_TE4JhXi>a;ZJ|1SjB6@d zxU`)S`c8)*=xoAByjlFNVJPH?I@%fP3j$<|^}t Sc^5-Q0@Rgtlu!z=(EkV1BkfrL literal 0 HcmV?d00001 diff --git a/main/manager-web/src/assets/xiaozhi-ai_vi.png b/main/manager-web/src/assets/xiaozhi-ai_vi.png new file mode 100644 index 0000000000000000000000000000000000000000..a54913ad3edf5c7f3794e062a25f1950601df5cf GIT binary patch literal 7171 zcmV+e9Q@;nP)C^Maw}Qv4AoK#*mmaXEkyj9T!XCz1 zf$bI89zIR1tRT!-1Jg$f2(yCl?tpIv=>foqmL>ixRFRbNos;8xB@zWvtRf190^a@p z{Tqs+D2k#eit-lt@3sTpy?f_EkJ%E&i4P#)e|56$W|cWsM^ThL!Z-;){nM{~Tms0o zk1C3yOcz@Y+IMY&)%ZP37$*w=*LZF8?z0APyWOk`yBaBqQYVa)C8&@34TqHg9st~L zH>=XRQi`G|N@vvgoe*K1JOWtuIHd$|x!tT%8~P}UvLlQW55N=JC#H1@02kZMDwo$t zQ4~cPf-U3szpO|-VZu1MLqEe806YMXy!@&tie0{;pWzFTcle17XGKvI<%DUC`Bip= zaS{Qz8hE+~dMUpbQb$pY3FG7j6dGVcw%x4G1=KNyLmi@lr+WY%0bB^FqbQ2fN*E^+ z?#(k@x$2I3e*5vs@O+gyb~BdjKK;f8lj1wk||R$(g`_+;>NJ7cOzwGb`1OlpIA-?BLlF%m(i#&?=H|s~ym|9f9{KgmIDr z_zoadLs(Iiw@z!!FQ@0a7jD-FZ7dlKXI%&+l&P`aZdPkynfsKMi?>N{3p0UC3Eg>mwzam|XNyk$CVc-wAPUqH)fk}SeFxf*gJ7nm4yZUZ@h zFUP(VtOS({$x##u%CQxgAcd5mFis*idcR?K779r`P)xHL!HS~1S^8vqxPahGfw}9O z`<4f5Bd_rRN>GCPvgNp3pe+d*ilXcxI6-|j6%fXW$E#CMn1<1Nh$Vm@L!A(cq9}8v zv+>J@;RWP9Mc;ZRmj4F%n7oUQ8}PmhwDna$hN38kSsR{T0!U2+@C5w{@~cxe@IX!o zE%2cz^QIhIfjQAD&o3uXJ{lS@5FZ^qxt8L5zx41J-NVfLooW(lMX|~)%w7TyCL6+d zCF=*~x3M(>g+c@)>!B#h0MxeVNADsRydmsfSdZ5DUrTYm>+NQB-}PWa?{^N$B%vsZ zC3ppA=mdSe-K=hgw#|m)6Xu(2-UF3*4o22PQ53~0gDSjP+5mSn{+E~Iq~m;Z40uWM z?CKo-Vt3a7&Nao5qS%Kg=zaGn+-^6k!IM)Sqilp^xyFMUz>1=rI40$dZES!$7XK^# zQYq1GgWEy2n^nHutj<99V2#%kfs#MF2k?HoSzW5(t0?Z+ZdU1bvwDwR-tK(wkOI_4 zzkvGajbW4Da5@ESJdgqGjjV^FD2f#Zmp9&SR%I9`7m!`%^Fbcoi|T_gPHrH-Te|B8 zw?;Sza%w6^Q4~9{7kF*64G!6gY_>519%yVo8(9xUQ511ZJioR!!2OB;Im4M9q{pbK5U}$>Q55B*c=!AF@8$#Ey=xW1Ss1QlRQkg?s9@kgcDxu7sIRfw(NKe1V=_TkMJ%e5_si10us9nzo(6iy(-zNa)A;3k^a)g6*K3fGg7RpmbLpe}BKT$4)*R?Gx#@%9SOrkO zEtN8|B!&Qwn3{2EhF^!tPm% zdWtCV@>(+K--4?^MkX?(jbDL2nnB>6&B6HfKraH%R4EIT0M1N1pTq+S;TK755M4li zQ(y|&rTN(f#Phv(dx%CSw`plHMR<>&Ga!(KGEeNqtawJXs_JFO5|_WI@^ zqbCP>pt4%K9(I$#51{J}Ea_2kW@=zM>e~+zhA@YF3=LeLsX+q(+mq`+>nQb;wx*K`N_ zp-2H}jJpft&% z;)QYY4YU(Ga@1@DN_`~m@roO2fAlJcpM!CQR5eI zlUk?;sf}f#vET>g7wj%gHF3o+6i-4}trYA+Lf$|kP$yiDsKZGhX!uqHn63f*QBYP1 z+K-S*2;(vM4E@L90%SN>xykdPI&j|}bF2c!>)T%4m}AekWWVrmJSTo5wF${k53~)I zAmj0FXkX_LB#FtzP&@#SVVn&5UB;mH{*6Xb!3RCZk7L|3apM;NI9@KdA-e}TrsstQ z2HqF;x=x{jR0Q(m7*Q()>zm3Fzyl3j(p3cC4E(aU5&s-unyJJhL0LwI<{`G5RmtOU zmoYE{8Q<@>o0aj#s)?0(_CS5xb!>hByr}#qjgOuNHS6+(aVI2AT!D<>MW2%Y+qmM2zf|J9F@yncJyd0t>=!G&?`%V0(&%w2s`js7Z1`(LNOeXenth4NC z02j6tnISfpPp^6`i#}V@;SU{Ca&={GGc1d}z_&-fPC-WRxm0}Yg!}fDEne{J@Q zMTc}i0Q9m)mw<5rV4dB{low&_+`UWG(Aoa#62lYX+h>uCO%WY z>N)TN_;XETE>E?w3_B+F*IdGX@L~T2+vf4*$P-w%2`h%-)kKC89qk7GEteK`F~I|^ICvxL@q(I7;f<@w zK>`S@DtE=E=Xb0Pa5gu%<(A|g!}Ugyz6|lCTw~hT1kkSPXlx~Bgzun@g@ZP_5(&vk z9ZsOWU}yA7Wxv_y8q)}4YU0k3M=pRH!)(!URb+~xL;IAVm599UK#O5kk_3>A^_F1D zjwirjRY|MHFW`Iw+{tWk)SY&}}=`^0BC@`Ok+wL$BxVl~0OE&fc7m65AqNCx<%w>G;8=5khwn-i9v2dY_A=fS@ zsMd{Nz}W`49Mjz3)|-hh=j_vGfS-f+p~BY;5R)SqTLI=TAvw8_oGCD;z8yH~+rEP~ zfcEDxa-oC)C3+aX;Y=!P)^KI0doAD3sgGLs>N_!h8Jip6N=$cy+YmCC<(YKJqRT<- z^{8*qtN#PN-!DKh>?yjx z>r3?W_yQf{oF_!D6;c}rPZ%=Lb8xoz4Ez)H46Fs(h-`a*#VWkDVYvdFtqpEADBBL; zYdN=2b)~?|3@ZHE4$FiK({^eUpaJ%8?7U|l$TL`#d-Vj`@xwS-oKle#MX`XS zF;c)?-wtN<-e@8){Jslcno5q>#{abXjNe~X$z<=qX(f6ZzTvJEvlJE6g{)euBl-F=xgv6Ihb#Tas&oyz7B% zbfp{sKBNR?39hFNAUot4=NX~tLmHvoQ*FKTfd?1ivYj7cHfWVN{F~=^o%T+;%xI2r zLV-%`uGxp%{#iO~OCcfw89B(n%8q=W0x$Zugy+`k%k>Bu|MejEKJ|M2_8GZ0;ZO0OT3-=I$(#>p*)+kHNVR22iyZ^Vi5>nOMDQy3@jPblv91-wi9 zqJtq(fF9Jmi(DRnmxk&c)1}^qDMLHoCCn-;`C**+$9Bn^?h5CZfn=%C9OH!nt$B0n z3$~k88pcThJ0VZAo!6=lh0e z)F30{zd6e(hw3u$yo%edhVLF`lUy(ukK7>0UPYCZI zy9w}qyIHyNWbIR+!HdSm${O?7^jJD-C&2tY)q&ypWB2U2=^(e9km{DoD#tOkP~^&(ifN&VHj(}u5Wn0hw(PF2Cy_?u%7LK z<5d^7+hL5#?#=;T23UZMjq<=DSD-=an*Td$_~!|!)h5R`l)vwB=*xrQ`GMj2FQ}D( zT?&aTLCeNt8NLAU(BTDU`a3wGgEzJiYU-nqoL0g(3Bow}W+CUOXL!D8-!?tpGd#bI zE`?0iHWlFKIk(xyKmw2h;FqJydk6QubcI*>3MpkAB9-mqq_ArI@<8i4rOcJZ9FC2jmRu6qI@>6Ow15MskJRp@v1a`8pl%Y6WrCFwmSeoU*>heQyn*ZXPXUJ|WRJoVm?y|> z`Yb`4Mta9fTA=80k4uYsxS|l0W!*WzW8t#)QG|&Z!9$t)gsMlH#KNK)l>_0{W*Y)5 z&`=OU;GJF=o(}-e2IqPL?JGi9F@AaW=3*D?F+2daXOUJ3ir{spsMa4#g!l%mJ6xg{N3QV7ag+Dw37+iZM@2q$6$Pn2S81?q72mrG200=j5exh)K3 zdxqx&8bWX~*xK+Wkr&StA2jH{+GRTwSH?7Z&40kY(OZI+t36D(=C|pNY!`LHkF@Jy z7ke$mn48)p@Wxia6yBvTDe^Illfsf0v&&cM4Y8<1mD0>jw z&1%h?1g|mO&8OEm5C_eh&P?QbCMPq#!MV9W`(u!e*mhw{u(@cGDOF9dw)z(JI4uAf;@6`VVqmC3Zg~Fe;1H!t&NA@{G)6@j z_Rh)n{y!Kfr;e@admjPZ3_Y3qRAkV8X>hIy&oI+_XijD!{%u97sPS7%);}e#!#K(3 zQlge84}qbW5;Q3NA2GH9kS_vJW#0C8kCE)7$QWdx}gv97+-TSi6(vWfo)WnR#0@>w@-{}AIU9t>DYn} z=N*ml7fj(8;PA6mV7NmeXv*2%fyKb{D$0iMd_&nb`3%EKp;cMxDXl7r8NW48!xVXB z1GpGlkq(#)<|SyBw(fFpt-_FP3d8fGp&lYOM17OSyLww|lfS^Ey&5IR=uL&>y~1I( zfjJSNeW@>nor;x9P#kay^4PRiuH@$~0Ob)~KsJS8rO(+BJqp7%mSLQH9kQY!)VFh! z^XCeu1P%CquR-sf2Xv?k+60Fte1*C3>mT{NX!x!Rk9b*?vlBc{n)dgmY!kkr>?Sgp zIb)V#oIDuzl}FG@{55E@m5%Mqk@WmZF7Zm^fxIf6Yg3PbK&jA9hOebq)T3-St0Ih( z5@s*u-f@wfR?O4`+uEL``~VbgztEj>tQUXq=@m$v&cw7R*iGW#=ZuinVXUO-ZFvV z`CikP>>>IDnw2*mYk?GI-T)84k1or?Jh*>`%rluaeaT@e*N`3XQ;-*?s%;J&H*}h+ z21We$VA_7C8tx4!`W zw$%zm(Lw$d?56Ka1>`UJU3lBHs>WmQ5@sPp73Em_HP{=U!@M+ewJ#y>ass~_?{N6u zd7uf{6>q@&>N+IC*!Vp%i!|j6@2I|jSvkRdmSLRy963dlZ&CJL7$=|bdQkrh?z}|D z_zs!a;w5$ibM^0U?2N!jpp80h6{Kv$xGx}UR7n+Om;=76hIow1FrHEBf}+Dq={sbe z*DKJ=uz;JsoeImF1AQDyK6tzZ0QT_b{BJ+bU-}(W9Qu$~8VZ>A^8mEs0PGwb&!ALh ze(j$Wsi3SOe1*AlEdWhYQ&69OF(Kl~LI8RemgxR0L?Dm+9q2v{3WMs4k@0J3gIih| zr~GochpcgZ7sg3z9Gyjc%kl$Ea7X=ivq}Y)N9*{oe=o;;PLz4@1(cTXROzx}km9(9 zX9zyr^zFiO8Po4?jG~X(lNRdh5P7wOdWdS!*WQONrg8X z<#vyiYQ2E$!|q|4nJLPV+ARcREIJ%>FVvO`vaw>cW=PbrhIQiC8aft`5sSE^7l$iF$NM(AQ<8%z*96i%Pao?UppDEF83<~V3 z;gm>URZ-DD<{IYQ)Z8TPP+ z<#NvbcC&K6yw7R3a+huPz-mzmWfu5DPGS$wdrbG)ZX?4$!?%WwJsMKH#lBr&J`=JM zJys96zfEd5f$bj3!?{d(D`)CfjZ@G)cIvc-XoY>gl zcH7tM$TvnhZZv?q4FUh^aE$Hc(;Tm3`lLM0WtW2{W+arLN!WU1J*?33Lh&4_;i|2` zK^u*1H>;9YH!d*!xWqMp_ma!A6YSgPr`oqGnw&TMJl=qe;I$)_`cT5a=;7a1dQ<#R z?zfxO*_12E0x$Y#I<6*vNa4`%1v(nP95=KSZhLS%3_t9tq%xqo(>KC058<~Rz{bis z>~!We4$Mk<5|RqYZ>nGZf;}UlY&qN(YtYL-9jSfgMA>du>2|Yv51PytBdb%V#O}Gb z5(7K2zMTy?*AvymK0(_7+s&%r-{hYlPjF#br!K5Pzfo=?gvakQj8{Uq0KHe|u%R2k zpFVnlarY_|p!?Q20zisZayEq>!BvcN7$>)&`r-@pk4H=aeDAXC$L*c52VA0_d1tuo z8vr+4GW#RwdL?zpKxG-%YVgjM0ONUgjNm@FW&?f!Dgzq9UK*+#bg!O4D{ZBvb{cWi zw^LBxPG{J+y|EhR$w>-&&qSc-v3f2gDY>0zSpJQweKWiP>@(LHVte>WcRKmYrGk#L)(Kzj}OjQ{2U{s!HL?AXC3Ol75@ ziKc6J`AoCX7Z|qH_)H6p2ZhVYZ|%MWAl<9yy!yO}0=w0cXHdVi_Pk3n<-RS{FWM@V zRc(2&+%JZ}W1Ekczfbl5NA&RNIe zV}sCp#Ktoa;B_C3T29OOZvpC~nTc*~HtJAXjbBAk6h%=KML88r${Q<+q9}@@D9Wr* z<5y7>MNt$*c{8Z-t0;=1D2k%I8Pxby6h%=KMN!@i{||bl#Pk^xJAwcJ002ovPDHLk FV1jL625A5Q literal 0 HcmV?d00001 diff --git a/main/manager-web/src/assets/xiaozhi-ai_zh_CN.png b/main/manager-web/src/assets/xiaozhi-ai_zh_CN.png new file mode 100644 index 0000000000000000000000000000000000000000..ef3834afc08b6e3c1ffa652275e4a2d3d455facc GIT binary patch literal 1946 zcmV;L2W9w)P)#zkSF%Cpze@m{hjesxUwZlV>lX+i`SRb?Z-5XoEffJlGKV5S zNaj!k2+5rPZJzY<@-q7WLX}GZE6aOzOvD72z>LCd6yty+50}Y6D*Hgn6%00?-bv+6#+r3qASo zv49q(H=+0BjkgF4l#WbGi2##6fDkJN08Y@>M0AWP9wJzm?9DZIIGgxBtP53cr`}eD zI0hxZR6=61gWQ63YU-*oR*b|ocg8m}8D1h_tbi$2A3~fk6HIcF4SicT{Q?dgjMH)w ze76I%0z4Rr28666t3s8b*LZ5o_buUv>NyTQ`b}N6g(@rfJ6C&N0elpyR4M%=J$9OxeJ)ga zfF5Yakm9aFl{L~tDqOae&%{Te%6(H;QR;6(YX``KV!Y@5^0qdDu*V=4?ob<$%2|9w zo+1DMTl1T}Nrw8(gf+nQ)sV=E%1s0&#Uy_GX-Z=9 zFKnYNm)tc62ko@xAbgimAK%VICr|`7lCAojMBs7|k1=pMg@%mw(A{T7%}U1vq%0UT zdRZX6vdxu6p~@5|+2p<@c$e!z?ANnmqZl>^%VfW@1 z+9HtpK3eEO7qgr5qgw)(#b{TbcbWHZMEeW&0=tP>P3U3?bkHIo6=UwbysjauUKQt* z$#xgxsh27>{Rd+a0O3M#f=*3sP3B-Myb59h!i2WTOwkX?F`q#}i|oD6v5#3i1UQI* zamu|3IsaM!3MTsrZWCIWvdu?}4#Zr+T0mrL@Cm)@bc4TR!J`)xEph#~s&HOBMvtAF>=-h$5`Tv_JzWM{KLGx$iso82 zrcspeNMc%~8MGVN)z!Dq=H{sRYb=6qg4u>Qb3Z&-VjhW5u^2ktC61jN#mf>HQmFC= zo;!m}%=++Hp>6pmZTL1ky5>Y&SywKoKA$ zbEb9-eoJw}%8WuulG}ma(|>}tFnpVnkj$A{1g1LFVJbvLU~t0IGv{fL$V@E)Ss~nB zjL0C`s%wn7H-Di|fRN0g2uwtzMj(VZ!|gyKjOl-?Y}6Z@2C!G&(+d8LLms-KbHKqI z!Imu)fkc@4ao3ZbZz51i8{$x01v+)vICdsXEdsfP2qi_J!?dNep>+tKU%4lgy6A>r^>5=k*uo#JmKoLlc@rX-IV2S_{ zfg&&sp%eJT@EVbVA}|f1A`ta33X&HTfoTX40o&uR2(f}9@K_w7&u!pVfQTWHClInK zB}p9(Os|cR+++KhhbPc{rpT_$knBr|tqva{ggh_Y4iJ(#6ahjqhax~o=1>F($sCFR gA(=xFAS82s1LfGVwb@@<$p8QV07*qoM6N<$g5r986951J literal 0 HcmV?d00001 diff --git a/main/manager-web/src/assets/xiaozhi-ai_zh_TW.png b/main/manager-web/src/assets/xiaozhi-ai_zh_TW.png new file mode 100644 index 0000000000000000000000000000000000000000..ac5f59aa4905100f8da843a3cc178fa25956f55d GIT binary patch literal 1828 zcmV+<2iy3GP)^&2XIQ-V@LC;>`9C6E%7 zH@}h+a0$mHa33UtNt{Hsej>~3o5{>MkT@o>m$vrr-PhL_2qDYmpIe^*A>_7D1PEC; z6ahjO4n=^Fh4b(65AN>n2A^07xd!m)c-*$uN_m`D(Nsd-1CSG6eyFwDd%ph@fYsF3 z*IK>$ygvy?2*>`32wdO^z}E4(&lApNN!$Z?nfTgg0A3;hBj!^@r~y#C2vkBo0??j^ z5x#<*f-xd6=AwD2gq(Vj_u151sX$-IXlkv7&7usbgme|;A=VzRz4v_YU9DB)@p=G2 zCFCbOteHgUN=(&SCmH|`z6WsnzGKSid6H*jImcS#- z%`?Ul=x33Q*+Q%xvd>yf+gCMxfeDnsNJw#LT0oZEL&SCXr1>qG&5cc*tu2$g9 z9mlm+mwZaZ0gAwauwmc#fnRL_Y%3vK7(v)#X30pmDl$ts)Q_Mf=%pBKv^=`5(unpop-v+ z@>8`|ZJ=WphrQ|jlg0_bgqGu#klRYgzldYnbo=_&un6%;-u#o5k+kiD>pMs79ezq1dpoNzi=c))-FgN`Qx9=&?Z4qex8v31_ z8yJfq)MwKG_=C7fH^D4{fms-(x4K{3zZo!*p&*OnIZL30`EWzUl@Bn2GB!-r!d#TI z%3kYZzqF57$F~*%;4cIR0Do$&8k;#7i;!u)Yh>ziPrwF&EaX9-K&@3|^Z3B??GBiMa=EfNvjx&v&(h`K+7X|6>qr^&;W8=o7G+V0)M_;FLXL3v+R`01lOq zV#KkYy9>s~ZnGT(a*0~2)~>8ktr`1WtufAeYhXU-{8oZ(mtFB1yjt3f^ z8%PSwX7rN}SF`b^Pzm{tIC=XgX~S1?2!53e&z*(mOKfVbRO*4GWIC}dfpc>KkGCIt z{0uSedze$=Jxw_v*$fKJp#+*DkcL>^cGo zQnqE9_hjBBLlH;=v*bv&Pz2Jze0h>B6ahjOPD;n%LqBIA7MV702)P~TJpBh43u9_? zl45Y|ebH5@P6^d2nDFt{@6o=`JKU#m^$G6ub0MV&e9qV->q}4(=uLYnIcLL}1i~)? z$OPB#o2}U0q*yRJxE)A9tyNQNb=N&WZ+1|uD3+38`UtaNCad112#_MEwR%Qb4%nAU z$WLn-M1>-dm`II4h#~oYIx;H&@0E}`W}i)F3`WN8+dmMYiqOR&cIKRXQdOhWSA-zs zwjfYCuc)=!PiV;dir;>u#-7tUMiCYZh<+2RfanF`Iy61QLOp_vIMI zoY$Di-U Date: Fri, 19 Dec 2025 14:33:35 +0800 Subject: [PATCH 84/93] =?UTF-8?q?add:=E7=99=BB=E5=BD=95=E3=80=81=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E3=80=81=E9=A6=96=E9=A1=B5=E3=80=81=E5=BF=98=E8=AE=B0?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E9=A1=B5=E7=9A=84=E5=A4=9A=E8=AF=AD=E8=A8=80?= =?UTF-8?q?logo=E6=98=BE=E7=A4=BA=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/manager-web/src/components/HeaderBar.vue | 20 +++++++++++++- main/manager-web/src/views/login.vue | 20 +++++++++++++- main/manager-web/src/views/register.vue | 27 +++++++++++++++++-- .../src/views/retrievePassword.vue | 27 +++++++++++++++++-- 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/main/manager-web/src/components/HeaderBar.vue b/main/manager-web/src/components/HeaderBar.vue index b274d649..baaa9fa6 100644 --- a/main/manager-web/src/components/HeaderBar.vue +++ b/main/manager-web/src/components/HeaderBar.vue @@ -4,7 +4,7 @@

@@ -257,6 +257,24 @@ export default { return this.$t("language.zhCN"); } }, + // 根据当前语言获取对应的xiaozhi-ai图标 + xiaozhiAiIcon() { + const currentLang = this.currentLanguage; + switch (currentLang) { + case "zh_CN": + return require("@/assets/xiaozhi-ai_zh_CN.png"); + case "zh_TW": + return require("@/assets/xiaozhi-ai_zh_TW.png"); + case "en": + return require("@/assets/xiaozhi-ai_en.png"); + case "de": + return require("@/assets/xiaozhi-ai_de.png"); + case "vi": + return require("@/assets/xiaozhi-ai_vi.png"); + default: + return require("@/assets/xiaozhi-ai_zh_CN.png"); + } + }, // 用户菜单选项 userMenuOptions() { return [ diff --git a/main/manager-web/src/views/login.vue b/main/manager-web/src/views/login.vue index cdcf3754..0f27e9c4 100644 --- a/main/manager-web/src/views/login.vue +++ b/main/manager-web/src/views/login.vue @@ -10,7 +10,7 @@ gap: 10px; "> - +