Merge branch 'xinnan-tech:main' into main

This commit is contained in:
LFNL-scholar
2025-03-24 09:24:30 +08:00
committed by GitHub
49 changed files with 1949 additions and 209 deletions
+26 -19
View File
@@ -9,7 +9,7 @@ import traceback
import threading
import websockets
from typing import Dict, Any
import plugins_func.loadplugins
from plugins_func.loadplugins import auto_import_modules
from config.logger import setup_logging
from core.utils.dialogue import Message, Dialogue
from core.handle.textHandle import handleTextMessage
@@ -25,6 +25,8 @@ from core.utils.auth_code_gen import AuthCodeGenerator
TAG = __name__
auto_import_modules('plugins_func.functions')
class TTSException(RuntimeError):
pass
@@ -109,11 +111,15 @@ class ConnectionHandler:
# 进行认证
await self.auth.authenticate(self.headers)
device_id = self.headers.get("device-id", None)
self.memory.init_memory(device_id, self.llm)
self.intent.set_llm(self.llm)
# 认证通过,继续处理
self.websocket = ws
self.session_id = str(uuid.uuid4())
self.welcome_msg = self.config["xiaozhi"]
self.welcome_msg["session_id"] = self.session_id
await self.websocket.send(json.dumps(self.welcome_msg))
# Load private configuration if device_id is provided
bUsePrivateConfig = self.config.get("use_private_config", False)
self.logger.bind(tag=TAG).info(f"bUsePrivateConfig: {bUsePrivateConfig}, device_id: {device_id}")
@@ -141,17 +147,8 @@ class ConnectionHandler:
self.private_config = None
raise
# 认证通过,继续处理
self.websocket = ws
self.session_id = str(uuid.uuid4())
self.welcome_msg = self.config["xiaozhi"]
self.welcome_msg["session_id"] = self.session_id
await self.websocket.send(json.dumps(self.welcome_msg))
# 异步初始化
await self.loop.run_in_executor(None, self._initialize_components)
self.executor.submit(self._initialize_components)
# tts 消化线程
tts_priority = threading.Thread(target=self._tts_priority_thread, daemon=True)
tts_priority.start()
@@ -187,16 +184,26 @@ class ConnectionHandler:
await handleAudioMessage(self, message)
def _initialize_components(self):
"""加载插件"""
self.func_handler = FunctionHandler(self)
"""加载提示词"""
self.prompt = self.config["prompt"]
if self.private_config:
self.prompt = self.private_config.private_config.get("prompt", self.prompt)
self.client_ip_info = get_ip_info(self.client_ip)
self.logger.bind(tag=TAG).info(f"Client ip info: {self.client_ip_info}")
self.prompt = self.prompt + f"\n我在:{self.client_ip_info}"
self.dialogue.put(Message(role="system", content=self.prompt))
self.func_handler = FunctionHandler(self.config)
"""加载记忆"""
device_id = self.headers.get("device-id", None)
self.memory.init_memory(device_id, self.llm)
self.intent.set_llm(self.llm)
"""加载位置信息"""
self.client_ip_info = get_ip_info(self.client_ip)
if self.client_ip_info is not None and "city" in self.client_ip_info:
self.logger.bind(tag=TAG).info(f"Client ip info: {self.client_ip_info}")
self.prompt = self.prompt + f"\nuser location:{self.client_ip_info}"
self.dialogue.update_system_message(self.prompt)
def change_system_prompt(self, prompt):
self.prompt = prompt
@@ -1,16 +1,16 @@
import asyncio
from enum import Enum
from config.logger import setup_logging
import json
from plugins_func.register import FunctionRegistry, ActionResponse, Action, ToolType
from plugins_func.functions.hass_init import append_devices_to_prompt
TAG = __name__
logger = setup_logging()
class FunctionHandler:
def __init__(self, config):
self.config = config
def __init__(self, conn):
self.conn = conn
self.config = conn.config
self.function_registry = FunctionRegistry()
self.register_nessary_functions()
self.register_config_functions()
@@ -26,9 +26,10 @@ class FunctionHandler:
func_names = ",".join(surport_plugins)
for function_desc in self.functions_desc:
if function_desc["function"]["name"] == "plugin_loader":
function_desc["function"]["description"] = function_desc["function"]["description"].replace("[plugins]", func_names)
function_desc["function"]["description"] = function_desc["function"]["description"].replace("[plugins]",
func_names)
break
def upload_functions_desc(self):
self.functions_desc = self.function_registry.get_all_function_desc()
@@ -47,7 +48,6 @@ class FunctionHandler:
def register_nessary_functions(self):
"""注册必要的函数"""
self.function_registry.register_function("handle_exit_intent")
self.function_registry.register_function("play_music")
self.function_registry.register_function("plugin_loader")
self.function_registry.register_function("get_time")
self.function_registry.register_function("raise_and_lower_the_volume")
@@ -57,6 +57,9 @@ class FunctionHandler:
for func in self.config["Intent"]["function_call"].get("functions", []):
self.function_registry.register_function(func)
"""home assistant需要初始化提示词"""
append_devices_to_prompt(self.conn)
def get_function(self, name):
return self.function_registry.get_function(name)
@@ -81,4 +84,4 @@ class FunctionHandler:
except Exception as e:
logger.bind(tag=TAG).error(f"处理function call错误: {e}")
return None
return None
@@ -1,8 +1,72 @@
import json
from config.logger import setup_logging
from core.handle.sendAudioHandle import send_stt_message
from core.utils.util import remove_punctuation_and_length
import shutil
import asyncio
import os
import random
import time
logger = setup_logging()
WAKEUP_CONFIG = {"dir": "config/assets/", "file_name": "wakeup_words", "create_time": time.time(), "refresh_time": 10,
"words": ["很高兴见到你", "你好啊", "我们又见面了", "最近可好?", "很高兴再次和你谈话", "在干嘛"]}
async def handleHelloMessage(conn):
await conn.websocket.send(json.dumps(conn.welcome_msg))
async def checkWakeupWords(conn, text):
enable_wakeup_words_response_cache = conn.config["enable_wakeup_words_response_cache"]
"""是否开启唤醒词加速"""
if not enable_wakeup_words_response_cache:
return False
"""检查是否是唤醒词"""
_, text = remove_punctuation_and_length(text)
if text in conn.config.get("wakeup_words"):
await send_stt_message(conn, text)
conn.tts_first_text_index = 0
conn.tts_last_text_index = 0
conn.llm_finish_task = True
file = getWakeupWordFile(WAKEUP_CONFIG["file_name"])
if file is None:
asyncio.create_task(wakeupWordsResponse(conn))
return False
opus_packets, duration = conn.tts.audio_to_opus_data(file)
conn.audio_play_queue.put((opus_packets, text, 0))
if time.time() - WAKEUP_CONFIG["create_time"] > WAKEUP_CONFIG["refresh_time"]:
asyncio.create_task(wakeupWordsResponse(conn))
return True
return False
def getWakeupWordFile(file_name):
for file in os.listdir(WAKEUP_CONFIG["dir"]):
if "my_"+file_name in file:
return f"config/assets/{file}"
"""查找config/assets/目录下名称为wakeup_words的文件"""
for file in os.listdir(WAKEUP_CONFIG["dir"]):
if file_name in file:
return f"config/assets/{file}"
return None
async def wakeupWordsResponse(conn):
"""唤醒词响应"""
wakeup_word = random.choice(WAKEUP_CONFIG["words"])
result = conn.llm.response_no_stream(conn.config["prompt"], wakeup_word)
tts_file = await asyncio.to_thread(conn.tts.to_tts, result)
if tts_file is not None and os.path.exists(tts_file):
file_type = os.path.splitext(tts_file)[1]
if file_type:
file_type = file_type.lstrip('.')
old_file = getWakeupWordFile("my_" +WAKEUP_CONFIG["file_name"])
if old_file is not None:
os.remove(old_file)
"""将文件挪到"wakeup_words.mp3"""
shutil.move(tts_file, WAKEUP_CONFIG["dir"] + "my_" +WAKEUP_CONFIG["file_name"] + "." + file_type)
WAKEUP_CONFIG["create_time"] = time.time()
@@ -2,6 +2,7 @@ from config.logger import setup_logging
import json
import uuid
from core.handle.sendAudioHandle import send_stt_message
from core.handle.helloHandle import checkWakeupWords
from core.utils.util import remove_punctuation_and_length
TAG = __name__
@@ -12,6 +13,10 @@ async def handle_user_intent(conn, text):
# 检查是否有明确的退出命令
if await check_direct_exit(conn, text):
return True
# 检查是否是唤醒词
if await checkWakeupWords(conn, text):
return True
if conn.use_function_call_mode:
# 使用支持function calling的聊天方法,不再进行意图分析
return False
@@ -35,6 +40,7 @@ async def check_direct_exit(conn, text):
return False
async def analyze_intent_with_llm(conn, text):
"""使用LLM分析用户意图"""
if not hasattr(conn, 'intent') or not conn.intent:
@@ -7,12 +7,26 @@ from core.utils.util import remove_punctuation_and_length, get_string_no_punctua
TAG = __name__
logger = setup_logging()
async def sendAudioMessage(conn, audios, text, text_index=0):
# 发送句子开始消息
if text_index == conn.tts_first_text_index:
logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
await send_tts_message(conn, "sentence_start", text)
# 播放音频
await sendAudio(conn, audios)
await send_tts_message(conn, "sentence_end", text)
# 发送结束消息(如果是最后一个文本)
if conn.llm_finish_task and text_index == conn.tts_last_text_index:
await send_tts_message(conn, 'stop', None)
if conn.close_after_chat:
await conn.close()
# 播放音频
async def sendAudio(conn, audios):
# 流控参数优化
frame_duration = 62 # 帧时长(毫秒),增加余量
start_time = time.perf_counter()
@@ -46,13 +60,6 @@ async def sendAudioMessage(conn, audios, text, text_index=0):
# 动态调整下次延迟,补偿发送耗时
play_position += frame_duration # 更新播放位置
await send_tts_message(conn, "sentence_end", text)
# 发送结束消息(如果是最后一个文本)
if conn.llm_finish_task and text_index == conn.tts_last_text_index:
await send_tts_message(conn, 'stop', None)
if conn.close_after_chat:
await conn.close()
async def send_tts_message(conn, state, text=None):
"""发送 TTS 状态消息"""
@@ -64,10 +71,20 @@ async def send_tts_message(conn, state, text=None):
if text is not None:
message["text"] = text
await conn.websocket.send(json.dumps(message))
# TTS播放结束
if state == "stop":
# 播放提示音
tts_notify = conn.config.get("enable_stop_tts_notify", False)
if tts_notify:
stop_tts_notify_voice = conn.config.get("stop_tts_notify_voice", "config/assets/tts_notify.mp3")
audios, duration = conn.tts.audio_to_opus_data(stop_tts_notify_voice)
await sendAudio(conn, audios)
# 清除服务端讲话状态
conn.clearSpeakStatus()
# 发送消息到客户端
await conn.websocket.send(json.dumps(message))
async def send_stt_message(conn, text):
"""发送 STT 状态消息"""
+17 -1
View File
@@ -2,7 +2,9 @@ from config.logger import setup_logging
import json
from core.handle.abortHandle import handleAbortMessage
from core.handle.helloHandle import handleHelloMessage
from core.utils.util import remove_punctuation_and_length
from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
from core.handle.sendAudioHandle import send_stt_message, send_tts_message
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
TAG = __name__
@@ -38,7 +40,21 @@ async def handleTextMessage(conn, message):
conn.client_have_voice = False
conn.asr_audio.clear()
if "text" in msg_json:
await startToChat(conn, msg_json["text"])
text = msg_json["text"]
_, text = remove_punctuation_and_length(text)
# 识别是否是唤醒词
is_wakeup_words = text in conn.config.get("wakeup_words")
# 是否开启唤醒词回复
enable_greeting = conn.config.get("enable_greeting", True)
if is_wakeup_words and not enable_greeting:
# 如果是唤醒词,且关闭了唤醒词回复,就不用回答
await send_stt_message(conn, text)
await send_tts_message(conn, "stop", None)
else:
# 否则需要LLM对文字内容进行答复
await startToChat(conn, text)
elif msg_json["type"] == "iot":
if "descriptors" in msg_json:
await handleIotDescriptors(conn, msg_json["descriptors"])
@@ -0,0 +1,52 @@
from config.logger import setup_logging
from http import HTTPStatus
from dashscope import Application
from core.providers.llm.base import LLMProviderBase
TAG = __name__
logger = setup_logging()
class LLMProvider(LLMProviderBase):
def __init__(self, config):
self.api_key = config["api_key"]
self.app_id = config["app_id"]
self.base_url = config.get("base_url")
self.is_No_prompt = config.get("is_no_prompt")
self.memory_id = config.get("ali_memory_id")
def response(self, session_id, dialogue):
try:
# 处理dialogue
if self.is_No_prompt:
dialogue.pop(0)
logger.bind(tag=TAG).debug(f"【阿里百练API服务】处理后的dialogue: {dialogue}")
# 构造调用参数
call_params = {
"api_key": self.api_key,
"app_id": self.app_id,
"session_id": session_id,
"messages": dialogue
}
if self.memory_id != False:
# 百练memory需要prompt参数
prompt = dialogue[-1].get("content")
call_params["memory_id"] = self.memory_id
call_params["prompt"] = prompt
logger.bind(tag=TAG).debug(f"【阿里百练API服务】处理后的prompt: {prompt}")
responses = Application.call(**call_params)
if responses.status_code != HTTPStatus.OK:
logger.bind(tag=TAG).error(
f"code={responses.status_code}, "
f"message={responses.message}, "
f"请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
)
yield "【阿里百练API服务响应异常】"
else:
logger.bind(tag=TAG).debug(f"【阿里百练API服务】构造参数: {call_params}")
yield responses.output.text
except Exception as e:
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
yield "【LLM服务响应异常】"
@@ -1,62 +0,0 @@
import requests
from requests.exceptions import RequestException
from config.logger import setup_logging
from core.providers.llm.base import LLMProviderBase
TAG = __name__
logger = setup_logging()
class LLMProvider(LLMProviderBase):
def __init__(self, config):
self.agent_id = config.get("agent_id") # 对应 agent_id
self.api_key = config.get("api_key")
self.base_url = config.get("base_url", config.get("url")) # 默认使用 base_url
self.api_url = f"{self.base_url}/api/conversation/process" # 拼接完整的 API URL
def response(self, session_id, dialogue):
print(dialogue)
try:
# home assistant语音助手自带意图,无需使用xiaozhi ai自带的,只需要把用户说的话传递给home assistant即可
# 提取最后一个 role 为 'user' 的 content
input_text = None
if isinstance(dialogue, list): # 确保 dialogue 是一个列表
# 逆序遍历,找到最后一个 role 为 'user' 的消息
for message in reversed(dialogue):
if message.get("role") == "user": # 找到 role 为 'user' 的消息
input_text = message.get("content", "")
break # 找到后立即退出循环
# 构造请求数据
payload = {
"text": input_text,
"agent_id": self.agent_id,
"conversation_id": session_id # 使用 session_id 作为 conversation_id
}
# 设置请求头
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
# 发起 POST 请求
response = requests.post(self.api_url, json=payload, headers=headers)
# 检查请求是否成功
response.raise_for_status()
# 解析返回数据
data = response.json()
speech = data.get("response", {}).get("speech", {}).get("plain", {}).get("speech", "")
# 返回生成的内容
if speech:
yield speech
else:
logger.bind(tag=TAG).warning("API 返回数据中没有 speech 内容")
except RequestException as e:
logger.bind(tag=TAG).error(f"HTTP 请求错误: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"生成响应时出错: {e}")
@@ -36,7 +36,7 @@ class TTSProviderBase(ABC):
return tmp_file
except Exception as e:
logger.bind(tag=TAG).info(f"Failed to generate TTS file: {e}")
logger.bind(tag=TAG).error(f"Failed to generate TTS file: {e}")
return None
@abstractmethod
@@ -35,6 +35,15 @@ class Dialogue:
self.getMessages(m, dialogue)
return dialogue
def update_system_message(self, new_content: str):
"""更新或添加系统消息"""
# 查找第一个系统消息
system_msg = next((msg for msg in self.dialogue if msg.role == "system"), None)
if system_msg:
system_msg.content = new_content
else:
self.put(Message(role="system", content=new_content))
def get_llm_dialogue_with_memory(self, memory_str: str = None) -> List[Dict[str, str]]:
if memory_str is None or len(memory_str) == 0:
return self.get_llm_dialogue()
+1 -3
View File
@@ -73,9 +73,7 @@ def get_ip_info(ip_addr):
resp = requests.get(url).json()
ip_info = {
"city": resp.get("cityName"),
"region": resp.get("regionName"),
"country": resp.get("countryName")
"city": resp.get("cityName")
}
return ip_info
except Exception as e: