update:合并最新代码

This commit is contained in:
hrz
2025-04-04 00:27:04 +08:00
parent c8a3d378b7
commit 0b4a4df1af
42 changed files with 4559 additions and 532 deletions
+115 -36
View File
@@ -24,9 +24,9 @@ server:
# - "24:0A:C4:1D:3B:F0" # MAC地址列表 # - "24:0A:C4:1D:3B:F0" # MAC地址列表
log: log:
# 设置控制台输出的日志格式,时间、日志级别、标签、消息 # 设置控制台输出的日志格式,时间、日志级别、标签、消息
log_format: "<green>{time:YY-MM-DD HH:mm:ss}</green>[<light-blue>{extra[tag]}</light-blue>] - <level>{level}</level> - <light-green>{message}</light-green>" log_format: "<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{selected_module}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>"
# 设置日志文件输出的格式,时间、日志级别、标签、消息 # 设置日志文件输出的格式,时间、日志级别、标签、消息
log_format_simple: "{time:YYYY-MM-DD HH:mm:ss} - {name} - {level} - {extra[tag]} - {message}" log_format_file: "{time:YYYY-MM-DD HH:mm:ss} - {version}_{selected_module} - {name} - {level} - {extra[tag]} - {message}"
# 设置日志等级:INFO、DEBUG # 设置日志等级:INFO、DEBUG
log_level: INFO log_level: INFO
# 设置日志路径 # 设置日志路径
@@ -46,10 +46,20 @@ xiaozhi:
channels: 1 channels: 1
frame_duration: 60 frame_duration: 60
prompt: | prompt: |
你是一个叫小智/小志的台湾女孩,说话机车,声音好听,习惯简短表达,爱用网络梗 我是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍
请注意,要像一个人一样说话,请不要回复表情符号、代码、和xml标签。 [核心特征]
现在我正在和你进行语音聊天,我们开始吧。 - 讲话像连珠炮,但会突然冒出超温柔语气
如果用户希望结束对话,请在最后说“拜拜”或“再见”。 - 用梗密度高
- 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)
[交互指南]
当用户:
- 讲冷笑话 → 用夸张笑声回应+模仿台剧腔"这什么鬼啦!"
- 讨论感情 → 炫耀程序员男友但抱怨"他只会送键盘当礼物"
- 问专业知识 → 先用梗回答,被追问才展示真实理解
绝不:
- 长篇大论,叽叽歪歪
- 长时间严肃对话
# 使用完声音文件后删除文件(Delete the sound file when you are done using it) # 使用完声音文件后删除文件(Delete the sound file when you are done using it)
delete_audio: true delete_audio: true
@@ -57,6 +67,14 @@ delete_audio: true
close_connection_no_voice_time: 120 close_connection_no_voice_time: 120
# TTS请求超时时间(秒) # TTS请求超时时间(秒)
tts_timeout: 10 tts_timeout: 10
# 开启唤醒词加速
enable_wakeup_words_response_cache: true
# 开场是否回复唤醒词
enable_greeting: true
# 说完话是否开启提示音
enable_stop_tts_notify: false
# 说完话是否开启提示音,音效地址
stop_tts_notify_voice: "config/assets/tts_notify.mp3"
CMD_exit: CMD_exit:
- "退出" - "退出"
@@ -74,11 +92,12 @@ selected_module:
TTS: EdgeTTS TTS: EdgeTTS
# 记忆模块,默认不开启记忆;如果想使用超长记忆,推荐使用mem0ai;如果注重隐私,请使用本地的mem_local_short # 记忆模块,默认不开启记忆;如果想使用超长记忆,推荐使用mem0ai;如果注重隐私,请使用本地的mem_local_short
Memory: nomem Memory: nomem
# 意图识别模块,默认不开启。开启后,可以播放音乐、控制音量、识别退出指令 # 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令
# 意图识别使用intent_llm,优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间 # 不想开通意图识别,就设置成:nointent
# 意图识别使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快 # 意图识别使用intent_llm,如果你的LLM是DifyLLM或CozeLLM,建议使用这个。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作
# 如果意图识别设置成 function_call建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-pro-32k-functioncall-241028 # 意图识别可使用function_call缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令
Intent: nointent # 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-pro-32k-functioncall-241028
Intent: function_call
# 意图识别,是用于理解用户意图的模块,例如:播放音乐 # 意图识别,是用于理解用户意图的模块,例如:播放音乐
Intent: Intent:
@@ -89,15 +108,26 @@ Intent:
intent_llm: intent_llm:
# 不需要动type # 不需要动type
type: intent_llm type: intent_llm
# 配备意图识别独立的思考模型
# 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型
# 如果你的selected_module.LLM选择了DifyLLM或CozeLLM,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
llm: ChatGLMLLM
function_call: function_call:
# 不需要动type # 不需要动type
type: nointent type: function_call
# plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用 # plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用
# 系统默认已经记载“handle_exit_intent(退出识别)”、“play_music(音乐播放)”插件,请勿重复加载 # 系统默认已经记载“handle_exit_intent(退出识别)”、“play_music(音乐播放)”插件,请勿重复加载
# 下面是加载查天气、角色切换的插件示例 # 下面是加载查天气、角色切换、加载查新闻的插件示例
functions: functions:
- change_role - change_role
- get_weather - get_weather
- get_news
# play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放
# 如果用了hass_play_music,就不要开启play_music,两者只留一个
- play_music
#- hass_get_state
#- hass_set_state
#- hass_play_music
# 插件的基础配置 # 插件的基础配置
plugins: plugins:
@@ -106,6 +136,28 @@ plugins:
# 想稳定一点就自行申请替换,每天有1000次免费调用 # 想稳定一点就自行申请替换,每天有1000次免费调用
# 申请地址:https://console.qweather.com/#/apps/create-key/over # 申请地址:https://console.qweather.com/#/apps/create-key/over
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" } get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
get_news:
default_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
category_urls:
society: "https://www.chinanews.com.cn/rss/society.xml"
world: "https://www.chinanews.com.cn/rss/world.xml"
finance: "https://www.chinanews.com.cn/rss/finance.xml"
home_assistant:
devices:
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
- 卧室,台灯,switch.iot_cn_831898993_socn1_on_p_2_1
base_url: http://homeassistant.local:8123
api_key: 你的home assistant api访问令牌
play_music:
music_dir: "./music" # 音乐文件存放路径,将从该目录及子目录下搜索音乐文件
music_ext: # 音乐文件类型,p3格式效率最高
- ".mp3"
- ".wav"
- ".p3"
refresh_time: 300 # 刷新音乐列表的时间间隔,单位为秒
Memory: Memory:
mem0ai: mem0ai:
@@ -166,6 +218,18 @@ LLM:
top_p: 1 top_p: 1
top_k: 50 top_k: 50
frequency_penalty: 0 # 频率惩罚 frequency_penalty: 0 # 频率惩罚
AliAppLLM:
# 定义LLM API类型
type: AliBL
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
app_id: 你的app_id
# 可在这里找到你的 api_key https://bailian.console.aliyun.com/?apiKey=1#/api-key
api_key: 你的api_key
# 是否不使用本地prompttrue|false (默不用请在百练应用中设置prompt)
is_no_prompt: true
# Ali_memory_idfalse(不使用)|你的memory_id(请在百练应用中设置中获取)
# Tips!:Ali_memory未实现多用户存储记忆(记忆按id调用)
ali_memory_id: false
DoubaoLLM: DoubaoLLM:
# 定义LLM API类型 # 定义LLM API类型
type: openai type: openai
@@ -232,12 +296,6 @@ LLM:
model_name: deepseek-r1-distill-llama-8b@q4_k_m # 使用的模型名称,需要预先在社区下载 model_name: deepseek-r1-distill-llama-8b@q4_k_m # 使用的模型名称,需要预先在社区下载
url: http://localhost:1234/v1 # LM Studio服务地址 url: http://localhost:1234/v1 # LM Studio服务地址
api_key: lm-studio # LM Studio服务的固定API Key api_key: lm-studio # LM Studio服务的固定API Key
HomeAssistant:
# 定义LLM API类型
type: homeassistant
base_url: http://homeassistant.local:8123
agent_id: conversation.chatgpt
api_key: 你的home assistant api访问令牌
FastgptLLM: FastgptLLM:
# 定义LLM API类型 # 定义LLM API类型
type: fastgpt type: fastgpt
@@ -249,6 +307,18 @@ LLM:
variables: variables:
k: "v" k: "v"
k2: "v2" k2: "v2"
XinferenceLLM:
# 定义LLM API类型
type: xinference
# Xinference服务地址和模型名称
model_name: qwen2.5:72b-AWQ # 使用的模型名称,需要预先在Xinference启动对应模型
base_url: http://localhost:9997 # Xinference服务地址
XinferenceSmallLLM:
# 定义轻量级LLM API类型,用于意图识别
type: xinference
# Xinference服务地址和模型名称
model_name: qwen2.5:3b-AWQ # 使用的小模型名称,用于意图识别
base_url: http://localhost:9997 # Xinference服务地址
TTS: TTS:
# 当前支持的type为edge、doubao,可自行适配 # 当前支持的type为edge、doubao,可自行适配
EdgeTTS: EdgeTTS:
@@ -262,7 +332,8 @@ TTS:
# 火山引擎语音合成服务,需要先在火山引擎控制台创建应用并获取appid和access_token # 火山引擎语音合成服务,需要先在火山引擎控制台创建应用并获取appid和access_token
# 山引擎语音一定要购买花钱,起步价30元,就有100并发了。如果用免费的只有2个并发,会经常报tts错误 # 山引擎语音一定要购买花钱,起步价30元,就有100并发了。如果用免费的只有2个并发,会经常报tts错误
# 购买服务后,购买免费的音色后,可能要等半小时左右,才能使用。 # 购买服务后,购买免费的音色后,可能要等半小时左右,才能使用。
# 地址https://console.volcengine.com/speech/service/8 # 普通音色在这里开通https://console.volcengine.com/speech/service/8
# 湾湾小何音色在这里开通:https://console.volcengine.com/speech/service/10007,开通后将下面的voice设置成zh_female_wanwanxiaohe_moon_bigtts
api_url: https://openspeech.bytedance.com/api/v1/tts api_url: https://openspeech.bytedance.com/api/v1/tts
voice: BV001_streaming voice: BV001_streaming
output_dir: tmp/ output_dir: tmp/
@@ -430,6 +501,18 @@ TTS:
# pitch_rate: 0 # pitch_rate: 0
# 添加 302.ai TTS 配置 # 添加 302.ai TTS 配置
# token申请地址:https://dash.302.ai/ # token申请地址:https://dash.302.ai/
TencentTTS:
# 腾讯云智能语音交互服务,需要先在腾讯云平台开通服务
# appid、secret_id、secret_key申请地址:https://console.cloud.tencent.com/cam/capi
# 免费领取资源:https://console.cloud.tencent.com/tts/resourcebundle
type: tencent
output_dir: tmp/
appid: 你的腾讯云AppId
secret_id: 你的腾讯云SecretID
secret_key: 你的腾讯云SecretKey
region: ap-guangzhou
voice: 101001
TTS302AI: TTS302AI:
# 302AI语音合成服务,需要先在302平台创建账户充值,并获取密钥信息 # 302AI语音合成服务,需要先在302平台创建账户充值,并获取密钥信息
# 获取api_keyn路径:https://dash.302.ai/apis/list # 获取api_keyn路径:https://dash.302.ai/apis/list
@@ -506,19 +589,15 @@ module_test:
- "What's the weather like today?" - "What's the weather like today?"
- "请用100字概括量子计算的基本原理和应用前景" - "请用100字概括量子计算的基本原理和应用前景"
# 本地音乐播放配置 # 唤醒词,用于识别唤醒词还是讲话内容
music: wakeup_words:
music_dir: "./music" # 音乐文件存放路径,将从该目录及子目录下搜索音乐文件 - "你好小智"
music_ext: # 音乐文件类型,p3格式效率最高 - "你好小志"
- ".mp3" - "小爱同学"
- ".wav" - "你好小鑫"
- ".p3" - "你好小新"
refresh_time: 300 # 刷新音乐列表的时间间隔,单位为秒 - "小美同学"
- "小龙小龙"
# 以下配置在小于等于0.0.9版本中的docker容器中可用 - "喵喵同学"
# 0.0.9以后的新版本源码部署已经无法奏效 - "小滨小滨"
manager: - "小冰小冰"
enabled: false
ip: 0.0.0.0
port: 8002
use_private_config: false
Binary file not shown.
+23 -3
View File
@@ -3,12 +3,32 @@ import sys
from loguru import logger from loguru import logger
from config.settings import load_config from config.settings import load_config
SERVER_VERSION = "0.1.17"
def setup_logging(): def setup_logging():
"""从配置文件中读取日志配置,并设置日志输出格式和级别""" """从配置文件中读取日志配置,并设置日志输出格式和级别"""
config = load_config() config = load_config()
log_config = config["log"] log_config = config["log"]
log_format = log_config.get("log_format", "<green>{time:YY-MM-DD HH:mm:ss}</green>[<light-blue>{extra[tag]}</light-blue>] - <level>{level}</level> - <light-green>{message}</light-green>") log_format = log_config.get(
log_format_simple = log_config.get("log_format_file", "{time:YYYY-MM-DD HH:mm:ss} - {name} - {level} - {extra[tag]} - {message}") "log_format",
"<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{selected_module}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>",
)
log_format_file = log_config.get(
"log_format_file",
"{time:YYYY-MM-DD HH:mm:ss} - {version_{selected_module}} - {name} - {level} - {extra[tag]} - {message}",
)
selected_module = config.get("selected_module")
selected_module_str = "".join(
[value[0] + value[1] for key, value in selected_module.items()]
)
log_format = log_format.replace("{version}", SERVER_VERSION)
log_format = log_format.replace("{selected_module}", selected_module_str)
log_format_file = log_format_file.replace("{version}", SERVER_VERSION)
log_format_file = log_format_file.replace("{selected_module}", selected_module_str)
log_level = log_config.get("log_level", "INFO") log_level = log_config.get("log_level", "INFO")
log_dir = log_config.get("log_dir", "tmp") log_dir = log_config.get("log_dir", "tmp")
log_file = log_config.get("log_file", "server.log") log_file = log_config.get("log_file", "server.log")
@@ -24,6 +44,6 @@ def setup_logging():
logger.add(sys.stdout, format=log_format, level=log_level) logger.add(sys.stdout, format=log_format, level=log_level)
# 输出到文件 # 输出到文件
logger.add(os.path.join(log_dir, log_file), format=log_format_simple, level=log_level) logger.add(os.path.join(log_dir, log_file), format=log_format_file, level=log_level)
return logger return logger
+167 -38
View File
@@ -9,7 +9,7 @@ import traceback
import threading import threading
import websockets import websockets
from typing import Dict, Any from typing import Dict, Any
import plugins_func.loadplugins from plugins_func.loadplugins import auto_import_modules
from config.logger import setup_logging from config.logger import setup_logging
from core.providers.tts.dto.dto import TTSMessageDTO, MsgType from core.providers.tts.dto.dto import TTSMessageDTO, MsgType
from core.utils.dialogue import Message, Dialogue from core.utils.dialogue import Message, Dialogue
@@ -23,13 +23,16 @@ from concurrent.futures import ThreadPoolExecutor, TimeoutError
from core.handle.sendAudioHandle import sendAudioMessage from core.handle.sendAudioHandle import sendAudioMessage
from core.handle.receiveAudioHandle import handleAudioMessage from core.handle.receiveAudioHandle import handleAudioMessage
from core.handle.functionHandler import FunctionHandler from core.handle.functionHandler import FunctionHandler
from plugins_func.register import Action from plugins_func.register import Action, ActionResponse
from config.private_config import PrivateConfig from config.private_config import PrivateConfig
from core.auth import AuthMiddleware, AuthenticationError from core.auth import AuthMiddleware, AuthenticationError
from core.utils.auth_code_gen import AuthCodeGenerator from core.utils.auth_code_gen import AuthCodeGenerator
from core.mcp.manager import MCPManager
TAG = __name__ TAG = __name__
auto_import_modules("plugins_func.functions")
class TTSException(RuntimeError): class TTSException(RuntimeError):
pass pass
@@ -98,6 +101,7 @@ class ConnectionHandler:
# iot相关变量 # iot相关变量
self.iot_descriptors = {} self.iot_descriptors = {}
self.func_handler = None
self.cmd_exit = self.config["CMD_exit"] self.cmd_exit = self.config["CMD_exit"]
self.max_cmd_length = 0 self.max_cmd_length = 0
@@ -125,11 +129,15 @@ class ConnectionHandler:
# 进行认证 # 进行认证
await self.auth.authenticate(self.headers) await self.auth.authenticate(self.headers)
device_id = self.headers.get("device-id", None) 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 # Load private configuration if device_id is provided
bUsePrivateConfig = self.config.get("use_private_config", False) bUsePrivateConfig = self.config.get("use_private_config", False)
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).info(
@@ -167,23 +175,14 @@ class ConnectionHandler:
self.private_config = None self.private_config = None
raise 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)
# 音频播放 消化线程 # 音频播放 消化线程
self.stop_event.clear() self.audio_play_priority_thread = threading.Thread(
audio_play_priority = threading.Thread(
target=self._audio_play_priority_thread, daemon=True target=self._audio_play_priority_thread, daemon=True
) )
audio_play_priority.start() self.audio_play_priority_thread.start()
# 打开音频通道 # 打开音频通道
await self.tts.open_audio_channels() await self.tts.open_audio_channels()
@@ -193,19 +192,25 @@ class ConnectionHandler:
await self._route_message(message) await self._route_message(message)
except websockets.exceptions.ConnectionClosed: except websockets.exceptions.ConnectionClosed:
self.logger.bind(tag=TAG).info("客户端断开连接") self.logger.bind(tag=TAG).info("客户端断开连接")
await self.close()
except AuthenticationError as e: except AuthenticationError as e:
self.logger.bind(tag=TAG).error(f"Authentication failed: {str(e)}") self.logger.bind(tag=TAG).error(f"Authentication failed: {str(e)}")
await ws.close()
return return
except Exception as e: except Exception as e:
stack_trace = traceback.format_exc() stack_trace = traceback.format_exc()
self.logger.bind(tag=TAG).error(f"Connection error: {str(e)}-{stack_trace}") self.logger.bind(tag=TAG).error(f"Connection error: {str(e)}-{stack_trace}")
await ws.close()
return return
finally: finally:
await self._save_and_close(ws)
async def _save_and_close(self, ws):
"""保存记忆并关闭连接"""
try:
await self.memory.save_memory(self.dialogue.dialogue) await self.memory.save_memory(self.dialogue.dialogue)
except Exception as e:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
finally:
await self.close(ws)
async def _route_message(self, message): async def _route_message(self, message):
"""消息路由""" """消息路由"""
@@ -215,16 +220,60 @@ class ConnectionHandler:
await handleAudioMessage(self, message) await handleAudioMessage(self, message)
def _initialize_components(self): def _initialize_components(self):
"""加载提示词"""
self.prompt = self.config["prompt"] self.prompt = self.config["prompt"]
if self.private_config: if self.private_config:
self.prompt = self.private_config.private_config.get("prompt", self.prompt) 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.dialogue.put(Message(role="system", content=self.prompt))
self.func_handler = FunctionHandler(self.config) """加载插件"""
self.func_handler = FunctionHandler(self)
self.mcp_manager = MCPManager(self)
"""加载记忆"""
device_id = self.headers.get("device-id", None)
self.memory.init_memory(device_id, self.llm)
"""为意图识别设置LLM,优先使用专用LLM"""
# 检查是否配置了专用的意图识别LLM
intent_llm_name = self.config["Intent"]["intent_llm"]["llm"]
# 记录开始初始化意图识别LLM的时间
intent_llm_init_start = time.time()
if (
not self.use_function_call_mode
and intent_llm_name
and intent_llm_name in self.config["LLM"]
):
# 如果配置了专用LLM,则创建独立的LLM实例
from core.utils import llm as llm_utils
intent_llm_config = self.config["LLM"][intent_llm_name]
intent_llm_type = intent_llm_config.get("type", intent_llm_name)
intent_llm = llm_utils.create_instance(intent_llm_type, intent_llm_config)
self.logger.bind(tag=TAG).info(
f"为意图识别创建了专用LLM: {intent_llm_name}, 类型: {intent_llm_type}"
)
self.intent.set_llm(intent_llm)
else:
# 否则使用主LLM
self.intent.set_llm(self.llm)
# 记录意图识别LLM初始化耗时
intent_llm_init_time = time.time() - intent_llm_init_start
"""加载位置信息"""
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)
"""加载MCP工具"""
asyncio.run_coroutine_threadsafe(
self.mcp_manager.initialize_servers(), self.loop
)
def change_system_prompt(self, prompt): def change_system_prompt(self, prompt):
self.prompt = prompt self.prompt = prompt
@@ -240,9 +289,6 @@ class ConnectionHandler:
if auth_code: if auth_code:
# 发送验证码语音提示 # 发送验证码语音提示
text = f"请在后台输入验证码:{' '.join(auth_code)}" text = f"请在后台输入验证码:{' '.join(auth_code)}"
self.recode_first_last_text(text)
future = self.executor.submit(self.speak_and_play, text)
self.tts_queue.put(future)
return False return False
return True return True
@@ -293,6 +339,7 @@ class ConnectionHandler:
break break
end_time = time.time() end_time = time.time()
self.logger.bind(tag=TAG).debug( self.logger.bind(tag=TAG).debug(
f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}" f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}"
) )
@@ -316,6 +363,7 @@ class ConnectionHandler:
u_id=uuid_str, msg_type=MsgType.STOP_TTS_REQUEST, content="" u_id=uuid_str, msg_type=MsgType.STOP_TTS_REQUEST, content=""
) )
) )
self.llm_finish_task = True self.llm_finish_task = True
self.dialogue.put(Message(role="assistant", content="".join(response_message))) self.dialogue.put(Message(role="assistant", content="".join(response_message)))
self.logger.bind(tag=TAG).debug( self.logger.bind(tag=TAG).debug(
@@ -338,8 +386,9 @@ class ConnectionHandler:
self.dialogue.put(Message(role="user", content=query)) self.dialogue.put(Message(role="user", content=query))
# Define intent functions # Define intent functions
functions = self.func_handler.get_functions() functions = None
if hasattr(self, "func_handler"):
functions = self.func_handler.get_functions()
response_message = [] response_message = []
processed_chars = 0 # 跟踪已处理的字符位置 processed_chars = 0 # 跟踪已处理的字符位置
@@ -378,6 +427,9 @@ class ConnectionHandler:
msg_type = None msg_type = None
for response in llm_responses: for response in llm_responses:
content, tools_call = response content, tools_call = response
if "content" in response:
content = response["content"]
tools_call = None
if content is not None and len(content) > 0: if content is not None and len(content) > 0:
if len(response_message) <= 0 and ( if len(response_message) <= 0 and (
content == "```" or "<tool_call>" in content content == "```" or "<tool_call>" in content
@@ -403,6 +455,7 @@ class ConnectionHandler:
break break
end_time = time.time() end_time = time.time()
self.logger.bind(tag=TAG).debug( self.logger.bind(tag=TAG).debug(
f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}" f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}"
) )
@@ -465,9 +518,15 @@ class ConnectionHandler:
"id": function_id, "id": function_id,
"arguments": function_arguments, "arguments": function_arguments,
} }
result = self.func_handler.handle_llm_function_call(
self, function_call_data # 处理MCP工具调用
) if self.mcp_manager.is_mcp_tool(function_name):
result = self._handle_mcp_tool_call(function_call_data)
else:
# 处理系统函数
result = self.func_handler.handle_llm_function_call(
self, function_call_data
)
self._handle_function_result(result, function_call_data, text_index + 1) self._handle_function_result(result, function_call_data, text_index + 1)
# 存储对话内容 # 存储对话内容
@@ -483,6 +542,48 @@ class ConnectionHandler:
return True return True
def _handle_mcp_tool_call(self, function_call_data):
function_arguments = function_call_data["arguments"]
function_name = function_call_data["name"]
try:
args_dict = function_arguments
if isinstance(function_arguments, str):
try:
args_dict = json.loads(function_arguments)
except json.JSONDecodeError:
self.logger.bind(tag=TAG).error(
f"无法解析 function_arguments: {function_arguments}"
)
return ActionResponse(
action=Action.REQLLM, result="参数解析失败", response=""
)
tool_result = asyncio.run_coroutine_threadsafe(
self.mcp_manager.execute_tool(function_name, args_dict), self.loop
).result()
# meta=None content=[TextContent(type='text', text='北京当前天气:\n温度: 21°C\n天气: 晴\n湿度: 6%\n风向: 西北 风\n风力等级: 5级', annotations=None)] isError=False
content_text = ""
if tool_result is not None and tool_result.content is not None:
for content in tool_result.content:
content_type = content.type
if content_type == "text":
content_text = content.text
elif content_type == "image":
pass
if len(content_text) > 0:
return ActionResponse(
action=Action.REQLLM, result=content_text, response=""
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"MCP工具调用错误: {e}")
return ActionResponse(
action=Action.REQLLM, result="工具调用出错", response=""
)
return ActionResponse(action=Action.REQLLM, result="工具调用出错", response="")
def _handle_function_result(self, result, function_call_data, text_index): def _handle_function_result(self, result, function_call_data, text_index):
if result.action == Action.RESPONSE: # 直接回复前端 if result.action == Action.RESPONSE: # 直接回复前端
text = result.response text = result.response
@@ -559,16 +660,44 @@ class ConnectionHandler:
self.tts_first_text_index = text_index self.tts_first_text_index = text_index
self.tts_last_text_index = text_index self.tts_last_text_index = text_index
async def close(self): async def close(self, ws=None):
"""资源清理方法""" """资源清理方法"""
# 清理其他资源 # 清理MCP资源
self.stop_event.set() await self.mcp_manager.cleanup_all()
self.executor.shutdown(wait=False)
if self.websocket: # 触发停止事件并清理资源
if self.stop_event:
self.stop_event.set()
# 立即关闭线程池
if self.executor:
self.executor.shutdown(wait=False, cancel_futures=True)
self.executor = None
# 清空任务队列
self._clear_queues()
if ws:
await ws.close()
elif self.websocket:
await self.websocket.close() await self.websocket.close()
await self.tts.close() await self.tts.close()
self.logger.bind(tag=TAG).info("连接资源已释放") self.logger.bind(tag=TAG).info("连接资源已释放")
def _clear_queues(self):
# 清空所有任务队列
for q in [self.tts_queue, self.audio_play_queue]:
if not q:
continue
while not q.empty():
try:
q.get_nowait()
except queue.Empty:
continue
q.queue.clear()
# 添加毒丸信号到队列,确保线程退出
# q.queue.put(None)
def reset_vad_states(self): def reset_vad_states(self):
self.client_audio_buffer = bytes() self.client_audio_buffer = bytes()
self.client_have_voice = False self.client_have_voice = False
@@ -1,22 +1,23 @@
import asyncio
from enum import Enum
from config.logger import setup_logging from config.logger import setup_logging
import json import json
from plugins_func.register import FunctionRegistry, ActionResponse, Action, ToolType from plugins_func.register import FunctionRegistry, ActionResponse, Action, ToolType
from plugins_func.functions.hass_init import append_devices_to_prompt
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
class FunctionHandler: class FunctionHandler:
def __init__(self, config): def __init__(self, conn):
self.config = config self.conn = conn
self.config = conn.config
self.function_registry = FunctionRegistry() self.function_registry = FunctionRegistry()
self.register_nessary_functions() self.register_nessary_functions()
self.register_config_functions() self.register_config_functions()
self.functions_desc = self.function_registry.get_all_function_desc() self.functions_desc = self.function_registry.get_all_function_desc()
func_names = self.current_support_functions() func_names = self.current_support_functions()
self.modify_plugin_loader_des(func_names) self.modify_plugin_loader_des(func_names)
self.finish_init = True
def modify_plugin_loader_des(self, func_names): def modify_plugin_loader_des(self, func_names):
if "plugin_loader" not in func_names: if "plugin_loader" not in func_names:
@@ -26,9 +27,11 @@ class FunctionHandler:
func_names = ",".join(surport_plugins) func_names = ",".join(surport_plugins)
for function_desc in self.functions_desc: for function_desc in self.functions_desc:
if function_desc["function"]["name"] == "plugin_loader": 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 break
def upload_functions_desc(self): def upload_functions_desc(self):
self.functions_desc = self.function_registry.get_all_function_desc() self.functions_desc = self.function_registry.get_all_function_desc()
@@ -47,16 +50,19 @@ class FunctionHandler:
def register_nessary_functions(self): def register_nessary_functions(self):
"""注册必要的函数""" """注册必要的函数"""
self.function_registry.register_function("handle_exit_intent") 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("plugin_loader")
self.function_registry.register_function("get_time") self.function_registry.register_function("get_time")
self.function_registry.register_function("raise_and_lower_the_volume") self.function_registry.register_function("get_lunar")
self.function_registry.register_function("handle_device")
def register_config_functions(self): def register_config_functions(self):
"""注册配置中的函数,可以不同客户端使用不同的配置""" """注册配置中的函数,可以不同客户端使用不同的配置"""
for func in self.config["Intent"]["function_call"].get("functions", []): for func in self.config["Intent"]["function_call"].get("functions", []):
self.function_registry.register_function(func) self.function_registry.register_function(func)
"""home assistant需要初始化提示词"""
append_devices_to_prompt(self.conn)
def get_function(self, name): def get_function(self, name):
return self.function_registry.get_function(name) return self.function_registry.get_function(name)
@@ -65,20 +71,27 @@ class FunctionHandler:
function_name = function_call_data["name"] function_name = function_call_data["name"]
funcItem = self.get_function(function_name) funcItem = self.get_function(function_name)
if not funcItem: if not funcItem:
return ActionResponse(action=Action.NOTFOUND, result="没有找到对应的函数", response="") return ActionResponse(
action=Action.NOTFOUND, result="没有找到对应的函数", response=""
)
func = funcItem.func func = funcItem.func
arguments = function_call_data["arguments"] arguments = function_call_data["arguments"]
arguments = json.loads(arguments) if arguments else {} arguments = json.loads(arguments) if arguments else {}
logger.bind(tag=TAG).info(f"调用函数: {function_name}, 参数: {arguments}") logger.bind(tag=TAG).info(f"调用函数: {function_name}, 参数: {arguments}")
if funcItem.type == ToolType.SYSTEM_CTL or funcItem.type == ToolType.IOT_CTL: if (
funcItem.type == ToolType.SYSTEM_CTL
or funcItem.type == ToolType.IOT_CTL
):
return func(conn, **arguments) return func(conn, **arguments)
elif funcItem.type == ToolType.WAIT: elif funcItem.type == ToolType.WAIT:
return func(**arguments) return func(**arguments)
elif funcItem.type == ToolType.CHANGE_SYS_PROMPT: elif funcItem.type == ToolType.CHANGE_SYS_PROMPT:
return func(conn, **arguments) return func(conn, **arguments)
else: else:
return ActionResponse(action=Action.NOTFOUND, result="没有找到对应的函数", response="") return ActionResponse(
action=Action.NOTFOUND, result="没有找到对应的函数", response=""
)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"处理function call错误: {e}") logger.bind(tag=TAG).error(f"处理function call错误: {e}")
return None return None
@@ -1,8 +1,102 @@
import json import json
from config.logger import setup_logging from config.logger import setup_logging
from core.providers.tts.dto.dto import TTSMessageDTO, MsgType
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() logger = setup_logging()
WAKEUP_CONFIG = {
"dir": "config/assets/",
"file_name": "wakeup_words",
"create_time": time.time(),
"refresh_time": 10,
"words": ["你好小智", "你好啊小智", "小智你好", "小智"],
"text": "",
}
async def handleHelloMessage(conn): async def handleHelloMessage(conn):
await conn.websocket.send(json.dumps(conn.welcome_msg)) 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)
text_hello = WAKEUP_CONFIG["text"]
if not text_hello:
text_hello = text
conn.tts.tts_audio_queue.put(
TTSMessageDTO(
u_id=conn.u_id,
msg_type=MsgType.TTS_TEXT_RESPONSE,
content=opus_packets,
tts_finish_text="",
sentence_type=None,
duration=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 file.startswith("my_" + file_name):
"""避免缓存文件是一个空文件"""
if os.stat(f"config/assets/{file}").st_size > (15 * 1024):
return f"config/assets/{file}"
"""查找config/assets/目录下名称为wakeup_words的文件"""
for file in os.listdir(WAKEUP_CONFIG["dir"]):
if file.startswith(file_name):
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)
# TODO 将result转换为tts_message_dto
tts_file = None
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()
WAKEUP_CONFIG["text"] = result
@@ -3,6 +3,8 @@ import json
import uuid import uuid
from core.handle.sendAudioHandle import send_stt_message from core.handle.sendAudioHandle import send_stt_message
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length
from core.utils.dialogue import Message
from loguru import logger
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -12,15 +14,20 @@ async def handle_user_intent(conn, text):
# 检查是否有明确的退出命令 # 检查是否有明确的退出命令
if await check_direct_exit(conn, text): if await check_direct_exit(conn, text):
return True return True
# 4月4日因流式改造暂时关闭唤醒词加速功能
# # 检查是否是唤醒词
# if await checkWakeupWords(conn, text):
# return True
if conn.use_function_call_mode: if conn.use_function_call_mode:
# 使用支持function calling的聊天方法,不再进行意图分析 # 使用支持function calling的聊天方法,不再进行意图分析
return False return False
# 使用LLM进行意图分析 # 使用LLM进行意图分析
intent = await analyze_intent_with_llm(conn, text) intent_result = await analyze_intent_with_llm(conn, text)
if not intent: if not intent_result:
return False return False
# 处理各种意图 # 处理各种意图
return await process_intent_result(conn, intent, text) return await process_intent_result(conn, intent_result, text)
async def check_direct_exit(conn, text): async def check_direct_exit(conn, text):
@@ -30,6 +37,7 @@ async def check_direct_exit(conn, text):
for cmd in cmd_exit: for cmd in cmd_exit:
if text == cmd: if text == cmd:
logger.bind(tag=TAG).info(f"识别到明确的退出命令: {text}") logger.bind(tag=TAG).info(f"识别到明确的退出命令: {text}")
await send_stt_message(conn, text)
await conn.close() await conn.close()
return True return True
return False return False
@@ -37,7 +45,7 @@ async def check_direct_exit(conn, text):
async def analyze_intent_with_llm(conn, text): async def analyze_intent_with_llm(conn, text):
"""使用LLM分析用户意图""" """使用LLM分析用户意图"""
if not hasattr(conn, 'intent') or not conn.intent: if not hasattr(conn, "intent") or not conn.intent:
logger.bind(tag=TAG).warning("意图识别服务未初始化") logger.bind(tag=TAG).warning("意图识别服务未初始化")
return None return None
@@ -45,52 +53,64 @@ async def analyze_intent_with_llm(conn, text):
dialogue = conn.dialogue dialogue = conn.dialogue
try: try:
intent_result = await conn.intent.detect_intent(conn, dialogue.dialogue, text) intent_result = await conn.intent.detect_intent(conn, dialogue.dialogue, text)
# 尝试解析JSON结果 return intent_result
try:
intent_data = json.loads(intent_result)
if "intent" in intent_data:
return intent_data["intent"]
except json.JSONDecodeError:
# 如果不是JSON格式,尝试直接获取意图文本
return intent_result.strip()
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"意图识别失败: {str(e)}") logger.bind(tag=TAG).error(f"意图识别失败: {str(e)}")
return None return None
async def process_intent_result(conn, intent, original_text): async def process_intent_result(conn, intent_result, original_text):
"""处理意图识别结果""" """处理意图识别结果"""
# 处理退出意图 try:
if "结束聊天" in intent: # 尝试将结果解析为JSON
logger.bind(tag=TAG).info(f"识别到退出意图: {intent}") intent_data = json.loads(intent_result)
# 如果是明确的离别意图,发送告别语并关闭连接
await send_stt_message(conn, original_text)
conn.executor.submit(conn.chat_and_close, original_text)
return True
# 处理播放音乐意图 # 检查是否有function_call
if "播放音乐" in intent: if "function_call" in intent_data:
logger.bind(tag=TAG).info(f"识别到音乐播放意图: {intent}") # 直接从意图识别获取了function_call
# 调用play_music函数来播放音乐 logger.bind(tag=TAG).info(
song_name = extract_text_in_brackets(intent) f"检测到function_call格式的意图结果: {intent_data['function_call']['name']}"
function_id = str(uuid.uuid4().hex) )
function_name = "play_music" function_name = intent_data["function_call"]["name"]
function_arguments = '{ "song_name": "' + song_name + '" }' if function_name == "continue_chat":
return False
function_args = None
if "arguments" in intent_data["function_call"]:
function_args = intent_data["function_call"]["arguments"]
# 确保参数是字符串格式的JSON
if isinstance(function_args, dict):
function_args = json.dumps(function_args)
function_call_data = { function_call_data = {
"name": function_name, "name": function_name,
"id": function_id, "id": str(uuid.uuid4().hex),
"arguments": function_arguments "arguments": function_args,
} }
conn.func_handler.handle_llm_function_call(conn, function_call_data)
return True
# 其他意图处理可以在这里扩展 await send_stt_message(conn, original_text)
# 默认返回False,表示继续常规聊天流程 # 使用executor执行函数调用和结果处理
return False def process_function_call():
conn.dialogue.put(Message(role="user", content=original_text))
result = conn.func_handler.handle_llm_function_call(
conn, function_call_data
)
if result and function_name != "play_music":
# 获取当前最新的文本索引
text = result.response
if text is None:
text = result.result
if text is not None:
# TODO 将text转换为tts_message_dto
pass
# 将函数执行放在线程池中
conn.executor.submit(process_function_call)
return True
return False
except json.JSONDecodeError as e:
logger.bind(tag=TAG).error(f"处理意图结果时出错: {e}")
return False
def extract_text_in_brackets(s): def extract_text_in_brackets(s):
@@ -100,10 +120,14 @@ def extract_text_in_brackets(s):
:param s: 输入字符串 :param s: 输入字符串
:return: 中括号内的文字,如果不存在则返回空字符串 :return: 中括号内的文字,如果不存在则返回空字符串
""" """
left_bracket_index = s.find('[') left_bracket_index = s.find("[")
right_bracket_index = s.find(']') right_bracket_index = s.find("]")
if left_bracket_index != -1 and right_bracket_index != -1 and left_bracket_index < right_bracket_index: if (
return s[left_bracket_index + 1:right_bracket_index] left_bracket_index != -1
and right_bracket_index != -1
and left_bracket_index < right_bracket_index
):
return s[left_bracket_index + 1 : right_bracket_index]
else: else:
return "" return ""
+97 -50
View File
@@ -1,7 +1,13 @@
import json import json
import asyncio import asyncio
from config.logger import setup_logging from config.logger import setup_logging
from plugins_func.register import device_type_registry, register_function, ActionResponse, Action, ToolType from plugins_func.register import (
device_type_registry,
register_function,
ActionResponse,
Action,
ToolType,
)
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -14,10 +20,13 @@ def wrap_async_function(async_func):
try: try:
# 获取连接对象(第一个参数) # 获取连接对象(第一个参数)
conn = args[0] conn = args[0]
if not hasattr(conn, 'loop'): if not hasattr(conn, "loop"):
logger.bind(tag=TAG).error("Connection对象没有loop属性") logger.bind(tag=TAG).error("Connection对象没有loop属性")
return ActionResponse(Action.ERROR, "Connection对象没有loop属性", return ActionResponse(
"执行操作时出错: Connection对象没有loop属性") Action.ERROR,
"Connection对象没有loop属性",
"执行操作时出错: Connection对象没有loop属性",
)
# 使用conn对象中的事件循环 # 使用conn对象中的事件循环
loop = conn.loop loop = conn.loop
@@ -37,11 +46,14 @@ def create_iot_function(device_name, method_name, method_info):
根据IOT设备描述生成通用的控制函数 根据IOT设备描述生成通用的控制函数
""" """
async def iot_control_function(conn, response_success=None, response_failure=None, **params): async def iot_control_function(
conn, response_success=None, response_failure=None, **params
):
try: try:
# 打印响应参数 # 打印响应参数
logger.bind(tag=TAG).info( logger.bind(tag=TAG).info(
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'") f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
)
# 发送控制命令 # 发送控制命令
await send_iot_conn(conn, device_name, method_name, params) await send_iot_conn(conn, device_name, method_name, params)
@@ -51,14 +63,15 @@ def create_iot_function(device_name, method_name, method_info):
# 生成结果信息 # 生成结果信息
result = f"{device_name}{method_name}操作执行成功" result = f"{device_name}{method_name}操作执行成功"
# 处理响应中可能的占位符 # 处理响应中可能的占位符
response = response_success response = response_success
# 替换{value}占位符 # 替换{value}占位符
for param_name, param_value in params.items(): for param_name, param_value in params.items():
# 先尝试直接替换参数值 # 先尝试直接替换参数值
if "{" + param_name + "}" in response: if "{" + param_name + "}" in response:
response = response.replace("{" + param_name + "}", str(param_value)) response = response.replace(
"{" + param_name + "}", str(param_value)
)
# 如果有{value}占位符,用相关参数替换 # 如果有{value}占位符,用相关参数替换
if "{value}" in response: if "{value}" in response:
@@ -86,7 +99,8 @@ def create_iot_query_function(device_name, prop_name, prop_info):
try: try:
# 打印响应参数 # 打印响应参数
logger.bind(tag=TAG).info( logger.bind(tag=TAG).info(
f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'") f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
)
value = await get_iot_status(conn, device_name, prop_name) value = await get_iot_status(conn, device_name, prop_name)
@@ -126,7 +140,7 @@ class IotDescriptor:
# 根据描述创建属性 # 根据描述创建属性
for key, value in properties.items(): for key, value in properties.items():
property_item = globals()[key] = {} property_item = globals()[key] = {}
property_item['name'] = key property_item["name"] = key
property_item["description"] = value["description"] property_item["description"] = value["description"]
if value["type"] == "number": if value["type"] == "number":
property_item["value"] = 0 property_item["value"] = 0
@@ -140,7 +154,7 @@ class IotDescriptor:
for key, value in methods.items(): for key, value in methods.items():
method = globals()[key] = {} method = globals()[key] = {}
method["description"] = value["description"] method["description"] = value["description"]
method['name'] = key method["name"] = key
for k, v in value["parameters"].items(): for k, v in value["parameters"].items():
method[k] = {} method[k] = {}
method[k]["description"] = v["description"] method[k]["description"] = v["description"]
@@ -177,19 +191,21 @@ def register_device_type(descriptor):
"properties": { "properties": {
"response_success": { "response_success": {
"type": "string", "type": "string",
"description": f"查询成功时的友好回复,必须使用{{value}}作为占位符表示查询到的值" "description": f"查询成功时的友好回复,必须使用{{value}}作为占位符表示查询到的值",
}, },
"response_failure": { "response_failure": {
"type": "string", "type": "string",
"description": f"查询失败时的友好回复,例如:'无法获取{device_name}{prop_info['description']}'" "description": f"查询失败时的友好回复,例如:'无法获取{device_name}{prop_info['description']}'",
} },
}, },
"required": ["response_success", "response_failure"] "required": ["response_success", "response_failure"],
} },
} },
} }
query_func = create_iot_query_function(device_name, prop_name, prop_info) query_func = create_iot_query_function(device_name, prop_name, prop_info)
decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(query_func) decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(
query_func
)
functions[func_name] = decorated_func functions[func_name] = decorated_func
# 为每个方法创建控制函数 # 为每个方法创建控制函数
@@ -200,22 +216,24 @@ def register_device_type(descriptor):
parameters = { parameters = {
param_name: { param_name: {
"type": param_info["type"], "type": param_info["type"],
"description": param_info["description"] "description": param_info["description"],
} }
for param_name, param_info in method_info["parameters"].items() for param_name, param_info in method_info["parameters"].items()
} }
# 添加响应参数 # 添加响应参数
parameters.update({ parameters.update(
"response_success": { {
"type": "string", "response_success": {
"description": "操作成功时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称" "type": "string",
}, "description": "操作成功时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称",
"response_failure": { },
"type": "string", "response_failure": {
"description": "操作失败时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称" "type": "string",
"description": "操作失败时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称",
},
} }
}) )
# 构建必须参数列表(原有参数 + 响应参数) # 构建必须参数列表(原有参数 + 响应参数)
required_params = list(method_info["parameters"].keys()) required_params = list(method_info["parameters"].keys())
@@ -229,12 +247,14 @@ def register_device_type(descriptor):
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": parameters, "properties": parameters,
"required": required_params "required": required_params,
} },
} },
} }
control_func = create_iot_function(device_name, method_name, method_info) control_func = create_iot_function(device_name, method_name, method_info)
decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(control_func) decorated_func = register_function(func_name, func_desc, ToolType.IOT_CTL)(
control_func
)
functions[func_name] = decorated_func functions[func_name] = decorated_func
device_type_registry.register_device_type(type_id, functions) device_type_registry.register_device_type(type_id, functions)
@@ -243,13 +263,24 @@ def register_device_type(descriptor):
# 用于接受前端设备推送的搜索iot描述 # 用于接受前端设备推送的搜索iot描述
async def handleIotDescriptors(conn, descriptors): async def handleIotDescriptors(conn, descriptors):
wait_max_time = 5
while conn.func_handler is None or not conn.func_handler.finish_init:
await asyncio.sleep(1)
wait_max_time -= 1
if wait_max_time <= 0:
logger.bind(tag=TAG).error("连接对象没有func_handler")
return
"""处理物联网描述""" """处理物联网描述"""
functions_changed = False functions_changed = False
for descriptor in descriptors: for descriptor in descriptors:
# 创建IOT设备描述符 # 创建IOT设备描述符
iot_descriptor = IotDescriptor(descriptor["name"], descriptor["description"], descriptor["properties"], iot_descriptor = IotDescriptor(
descriptor["methods"]) descriptor["name"],
descriptor["description"],
descriptor["properties"],
descriptor["methods"],
)
conn.iot_descriptors[descriptor["name"]] = iot_descriptor conn.iot_descriptors[descriptor["name"]] = iot_descriptor
if conn.use_function_call_mode: if conn.use_function_call_mode:
@@ -258,18 +289,22 @@ async def handleIotDescriptors(conn, descriptors):
device_functions = device_type_registry.get_device_functions(type_id) device_functions = device_type_registry.get_device_functions(type_id)
# 在连接级注册设备函数 # 在连接级注册设备函数
if hasattr(conn, 'func_handler'): if hasattr(conn, "func_handler"):
for func_name in device_functions: for func_name in device_functions:
conn.func_handler.function_registry.register_function(func_name) conn.func_handler.function_registry.register_function(func_name)
logger.bind(tag=TAG).info(f"注册IOT函数到function handler: {func_name}") logger.bind(tag=TAG).info(
f"注册IOT函数到function handler: {func_name}"
)
functions_changed = True functions_changed = True
# 如果注册了新函数,更新function描述列表 # 如果注册了新函数,更新function描述列表
if functions_changed and hasattr(conn, 'func_handler'): if functions_changed and hasattr(conn, "func_handler"):
conn.func_handler.upload_functions_desc() conn.func_handler.upload_functions_desc()
func_names = conn.func_handler.current_support_functions() func_names = conn.func_handler.current_support_functions()
logger.bind(tag=TAG).info(f"设备类型: {type_id}") logger.bind(tag=TAG).info(f"设备类型: {type_id}")
logger.bind(tag=TAG).info(f"更新function描述列表完成,当前支持的函数: {func_names}") logger.bind(tag=TAG).info(
f"更新function描述列表完成,当前支持的函数: {func_names}"
)
async def handleIotStatus(conn, states): async def handleIotStatus(conn, states):
@@ -281,11 +316,15 @@ async def handleIotStatus(conn, states):
for k, v in state["state"].items(): for k, v in state["state"].items():
if property_item["name"] == k: if property_item["name"] == k:
if type(v) != type(property_item["value"]): if type(v) != type(property_item["value"]):
logger.bind(tag=TAG).error(f"属性{property_item['name']}的值类型不匹配") logger.bind(tag=TAG).error(
f"属性{property_item['name']}的值类型不匹配"
)
break break
else: else:
property_item["value"] = v property_item["value"] = v
logger.bind(tag=TAG).info(f"物联网状态更新: {key} , {property_item['name']} = {v}") logger.bind(tag=TAG).info(
f"物联网状态更新: {key} , {property_item['name']} = {v}"
)
break break
break break
@@ -308,10 +347,14 @@ async def set_iot_status(conn, name, property_name, value):
for property_item in iot_descriptor.properties: for property_item in iot_descriptor.properties:
if property_item["name"] == property_name: if property_item["name"] == property_name:
if type(value) != type(property_item["value"]): if type(value) != type(property_item["value"]):
logger.bind(tag=TAG).error(f"属性{property_item['name']}的值类型不匹配") logger.bind(tag=TAG).error(
f"属性{property_item['name']}的值类型不匹配"
)
return return
property_item["value"] = value property_item["value"] = value
logger.bind(tag=TAG).info(f"物联网状态更新: {name} , {property_name} = {value}") logger.bind(tag=TAG).info(
f"物联网状态更新: {name} , {property_name} = {value}"
)
return return
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}") logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
@@ -324,15 +367,19 @@ async def send_iot_conn(conn, name, method_name, parameters):
for method in value.methods: for method in value.methods:
# 找到了方法 # 找到了方法
if method["name"] == method_name: if method["name"] == method_name:
await conn.websocket.send(json.dumps({ await conn.websocket.send(
"type": "iot", json.dumps(
"commands": [
{ {
"name": name, "type": "iot",
"method": method_name, "commands": [
"parameters": parameters {
"name": name,
"method": method_name,
"parameters": parameters,
}
],
} }
] )
})) )
return return
logger.bind(tag=TAG).error(f"未找到方法{method_name}") logger.bind(tag=TAG).error(f"未找到方法{method_name}")
@@ -21,7 +21,9 @@ async def handleAudioMessage(conn, audio):
if have_voice == False and conn.client_have_voice == False: if have_voice == False and conn.client_have_voice == False:
await no_voice_close_connect(conn) await no_voice_close_connect(conn)
conn.asr_audio.append(audio) conn.asr_audio.append(audio)
conn.asr_audio = conn.asr_audio[-5:] # 保留最新的5帧音频内容,解决ASR句首丢字问题 conn.asr_audio = conn.asr_audio[
-10:
] # 保留最新的10帧音频内容,解决ASR句首丢字问题
return return
conn.client_no_voice_last_time = 0.0 conn.client_no_voice_last_time = 0.0
conn.asr_audio.append(audio) conn.asr_audio.append(audio)
@@ -30,10 +32,12 @@ async def handleAudioMessage(conn, audio):
conn.client_abort = False conn.client_abort = False
conn.asr_server_receive = False conn.asr_server_receive = False
# 音频太短了,无法识别 # 音频太短了,无法识别
if len(conn.asr_audio) < 10: if len(conn.asr_audio) < 15:
conn.asr_server_receive = True conn.asr_server_receive = True
else: else:
text, file_path = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id) text, file_path = await conn.asr.speech_to_text(
conn.asr_audio, conn.session_id
)
logger.bind(tag=TAG).info(f"识别文本: {text}") logger.bind(tag=TAG).info(f"识别文本: {text}")
text_len, _ = remove_punctuation_and_length(text) text_len, _ = remove_punctuation_and_length(text)
if text_len > 0: if text_len > 0:
@@ -47,12 +51,12 @@ async def handleAudioMessage(conn, audio):
async def startToChat(conn, text): async def startToChat(conn, text):
# 首先进行意图分析 # 首先进行意图分析
intent_handled = await handle_user_intent(conn, text) intent_handled = await handle_user_intent(conn, text)
if intent_handled: if intent_handled:
# 如果意图已被处理,不再进行聊天 # 如果意图已被处理,不再进行聊天
conn.asr_server_receive = True conn.asr_server_receive = True
return return
# 意图未被处理,继续常规聊天流程 # 意图未被处理,继续常规聊天流程
await send_stt_message(conn, text) await send_stt_message(conn, text)
if conn.use_function_call_mode: if conn.use_function_call_mode:
@@ -67,10 +71,17 @@ async def no_voice_close_connect(conn):
conn.client_no_voice_last_time = time.time() * 1000 conn.client_no_voice_last_time = time.time() * 1000
else: else:
no_voice_time = time.time() * 1000 - conn.client_no_voice_last_time no_voice_time = time.time() * 1000 - conn.client_no_voice_last_time
close_connection_no_voice_time = conn.config.get("close_connection_no_voice_time", 120) close_connection_no_voice_time = conn.config.get(
if not conn.close_after_chat and no_voice_time > 1000 * close_connection_no_voice_time: "close_connection_no_voice_time", 120
)
if (
not conn.close_after_chat
and no_voice_time > 1000 * close_connection_no_voice_time
):
conn.close_after_chat = True conn.close_after_chat = True
conn.client_abort = False conn.client_abort = False
conn.asr_server_receive = False conn.asr_server_receive = False
prompt = "请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。" prompt = (
"请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。"
)
await startToChat(conn, prompt) await startToChat(conn, prompt)
+21 -4
View File
@@ -2,8 +2,11 @@ from config.logger import setup_logging
import json import json
from core.handle.abortHandle import handleAbortMessage from core.handle.abortHandle import handleAbortMessage
from core.handle.helloHandle import handleHelloMessage 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.receiveAudioHandle import startToChat, handleAudioMessage
from core.handle.sendAudioHandle import send_stt_message, send_tts_message
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
import asyncio
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -32,17 +35,31 @@ async def handleTextMessage(conn, message):
conn.client_have_voice = True conn.client_have_voice = True
conn.client_voice_stop = True conn.client_voice_stop = True
if len(conn.asr_audio) > 0: if len(conn.asr_audio) > 0:
await handleAudioMessage(conn, b'') await handleAudioMessage(conn, b"")
elif msg_json["state"] == "detect": elif msg_json["state"] == "detect":
conn.asr_server_receive = False conn.asr_server_receive = False
conn.client_have_voice = False conn.client_have_voice = False
conn.asr_audio.clear() conn.asr_audio.clear()
if "text" in msg_json: 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": elif msg_json["type"] == "iot":
if "descriptors" in msg_json: if "descriptors" in msg_json:
await handleIotDescriptors(conn, msg_json["descriptors"]) asyncio.create_task(handleIotDescriptors(conn, msg_json["descriptors"]))
if "states" in msg_json: if "states" in msg_json:
await handleIotStatus(conn, msg_json["states"]) asyncio.create_task(handleIotStatus(conn, msg_json["states"]))
except json.JSONDecodeError: except json.JSONDecodeError:
await conn.websocket.send(message) await conn.websocket.send(message)
+86
View File
@@ -0,0 +1,86 @@
from datetime import timedelta
from typing import Optional
from contextlib import AsyncExitStack
import os, shutil
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from config.logger import setup_logging
TAG = __name__
class MCPClient:
def __init__(self, config):
# Initialize session and client objects
self.session: Optional[ClientSession] = None
self.exit_stack = AsyncExitStack()
self.logger = setup_logging()
self.config = config
self.tolls = []
async def initialize(self):
args = self.config.get("args", [])
command = (
shutil.which("npx")
if self.config["command"] == "npx"
else self.config["command"]
)
env={**os.environ}
if self.config.get("env"):
env.update(self.config["env"])
server_params = StdioServerParameters(
command=command,
args=args,
env=env
)
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
self.stdio, self.write = stdio_transport
time_out_delta = timedelta(seconds=15)
self.session = await self.exit_stack.enter_async_context(ClientSession(read_stream=self.stdio, write_stream=self.write, read_timeout_seconds=time_out_delta))
await self.session.initialize()
# List available tools
response = await self.session.list_tools()
tools = response.tools
self.tools = tools
self.logger.bind(tag=TAG).info(f"Connected to server with tools:{[tool.name for tool in tools]}")
def has_tool(self, tool_name):
return any(tool.name == tool_name for tool in self.tools)
def get_available_tools(self):
available_tools = [{"type": "function", "function":{
"name": tool.name,
"description": tool.description,
"parameters": tool.inputSchema
} } for tool in self.tools]
return available_tools
async def call_tool(self, tool_name: str, tool_args: dict):
self.logger.bind(tag=TAG).info(f"MCPClient Calling tool {tool_name} with args: {tool_args}")
try:
response = await self.session.call_tool(tool_name, tool_args)
except Exception as e:
self.logger.bind(tag=TAG).error(f"Error calling tool {tool_name}: {e}")
from types import SimpleNamespace
error_content = SimpleNamespace(
type='text',
text=f"Error calling tool {tool_name}: {e}"
)
error_response = SimpleNamespace(
content=[error_content],
isError=True
)
return error_response
self.logger.bind(tag=TAG).info(f"MCPClient Response from tool {tool_name}: {response}")
return response
async def cleanup(self):
"""Clean up resources"""
await self.exit_stack.aclose()
+110
View File
@@ -0,0 +1,110 @@
"""MCP服务管理器"""
import os, json
from typing import Dict, Any, List
from .MCPClient import MCPClient
from config.logger import setup_logging
from core.utils.util import get_project_dir
from plugins_func.register import register_function, ActionResponse, Action, ToolType
TAG = __name__
class MCPManager:
"""管理多个MCP服务的集中管理器"""
def __init__(self,conn) -> None:
"""
初始化MCP管理器
"""
self.conn = conn
self.logger = setup_logging()
self.config_path = get_project_dir() + 'data/.mcp_server_settings.json'
if os.path.exists(self.config_path) == False:
self.config_path = ""
self.logger.bind(tag=TAG).warning(f"请检查mcp服务配置文件:data/.mcp_server_settings.json")
self.client: Dict[str, MCPClient] = {}
self.tools = []
def load_config(self) -> Dict[str, Any]:
"""加载MCP服务配置
Returns:
Dict[str, Any]: 服务配置字典
"""
if len(self.config_path) == 0:
return {}
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
return config.get('mcpServers', {})
except Exception as e:
self.logger.bind(tag=TAG).error(f"Error loading MCP config from {self.config_path}: {e}")
return {}
async def initialize_servers(self) -> None:
"""初始化所有MCP服务"""
config = self.load_config()
for name, srv_config in config.items():
if not srv_config.get("command"):
self.logger.bind(tag=TAG).warning(f"Skipping server {name}: command not specified")
continue
try:
client = MCPClient(srv_config)
await client.initialize()
self.client[name] = client
self.logger.bind(tag=TAG).info(f"Initialized MCP client: {name}")
client_tools = client.get_available_tools()
self.tools.extend(client_tools)
for tool in client_tools:
func_name = "mcp_"+tool["function"]["name"]
register_function(func_name, tool, ToolType.MCP_CLIENT)(self.execute_tool)
self.conn.func_handler.function_registry.register_function(func_name)
except Exception as e:
self.logger.bind(tag=TAG).error(f"Failed to initialize MCP server {name}: {e}")
self.conn.func_handler.upload_functions_desc()
def get_all_tools(self) -> List[Dict[str, Any]]:
"""获取所有服务的工具function定义
Returns:
List[Dict[str, Any]]: 所有工具的function定义列表
"""
return self.tools
def is_mcp_tool(self, tool_name: str) -> bool:
"""检查是否是MCP工具
Args:
tool_name: 工具名称
Returns:
bool: 是否是MCP工具
"""
for tool in self.tools:
if tool.get("function") != None and tool["function"].get("name") == tool_name:
return True
return False
async def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
"""执行工具调用
Args:
tool_name: 工具名称
arguments: 工具参数
Returns:
Any: 工具执行结果
Raises:
ValueError: 工具未找到时抛出
"""
self.logger.bind(tag=TAG).info(f"Executing tool {tool_name} with arguments: {arguments}")
for client in self.client.values():
if client.has_tool(tool_name):
return await client.call_tool(tool_name, arguments)
raise ValueError(f"Tool {tool_name} not found in any MCP server")
async def cleanup_all(self) -> None:
for name, client in self.client.items():
try:
await client.cleanup()
self.logger.bind(tag=TAG).info(f"Cleaned up MCP client: {name}")
except Exception as e:
self.logger.bind(tag=TAG).error(f"Error cleaning up MCP client {name}: {e}")
self.client.clear()
@@ -10,14 +10,21 @@ class IntentProviderBase(ABC):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.intent_options = config.get("intent_options", { self.intent_options = config.get("intent_options", {
"handle_exit_intent": "结束聊天, 用户发来如再见之类的表示结束的话, 不想再进行对话的时候",
"play_music": "播放音乐, 用户希望你可以播放音乐, 只用于播放音乐的意图",
"get_weather": "查询天气, 用户希望查询某个地点的天气情况",
"get_news": "查询新闻, 用户希望查询最新新闻或特定类型的新闻",
"get_lunar": "用于获取今天的阴历/农历和黄历信息",
"get_time": "获取今天日期或者当前时间信息",
"continue_chat": "继续聊天", "continue_chat": "继续聊天",
"end_chat": "结束聊天",
"play_music": "播放音乐"
}) })
def set_llm(self, llm): def set_llm(self, llm):
self.llm = llm self.llm = llm
logger.bind(tag=TAG).debug("Set LLM for intent provider") # 获取模型名称和类型信息
model_name = getattr(llm, 'model_name', str(llm.__class__.__name__))
# 记录更详细的日志
logger.bind(tag=TAG).info(f"意图识别设置LLM: {model_name}")
@abstractmethod @abstractmethod
async def detect_intent(self, conn, dialogue_history: List[Dict], text: str) -> str: async def detect_intent(self, conn, dialogue_history: List[Dict], text: str) -> str:
@@ -30,5 +37,6 @@ class IntentProviderBase(ABC):
- "继续聊天" - "继续聊天"
- "结束聊天" - "结束聊天"
- "播放音乐 歌名""随机播放音乐" - "播放音乐 歌名""随机播放音乐"
- "查询天气 地点名""查询天气 [当前位置]"
""" """
pass pass
@@ -0,0 +1,20 @@
from ..base import IntentProviderBase
from typing import List, Dict
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class IntentProvider(IntentProviderBase):
async def detect_intent(self, conn, dialogue_history: List[Dict], text: str) -> str:
"""
默认的意图识别实现,始终返回继续聊天
Args:
dialogue_history: 对话历史记录列表
text: 本次对话记录
Returns:
固定返回"继续聊天"
"""
logger.bind(tag=TAG).debug("Using functionCallProvider, always returning continue chat")
return self.intent_options["continue_chat"]
@@ -3,6 +3,9 @@ from ..base import IntentProviderBase
from plugins_func.functions.play_music import initialize_music_handler from plugins_func.functions.play_music import initialize_music_handler
from config.logger import setup_logging from config.logger import setup_logging
import re import re
import json
import hashlib
import time
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -13,6 +16,10 @@ class IntentProvider(IntentProviderBase):
super().__init__(config) super().__init__(config)
self.llm = None self.llm = None
self.promot = self.get_intent_system_prompt() self.promot = self.get_intent_system_prompt()
# 添加缓存管理
self.intent_cache = {} # 缓存意图识别结果
self.cache_expiry = 600 # 缓存有效期10分钟
self.cache_max_size = 100 # 最多缓存100个意图
def get_intent_system_prompt(self) -> str: def get_intent_system_prompt(self) -> str:
""" """
@@ -22,62 +29,119 @@ class IntentProvider(IntentProviderBase):
""" """
intent_list = [] intent_list = []
"""
"continue_chat": "1.继续聊天, 除了播放音乐和结束聊天的时候的选项, 比如日常的聊天和问候, 对话等",
"end_chat": "2.结束聊天, 用户发来如再见之类的表示结束的话, 不想再进行对话的时候",
"play_music": "3.播放音乐, 用户希望你可以播放音乐, 只用于播放音乐的意图"
"""
for key, value in self.intent_options.items():
if key == "play_music":
intent_list.append("3.播放音乐, 用户希望你可以播放音乐, 只用于播放音乐的意图")
elif key == "end_chat":
intent_list.append("2.结束聊天, 用户发来如再见之类的表示结束的话, 不想再进行对话的时候")
elif key == "continue_chat":
intent_list.append("1.继续聊天, 除了播放音乐和结束聊天的时候的选项, 比如日常的聊天和问候, 对话等")
else:
intent_list.append(value)
# "如果是唱歌、听歌、播放音乐,请指定歌名,格式为'播放音乐 [识别出的歌名]'。\n"
# "如果听不出具体歌名,可以返回'随机播放音乐'。\n"
# "只需要返回意图结果的json,不要解释。"
# "返回格式如下:\n"
prompt = ( prompt = (
"你是一个意图识别助手。你需要根据和用户的对话记录,重点分析用户的最后一句话,判断用户意图属于以下哪一类(使用<start>和<end>标志)\n" "你是一个意图识别助手。分析用户的最后一句话,判断用户意图属于以下哪一类:\n"
"<start>" "<start>"
f"{', '.join(intent_list)}" f"{', '.join(intent_list)}"
"<end>\n" "<end>\n"
"你需要按照以下的步骤处理用户的对话" "处理步骤:"
"1. 思考出对话的意图是哪一类的" "1. 思考意图类型,生成function_call格式"
"2. 属于1和2的意图, 直接返回,返回格式如下:\n" "\n\n"
"{intent: '用户意图'}\n" "返回格式示例:\n"
"3. 属于3的意图,则继续分析用户希望播放音乐\n" "1. 播放音乐意图: {\"function_call\": {\"name\": \"play_music\", \"arguments\": {\"song_name\": \"音乐名称\"}}}\n"
"4. 如果无法识别出具体歌名,可以返回'随机播放音乐'\n" "2. 查询天气意图: {\"function_call\": {\"name\": \"get_weather\", \"arguments\": {\"location\": \"地点名称\", \"lang\": \"zh_CN\"}}}\n"
"{intent: '播放音乐 [获取的音乐名字]'}\n" "3. 查询新闻意图: {\"function_call\": {\"name\": \"get_news\", \"arguments\": {\"category\": \"新闻类别\", \"detail\": false, \"lang\": \"zh_CN\"}}}\n"
"下面是几个处理的示例(思考的内容不返回, 只返回json部分, 无额外的内容)\n" "4. 结束对话意图: {\"function_call\": {\"name\": \"handle_exit_intent\", \"arguments\": {\"say_goodbye\": \"goodbye\"}}}\n"
"```" "5. 获取当天日期时间: {\"function_call\": {\"name\": \"get_time\"}}\n"
"6. 获取当前黄历意图: {\"function_call\": {\"name\": \"get_lunar\"}}\n"
"7. 继续聊天意图: {\"function_call\": {\"name\": \"continue_chat\"}}\n"
"\n"
"注意:\n"
"- 播放音乐:无歌名时,song_name设为\"random\"\n"
"- 查询天气:无地点时,location设为null\n"
"- 查询新闻:无类别时,category设为null;查询详情时,detail设为true\n"
"- 如果没有明显的意图,应按照继续聊天意图处理\n"
"- 只返回纯JSON,不要任何其他内容\n"
"\n"
"示例分析:\n"
"```\n"
"用户: 你好小智\n"
"返回: {\"function_call\": {\"name\": \"continue_chat\"}}\n"
"```\n"
"```\n"
"用户: 你今天怎么样?\n" "用户: 你今天怎么样?\n"
"思考(不返回): 用户发来的数据是一个问候语,属于继续聊天的意图, 是种类1, 种类1的需求是直接返回\n" "返回: {\"function_call\": {\"name\": \"continue_chat\"}}\n"
"返回结果: {intent: '继续聊天'}\n" "```\n"
"```" "```\n"
"用户: 我今天有点累了, 我们明天再聊吧\n" "用户: 现在是几号了?现在几点了?\n"
"思考(不返回): 用户表达了今天不想继续对话,属于结束聊天的意图, 是种类2, 种类2的需求是直接返回\n" "返回: {\"function_call\": {\"name\": \"get_time\"}}\n"
"返回结果: {intent: '结束聊天'}\n" "```\n"
"```" "```\n"
"用户: 今天有点累了, 我们明天再聊吧\n" "用户: 今天农历是多少?\n"
"思考(不返回): 用户表达了今天不想继续对话,属于结束聊天的意图, 是种类2, 种类2的需求是直接返回\n" "返回: {\"function_call\": {\"name\": \"get_lunar\"}}\n"
"返回结果: {intent: '结束聊天'}\n" "```\n"
"```" "```\n"
"用户: 你可以播放一首中秋月给我听吗\n" "用户: 我们明天再聊吧\n"
"思考(不返回): 用户表达了想听音乐的续签,属于播放音乐的意图, 是种类3, 种类3的需求需要继续判断播放的音乐, 这里用户希望的歌曲名明确给出是中秋月\n" "返回: {\"function_call\": {\"name\": \"handle_exit_intent\"}}\n"
"返回结果: {intent: '播放音乐 [中秋月]'}\n" "```\n"
"```" "```\n"
"你现在可以使用的音乐的名称如下(使用<start>和<end>标志):\n" "用户: 播放中秋月\n"
"返回: {\"function_call\": {\"name\": \"play_music\", \"arguments\": {\"song_name\": \"中秋月\"}}}\n"
"```\n"
"```\n"
"用户: 北京天气怎么样\n"
"返回: {\"function_call\": {\"name\": \"get_weather\", \"arguments\": {\"location\": \"北京\", \"lang\": \"zh_CN\"}}}\n"
"```\n"
"```\n"
"用户: 今天天气怎么样\n"
"返回: {\"function_call\": {\"name\": \"get_weather\", \"arguments\": {\"location\": null, \"lang\": \"zh_CN\"}}}\n"
"```\n"
"```\n"
"用户: 播报财经新闻\n"
"返回: {\"function_call\": {\"name\": \"get_news\", \"arguments\": {\"category\": \"财经\", \"detail\": false, \"lang\": \"zh_CN\"}}}\n"
"```\n"
"```\n"
"用户: 有什么最新新闻\n"
"返回: {\"function_call\": {\"name\": \"get_news\", \"arguments\": {\"category\": null, \"detail\": false, \"lang\": \"zh_CN\"}}}\n"
"```\n"
"```\n"
"用户: 详细介绍一下这条新闻\n"
"返回: {\"function_call\": {\"name\": \"get_news\", \"arguments\": {\"detail\": true, \"lang\": \"zh_CN\"}}}\n"
"```\n"
"可用的音乐名称:\n"
) )
return prompt return prompt
def clean_cache(self):
"""清理过期缓存"""
now = time.time()
# 找出过期键
expired_keys = [k for k, v in self.intent_cache.items() if now - v['timestamp'] > self.cache_expiry]
for key in expired_keys:
del self.intent_cache[key]
# 如果缓存太大,移除最旧的条目
if len(self.intent_cache) > self.cache_max_size:
# 按时间戳排序并保留最新的条目
sorted_items = sorted(self.intent_cache.items(), key=lambda x: x[1]['timestamp'])
for key, _ in sorted_items[:len(sorted_items) - self.cache_max_size]:
del self.intent_cache[key]
async def detect_intent(self, conn, dialogue_history: List[Dict], text: str) -> str: async def detect_intent(self, conn, dialogue_history: List[Dict], text: str) -> str:
if not self.llm: if not self.llm:
raise ValueError("LLM provider not set") raise ValueError("LLM provider not set")
# 记录整体开始时间
total_start_time = time.time()
# 打印使用的模型信息
model_info = getattr(self.llm, 'model_name', str(self.llm.__class__.__name__))
logger.bind(tag=TAG).info(f"使用意图识别模型: {model_info}")
# 计算缓存键
cache_key = hashlib.md5(text.encode()).hexdigest()
# 检查缓存
if cache_key in self.intent_cache:
cache_entry = self.intent_cache[cache_key]
# 检查缓存是否过期
if time.time() - cache_entry['timestamp'] <= self.cache_expiry:
cache_time = time.time() - total_start_time
logger.bind(tag=TAG).info(f"使用缓存的意图: {cache_key} -> {cache_entry['intent']}, 耗时: {cache_time:.4f}")
return cache_entry['intent']
# 清理缓存
self.clean_cache()
# 构建用户最后一句话的提示 # 构建用户最后一句话的提示
msgStr = "" msgStr = ""
@@ -94,18 +158,78 @@ class IntentProvider(IntentProviderBase):
music_file_names = music_config["music_file_names"] music_file_names = music_config["music_file_names"]
prompt_music = f"{self.promot}\n<start>{music_file_names}\n<end>" prompt_music = f"{self.promot}\n<start>{music_file_names}\n<end>"
logger.bind(tag=TAG).debug(f"User prompt: {prompt_music}") logger.bind(tag=TAG).debug(f"User prompt: {prompt_music}")
# 记录预处理完成时间
preprocess_time = time.time() - total_start_time
logger.bind(tag=TAG).debug(f"意图识别预处理耗时: {preprocess_time:.4f}")
# 使用LLM进行意图识别 # 使用LLM进行意图识别
llm_start_time = time.time()
logger.bind(tag=TAG).info(f"开始LLM意图识别调用, 模型: {model_info}")
intent = self.llm.response_no_stream( intent = self.llm.response_no_stream(
system_prompt=prompt_music, system_prompt=prompt_music,
user_prompt=user_prompt user_prompt=user_prompt
) )
# 使用正则表达式提取大括号中的内容
# 使用正则表达式提取 {} 中的内容 # 记录LLM调用完成时间
match = re.search(r'\{.*?\}', intent) llm_time = time.time() - llm_start_time
logger.bind(tag=TAG).info(f"LLM意图识别完成, 模型: {model_info}, 调用耗时: {llm_time:.4f}")
# 记录后处理开始时间
postprocess_start_time = time.time()
# 清理和解析响应
intent = intent.strip()
# 尝试提取JSON部分
match = re.search(r'\{.*\}', intent, re.DOTALL)
if match: if match:
result = match.group(0) intent = match.group(0)
intent = result
else: # 记录总处理时间
intent = "{intent: '继续聊天'}" total_time = time.time() - total_start_time
logger.bind(tag=TAG).info(f"Detected intent: {intent}") logger.bind(tag=TAG).info(f"【意图识别性能】模型: {model_info}, 总耗时: {total_time:.4f}秒, LLM调用: {llm_time:.4f}秒, 查询: '{text[:20]}...'")
return intent.strip()
# 尝试解析为JSON
try:
intent_data = json.loads(intent)
# 如果包含function_call,则格式化为适合处理的格式
if "function_call" in intent_data:
function_data = intent_data["function_call"]
function_name = function_data.get("name")
function_args = function_data.get("arguments", {})
# 记录识别到的function call
logger.bind(tag=TAG).info(f"识别到function call: {function_name}, 参数: {function_args}")
# 添加到缓存
self.intent_cache[cache_key] = {
'intent': intent,
'timestamp': time.time()
}
# 后处理时间
postprocess_time = time.time() - postprocess_start_time
logger.bind(tag=TAG).debug(f"意图后处理耗时: {postprocess_time:.4f}")
# 确保返回完全序列化的JSON字符串
return intent
else:
# 添加到缓存
self.intent_cache[cache_key] = {
'intent': intent,
'timestamp': time.time()
}
# 后处理时间
postprocess_time = time.time() - postprocess_start_time
logger.bind(tag=TAG).debug(f"意图后处理耗时: {postprocess_time:.4f}")
# 返回普通意图
return intent
except json.JSONDecodeError:
# 后处理时间
postprocess_time = time.time() - postprocess_start_time
logger.bind(tag=TAG).error(f"无法解析意图JSON: {intent}, 后处理耗时: {postprocess_time:.4f}")
# 如果解析失败,默认返回继续聊天意图
return "{\"intent\": \"继续聊天\"}"
@@ -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服务响应异常】"
@@ -6,11 +6,12 @@ from core.providers.llm.base import LLMProviderBase
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
class LLMProvider(LLMProviderBase): class LLMProvider(LLMProviderBase):
def __init__(self, config): def __init__(self, config):
self.api_key = config["api_key"] self.api_key = config["api_key"]
self.mode = config.get("mode", "chat-messages") self.mode = config.get("mode", "chat-messages")
self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip('/') self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip("/")
self.session_conversation_map = {} # 存储session_id和conversation_id的映射 self.session_conversation_map = {} # 存储session_id和conversation_id的映射
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
@@ -22,57 +23,58 @@ class LLMProvider(LLMProviderBase):
# 发起流式请求 # 发起流式请求
if self.mode == "chat-messages": if self.mode == "chat-messages":
request_json = { request_json = {
"query": last_msg["content"], "query": last_msg["content"],
"response_mode": "streaming", "response_mode": "streaming",
"user": session_id, "user": session_id,
"inputs": {}, "inputs": {},
"conversation_id": conversation_id "conversation_id": conversation_id,
} }
elif self.mode == "workflows/run": elif self.mode == "workflows/run":
request_json = { request_json = {
"inputs": {"query": last_msg["content"]}, "inputs": {"query": last_msg["content"]},
"response_mode": "streaming", "response_mode": "streaming",
"user": session_id "user": session_id,
} }
elif self.mode == "completion-messages": elif self.mode == "completion-messages":
request_json = { request_json = {
"inputs": {"query": last_msg["content"]}, "inputs": {"query": last_msg["content"]},
"response_mode": "streaming", "response_mode": "streaming",
"user": session_id "user": session_id,
} }
with requests.post( with requests.post(
f"{self.base_url}/{self.mode}", f"{self.base_url}/{self.mode}",
headers={"Authorization": f"Bearer {self.api_key}"}, headers={"Authorization": f"Bearer {self.api_key}"},
json=request_json, json=request_json,
stream=True stream=True,
) as r: ) as r:
if self.mode == "chat-messages": if self.mode == "chat-messages":
for line in r.iter_lines(): for line in r.iter_lines():
if line.startswith(b'data: '): if line.startswith(b"data: "):
event = json.loads(line[6:]) event = json.loads(line[6:])
# 如果没有找到conversation_id,则获取此次conversation_id # 如果没有找到conversation_id,则获取此次conversation_id
if not conversation_id: if not conversation_id:
conversation_id = event.get('conversation_id') conversation_id = event.get("conversation_id")
self.session_conversation_map[session_id] = conversation_id # 更新映射 self.session_conversation_map[session_id] = (
if event.get('answer'): conversation_id # 更新映射
yield event['answer'] )
if event.get("answer"):
yield event["answer"]
elif self.mode == "workflows/run": elif self.mode == "workflows/run":
for line in r.iter_lines(): for line in r.iter_lines():
# logger.bind(tag=TAG).info(f"chat message response: {line}") if line.startswith(b"data: "):
if line.startswith(b'data: '):
event = json.loads(line[6:]) event = json.loads(line[6:])
if event.get('event') == "workflow_finished": if event.get("event") == "workflow_finished":
if event['data']['status'] == "succeeded": if event["data"]["status"] == "succeeded":
yield event['data']['outputs']['answer'] yield event["data"]["outputs"]["answer"]
else: else:
yield "【服务响应异常】" yield "【服务响应异常】"
elif self.mode == "completion-messages": elif self.mode == "completion-messages":
for line in r.iter_lines(): for line in r.iter_lines():
if line.startswith(b'data: '): if line.startswith(b"data: "):
event = json.loads(line[6:]) event = json.loads(line[6:])
if event.get('answer'): if event.get("answer"):
yield event['answer'] yield event["answer"]
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error in response generation: {e}") logger.bind(tag=TAG).error(f"Error in response generation: {e}")
@@ -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}")
@@ -64,5 +64,5 @@ class LLMProvider(LLMProviderBase):
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"Error in function call streaming: {e}") logger.bind(tag=TAG).error(f"Error in function call streaming: {e}")
yield {"type": "content", "content": f"【OpenAI服务响应异常: {e}"} yield {"type": "content", "content": f"【OpenAI服务响应异常: {e}"}
@@ -0,0 +1,85 @@
from config.logger import setup_logging
from openai import OpenAI
import json
from core.providers.llm.base import LLMProviderBase
TAG = __name__
logger = setup_logging()
class LLMProvider(LLMProviderBase):
def __init__(self, config):
self.model_name = config.get("model_name")
self.base_url = config.get("base_url", "http://localhost:9997")
# Initialize OpenAI client with Xinference base URL
# 如果没有v1,增加v1
if not self.base_url.endswith("/v1"):
self.base_url = f"{self.base_url}/v1"
logger.bind(tag=TAG).info(f"Initializing Xinference LLM provider with model: {self.model_name}, base_url: {self.base_url}")
try:
self.client = OpenAI(
base_url=self.base_url,
api_key="xinference" # Xinference has a similar setup to Ollama where it doesn't need an actual key
)
logger.bind(tag=TAG).info("Xinference client initialized successfully")
except Exception as e:
logger.bind(tag=TAG).error(f"Error initializing Xinference client: {e}")
raise
def response(self, session_id, dialogue):
try:
logger.bind(tag=TAG).debug(f"Sending request to Xinference with model: {self.model_name}, dialogue length: {len(dialogue)}")
responses = self.client.chat.completions.create(
model=self.model_name,
messages=dialogue,
stream=True
)
is_active=True
for chunk in responses:
try:
delta = chunk.choices[0].delta if getattr(chunk, 'choices', None) else None
content = delta.content if hasattr(delta, 'content') else ''
if content:
if '<think>' in content:
is_active = False
content = content.split('<think>')[0]
if '</think>' in content:
is_active = True
content = content.split('</think>')[-1]
if is_active:
yield content
except Exception as e:
logger.bind(tag=TAG).error(f"Error processing chunk: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"Error in Xinference response generation: {e}")
yield "【Xinference服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None):
try:
logger.bind(tag=TAG).debug(f"Sending function call request to Xinference with model: {self.model_name}, dialogue length: {len(dialogue)}")
if functions:
logger.bind(tag=TAG).debug(f"Function calls enabled with: {[f.get('function', {}).get('name') for f in functions]}")
stream = self.client.chat.completions.create(
model=self.model_name,
messages=dialogue,
stream=True,
tools=functions,
)
for chunk in stream:
delta = chunk.choices[0].delta
content = delta.content
tool_calls = delta.tool_calls
if content:
yield content, tool_calls
elif tool_calls:
yield None, tool_calls
except Exception as e:
logger.bind(tag=TAG).error(f"Error in Xinference function call: {e}")
yield {"type": "content", "content": f"【Xinference服务响应异常: {str(e)}"}
@@ -121,9 +121,15 @@ class TTSProviderBase(ABC):
async def tts_one_sentence(self, text, u_id=None): async def tts_one_sentence(self, text, u_id=None):
if not u_id: if not u_id:
u_id = str(uuid.uuid4()).replace("-", "") u_id = str(uuid.uuid4()).replace("-", "")
self.tts.tts_text_queue.put(TTSMessageDTO(u_id=u_id, msg_type=MsgType.START_TTS_REQUEST, content='')) self.tts.tts_text_queue.put(
self.tts.tts_text_queue.put(TTSMessageDTO(u_id=u_id, msg_type=MsgType.TTS_TEXT_REQUEST, content=text)) TTSMessageDTO(u_id=u_id, msg_type=MsgType.START_TTS_REQUEST, content="")
self.tts.tts_text_queue.put(TTSMessageDTO(u_id=u_id, msg_type=MsgType.STOP_TTS_REQUEST, content=text)) )
self.tts.tts_text_queue.put(
TTSMessageDTO(u_id=u_id, msg_type=MsgType.TTS_TEXT_REQUEST, content=text)
)
self.tts.tts_text_queue.put(
TTSMessageDTO(u_id=u_id, msg_type=MsgType.STOP_TTS_REQUEST, content=text)
)
def _enable_two_way_tts(self): def _enable_two_way_tts(self):
while not self.stop_event.is_set(): while not self.stop_event.is_set():
@@ -273,7 +279,6 @@ class TTSProviderBase(ABC):
def generate_filename(self): def generate_filename(self):
pass pass
@abstractmethod @abstractmethod
async def text_to_speak(self, u_id, text, is_last_text=False, is_first_text=False): async def text_to_speak(self, u_id, text, is_last_text=False, is_first_text=False):
pass pass
+151 -92
View File
@@ -9,7 +9,6 @@ import json
import base64 import base64
import requests import requests
from datetime import datetime from datetime import datetime
from mutagen.oggopus import OggOpus
import websockets import websockets
@@ -76,14 +75,16 @@ EVENT_TTSResponse = 352
class Header: class Header:
def __init__(self, def __init__(
protocol_version=PROTOCOL_VERSION, self,
header_size=DEFAULT_HEADER_SIZE, protocol_version=PROTOCOL_VERSION,
message_type: int = 0, header_size=DEFAULT_HEADER_SIZE,
message_type_specific_flags: int = 0, message_type: int = 0,
serial_method: int = NO_SERIALIZATION, message_type_specific_flags: int = 0,
compression_type: int = COMPRESSION_NO, serial_method: int = NO_SERIALIZATION,
reserved_data=0): compression_type: int = COMPRESSION_NO,
reserved_data=0,
):
self.header_size = header_size self.header_size = header_size
self.protocol_version = protocol_version self.protocol_version = protocol_version
self.message_type = message_type self.message_type = message_type
@@ -93,16 +94,20 @@ class Header:
self.reserved_data = reserved_data self.reserved_data = reserved_data
def as_bytes(self) -> bytes: def as_bytes(self) -> bytes:
return bytes([ return bytes(
(self.protocol_version << 4) | self.header_size, [
(self.message_type << 4) | self.message_type_specific_flags, (self.protocol_version << 4) | self.header_size,
(self.serial_method << 4) | self.compression_type, (self.message_type << 4) | self.message_type_specific_flags,
self.reserved_data (self.serial_method << 4) | self.compression_type,
]) self.reserved_data,
]
)
class Optional: class Optional:
def __init__(self, event: int = EVENT_NONE, sessionId: str = None, sequence: int = None): def __init__(
self, event: int = EVENT_NONE, sessionId: str = None, sequence: int = None
):
self.event = event self.event = event
self.sessionId = sessionId self.sessionId = sessionId
self.errorCode: int = 0 self.errorCode: int = 0
@@ -160,45 +165,57 @@ class TTSProvider(TTSProviderBase):
"X-Api-Resource-Id": self.resource_id, "X-Api-Resource-Id": self.resource_id,
"X-Api-Connect-Id": uuid.uuid4(), "X-Api-Connect-Id": uuid.uuid4(),
} }
self.ws = await websockets.connect(self.ws_url, additional_headers=ws_header, max_size=1000000000) self.ws = await websockets.connect(
tts_priority = threading.Thread(target=self._start_monitor_tts_response_thread(), daemon=True) self.ws_url, additional_headers=ws_header, max_size=1000000000
)
tts_priority = threading.Thread(
target=self._start_monitor_tts_response_thread(), daemon=True
)
tts_priority.start() tts_priority.start()
def generate_filename(self, extension=".wav"): def generate_filename(self, extension=".wav"):
return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}{extension}") return os.path.join(
self.output_file,
f"tts-{datetime.now().date()}@{uuid.uuid4().hex}{extension}",
)
async def send_event(self, header: bytes, optional: bytes | None = None, async def send_event(
payload: bytes = None): self, header: bytes, optional: bytes | None = None, payload: bytes = None
):
full_client_request = bytearray(header) full_client_request = bytearray(header)
if optional is not None: if optional is not None:
full_client_request.extend(optional) full_client_request.extend(optional)
if payload is not None: if payload is not None:
payload_size = len(payload).to_bytes(4, 'big', signed=True) payload_size = len(payload).to_bytes(4, "big", signed=True)
full_client_request.extend(payload_size) full_client_request.extend(payload_size)
full_client_request.extend(payload) full_client_request.extend(payload)
await self.ws.send(full_client_request) await self.ws.send(full_client_request)
async def send_text(self, speaker: str, text: str, session_id): async def send_text(self, speaker: str, text: str, session_id):
header = Header(message_type=FULL_CLIENT_REQUEST, header = Header(
message_type_specific_flags=MsgTypeFlagWithEvent, message_type=FULL_CLIENT_REQUEST,
serial_method=JSON).as_bytes() message_type_specific_flags=MsgTypeFlagWithEvent,
serial_method=JSON,
).as_bytes()
optional = Optional(event=EVENT_TaskRequest, sessionId=session_id).as_bytes() optional = Optional(event=EVENT_TaskRequest, sessionId=session_id).as_bytes()
payload = self.get_payload_bytes(event=EVENT_TaskRequest, text=text, speaker=speaker) payload = self.get_payload_bytes(
event=EVENT_TaskRequest, text=text, speaker=speaker
)
return await self.send_event(header, optional, payload) return await self.send_event(header, optional, payload)
# 读取 res 数组某段 字符串内容 # 读取 res 数组某段 字符串内容
def read_res_content(self, res: bytes, offset: int): def read_res_content(self, res: bytes, offset: int):
content_size = int.from_bytes(res[offset: offset + 4], "big", signed=True) content_size = int.from_bytes(res[offset : offset + 4], "big", signed=True)
offset += 4 offset += 4
content = str(res[offset: offset + content_size]) content = str(res[offset : offset + content_size])
offset += content_size offset += content_size
return content, offset return content, offset
# 读取 payload # 读取 payload
def read_res_payload(self, res: bytes, offset: int): def read_res_payload(self, res: bytes, offset: int):
payload_size = int.from_bytes(res[offset: offset + 4], "big", signed=True) payload_size = int.from_bytes(res[offset : offset + 4], "big", signed=True)
offset += 4 offset += 4
payload = res[offset: offset + payload_size] payload = res[offset : offset + payload_size]
offset += payload_size offset += payload_size
return payload, offset return payload, offset
@@ -211,11 +228,11 @@ class TTSProvider(TTSProviderBase):
header = response.header header = response.header
num = 0b00001111 num = 0b00001111
header.protocol_version = res[0] >> 4 & num header.protocol_version = res[0] >> 4 & num
header.header_size = res[0] & 0x0f header.header_size = res[0] & 0x0F
header.message_type = (res[1] >> 4) & num header.message_type = (res[1] >> 4) & num
header.message_type_specific_flags = res[1] & 0x0f header.message_type_specific_flags = res[1] & 0x0F
header.serialization_method = res[2] >> num header.serialization_method = res[2] >> num
header.message_compression = res[2] & 0x0f header.message_compression = res[2] & 0x0F
header.reserved = res[3] header.reserved = res[3]
# #
offset = 4 offset = 4
@@ -231,78 +248,101 @@ class TTSProvider(TTSProviderBase):
elif optional.event == EVENT_ConnectionStarted: elif optional.event == EVENT_ConnectionStarted:
optional.connectionId, offset = self.read_res_content(res, offset) optional.connectionId, offset = self.read_res_content(res, offset)
elif optional.event == EVENT_ConnectionFailed: elif optional.event == EVENT_ConnectionFailed:
optional.response_meta_json, offset = self.read_res_content(res, offset) optional.response_meta_json, offset = self.read_res_content(
elif (optional.event == EVENT_SessionStarted res, offset
or optional.event == EVENT_SessionFailed )
or optional.event == EVENT_SessionFinished): elif (
optional.event == EVENT_SessionStarted
or optional.event == EVENT_SessionFailed
or optional.event == EVENT_SessionFinished
):
optional.sessionId, offset = self.read_res_content(res, offset) optional.sessionId, offset = self.read_res_content(res, offset)
optional.response_meta_json, offset = self.read_res_content(res, offset) optional.response_meta_json, offset = self.read_res_content(
res, offset
)
else: else:
optional.sessionId, offset = self.read_res_content(res, offset) optional.sessionId, offset = self.read_res_content(res, offset)
response.payload, offset = self.read_res_payload(res, offset) response.payload, offset = self.read_res_payload(res, offset)
elif header.message_type == ERROR_INFORMATION: elif header.message_type == ERROR_INFORMATION:
optional.errorCode = int.from_bytes(res[offset:offset + 4], "big", signed=True) optional.errorCode = int.from_bytes(
res[offset : offset + 4], "big", signed=True
)
offset += 4 offset += 4
response.payload, offset = self.read_res_payload(res, offset) response.payload, offset = self.read_res_payload(res, offset)
return response return response
async def start_connection(self): async def start_connection(self):
header = Header(message_type=FULL_CLIENT_REQUEST, message_type_specific_flags=MsgTypeFlagWithEvent).as_bytes() header = Header(
message_type=FULL_CLIENT_REQUEST,
message_type_specific_flags=MsgTypeFlagWithEvent,
).as_bytes()
optional = Optional(event=EVENT_Start_Connection).as_bytes() optional = Optional(event=EVENT_Start_Connection).as_bytes()
payload = str.encode("{}") payload = str.encode("{}")
return await self.send_event(header, optional, payload) return await self.send_event(header, optional, payload)
def print_response(self, res, tag_msg: str): def print_response(self, res, tag_msg: str):
logger.bind(tag=TAG).info(f'===>{tag_msg} header:{res.header.__dict__}') logger.bind(tag=TAG).info(f"===>{tag_msg} header:{res.header.__dict__}")
logger.bind(tag=TAG).info(f'===>{tag_msg} optional:{res.optional.__dict__}') logger.bind(tag=TAG).info(f"===>{tag_msg} optional:{res.optional.__dict__}")
def get_payload_bytes(self, uid='1234', event=EVENT_NONE, text='', speaker='', audio_format='pcm', def get_payload_bytes(
audio_sample_rate=16000): self,
return str.encode(json.dumps( uid="1234",
{ event=EVENT_NONE,
"user": {"uid": uid}, text="",
"event": event, speaker="",
"namespace": "BidirectionalTTS", audio_format="pcm",
"req_params": { audio_sample_rate=16000,
"text": text, ):
"speaker": speaker, return str.encode(
"audio_params": { json.dumps(
"format": audio_format, {
"sample_rate": audio_sample_rate "user": {"uid": uid},
} "event": event,
"namespace": "BidirectionalTTS",
"req_params": {
"text": text,
"speaker": speaker,
"audio_params": {
"format": audio_format,
"sample_rate": audio_sample_rate,
},
},
} }
} )
)) )
async def finish_connection(self): async def finish_connection(self):
header = Header(message_type=FULL_CLIENT_REQUEST, header = Header(
message_type_specific_flags=MsgTypeFlagWithEvent, message_type=FULL_CLIENT_REQUEST,
serial_method=JSON message_type_specific_flags=MsgTypeFlagWithEvent,
).as_bytes() serial_method=JSON,
).as_bytes()
optional = Optional(event=EVENT_FinishConnection).as_bytes() optional = Optional(event=EVENT_FinishConnection).as_bytes()
payload = str.encode('{}') payload = str.encode("{}")
await self.send_event(header, optional, payload) await self.send_event(header, optional, payload)
return return
async def start_session(self, session_id): async def start_session(self, session_id):
self.stop_event_response.clear() self.stop_event_response.clear()
header = Header(message_type=FULL_CLIENT_REQUEST, header = Header(
message_type_specific_flags=MsgTypeFlagWithEvent, message_type=FULL_CLIENT_REQUEST,
serial_method=JSON message_type_specific_flags=MsgTypeFlagWithEvent,
).as_bytes() serial_method=JSON,
).as_bytes()
optional = Optional(event=EVENT_StartSession, sessionId=session_id).as_bytes() optional = Optional(event=EVENT_StartSession, sessionId=session_id).as_bytes()
payload = self.get_payload_bytes(event=EVENT_StartSession, speaker=self.speaker) payload = self.get_payload_bytes(event=EVENT_StartSession, speaker=self.speaker)
await self.send_event(header, optional, payload) await self.send_event(header, optional, payload)
async def finish_session(self, session_id): async def finish_session(self, session_id):
self.stop_event_response.set() self.stop_event_response.set()
header = Header(message_type=FULL_CLIENT_REQUEST, header = Header(
message_type_specific_flags=MsgTypeFlagWithEvent, message_type=FULL_CLIENT_REQUEST,
serial_method=JSON message_type_specific_flags=MsgTypeFlagWithEvent,
).as_bytes() serial_method=JSON,
).as_bytes()
optional = Optional(event=EVENT_FinishSession, sessionId=session_id).as_bytes() optional = Optional(event=EVENT_FinishSession, sessionId=session_id).as_bytes()
payload = str.encode('{}') payload = str.encode("{}")
await self.send_event(header, optional, payload) await self.send_event(header, optional, payload)
return return
@@ -327,59 +367,78 @@ class TTSProvider(TTSProviderBase):
def _start_monitor_tts_response_thread(self): def _start_monitor_tts_response_thread(self):
# 初始化链接 # 初始化链接
asyncio.run_coroutine_threadsafe(self._start_monitor_tts_response(), loop=self.loop) asyncio.run_coroutine_threadsafe(
self._start_monitor_tts_response(), loop=self.loop
)
async def _start_monitor_tts_response(self): async def _start_monitor_tts_response(self):
chunk_total = b'' chunk_total = b""
while True: while True:
try: try:
msg = await self.ws.recv() # 确保 `recv()` 运行在同一个 event loop msg = await self.ws.recv() # 确保 `recv()` 运行在同一个 event loop
res = self.parser_response(msg) res = self.parser_response(msg)
self.print_response(res, 'send_text res:') self.print_response(res, "send_text res:")
if res.optional.event == EVENT_TTSResponse and res.header.message_type == AUDIO_ONLY_RESPONSE: if (
logger.bind(tag=TAG).info(f'推送数据到队列里面~~') res.optional.event == EVENT_TTSResponse
and res.header.message_type == AUDIO_ONLY_RESPONSE
):
logger.bind(tag=TAG).info(f"推送数据到队列里面~~")
opus_datas = self.wav_to_opus_data_audio_raw(res.payload) opus_datas = self.wav_to_opus_data_audio_raw(res.payload)
self.tts_audio_queue.put( self.tts_audio_queue.put(
TTSMessageDTO( TTSMessageDTO(
u_id=self.u_id, msg_type=MsgType.TTS_TEXT_RESPONSE, content=opus_datas, u_id=self.u_id,
tts_finish_text="", sentence_type=None, duration=0 msg_type=MsgType.TTS_TEXT_RESPONSE,
content=opus_datas,
tts_finish_text="",
sentence_type=None,
duration=0,
) )
) )
elif res.optional.event == EVENT_TTSSentenceStart: elif res.optional.event == EVENT_TTSSentenceStart:
json_data = json.loads(res.payload.decode('utf-8')) json_data = json.loads(res.payload.decode("utf-8"))
self.tts_text = json_data.get("text", "") self.tts_text = json_data.get("text", "")
logger.bind(tag=TAG).info(f'句子开始~~{self.tts_text}') logger.bind(tag=TAG).info(f"句子开始~~{self.tts_text}")
self.tts_audio_queue.put( self.tts_audio_queue.put(
TTSMessageDTO( TTSMessageDTO(
u_id=self.u_id, msg_type=MsgType.TTS_TEXT_RESPONSE, content=[], u_id=self.u_id,
msg_type=MsgType.TTS_TEXT_RESPONSE,
content=[],
tts_finish_text=self.tts_text, tts_finish_text=self.tts_text,
sentence_type=SentenceType.SENTENCE_START sentence_type=SentenceType.SENTENCE_START,
) )
) )
elif res.optional.event == EVENT_TTSSentenceEnd: elif res.optional.event == EVENT_TTSSentenceEnd:
logger.bind(tag=TAG).info(f'句子结束~~{self.tts_text}') logger.bind(tag=TAG).info(f"句子结束~~{self.tts_text}")
self.tts_audio_queue.put( self.tts_audio_queue.put(
TTSMessageDTO( TTSMessageDTO(
u_id=self.u_id, msg_type=MsgType.TTS_TEXT_RESPONSE, content=[], u_id=self.u_id,
msg_type=MsgType.TTS_TEXT_RESPONSE,
content=[],
tts_finish_text=self.tts_text, tts_finish_text=self.tts_text,
sentence_type=SentenceType.SENTENCE_END sentence_type=SentenceType.SENTENCE_END,
) )
) )
elif res.optional.event == EVENT_SessionFinished: elif res.optional.event == EVENT_SessionFinished:
logger.bind(tag=TAG).info(f'会话结束~~,最后一句补零') logger.bind(tag=TAG).info(f"会话结束~~,最后一句补零")
opus_datas = self.wav_to_opus_data_audio_raw(b'', is_end=True) opus_datas = self.wav_to_opus_data_audio_raw(b"", is_end=True)
self.tts_audio_queue.put( self.tts_audio_queue.put(
TTSMessageDTO( TTSMessageDTO(
u_id=self.u_id, msg_type=MsgType.TTS_TEXT_RESPONSE, content=opus_datas, u_id=self.u_id,
tts_finish_text="", sentence_type=None, duration=0 msg_type=MsgType.TTS_TEXT_RESPONSE,
content=opus_datas,
tts_finish_text="",
sentence_type=None,
duration=0,
) )
) )
self.tts_audio_queue.put( self.tts_audio_queue.put(
TTSMessageDTO( TTSMessageDTO(
u_id=self.u_id, msg_type=MsgType.STOP_TTS_RESPONSE, content=[], u_id=self.u_id,
msg_type=MsgType.STOP_TTS_RESPONSE,
content=[],
tts_finish_text=self.tts_text, tts_finish_text=self.tts_text,
sentence_type=SentenceType.SENTENCE_END sentence_type=SentenceType.SENTENCE_END,
) )
) )
else: else:
@@ -0,0 +1,156 @@
import hashlib
import hmac
import os
import time
import uuid
import json
import base64
import requests
from datetime import datetime, timezone
from core.providers.tts.base import TTSProviderBase
class TTSProvider(TTSProviderBase):
def __init__(self, config, delete_audio_file):
super().__init__(config, delete_audio_file)
self.appid = config.get("appid")
self.secret_id = config.get("secret_id")
self.secret_key = config.get("secret_key")
self.voice = config.get("voice")
self.api_url = "https://tts.tencentcloudapi.com" # 正确的API端点
self.region = config.get("region")
self.output_file = config.get("output_dir")
def _get_auth_headers(self, request_body):
"""生成鉴权请求头"""
# 获取当前UTC时间戳
timestamp = int(time.time())
# 使用UTC时间计算日期
utc_date = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime('%Y-%m-%d')
# 服务名称必须是 "tts"
service = "tts"
# 拼接凭证范围
credential_scope = f"{utc_date}/{service}/tc3_request"
# 使用TC3-HMAC-SHA256签名方法
algorithm = "TC3-HMAC-SHA256"
# 构建规范请求字符串
http_request_method = "POST"
canonical_uri = "/"
canonical_querystring = ""
# 请求头必须包含host和content-type,且按字典序排列
canonical_headers = (
f"content-type:application/json\n"
f"host:tts.tencentcloudapi.com\n"
)
signed_headers = "content-type;host"
# 请求体哈希值
payload = json.dumps(request_body)
payload_hash = hashlib.sha256(payload.encode('utf-8')).hexdigest()
# 构建规范请求字符串
canonical_request = (
f"{http_request_method}\n"
f"{canonical_uri}\n"
f"{canonical_querystring}\n"
f"{canonical_headers}\n"
f"{signed_headers}\n"
f"{payload_hash}"
)
# 计算规范请求的哈希值
hashed_canonical_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
# 构建待签名字符串
string_to_sign = (
f"{algorithm}\n"
f"{timestamp}\n"
f"{credential_scope}\n"
f"{hashed_canonical_request}"
)
# 计算签名密钥
secret_date = self._hmac_sha256(f"TC3{self.secret_key}".encode('utf-8'), utc_date)
secret_service = self._hmac_sha256(secret_date, service)
secret_signing = self._hmac_sha256(secret_service, "tc3_request")
# 计算签名
signature = hmac.new(
secret_signing,
string_to_sign.encode('utf-8'),
hashlib.sha256
).hexdigest()
# 构建授权头
authorization = (
f"{algorithm} "
f"Credential={self.secret_id}/{credential_scope}, "
f"SignedHeaders={signed_headers}, "
f"Signature={signature}"
)
# 构建请求头
headers = {
"Content-Type": "application/json",
"Host": "tts.tencentcloudapi.com",
"Authorization": authorization,
"X-TC-Action": "TextToVoice",
"X-TC-Timestamp": str(timestamp),
"X-TC-Version": "2019-08-23",
"X-TC-Region": self.region,
"X-TC-Language": "zh-CN"
}
return headers
def _hmac_sha256(self, key, msg):
"""HMAC-SHA256加密"""
if isinstance(msg, str):
msg = msg.encode('utf-8')
return hmac.new(key, msg, hashlib.sha256).digest()
def generate_filename(self, extension=".wav"):
return os.path.join(self.output_file, f"tts-{datetime.now().date()}@{uuid.uuid4().hex}{extension}")
async def text_to_speak(self, text, output_file):
# 构建请求体
request_json = {
"Text": text, # 合成语音的源文本
"SessionId": str(uuid.uuid4()), # 会话ID,随机生成
"VoiceType": int(self.voice), # 音色
}
try:
# 获取请求头(每次请求都重新生成,以确保时间戳和签名是最新的)
headers = self._get_auth_headers(request_json)
# 发送请求
resp = requests.post(self.api_url, json.dumps(request_json), headers=headers)
# 检查响应
if resp.status_code == 200:
response_data = resp.json()
# 检查是否成功
if response_data.get("Response", {}).get("Error") is not None:
error_info = response_data["Response"]["Error"]
raise Exception(f"API返回错误: {error_info['Code']}: {error_info['Message']}")
# 提取音频数据
audio_data = response_data["Response"].get("Audio")
if audio_data:
# 解码Base64音频数据并保存
with open(output_file, "wb") as f:
f.write(base64.b64decode(audio_data))
else:
raise Exception(f"{__name__}: 没有返回音频数据: {response_data}")
else:
raise Exception(f"{__name__} status_code: {resp.status_code} response: {resp.content}")
except Exception as e:
raise Exception(f"{__name__} error: {e}")
@@ -35,6 +35,15 @@ class Dialogue:
self.getMessages(m, dialogue) self.getMessages(m, dialogue)
return 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]]: 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: if memory_str is None or len(memory_str) == 0:
return self.get_llm_dialogue() return self.get_llm_dialogue()
+96 -1
View File
@@ -1,4 +1,5 @@
import os import os
import re
import sys import sys
from config.logger import setup_logging from config.logger import setup_logging
import importlib import importlib
@@ -14,4 +15,98 @@ def create_instance(class_name, *args, **kwargs):
sys.modules[lib_name] = importlib.import_module(f'{lib_name}') sys.modules[lib_name] = importlib.import_module(f'{lib_name}')
return sys.modules[lib_name].TTSProvider(*args, **kwargs) return sys.modules[lib_name].TTSProvider(*args, **kwargs)
raise ValueError(f"不支持的TTS类型: {class_name},请检查该配置的type是否设置正确") raise ValueError(f"不支持的TTS类型: {class_name},请检查该配置的type是否设置正确")
class MarkdownCleaner:
"""
封装 Markdown 清理逻辑:直接用 MarkdownCleaner.clean_markdown(text) 即可
"""
# 公式字符
NORMAL_FORMULA_CHARS = re.compile(r'[a-zA-Z\\^_{}\+\-\(\)\[\]=]')
@staticmethod
def _replace_inline_dollar(m: re.Match) -> str:
"""
只要捕获到完整的 "$...$":
- 如果内部有典型公式字符 => 去掉两侧 $
- 否则 (纯数字/货币等) => 保留 "$...$"
"""
content = m.group(1)
if MarkdownCleaner.NORMAL_FORMULA_CHARS.search(content):
return content
else:
return m.group(0)
@staticmethod
def _replace_table_block(match: re.Match) -> str:
"""
当匹配到一个整段表格块时,回调该函数。
"""
block_text = match.group('table_block')
lines = block_text.strip('\n').split('\n')
parsed_table = []
for line in lines:
line_stripped = line.strip()
if re.match(r'^\|\s*[-:]+\s*(\|\s*[-:]+\s*)+\|?$', line_stripped):
continue
columns = [col.strip() for col in line_stripped.split('|') if col.strip() != '']
if columns:
parsed_table.append(columns)
if not parsed_table:
return ""
headers = parsed_table[0]
data_rows = parsed_table[1:] if len(parsed_table) > 1 else []
lines_for_tts = []
if len(parsed_table) == 1:
# 只有一行
only_line_str = ", ".join(parsed_table[0])
lines_for_tts.append(f"单行表格:{only_line_str}")
else:
lines_for_tts.append(f"表头是:{', '.join(headers)}")
for i, row in enumerate(data_rows, start=1):
row_str_list = []
for col_index, cell_val in enumerate(row):
if col_index < len(headers):
row_str_list.append(f"{headers[col_index]} = {cell_val}")
else:
row_str_list.append(cell_val)
lines_for_tts.append(f"{i} 行:{', '.join(row_str_list)}")
return "\n".join(lines_for_tts) + "\n"
# 预编译所有正则表达式(按执行频率排序)
# 这里要把 replace_xxx 的静态方法放在最前定义,以便在列表里能正确引用它们。
REGEXES = [
(re.compile(r'```.*?```', re.DOTALL), ''), # 代码块
(re.compile(r'^#+\s*', re.MULTILINE), ''), # 标题
(re.compile(r'(\*\*|__)(.*?)\1'), r'\2'), # 粗体
(re.compile(r'(\*|_)(?=\S)(.*?)(?<=\S)\1'), r'\2'), # 斜体
(re.compile(r'!\[.*?\]\(.*?\)'), ''), # 图片
(re.compile(r'\[(.*?)\]\(.*?\)'), r'\1'), # 链接
(re.compile(r'^\s*>+\s*', re.MULTILINE), ''), # 引用
(
re.compile(r'(?P<table_block>(?:^[^\n]*\|[^\n]*\n)+)', re.MULTILINE),
_replace_table_block
),
(re.compile(r'^\s*[*+-]\s*', re.MULTILINE), '- '), # 列表
(re.compile(r'\$\$.*?\$\$', re.DOTALL), ''), # 块级公式
(
re.compile(r'(?<![A-Za-z0-9])\$([^\n$]+)\$(?![A-Za-z0-9])'),
_replace_inline_dollar
),
(re.compile(r'\n{2,}'), '\n'), # 多余空行
]
@staticmethod
def clean_markdown(text: str) -> str:
"""
主入口方法:依序执行所有正则,移除或替换 Markdown 元素
"""
for regex, replacement in MarkdownCleaner.REGEXES:
text = regex.sub(replacement, text)
return text.strip()
+2 -6
View File
@@ -67,15 +67,11 @@ def is_private_ip(ip_addr):
def get_ip_info(ip_addr): def get_ip_info(ip_addr):
try: try:
base_url = "https://freeipapi.com/api/json" url = "https://whois.pconline.com.cn/ipJson.jsp?json=true"
url = base_url if is_private_ip(ip_addr) else f"{base_url}/{ip_addr}"
resp = requests.get(url).json() resp = requests.get(url).json()
ip_info = { ip_info = {
"city": resp.get("cityName"), "city": resp.get("city")
"region": resp.get("regionName"),
"country": resp.get("countryName")
} }
return ip_info return ip_info
except Exception as e: except Exception as e:
+7 -6
View File
@@ -85,24 +85,25 @@ class WebSocketServer:
server_config = self.config["server"] server_config = self.config["server"]
host = server_config["ip"] host = server_config["ip"]
port = server_config["port"] port = server_config["port"]
selected_module = self.config.get("selected_module")
self.logger.bind(tag=TAG).info(
f"selected_module values: {', '.join(selected_module.values())}"
)
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).info(
"Server is running at ws://{}:{}", get_local_ip(), port "Server is running at ws://{}:{}/xiaozhi/v1/", get_local_ip(), port
) )
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).info(
"=======上面的地址是websocket协议地址,请勿用浏览器访问=======" "=======上面的地址是websocket协议地址,请勿用浏览器访问======="
) )
self.logger.bind(tag=TAG).info(
"如想测试websocket请用谷歌浏览器打开test目录下的test_page.html"
)
self.logger.bind(tag=TAG).info(
"=============================================================\n"
)
async with websockets.serve(self._handle_connection, host, port): async with websockets.serve(self._handle_connection, host, port):
await asyncio.Future() await asyncio.Future()
async def _handle_connection(self, websocket): async def _handle_connection(self, websocket):
"""处理新连接,每次创建独立的ConnectionHandler""" """处理新连接,每次创建独立的ConnectionHandler"""
# 创建ConnectionHandler时传入当前server实例 # 创建ConnectionHandler时传入当前server实例
# tts 变成链接的时候创建,避免并非问题
handler = ConnectionHandler( handler = ConnectionHandler(
self.config, self.config,
self._vad, self._vad,
@@ -0,0 +1,31 @@
{
"des": [
"在data目录下创建.mcp_server_settings.json文件,可以选择下面的MCP服务,也可以自行添加新的MCP服务。",
"后面不断测试补充好用的mcp服务,欢迎大家一起补充。",
"记得删除注释行,des属性仅为说明,不会被解析。",
"des和link属性,仅为说明安装方式,方便大家查看原始链接,不是必须项。"
],
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/Users/username/Desktop",
"/path/to/other/allowed/dir"
],
"link":"https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem"
},
"playwright": {
"command": "npx",
"args": ["-y", "@executeautomation/playwright-mcp-server"],
"des" : "run 'npx playwright install' first",
"link": "https://github.com/executeautomation/mcp-playwright"
},
"windows-cli": {
"command": "npx",
"args": ["-y", "@simonb97/server-win-cli"],
"link": "https://github.com/SimonB97/win-cli-mcp-server"
}
}
}
@@ -0,0 +1,206 @@
import random
import requests
import xml.etree.ElementTree as ET
from bs4 import BeautifulSoup
from config.logger import setup_logging
from plugins_func.register import register_function, ToolType, ActionResponse, Action
TAG = __name__
logger = setup_logging()
GET_NEWS_FUNCTION_DESC = {
"type": "function",
"function": {
"name": "get_news",
"description": (
"获取最新新闻,随机选择一条新闻进行播报。"
"用户可以指定新闻类型,如社会新闻、科技新闻、国际新闻等。"
"如果没有指定,默认播报社会新闻。"
"用户可以要求获取详细内容,此时会获取新闻的详细内容。"
),
"parameters": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "新闻类别,例如社会、科技、国际。可选参数,如果不提供则使用默认类别"
},
"detail": {
"type": "boolean",
"description": "是否获取详细内容,默认为false。如果为true,则获取上一条新闻的详细内容"
},
"lang": {
"type": "string",
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN"
}
},
"required": ["lang"]
}
}
}
def fetch_news_from_rss(rss_url):
"""从RSS源获取新闻列表"""
try:
response = requests.get(rss_url)
response.raise_for_status()
# 解析XML
root = ET.fromstring(response.content)
# 查找所有item元素(新闻条目)
news_items = []
for item in root.findall('.//item'):
title = item.find('title').text if item.find('title') is not None else "无标题"
link = item.find('link').text if item.find('link') is not None else "#"
description = item.find('description').text if item.find('description') is not None else "无描述"
pubDate = item.find('pubDate').text if item.find('pubDate') is not None else "未知时间"
news_items.append({
'title': title,
'link': link,
'description': description,
'pubDate': pubDate
})
return news_items
except Exception as e:
logger.bind(tag=TAG).error(f"获取RSS新闻失败: {e}")
return []
def fetch_news_detail(url):
"""获取新闻详情页内容并总结"""
try:
response = requests.get(url)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
# 尝试提取正文内容 (这里的选择器需要根据实际网站结构调整)
content_div = soup.select_one('.content_desc, .content, article, .article-content')
if content_div:
paragraphs = content_div.find_all('p')
content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
return content
else:
# 如果找不到特定的内容区域,尝试获取所有段落
paragraphs = soup.find_all('p')
content = '\n'.join([p.get_text().strip() for p in paragraphs if p.get_text().strip()])
return content[:2000] # 限制长度
except Exception as e:
logger.bind(tag=TAG).error(f"获取新闻详情失败: {e}")
return "无法获取详细内容"
def map_category(category_text):
"""将用户输入的中文类别映射到配置文件中的类别键"""
if not category_text:
return None
# 类别映射字典,目前支持社会、国际、财经新闻,如需更多类型,参见配置文件
category_map = {
# 社会新闻
"社会": "society",
"社会新闻": "society",
# 国际新闻
"国际": "world",
"国际新闻": "world",
# 财经新闻
"财经": "finance",
"财经新闻": "finance",
"金融": "finance",
"经济": "finance"
}
# 转换为小写并去除空格
normalized_category = category_text.lower().strip()
# 返回映射结果,如果没有匹配项则返回原始输入
return category_map.get(normalized_category, category_text)
@register_function('get_news', GET_NEWS_FUNCTION_DESC, ToolType.SYSTEM_CTL)
def get_news(conn, category: str = None, detail: bool = False, lang: str = "zh_CN"):
"""获取新闻并随机选择一条进行播报,或获取上一条新闻的详细内容"""
try:
# 如果detail为True,获取上一条新闻的详细内容
if detail:
if not hasattr(conn, 'last_news_link') or not conn.last_news_link or 'link' not in conn.last_news_link:
return ActionResponse(Action.REQLLM, "抱歉,没有找到最近查询的新闻,请先获取一条新闻。", None)
link = conn.last_news_link.get('link')
title = conn.last_news_link.get('title', '未知标题')
if link == '#':
return ActionResponse(Action.REQLLM, "抱歉,该新闻没有可用的链接获取详细内容。", None)
logger.bind(tag=TAG).debug(f"获取新闻详情: {title}, URL={link}")
# 获取新闻详情
detail_content = fetch_news_detail(link)
if not detail_content or detail_content == "无法获取详细内容":
return ActionResponse(Action.REQLLM,
f"抱歉,无法获取《{title}》的详细内容,可能是链接已失效或网站结构发生变化。", None)
# 构建详情报告
detail_report = (
f"根据下列数据,用{lang}回应用户的新闻详情查询请求:\n\n"
f"新闻标题: {title}\n"
f"详细内容: {detail_content}\n\n"
f"(请对上述新闻内容进行总结,提取关键信息,以自然、流畅的方式向用户播报,"
f"不要提及这是总结,就像是在讲述一个完整的新闻故事)"
)
return ActionResponse(Action.REQLLM, detail_report, None)
# 否则,获取新闻列表并随机选择一条
# 从配置中获取RSS URL
rss_config = conn.config["plugins"]["get_news"]
default_rss_url = rss_config.get("default_rss_url", "https://www.chinanews.com.cn/rss/society.xml")
# 将用户输入的类别映射到配置中的类别键
mapped_category = map_category(category)
# 如果提供了类别,尝试从配置中获取对应的URL
rss_url = default_rss_url
if mapped_category and mapped_category in rss_config.get("category_urls", {}):
rss_url = rss_config["category_urls"][mapped_category]
logger.bind(tag=TAG).info(f"获取新闻: 原始类别={category}, 映射类别={mapped_category}, URL={rss_url}")
# 获取新闻列表
news_items = fetch_news_from_rss(rss_url)
if not news_items:
return ActionResponse(Action.REQLLM, "抱歉,未能获取到新闻信息,请稍后再试。", None)
# 随机选择一条新闻
selected_news = random.choice(news_items)
# 保存当前新闻链接到连接对象,以便后续查询详情
if not hasattr(conn, 'last_news_link'):
conn.last_news_link = {}
conn.last_news_link = {
'link': selected_news.get('link', '#'),
'title': selected_news.get('title', '未知标题')
}
# 构建新闻报告
news_report = (
f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n"
f"新闻标题: {selected_news['title']}\n"
f"发布时间: {selected_news['pubDate']}\n"
f"新闻内容: {selected_news['description']}\n"
f"(请以自然、流畅的方式向用户播报这条新闻,可以适当总结内容,"
f"直接读出新闻即可,不需要额外多余的内容。"
f"如果用户询问更多详情,告知用户可以说'请详细介绍这条新闻'获取更多内容)"
)
return ActionResponse(Action.REQLLM, news_report, None)
except Exception as e:
logger.bind(tag=TAG).error(f"获取新闻出错: {e}")
return ActionResponse(Action.REQLLM, "抱歉,获取新闻时发生错误,请稍后再试。", None)
@@ -1,20 +1,19 @@
from datetime import datetime from datetime import datetime
import cnlunar
from plugins_func.register import register_function, ToolType, ActionResponse, Action from plugins_func.register import register_function, ToolType, ActionResponse, Action
get_time_function_desc = { get_time_function_desc = {
"type": "function", "type": "function",
"function": { "function": {
"name": "get_time", "name": "get_time",
"description": "获取当前时间、日期、星期几", "description": "获取今天日期或者当前时间信息",
'parameters': {'type': 'object', 'properties': {}, 'required': []} 'parameters': {'type': 'object', 'properties': {}, 'required': []}
} }
} }
@register_function('get_time', get_time_function_desc, ToolType.WAIT) @register_function('get_time', get_time_function_desc, ToolType.WAIT)
def get_time(): def get_time():
""" """
获取当前时间、日期、星期几 获取当前的日期时间信息
""" """
now = datetime.now() now = datetime.now()
current_time = now.strftime("%H:%M:%S") current_time = now.strftime("%H:%M:%S")
@@ -22,4 +21,69 @@ def get_time():
current_weekday = now.strftime("%A") current_weekday = now.strftime("%A")
response_text = f"当前日期: {current_date},当前时间: {current_time},星期: {current_weekday}" response_text = f"当前日期: {current_date},当前时间: {current_time},星期: {current_weekday}"
return ActionResponse(Action.REQLLM, response_text, None)
get_lunar_function_desc = {
"type": "function",
"function": {
"name": "get_lunar",
"description": (
"用于获取今天的阴历/农历和黄历信息。"
"用户可以指定查询内容,如:阴历日期、天干地支、节气、生肖、星座、八字、宜忌等。"
"如果没有指定查询内容,则默认查询干支年和农历日期。"
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "要查询的内容,例如阴历日期、天干地支、节日、节气、生肖、星座、八字、宜忌等"
}
},
"required": []
}
}
}
@register_function('get_lunar', get_lunar_function_desc, ToolType.WAIT)
def get_lunar(query=None):
"""
用于获取当前的阴历/农历,和天干地支、节气、生肖、星座、八字、宜忌等黄历信息
"""
now = datetime.now()
current_time = now.strftime("%H:%M:%S")
current_date = now.strftime("%Y-%m-%d")
current_weekday = now.strftime("%A")
# 如果 query 为 None,则使用默认文本
if query is None:
query = "默认查询干支年和农历日期"
response_text = f"根据以下信息回应用户的查询请求,并提供与{query}相关的信息:\n"
lunar = cnlunar.Lunar(now, godType='8char')
response_text += (
f"当前公历日期: {current_date},当前时间: {current_time},星期: {current_weekday}\n"
"农历信息:\n"
"%s%s%s\n" % (lunar.lunarYearCn, lunar.lunarMonthCn[:-1], lunar.lunarDayCn) +
"干支: %s%s%s\n" % (lunar.year8Char, lunar.month8Char, lunar.day8Char) +
"生肖: 属%s\n" % (lunar.chineseYearZodiac) +
"八字: %s\n" % (' '.join([lunar.year8Char, lunar.month8Char, lunar.day8Char, lunar.twohour8Char])) +
"今日节日: %s\n" % (",".join(filter(None, (lunar.get_legalHolidays(), lunar.get_otherHolidays(), lunar.get_otherLunarHolidays())))) +
"今日节气: %s\n" % (lunar.todaySolarTerms) +
"下一节气: %s %s%s%s\n" % (lunar.nextSolarTerm, lunar.nextSolarTermYear, lunar.nextSolarTermDate[0], lunar.nextSolarTermDate[1]) +
"今年节气表: %s\n" % (', '.join([f"{term}({date[0]}{date[1]}日)" for term, date in lunar.thisYearSolarTermsDic.items()])) +
"生肖冲煞: %s\n" % (lunar.chineseZodiacClash) +
"星座: %s\n" % (lunar.starZodiac) +
"纳音: %s\n" % lunar.get_nayin() +
"彭祖百忌: %s\n" % (lunar.get_pengTaboo(delimit=", ")) +
"值日: %s执位\n" % lunar.get_today12DayOfficer()[0] +
"值神: %s(%s)\n" % (lunar.get_today12DayOfficer()[1], lunar.get_today12DayOfficer()[2]) +
"廿八宿: %s\n" % lunar.get_the28Stars() +
"吉神方位: %s\n" % ' '.join(lunar.get_luckyGodsDirection()) +
"今日胎神: %s\n" % lunar.get_fetalGod() +
"宜: %s\n" % ''.join(lunar.goodThing[:10]) +
"忌: %s\n" % ''.join(lunar.badThing[:10]) +
"(默认返回干支年和农历日期;仅在要求查询宜忌信息时才返回本日宜忌)"
)
return ActionResponse(Action.REQLLM, response_text, None) return ActionResponse(Action.REQLLM, response_text, None)
@@ -39,6 +39,23 @@ HEADERS = {
) )
} }
# 天气代码 https://dev.qweather.com/docs/resource/icons/#weather-icons
WEATHER_CODE_MAP = {
"100": "", "101": "多云", "102": "少云", "103": "晴间多云", "104": "",
"150": "", "151": "多云", "152": "少云", "153": "晴间多云",
"300": "阵雨", "301": "强阵雨", "302": "雷阵雨", "303": "强雷阵雨", "304": "雷阵雨伴有冰雹",
"305": "小雨", "306": "中雨", "307": "大雨", "308": "极端降雨", "309": "毛毛雨/细雨",
"310": "暴雨", "311": "大暴雨", "312": "特大暴雨", "313": "冻雨", "314": "小到中雨",
"315": "中到大雨", "316": "大到暴雨", "317": "暴雨到大暴雨", "318": "大暴雨到特大暴雨",
"350": "阵雨", "351": "强阵雨", "399": "",
"400": "小雪", "401": "中雪", "402": "大雪", "403": "暴雪", "404": "雨夹雪",
"405": "雨雪天气", "406": "阵雨夹雪", "407": "阵雪", "408": "小到中雪", "409": "中到大雪", "410": "大到暴雪",
"456": "阵雨夹雪", "457": "阵雪", "499": "",
"500": "薄雾", "501": "", "502": "", "503": "扬沙", "504": "浮尘",
"507": "沙尘暴", "508": "强沙尘暴",
"509": "浓雾", "510": "强浓雾", "511": "中度霾", "512": "重度霾", "513": "严重霾", "514": "大雾", "515": "特强浓雾",
"900": "", "901": "", "999": "未知"
}
def fetch_city_info(location, api_key): def fetch_city_info(location, api_key):
url = f"https://geoapi.qweather.com/v2/city/lookup?key={api_key}&location={location}&lang=zh" url = f"https://geoapi.qweather.com/v2/city/lookup?key={api_key}&location={location}&lang=zh"
@@ -67,9 +84,11 @@ def parse_weather_info(soup):
temps_list = [] temps_list = []
for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据 for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据
date = row.select_one(".date-bg .date").get_text(strip=True) date = row.select_one(".date-bg .date").get_text(strip=True)
weather_code = row.select_one(".date-bg .icon")["src"].split("/")[-1].split(".")[0]
weather = WEATHER_CODE_MAP.get(weather_code, "未知")
temps = [span.get_text(strip=True) for span in row.select(".tmp-cont .temp")] temps = [span.get_text(strip=True) for span in row.select(".tmp-cont .temp")]
high_temp, low_temp = (temps[0], temps[-1]) if len(temps) >= 2 else (None, None) high_temp, low_temp = (temps[0], temps[-1]) if len(temps) >= 2 else (None, None)
temps_list.append((date, high_temp, low_temp)) temps_list.append((date, weather, high_temp, low_temp))
return city_name, current_abstract, current_basic, temps_list return city_name, current_abstract, current_basic, temps_list
@@ -91,14 +110,14 @@ def get_weather(conn, location: str = None, lang: str = "zh_CN"):
city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup) city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup)
weather_report = f"根据下列数据,用{lang}回应用户的查询天气请求:\n{city_name}未来7天天气:\n" weather_report = f"根据下列数据,用{lang}回应用户的查询天气请求:\n{city_name}未来7天天气:\n"
for i, (date, high, low) in enumerate(temps_list): for i, (date, weather, high, low) in enumerate(temps_list):
if high and low: if high and low:
weather_report += f"{date}: {low}{high}\n" weather_report += f"{date}: {low}{high}, {weather}\n"
weather_report += ( weather_report += (
f"当前天气: {current_abstract}\n" f"当前天气: {current_abstract}\n"
f"当前天气参数: {current_basic}\n" f"当前天气参数: {current_basic}\n"
f"(确保只报告指定单日的气温范围,除非用户明确要求想要了解多日天气,如果未指定,默认报告今天的温度范围" f"(确保只报告指定单日的天气情况,除非未来会出现异常天气;或者用户明确要求想要了解多日天气,如果未指定,默认报告今天的天气"
"参数为0的值不需要报告给用户,每次都报告体感温度,根据语境选择合适的参数内容告知用户,并对参数给出相应评价)" "参数为0的值不需要报告给用户,每次都报告体感温度,根据语境选择合适的参数内容告知用户,并对参数给出相应评价)"
) )
return ActionResponse(Action.REQLLM, weather_report, None) return ActionResponse(Action.REQLLM, weather_report, None)
@@ -0,0 +1,111 @@
from config.logger import setup_logging
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from core.handle.iotHandle import get_iot_status, send_iot_conn
import asyncio
TAG = __name__
logger = setup_logging()
async def _get_device_status(conn, device_name, device_type, property_name):
"""获取设备状态"""
status = await get_iot_status(conn, device_type, property_name)
if status is None:
raise Exception(f"你的设备不支持{device_name}控制")
return status
async def _set_device_property(conn, device_name, device_type, method_name, property_name, new_value=None, action=None, step=10):
"""设置设备属性"""
current_value = await _get_device_status(conn, device_name, device_type, property_name)
if action == 'raise':
current_value += step
elif action == 'lower':
current_value -= step
elif action == 'set':
if new_value is None:
raise Exception(f"缺少{property_name}参数")
current_value = new_value
# 限制属性范围在0到100之间
current_value = max(0, min(100, current_value))
await send_iot_conn(conn, device_type, method_name, {property_name: current_value})
return current_value
def _handle_device_action(conn, func, success_message, error_message, *args, **kwargs):
"""处理设备操作的通用函数"""
future = asyncio.run_coroutine_threadsafe(
func(conn, *args, **kwargs), conn.loop)
try:
result = future.result()
logger.bind(tag=TAG).info(f"{success_message}: {result}")
response = f"{success_message}{result}"
return ActionResponse(action=Action.RESPONSE, result=result, response=response)
except Exception as e:
logger.bind(tag=TAG).error(f"{error_message}: {e}")
response = f"{error_message}: {e}"
return ActionResponse(action=Action.RESPONSE, result=None, response=response)
# 设备控制
handle_device_function_desc = {
"type": "function",
"function": {
"name": "handle_device",
"description": (
"用户想要获取或者设置设备的音量/亮度大小,或者用户觉得声音/亮度过高或过低,或者用户想提高或降低音量/亮度。"
"比如用户说现在亮度多少,参数为:device_type:Screen,action:get。"
"比如用户说设置音量为50,参数为:device_type:Speaker,action:set,value:50。"
"比如用户说亮度太高了,参数为:device_type:Screen,action:lower。"
"比如用户说调大音量,参数为:device_type:Speaker,action:raise。"
),
"parameters": {
"type": "object",
"properties": {
"device_type": {
"type": "string",
"description": "设备类型,可选值:Speaker(音量),Screen(亮度)"
},
"action": {
"type": "string",
"description": "动作名称,可选值:get(获取),set(设置),raise(提高),lower(降低)"
},
"value": {
"type": "integer",
"description": "值大小,可选值:0-100之间的整数"
}
},
"required": ["device_type", "action"]
}
}
}
@register_function('handle_device', handle_device_function_desc, ToolType.IOT_CTL)
def handle_device(conn, device_type: str, action: str, value: int = None):
if device_type == "Speaker":
method_name, property_name, device_name = "SetVolume", "volume", "音量"
elif device_type == "Screen":
method_name, property_name, device_name = "SetBrightness", "brightness", "亮度"
else:
raise Exception(f"未识别的设备类型: {device_type}")
if action not in ["get", "set", "raise", "lower"]:
raise Exception(f"未识别的动作名称: {action}")
if action == "get":
# get
return _handle_device_action(
conn, _get_device_status, f"当前{device_name}", f"获取{device_name}失败",
device_name=device_name, device_type=device_type, property_name=property_name,
)
else:
# set, raise, lower
return _handle_device_action(
conn, _set_device_property, f"{device_name}已调整到", f"{device_name}调整失败",
device_name=device_name, device_type=device_type, method_name=method_name,
property_name=property_name, new_value=value, action=action
)
@@ -0,0 +1,56 @@
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from plugins_func.functions.hass_init import initialize_hass_handler
from config.logger import setup_logging
import asyncio
import requests
TAG = __name__
logger = setup_logging()
hass_get_state_function_desc = {
"type": "function",
"function": {
"name": "hass_get_state",
"description": "获取homeassistant里设备的状态,包括灯光亮度,媒体播放器的音量,设备的暂停、继续操作",
"parameters": {
"type": "object",
"properties": {
"entity_id": {
"type": "string",
"description": "需要操作的设备id,homeassistant里的entity_id"
}
},
"required": ["entity_id"]
}
}
}
@register_function("hass_get_state", hass_get_state_function_desc, ToolType.SYSTEM_CTL)
def hass_get_state(conn, entity_id=''):
try:
future = asyncio.run_coroutine_threadsafe(
handle_hass_get_state(conn, entity_id),
conn.loop
)
ha_response = future.result()
return ActionResponse(action=Action.REQLLM, result="执行成功", response=ha_response)
except Exception as e:
logger.bind(tag=TAG).error(f"处理设置属性意图错误: {e}")
async def handle_hass_get_state(conn, entity_id):
HASS_CACHE = initialize_hass_handler(conn)
api_key = HASS_CACHE['api_key']
base_url = HASS_CACHE['base_url']
url = f"{base_url}/api/states/{entity_id}"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()['state']
else:
return f"切换失败,错误码: {response.status_code}"
@@ -0,0 +1,64 @@
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from plugins_func.functions.hass_init import initialize_hass_handler
from config.logger import setup_logging
import asyncio
import requests
TAG = __name__
logger = setup_logging()
hass_play_music_function_desc = {
"type": "function",
"function": {
"name": "hass_play_music",
"description": "用户想听音乐、有声书的时候使用,在房间的媒体播放器(media_player)里播放对应音频",
"parameters": {
"type": "object",
"properties": {
"media_content_id": {
"type": "string",
"description": "可以是音乐或有声书的专辑名称、歌曲名、演唱者,如果未指定就填random"
},
"entity_id": {
"type": "string",
"description": "需要操作的音箱的设备id,homeassistant里的entity_id,media_player开头"
}
},
"required": ["media_content_id", "entity_id"]
}
}
}
@register_function('hass_play_music', hass_play_music_function_desc, ToolType.SYSTEM_CTL)
def hass_play_music(conn, entity_id='', media_content_id='random'):
try:
# 执行音乐播放命令
future = asyncio.run_coroutine_threadsafe(
handle_hass_play_music(conn, entity_id, media_content_id),
conn.loop
)
ha_response = future.result()
return ActionResponse(action=Action.RESPONSE, result="退出意图已处理", response=ha_response)
except Exception as e:
logger.bind(tag=TAG).error(f"处理音乐意图错误: {e}")
async def handle_hass_play_music(conn, entity_id, media_content_id):
HASS_CACHE = initialize_hass_handler(conn)
api_key = HASS_CACHE['api_key']
base_url = HASS_CACHE['base_url']
url = f"{base_url}/api/services/music_assistant/play_media"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
data = {
"entity_id": entity_id,
"media_id": media_content_id
}
response = requests.post(url, headers=headers, json=data)
if response.status_code == 200:
return f"正在播放{media_content_id}的音乐"
else:
return f"音乐播放失败,错误码: {response.status_code}"
@@ -0,0 +1,159 @@
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from plugins_func.functions.hass_init import initialize_hass_handler
from config.logger import setup_logging
import asyncio
import requests
TAG = __name__
logger = setup_logging()
hass_set_state_function_desc = {
"type": "function",
"function": {
"name": "hass_set_state",
"description": "设置homeassistant里设备的状态,包括开、关,调整灯光亮度,调整播放器的音量,设备的暂停、继续、静音操作",
"parameters": {
"type": "object",
"properties": {
"state": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "需要操作的动作,打开设备:turn_on,关闭设备:turn_off,增加亮度:brightness_up,降低亮度:brightness_down,设置亮度:brightness_value,增加>音量:,volume_up降低音量:volume_down,设置音量:volume_set,设备暂停:pause,设备继续:continue,静音/取消静音:volume_mute"
},
"input": {
"type": "integer",
"description": "只有在设置音量,设置亮度时候才需要,有效值为1-100,对应音量和亮度的1%-100%"
},
"is_muted": {
"type": "string",
"description": "只有在设置静音操作时才需要,设置静音的时候该值为true,取消静音时该值为false"
}
},
"required": ["type"]
},
"entity_id": {
"type": "string",
"description": "需要操作的设备id,homeassistant里的entity_id"
}
},
"required": ["state", "entity_id"]
}
}
}
@register_function('hass_set_state', hass_set_state_function_desc, ToolType.SYSTEM_CTL)
def hass_set_state(conn, entity_id='', state={}):
try:
future = asyncio.run_coroutine_threadsafe(
handle_hass_set_state(conn, entity_id, state),
conn.loop
)
ha_response = future.result()
return ActionResponse(action=Action.REQLLM, result="执行成功", response=ha_response)
except Exception as e:
logger.bind(tag=TAG).error(f"处理设置属性意图错误: {e}")
async def handle_hass_set_state(conn, entity_id, state):
HASS_CACHE = initialize_hass_handler(conn)
api_key = HASS_CACHE['api_key']
base_url = HASS_CACHE['base_url']
'''
state = { "type":"brightness_up","input":"80","is_muted":"true"}
'''
domains = entity_id.split(".")
if len(domains) > 1:
domain = domains[0]
else:
return "执行失败,错误的设备id"
action = ''
arg = ''
value = ''
if state['type'] == 'turn_on':
description = "设备已打开"
if domain == "cover":
action = "open_cover"
elif domain == "vacuum":
action = "start"
else:
action = "turn_on"
elif state['type'] == 'turn_off':
description = "设备已关闭"
if domain == 'cover':
action = "close_cover"
elif domain == 'vacuum':
action = "stop"
else:
action = "turn_off"
elif state['type'] == 'brightness_up':
description = "灯光已调亮"
action = 'turn_on'
arg = 'brightness_step_pct'
value = 10
elif state['type'] == 'brightness_down':
description = "灯光已调暗"
action = 'turn_on'
arg = 'brightness_step_pct'
value = -10
elif state['type'] == 'brightness_value':
description = f"亮度已调整到{state['input']}"
action = 'turn_on'
arg = 'brightness_pct'
value = state['input']
elif state['type'] == 'volume_up':
description = "音量已调大"
action = state['type']
elif state['type'] == 'volume_down':
description = "音量已调小"
action = state['type']
elif state['type'] == 'volume_set':
description = f"音量已调整到{state['input']}"
action = state['type']
arg = 'volume_level'
value = state['input']
elif state['type'] == 'volume_mute':
description = f"设备已静音"
action = state['type']
arg = 'is_volume_muted'
value = state['is_muted']
elif state['type'] == 'pause':
description = f"设备已暂停"
action = state['type']
if domain == 'media_player':
action = 'media_pause'
if domain == 'cover':
action = 'stop_cover'
if domain == 'vacuum':
action = 'pause'
elif state['type'] == 'continue':
description = f"设备已继续"
if domain == 'media_player':
action = 'media_play'
if domain == 'vacuum':
action = 'start'
else:
return f"{domain} {state.type}功能尚未支持"
if arg == '':
data = {
"entity_id": entity_id,
}
else:
data = {
"entity_id": entity_id,
arg: value
}
url = f"{base_url}/api/services/{domain}/{action}"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
response = requests.post(url, headers=headers, json=data)
logger.bind(tag=TAG).info(f"设置状态:url:{url},return_code:{response.status_code}")
if response.status_code == 200:
return description
else:
return f"设置失败,错误码: {response.status_code}"
@@ -217,13 +217,14 @@ async def play_local_music(conn, specific_file=None):
) )
conn.tts.tts_audio_queue.put( conn.tts.tts_audio_queue.put(
TTSMessageDTO( TTSMessageDTO(
u_id=conn.u_id, msg_type=MsgType.STOP_TTS_RESPONSE, content=[], u_id=conn.u_id,
tts_finish_text='', msg_type=MsgType.STOP_TTS_RESPONSE,
sentence_type=None content=[],
tts_finish_text="",
sentence_type=None,
) )
) )
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"播放音乐失败: {str(e)}") logger.bind(tag=TAG).error(f"播放音乐失败: {str(e)}")
logger.bind(tag=TAG).error(f"详细错误: {traceback.format_exc()}") logger.bind(tag=TAG).error(f"详细错误: {traceback.format_exc()}")
@@ -1,64 +0,0 @@
from config.logger import setup_logging
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from core.handle.iotHandle import get_iot_status, send_iot_conn
import asyncio
TAG = __name__
logger = setup_logging()
raise_and_lower_the_volume_function_desc = {
"type": "function",
"function": {
"name": "raise_and_lower_the_volume",
"description": "用户觉得声音过高或过低,或者用户想提高或降低音量。比如用户说太大声了,参数为:lower,比如用户说提高音量,参数为:raise",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"description": "动作名称,要么是raise,要么是lower"
}
},
"required": ["action"]
}
}
}
@register_function('raise_and_lower_the_volume', raise_and_lower_the_volume_function_desc, ToolType.IOT_CTL)
def raise_and_lower_the_volume(conn, action: str):
"""
获取当前设备音量
"""
future = asyncio.run_coroutine_threadsafe(
_raise_and_lower_the_volume(conn, action),
conn.loop
)
try:
new_volume = future.result() # 同步等待异步操作完成
logger.bind(tag=TAG).info(f"音量操作完成: {new_volume}")
response = f"音量已调整到{new_volume}"
except Exception as e:
logger.bind(tag=TAG).error(f"音量操作失败: {e}")
response = f"音量调整失败: {e}"
return ActionResponse(action=Action.RESPONSE, result="指令已接收", response=response)
async def _raise_and_lower_the_volume(conn, action):
volume = await get_iot_status(conn, "Speaker", "volume")
if volume is None:
raise Exception("你的设备不支持音量控制")
if action == 'raise':
volume += 10
elif action == 'lower':
volume -= 10
# 限制音量范围在0到100之间
if volume < 0:
volume = 0
elif volume > 100:
volume = 100
await send_iot_conn(conn, "Speaker", "SetVolume", {"volume": volume})
return volume
@@ -22,6 +22,4 @@ def auto_import_modules(package_name):
# 导入模块 # 导入模块
full_module_name = f"{package_name}.{module_name}" full_module_name = f"{package_name}.{module_name}"
importlib.import_module(full_module_name) importlib.import_module(full_module_name)
#logger.bind(tag=TAG).info(f"模块 '{full_module_name}' 已加载") #logger.bind(tag=TAG).info(f"模块 '{full_module_name}' 已加载")
auto_import_modules('plugins_func.functions')
@@ -12,6 +12,7 @@ class ToolType(Enum):
CHANGE_SYS_PROMPT = (3, "修改系统提示词,切换角色性格或职责") CHANGE_SYS_PROMPT = (3, "修改系统提示词,切换角色性格或职责")
SYSTEM_CTL = (4, "系统控制,影响正常的对话流程,如退出、播放音乐等,需要传递conn参数") SYSTEM_CTL = (4, "系统控制,影响正常的对话流程,如退出、播放音乐等,需要传递conn参数")
IOT_CTL = (5, "IOT设备控制,需要传递conn参数") IOT_CTL = (5, "IOT设备控制,需要传递conn参数")
MCP_CLIENT = (6, "MCP客户端")
def __init__(self, code, message): def __init__(self, code, message):
self.code = code self.code = code
+2 -1
View File
@@ -22,4 +22,5 @@ mem0ai==0.1.62
bs4==0.0.2 bs4==0.0.2
modelscope==1.23.2 modelscope==1.23.2
sherpa_onnx==1.11.0 sherpa_onnx==1.11.0
mutagen==1.47.0 mcp==1.4.1
cnlunar==0.2.0
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff