Merge branch 'newsnow' into add_local_tts_change_voice

This commit is contained in:
hrz
2025-06-30 10:48:06 +08:00
committed by GitHub
131 changed files with 5529 additions and 1786 deletions
+12 -7
View File
@@ -104,7 +104,8 @@ wakeup_words:
- "喵喵同学"
- "小滨小滨"
- "小冰小冰"
# MCP接入点地址
mcp_endpoint: 你的接入点 websocket地址
# 插件的基础配置
plugins:
# 获取天气插件的配置,这里填写你的api_key
@@ -117,11 +118,15 @@ plugins:
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
get_news_from_chinanews:
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"
get_news_from_newsnow: {"url": "https://newsnow.busiyi.world/api/s?id="}
society_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
world_rss_url: "https://www.chinanews.com.cn/rss/world.xml"
finance_rss_url: "https://www.chinanews.com.cn/rss/finance.xml"
get_news_from_newsnow:
url: "https://newsnow.busiyi.world/api/s?id="
news_sources:
thepaper: "澎湃新闻"
baidu: "百度热搜"
cls-depth: "财联社"
home_assistant:
devices:
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
@@ -766,4 +771,4 @@ TTS:
# 支持声音克隆,可自行上传音频,填入voice参数,voice参数为空时,使用默认声音
access_token: "U4YdYXVfpwWnk2t5Gp822zWPCuORyeJL"
voice: "OUeAo1mhq6IBExi"
output_dir: tmp/
output_dir: tmp/
+5 -5
View File
@@ -5,7 +5,7 @@ from config.config_loader import load_config
from config.settings import check_config_file
from datetime import datetime
SERVER_VERSION = "0.5.5"
SERVER_VERSION = "0.6.1"
_logger_initialized = False
@@ -88,7 +88,7 @@ def setup_logging():
# 输出到文件 - 统一目录,按大小轮转
# 日志文件完整路径
log_file_path = os.path.join(log_dir, log_file)
# 添加日志处理器
logger.add(
log_file_path,
@@ -146,14 +146,14 @@ def update_module_string(selected_module_str):
level=log_config.get("log_level", "INFO"),
filter=formatter,
)
# 更新文件日志配置 - 统一目录,按大小轮转
log_dir = log_config.get("log_dir", "tmp")
log_file = log_config.get("log_file", "server.log")
# 日志文件完整路径
log_file_path = os.path.join(log_dir, log_file)
logger.add(
log_file_path,
format=log_format_file,
@@ -8,6 +8,7 @@ from config.config_loader import get_private_config_from_api
from core.utils.auth import AuthToken
import base64
from typing import Tuple, Optional
from plugins_func.register import Action
TAG = __name__
@@ -55,11 +56,7 @@ class VisionHandler:
device_id = request.headers.get("Device-Id", "")
client_id = request.headers.get("Client-Id", "")
if device_id != token_device_id:
return web.Response(
text=json.dumps(self._create_error_response("设备ID与token不匹配")),
content_type="application/json",
status=401,
)
raise ValueError("设备ID与token不匹配")
# 解析multipart/form-data请求
reader = await request.multipart()
@@ -126,7 +123,8 @@ class VisionHandler:
return_json = {
"success": True,
"result": result,
"action": Action.RESPONSE.name,
"response": result,
}
response = web.Response(
+51 -115
View File
@@ -10,7 +10,6 @@ import threading
import traceback
import subprocess
import websockets
from core.handle.mcpHandle import call_mcp_tool
from core.utils.util import (
extract_json_from_string,
check_vad_update,
@@ -18,7 +17,6 @@ from core.utils.util import (
filter_sensitive_info,
)
from typing import Dict, Any
from core.mcp.manager import MCPManager
from core.utils.modules_initialize import (
initialize_modules,
initialize_tts,
@@ -30,7 +28,7 @@ from concurrent.futures import ThreadPoolExecutor
from core.utils.dialogue import Message, Dialogue
from core.providers.asr.dto.dto import InterfaceType
from core.handle.textHandle import handleTextMessage
from core.handle.functionHandler import FunctionHandler
from core.providers.tools.unified_tool_handler import UnifiedToolHandler
from plugins_func.loadplugins import auto_import_modules
from plugins_func.register import Action, ActionResponse
from core.auth import AuthMiddleware, AuthenticationError
@@ -112,8 +110,7 @@ class ConnectionHandler:
# vad相关变量
self.client_audio_buffer = bytearray()
self.client_have_voice = False
self.client_have_voice_last_time = 0.0
self.client_no_voice_last_time = 0.0
self.last_activity_time = 0.0 # 统一的活动时间戳(毫秒)
self.client_voice_stop = False
# asr相关变量
@@ -144,10 +141,10 @@ class ConnectionHandler:
self.load_function_plugin = False
self.intent_type = "nointent"
self.timeout_task = None
self.timeout_seconds = (
int(self.config.get("close_connection_no_voice_time", 120)) + 60
) # 在原来第一道关闭的基础上加60秒,进行二道关闭
self.timeout_task = None
# {"mcp":true} 表示启用MCP功能
self.features = None
@@ -188,6 +185,9 @@ class ConnectionHandler:
self.websocket = ws
self.device_id = self.headers.get("device-id", None)
# 初始化活动时间戳
self.last_activity_time = time.time() * 1000
# 启动超时检查任务
self.timeout_task = asyncio.create_task(self._check_timeout())
@@ -242,18 +242,10 @@ class ConnectionHandler:
# 立即关闭连接,不等待记忆保存完成
await self.close(ws)
async def reset_timeout(self):
"""重置超时计时器"""
if self.timeout_task:
self.timeout_task.cancel()
self.timeout_task = asyncio.create_task(self._check_timeout())
async def _route_message(self, message):
"""消息路由"""
# 重置超时计时器
await self.reset_timeout()
if isinstance(message, str):
self.last_activity_time = time.time() * 1000
await handleTextMessage(self, message)
elif isinstance(message, bytes):
if self.vad is None:
@@ -425,10 +417,12 @@ class ConnectionHandler:
init_asr = check_asr_update(self.common_config, private_config)
if init_vad:
self.config["VAD"] = private_config["VAD"]
self.config["selected_module"]["VAD"] = private_config["selected_module"][
"VAD"
]
if init_asr:
self.config["ASR"] = private_config["ASR"]
self.config["selected_module"]["ASR"] = private_config["selected_module"][
"ASR"
]
@@ -453,9 +447,17 @@ class ConnectionHandler:
if private_config.get("Intent", None) is not None:
init_intent = True
self.config["Intent"] = private_config["Intent"]
self.config["selected_module"]["Intent"] = private_config[
"selected_module"
]["Intent"]
model_intent = private_config.get("selected_module", {}).get("Intent", {})
self.config["selected_module"]["Intent"] = model_intent
# 加载插件配置
if model_intent != "Intent_nointent":
plugin_from_server = private_config.get("plugins", {})
for plugin, config_str in plugin_from_server.items():
plugin_from_server[plugin] = json.loads(config_str)
self.config["plugins"] = plugin_from_server
self.config["Intent"][self.config["selected_module"]["Intent"]][
"functions"
] = plugin_from_server.keys()
if private_config.get("prompt", None) is not None:
self.config["prompt"] = private_config["prompt"]
if private_config.get("summaryMemory", None) is not None:
@@ -464,6 +466,8 @@ class ConnectionHandler:
self.max_output_size = int(private_config["device_max_output_size"])
if private_config.get("chat_history_conf", None) is not None:
self.chat_history_conf = int(private_config["chat_history_conf"])
if private_config.get("mcp_endpoint", None) is not None:
self.config["mcp_endpoint"] = private_config["mcp_endpoint"]
try:
modules = initialize_modules(
self.logger,
@@ -575,14 +579,12 @@ class ConnectionHandler:
self.intent.set_llm(self.llm)
self.logger.bind(tag=TAG).info("使用主LLM作为意图识别模型")
"""加载插件"""
self.func_handler = FunctionHandler(self)
self.mcp_manager = MCPManager(self)
"""加载统一工具处理器"""
self.func_handler = UnifiedToolHandler(self)
"""加载MCP工具"""
asyncio.run_coroutine_threadsafe(
self.mcp_manager.initialize_servers(), self.loop
)
# 异步初始化工具处理器
if hasattr(self, "loop") and self.loop:
asyncio.run_coroutine_threadsafe(self.func_handler._initialize(), self.loop)
def change_system_prompt(self, prompt):
self.prompt = prompt
@@ -600,12 +602,6 @@ class ConnectionHandler:
functions = None
if self.intent_type == "function_call" and hasattr(self, "func_handler"):
functions = self.func_handler.get_functions()
if hasattr(self, "mcp_client"):
mcp_tools = self.mcp_client.get_available_tools()
if mcp_tools is not None and len(mcp_tools) > 0:
if functions is None:
functions = []
functions.extend(mcp_tools)
response_message = []
try:
@@ -617,8 +613,7 @@ class ConnectionHandler:
)
memory_str = future.result()
uuid_str = str(uuid.uuid4()).replace("-", "")
self.sentence_id = uuid_str
self.sentence_id = str(uuid.uuid4().hex)
if self.intent_type == "function_call" and functions is not None:
# 使用支持functions的streaming接口
@@ -723,37 +718,13 @@ class ConnectionHandler:
"arguments": function_arguments,
}
# 处理Server端MCP工具调用
if self.mcp_manager.is_mcp_tool(function_name):
result = self._handle_mcp_tool_call(function_call_data)
elif hasattr(self, "mcp_client") and self.mcp_client.has_tool(
function_name
):
# 如果是小智端MCP工具调用
self.logger.bind(tag=TAG).debug(
f"调用小智端MCP工具: {function_name}, 参数: {function_arguments}"
)
try:
result = asyncio.run_coroutine_threadsafe(
call_mcp_tool(
self, self.mcp_client, function_name, function_arguments
),
self.loop,
).result()
self.logger.bind(tag=TAG).debug(f"MCP工具调用结果: {result}")
result = ActionResponse(
action=Action.REQLLM, result=result, response=""
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"MCP工具调用失败: {e}")
result = ActionResponse(
action=Action.REQLLM, result="MCP工具调用失败", response=""
)
else:
# 处理系统函数
result = self.func_handler.handle_llm_function_call(
# 使用统一工具处理器处理所有工具调用
result = asyncio.run_coroutine_threadsafe(
self.func_handler.handle_llm_function_call(
self, function_call_data
)
),
self.loop,
).result()
self._handle_function_result(result, function_call_data)
# 存储对话内容
@@ -776,48 +747,6 @@ class ConnectionHandler:
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):
if result.action == Action.RESPONSE: # 直接回复前端
text = result.response
@@ -857,7 +786,7 @@ class ConnectionHandler:
)
self.chat(text, tool_call=True)
elif result.action == Action.NOTFOUND or result.action == Action.ERROR:
text = result.result
text = result.response if result.response else result.result
self.tts.tts_one_sentence(self, ContentType.TEXT, content_detail=text)
self.dialogue.put(Message(role="assistant", content=text))
else:
@@ -912,9 +841,9 @@ class ConnectionHandler:
self.timeout_task.cancel()
self.timeout_task = None
# 清理MCP资源
if hasattr(self, "mcp_manager") and self.mcp_manager:
await self.mcp_manager.cleanup_all()
# 清理工具处理器资源
if hasattr(self, "func_handler") and self.func_handler:
await self.func_handler.cleanup()
# 触发停止事件
if self.stop_event:
@@ -966,7 +895,6 @@ class ConnectionHandler:
def reset_vad_states(self):
self.client_audio_buffer = bytearray()
self.client_have_voice = False
self.client_have_voice_last_time = 0
self.client_voice_stop = False
self.logger.bind(tag=TAG).debug("VAD states reset.")
@@ -985,10 +913,18 @@ class ConnectionHandler:
"""检查连接超时"""
try:
while not self.stop_event.is_set():
await asyncio.sleep(self.timeout_seconds)
if not self.stop_event.is_set():
self.logger.bind(tag=TAG).info("连接超时,准备关闭")
await self.close(self.websocket)
break
# 检查是否超时(只有在时间戳已初始化的情况下)
if self.last_activity_time > 0.0:
current_time = time.time() * 1000
if (
current_time - self.last_activity_time
> self.timeout_seconds * 1000
):
if not self.stop_event.is_set():
self.logger.bind(tag=TAG).info("连接超时,准备关闭")
await self.close(self.websocket)
break
# 每10秒检查一次,避免过于频繁
await asyncio.sleep(10)
except Exception as e:
self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}")
@@ -1,109 +0,0 @@
from config.logger import setup_logging
import json
from plugins_func.register import (
FunctionRegistry,
ActionResponse,
Action,
ToolType,
DeviceTypeRegistry,
)
from plugins_func.functions.hass_init import append_devices_to_prompt
TAG = __name__
class FunctionHandler:
def __init__(self, conn):
self.conn = conn
self.config = conn.config
self.device_type_registry = DeviceTypeRegistry()
self.function_registry = FunctionRegistry()
self.register_nessary_functions()
self.register_config_functions()
self.functions_desc = self.function_registry.get_all_function_desc()
func_names = self.current_support_functions()
self.modify_plugin_loader_des(func_names)
self.finish_init = True
def modify_plugin_loader_des(self, func_names):
if "plugin_loader" not in func_names:
return
# 可编辑的列表中去掉plugin_loader
surport_plugins = [func for func in func_names if func != "plugin_loader"]
func_names = ",".join(surport_plugins)
for function_desc in self.functions_desc:
if function_desc["function"]["name"] == "plugin_loader":
function_desc["function"]["description"] = function_desc["function"][
"description"
].replace("[plugins]", func_names)
break
def upload_functions_desc(self):
self.functions_desc = self.function_registry.get_all_function_desc()
def current_support_functions(self):
func_names = []
for func in self.functions_desc:
func_names.append(func["function"]["name"])
# 打印当前支持的函数列表
self.conn.logger.bind(tag=TAG, session_id=self.conn.session_id).info(
f"当前支持的函数列表: {func_names}"
)
return func_names
def get_functions(self):
"""获取功能调用配置"""
return self.functions_desc
def register_nessary_functions(self):
"""注册必要的函数"""
self.function_registry.register_function("handle_exit_intent")
self.function_registry.register_function("plugin_loader")
self.function_registry.register_function("get_time")
self.function_registry.register_function("get_lunar")
# self.function_registry.register_function("handle_speaker_volume_or_screen_brightness")
def register_config_functions(self):
"""注册配置中的函数,可以不同客户端使用不同的配置"""
for func in self.config["Intent"][self.config["selected_module"]["Intent"]].get(
"functions", []
):
self.function_registry.register_function(func)
"""home assistant需要初始化提示词"""
append_devices_to_prompt(self.conn)
def get_function(self, name):
return self.function_registry.get_function(name)
def handle_llm_function_call(self, conn, function_call_data):
try:
function_name = function_call_data["name"]
funcItem = self.get_function(function_name)
if not funcItem:
return ActionResponse(
action=Action.NOTFOUND, result="没有找到对应的函数", response=""
)
func = funcItem.func
arguments = function_call_data["arguments"]
arguments = json.loads(arguments) if arguments else {}
self.conn.logger.bind(tag=TAG).debug(
f"调用函数: {function_name}, 参数: {arguments}"
)
if (
funcItem.type == ToolType.SYSTEM_CTL
or funcItem.type == ToolType.IOT_CTL
):
return func(conn, **arguments)
elif funcItem.type == ToolType.WAIT:
return func(**arguments)
elif funcItem.type == ToolType.CHANGE_SYS_PROMPT:
return func(conn, **arguments)
else:
return ActionResponse(
action=Action.NOTFOUND, result="没有找到对应的函数", response=""
)
except Exception as e:
self.conn.logger.bind(tag=TAG).error(f"处理function call错误: {e}")
return None
@@ -7,7 +7,7 @@ from core.utils.util import audio_to_data
from core.handle.sendAudioHandle import sendAudioMessage, send_stt_message
from core.utils.util import remove_punctuation_and_length, opus_datas_to_wav_bytes
from core.providers.tts.dto.dto import ContentType, SentenceType
from core.handle.mcpHandle import (
from core.providers.tools.device_mcp import (
MCPClient,
send_mcp_initialize_message,
send_mcp_tools_list_request,
@@ -6,7 +6,7 @@ from core.handle.helloHandle import checkWakeupWords
from core.utils.util import remove_punctuation_and_length
from core.providers.tts.dto.dto import ContentType
from core.utils.dialogue import Message
from core.handle.mcpHandle import call_mcp_tool
from core.providers.tools.device_mcp import call_mcp_tool
from plugins_func.register import Action, ActionResponse
from loguru import logger
@@ -84,9 +84,11 @@ async def process_intent_result(conn, intent_result, original_text):
if not funcItem:
conn.func_handler.function_registry.register_function("play_music")
function_args = None
function_args = {}
if "arguments" in intent_data["function_call"]:
function_args = intent_data["function_call"]["arguments"]
if function_args is None:
function_args = {}
# 确保参数是字符串格式的JSON
if isinstance(function_args, dict):
function_args = json.dumps(function_args)
@@ -104,36 +106,18 @@ async def process_intent_result(conn, intent_result, original_text):
def process_function_call():
conn.dialogue.put(Message(role="user", content=original_text))
# 处理Server端MCP工具调用
if conn.mcp_manager.is_mcp_tool(function_name):
result = conn._handle_mcp_tool_call(function_call_data)
elif hasattr(conn, "mcp_client") and conn.mcp_client.has_tool(
function_name
):
# 如果是小智端MCP工具调用
conn.logger.bind(tag=TAG).debug(
f"调用小智端MCP工具: {function_name}, 参数: {function_args}"
)
try:
result = asyncio.run_coroutine_threadsafe(
call_mcp_tool(
conn, conn.mcp_client, function_name, function_args
),
conn.loop,
).result()
conn.logger.bind(tag=TAG).debug(f"MCP工具调用结果: {result}")
result = ActionResponse(
action=Action.REQLLM, result=result, response=""
)
except Exception as e:
conn.logger.bind(tag=TAG).error(f"MCP工具调用失败: {e}")
result = ActionResponse(
action=Action.REQLLM, result="MCP工具调用失败", response=""
)
else:
# 处理系统函数
result = conn.func_handler.handle_llm_function_call(
conn, function_call_data
# 使用统一工具处理器处理所有工具调用
try:
result = asyncio.run_coroutine_threadsafe(
conn.func_handler.handle_llm_function_call(
conn, function_call_data
),
conn.loop,
).result()
except Exception as e:
conn.logger.bind(tag=TAG).error(f"工具调用失败: {e}")
result = ActionResponse(
action=Action.ERROR, result=str(e), response=str(e)
)
if result:
@@ -1,422 +0,0 @@
import json
import asyncio
from plugins_func.register import (
FunctionItem,
register_device_function,
ActionResponse,
Action,
ToolType,
)
TAG = __name__
def wrap_async_function(async_func):
"""包装异步函数为同步函数"""
def wrapper(*args, **kwargs):
try:
# 获取连接对象(第一个参数)
conn = args[0]
if not hasattr(conn, "loop"):
conn.logger.bind(tag=TAG).error("Connection对象没有loop属性")
return ActionResponse(
Action.ERROR,
"Connection对象没有loop属性",
"执行操作时出错: Connection对象没有loop属性",
)
# 使用conn对象中的事件循环
loop = conn.loop
# 在conn的事件循环中运行异步函数
future = asyncio.run_coroutine_threadsafe(async_func(*args, **kwargs), loop)
# 等待结果返回
return future.result()
except Exception as e:
conn.logger.bind(tag=TAG).error(f"运行异步函数时出错: {e}")
return ActionResponse(Action.ERROR, str(e), f"执行操作时出错: {e}")
return wrapper
def create_iot_function(device_name, method_name, method_info):
"""
根据IOT设备描述生成通用的控制函数
"""
async def iot_control_function(
conn, response_success=None, response_failure=None, **params
):
try:
# 设置默认响应消息
if not response_success:
response_success = "操作成功"
if not response_failure:
response_failure = "操作失败"
# 打印响应参数
conn.logger.bind(tag=TAG).debug(
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
)
# 发送控制命令
await send_iot_conn(conn, device_name, method_name, params)
# 等待一小段时间让状态更新
await asyncio.sleep(0.1)
# 生成结果信息
result = f"{device_name}{method_name}操作执行成功"
# 处理响应中可能的占位符
response = response_success
# 替换{value}占位符
for param_name, param_value in params.items():
# 先尝试直接替换参数值
if "{" + param_name + "}" in response:
response = response.replace(
"{" + param_name + "}", str(param_value)
)
# 如果有{value}占位符,用相关参数替换
if "{value}" in response:
response = response.replace("{value}", str(param_value))
break
return ActionResponse(Action.RESPONSE, result, response)
except Exception as e:
conn.logger.bind(tag=TAG).error(
f"执行{device_name}{method_name}操作失败: {e}"
)
# 操作失败时使用大模型提供的失败响应
response = response_failure
return ActionResponse(Action.ERROR, str(e), response)
return wrap_async_function(iot_control_function)
def create_iot_query_function(device_name, prop_name, prop_info):
"""
根据IOT设备属性创建查询函数
"""
async def iot_query_function(conn, response_success=None, response_failure=None):
try:
# 打印响应参数
conn.logger.bind(tag=TAG).info(
f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
)
value = await get_iot_status(conn, device_name, prop_name)
# 查询成功,生成结果
if value is not None:
# 使用大模型提供的成功响应,并替换其中的占位符
response = response_success.replace("{value}", str(value))
return ActionResponse(Action.RESPONSE, str(value), response)
else:
# 查询失败,使用大模型提供的失败响应
response = response_failure
return ActionResponse(Action.ERROR, f"属性{prop_name}不存在", response)
except Exception as e:
conn.logger.bind(tag=TAG).error(
f"查询{device_name}{prop_name}时出错: {e}"
)
# 查询出错时使用大模型提供的失败响应
response = response_failure
return ActionResponse(Action.ERROR, str(e), response)
return wrap_async_function(iot_query_function)
class IotDescriptor:
"""
A class to represent an IoT descriptor.
"""
def __init__(self, name, description, properties, methods):
self.name = name
self.description = description
self.properties = []
self.methods = []
# 根据描述创建属性
if properties is not None:
for key, value in properties.items():
property_item = {}
property_item["name"] = key
property_item["description"] = value["description"]
if value["type"] == "number":
property_item["value"] = 0
elif value["type"] == "boolean":
property_item["value"] = False
else:
property_item["value"] = ""
self.properties.append(property_item)
# 根据描述创建方法
if methods is not None:
for key, value in methods.items():
method = {}
method["description"] = value["description"]
method["name"] = key
# 检查方法是否有参数
if "parameters" in value:
method["parameters"] = {}
for k, v in value["parameters"].items():
method["parameters"][k] = {
"description": v["description"],
"type": v["type"],
}
self.methods.append(method)
def register_device_type(descriptor, device_type_registry):
"""注册设备类型及其功能"""
device_name = descriptor["name"]
type_id = device_type_registry.generate_device_type_id(descriptor)
# 如果该类型已注册,直接返回类型ID
if type_id in device_type_registry.type_functions:
return type_id
functions = {}
# 为每个属性创建查询函数
for prop_name, prop_info in descriptor["properties"].items():
func_name = f"get_{device_name.lower()}_{prop_name.lower()}"
func_desc = {
"type": "function",
"function": {
"name": func_name,
"description": f"查询{descriptor['description']}{prop_info['description']}",
"parameters": {
"type": "object",
"properties": {
"response_success": {
"type": "string",
"description": f"查询成功时的友好回复,必须使用{{value}}作为占位符表示查询到的值",
},
"response_failure": {
"type": "string",
"description": f"查询失败时的友好回复,例如:'无法获取{device_name}{prop_info['description']}'",
},
},
"required": ["response_success", "response_failure"],
},
},
}
query_func = create_iot_query_function(device_name, prop_name, prop_info)
decorated_func = register_device_function(
func_name, func_desc, ToolType.IOT_CTL
)(query_func)
functions[func_name] = FunctionItem(
func_name, func_desc, decorated_func, ToolType.IOT_CTL
)
# 为每个方法创建控制函数
for method_name, method_info in descriptor["methods"].items():
func_name = f"{device_name.lower()}_{method_name.lower()}"
# 创建参数字典,添加原有参数
parameters = {}
required_params = []
# 如果方法有参数,则添加参数信息
if "parameters" in method_info:
parameters = {
param_name: {
"type": param_info["type"],
"description": param_info["description"],
}
for param_name, param_info in method_info["parameters"].items()
}
required_params = list(method_info["parameters"].keys())
# 添加响应参数
parameters.update(
{
"response_success": {
"type": "string",
"description": "操作成功时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称",
},
"response_failure": {
"type": "string",
"description": "操作失败时的友好回复,关于该设备的操作结果,设备名称尽量使用description中的名称",
},
}
)
# 构建必须参数列表(原有参数 + 响应参数)
required_params.extend(["response_success", "response_failure"])
func_desc = {
"type": "function",
"function": {
"name": func_name,
"description": f"{descriptor['description']} - {method_info['description']}",
"parameters": {
"type": "object",
"properties": parameters,
"required": required_params,
},
},
}
control_func = create_iot_function(device_name, method_name, method_info)
decorated_func = register_device_function(
func_name, func_desc, ToolType.IOT_CTL
)(control_func)
functions[func_name] = FunctionItem(
func_name, func_desc, decorated_func, ToolType.IOT_CTL
)
device_type_registry.register_device_type(type_id, functions)
return type_id
# 用于接受前端设备推送的搜索iot描述
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:
conn.logger.bind(tag=TAG).debug("连接对象没有func_handler")
return
"""处理物联网描述"""
functions_changed = False
for descriptor in descriptors:
# 如果descriptor没有properties和methods,则直接跳过
if "properties" not in descriptor and "methods" not in descriptor:
continue
# 处理缺失properties的情况
if "properties" not in descriptor:
descriptor["properties"] = {}
# 从methods中提取所有参数作为properties
if "methods" in descriptor:
for method_name, method_info in descriptor["methods"].items():
if "parameters" in method_info:
for param_name, param_info in method_info["parameters"].items():
# 将参数信息转换为属性信息
descriptor["properties"][param_name] = {
"description": param_info["description"],
"type": param_info["type"],
}
# 创建IOT设备描述符
iot_descriptor = IotDescriptor(
descriptor["name"],
descriptor["description"],
descriptor["properties"],
descriptor["methods"],
)
conn.iot_descriptors[descriptor["name"]] = iot_descriptor
if conn.load_function_plugin:
# 注册或获取设备类型
device_type_registry = conn.func_handler.device_type_registry
type_id = register_device_type(descriptor, device_type_registry)
device_functions = device_type_registry.get_device_functions(type_id)
# 在连接级注册设备函数
if hasattr(conn, "func_handler"):
for func_name, func_item in device_functions.items():
conn.func_handler.function_registry.register_function(
func_name, func_item
)
conn.logger.bind(tag=TAG).info(
f"注册IOT函数到function handler: {func_name}"
)
functions_changed = True
# 如果注册了新函数,更新function描述列表
if functions_changed and hasattr(conn, "func_handler"):
conn.func_handler.upload_functions_desc()
func_names = conn.func_handler.current_support_functions()
conn.logger.bind(tag=TAG).info(f"设备类型: {type_id}")
conn.logger.bind(tag=TAG).info(
f"更新function描述列表完成,当前支持的函数: {func_names}"
)
async def handleIotStatus(conn, states):
"""处理物联网状态"""
for state in states:
for key, value in conn.iot_descriptors.items():
if key == state["name"]:
for property_item in value.properties:
for k, v in state["state"].items():
if property_item["name"] == k:
if type(v) != type(property_item["value"]):
conn.logger.bind(tag=TAG).error(
f"属性{property_item['name']}的值类型不匹配"
)
break
else:
property_item["value"] = v
conn.logger.bind(tag=TAG).info(
f"物联网状态更新: {key} , {property_item['name']} = {v}"
)
break
break
async def get_iot_status(conn, name, property_name):
"""获取物联网状态"""
for key, value in conn.iot_descriptors.items():
if key == name:
for property_item in value.properties:
if property_item["name"] == property_name:
return property_item["value"]
conn.logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
return None
async def set_iot_status(conn, name, property_name, value):
"""设置物联网状态"""
for key, iot_descriptor in conn.iot_descriptors.items():
if key == name:
for property_item in iot_descriptor.properties:
if property_item["name"] == property_name:
if type(value) != type(property_item["value"]):
conn.logger.bind(tag=TAG).error(
f"属性{property_item['name']}的值类型不匹配"
)
return
property_item["value"] = value
conn.logger.bind(tag=TAG).info(
f"物联网状态更新: {name} , {property_name} = {value}"
)
return
conn.logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
async def send_iot_conn(conn, name, method_name, parameters):
"""发送物联网指令"""
for key, value in conn.iot_descriptors.items():
if key == name:
# 找到了设备
for method in value.methods:
# 找到了方法
if method["name"] == method_name:
# 构建命令对象
command = {
"name": name,
"method": method_name,
}
# 只有当参数不为空时才添加parameters字段
if parameters:
command["parameters"] = parameters
send_message = json.dumps({"type": "iot", "commands": [command]})
await conn.websocket.send(send_message)
conn.logger.bind(tag=TAG).info(f"发送物联网指令: {send_message}")
return
conn.logger.bind(tag=TAG).error(f"未找到方法{method_name}")
@@ -66,12 +66,11 @@ async def startToChat(conn, text):
async def no_voice_close_connect(conn, have_voice):
if have_voice:
conn.client_no_voice_last_time = 0.0
conn.last_activity_time = time.time() * 1000
return
if conn.client_no_voice_last_time == 0.0:
conn.client_no_voice_last_time = time.time() * 1000
else:
no_voice_time = time.time() * 1000 - conn.client_no_voice_last_time
# 只有在已经初始化过时间戳的情况下才进行超时检查
if conn.last_activity_time > 0.0:
no_voice_time = time.time() * 1000 - conn.last_activity_time
close_connection_no_voice_time = int(
conn.config.get("close_connection_no_voice_time", 120)
)
@@ -92,10 +92,8 @@ async def sendAudio(conn, audios, pre_buffer=True):
if conn.client_abort:
break
# 每分钟重置一次计时器
if time.perf_counter() - last_reset_time > 60:
await conn.reset_timeout()
last_reset_time = time.perf_counter()
# 重置没有声音的状态
conn.last_activity_time = time.time() * 1000
# 计算预期发送时间
expected_time = start_time + (play_position / 1000)
@@ -1,11 +1,11 @@
import json
from core.handle.abortHandle import handleAbortMessage
from core.handle.helloHandle import handleHelloMessage
from core.handle.mcpHandle import handle_mcp_message
from core.providers.tools.device_mcp import handle_mcp_message
from core.utils.util import remove_punctuation_and_length, filter_sensitive_info
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.providers.tools.device_iot import handleIotDescriptors, handleIotStatus
from core.handle.reportHandle import enqueue_asr_report
import asyncio
@@ -77,7 +77,7 @@ async def handleTextMessage(conn, message):
if "states" in msg_json:
asyncio.create_task(handleIotStatus(conn, msg_json["states"]))
elif msg_json["type"] == "mcp":
conn.logger.bind(tag=TAG).info(f"收到mcp消息:{message}")
conn.logger.bind(tag=TAG).info(f"收到mcp消息:{message[:100]}")
if "payload" in msg_json:
asyncio.create_task(
handle_mcp_message(conn, conn.mcp_client, msg_json["payload"])
-131
View File
@@ -1,131 +0,0 @@
"""MCP服务管理器"""
import asyncio
import os, json
from typing import Dict, Any, List
from .MCPClient import MCPClient
from plugins_func.register import register_function, ToolType
from config.config_loader import get_project_dir
TAG = __name__
class MCPManager:
"""管理多个MCP服务的集中管理器"""
def __init__(self, conn) -> None:
"""
初始化MCP管理器
"""
self.conn = conn
self.config_path = get_project_dir() + "data/.mcp_server_settings.json"
if os.path.exists(self.config_path) == False:
self.config_path = ""
self.conn.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.conn.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") and not srv_config.get("url"):
self.conn.logger.bind(tag=TAG).warning(
f"Skipping server {name}: neither command nor url specified"
)
continue
try:
client = MCPClient(srv_config)
await client.initialize()
self.client[name] = client
self.conn.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.conn.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.conn.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:
"""依次关闭所有 MCPClient,不让异常阻断整体流程。"""
for name, client in list(self.client.items()):
try:
await asyncio.wait_for(client.cleanup(), timeout=20)
self.conn.logger.bind(tag=TAG).info(f"MCP client closed: {name}")
except (asyncio.TimeoutError, Exception) as e:
self.conn.logger.bind(tag=TAG).error(
f"Error closing MCP client {name}: {e}"
)
self.client.clear()
@@ -93,9 +93,7 @@ class ASRProvider(ASRProviderBase):
# 检查初始化响应
if "code" in result and result["code"] != 1000:
error_msg = f"ASR服务初始化失败: {result.get('payload_msg', {}).get('message', '未知错误')}"
if "payload_msg" in result:
error_msg += f"\n详细错误信息: {json.dumps(result['payload_msg'], ensure_ascii=False)}"
error_msg = f"ASR服务初始化失败: {result.get('payload_msg', {}).get('error', '未知错误')}"
logger.bind(tag=TAG).error(error_msg)
raise Exception(error_msg)
@@ -256,7 +254,6 @@ class ASRProvider(ASRProviderBase):
"X-Api-Access-Key": self.access_token,
"X-Api-Resource-Id": "volc.bigasr.sauc.duration",
"X-Api-Connect-Id": str(uuid.uuid4()),
"Host": "openspeech.bytedance.com",
}
def generate_header(
@@ -309,9 +306,14 @@ class ASRProvider(ASRProviderBase):
# 如果是错误响应
if message_type == 0x0F: # SERVER_ERROR_RESPONSE
code = int.from_bytes(header[4:8], "big", signed=False)
error_msg = res[8:].decode("utf-8")
return {"code": code, "error": error_msg}
code = int.from_bytes(res[4:8], "big", signed=False)
msg_length = int.from_bytes(res[8:12], "big", signed=False)
error_msg = json.loads(res[12:].decode("utf-8"))
return {
"code": code,
"msg_length": msg_length,
"payload_msg": error_msg,
}
# 获取JSON数据(跳过12字节头部)
try:
@@ -76,6 +76,14 @@ class IntentProvider(IntentProviderBase):
'返回: {"function_call": {"name": "get_battery_level", "arguments": {"response_success": "当前电池电量为{value}%", "response_failure": "无法获取Battery的当前电量百分比"}}}\n'
"```\n"
"```\n"
"用户: 当前屏幕亮度是多少?\n"
'返回: {"function_call": {"name": "self_screen_get_brightness"}}\n'
"```\n"
"```\n"
"用户: 设置屏幕亮度为50%\n"
'返回: {"function_call": {"name": "self_screen_set_brightness", "arguments": {"brightness": 50}}}\n'
"```\n"
"```\n"
"用户: 我想结束对话\n"
'返回: {"function_call": {"name": "handle_exit_intent", "arguments": {"say_goodbye": "goodbye"}}}\n'
"```\n"
@@ -87,6 +95,10 @@ class IntentProvider(IntentProviderBase):
"1. 只返回JSON格式,不要包含任何其他文字\n"
'2. 如果没有找到匹配的函数,返回{"function_call": {"name": "continue_chat"}}\n'
"3. 确保返回的JSON格式正确,包含所有必要的字段\n"
"特殊说明:\n"
"- 当用户单次输入包含多个指令时(如'打开灯并且调高音量'\n"
"- 请返回多个function_call组成的JSON数组\n"
"- 示例:{'function_calls': [{name:'light_on'}, {name:'volume_up'}]}"
)
return prompt
@@ -164,7 +176,11 @@ class IntentProvider(IntentProviderBase):
music_file_names = music_config["music_file_names"]
prompt_music = f"{self.promot}\n<musicNames>{music_file_names}\n</musicNames>"
devices = conn.config["plugins"]["home_assistant"].get("devices", [])
home_assistant_cfg = conn.config["plugins"].get("home_assistant")
if home_assistant_cfg:
devices = home_assistant_cfg.get("devices", [])
else:
devices = []
if len(devices) > 0:
hass_prompt = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n"
for device in devices:
@@ -23,7 +23,9 @@ class LLMProvider(LLMProviderBase):
self.bot_id = str(config.get("bot_id"))
self.user_id = str(config.get("user_id"))
self.session_conversation_map = {} # 存储session_id和conversation_id的映射
check_model_key("CozeLLM", self.personal_access_token)
model_key_msg = check_model_key("CozeLLM", self.personal_access_token)
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
def response(self, session_id, dialogue, **kwargs):
coze_api_token = self.personal_access_token
@@ -15,7 +15,9 @@ class LLMProvider(LLMProviderBase):
self.mode = config.get("mode", "chat-messages")
self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip("/")
self.session_conversation_map = {} # 存储session_id和conversation_id的映射
check_model_key("DifyLLM", self.api_key)
model_key_msg = check_model_key("DifyLLM", self.api_key)
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
def response(self, session_id, dialogue, **kwargs):
try:
@@ -14,7 +14,9 @@ class LLMProvider(LLMProviderBase):
self.base_url = config.get("base_url")
self.detail = config.get("detail", False)
self.variables = config.get("variables", {})
check_model_key("FastGPTLLM", self.api_key)
model_key_msg = check_model_key("FastGPTLLM", self.api_key)
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
def response(self, session_id, dialogue, **kwargs):
try:
@@ -73,8 +73,9 @@ class LLMProvider(LLMProviderBase):
http_proxy = cfg.get("http_proxy")
https_proxy = cfg.get("https_proxy")
if not check_model_key("LLM", self.api_key):
raise ValueError("无效的Gemini API Key,请检查是否配置正确")
model_key_msg = check_model_key("LLM", self.api_key)
if model_key_msg:
log.bind(tag=TAG).error(model_key_msg)
if http_proxy or https_proxy:
log.bind(tag=TAG).info(
@@ -1,3 +1,4 @@
import httpx
import openai
from openai.types import CompletionUsage
from config.logger import setup_logging
@@ -16,26 +17,36 @@ class LLMProvider(LLMProviderBase):
self.base_url = config.get("base_url")
else:
self.base_url = config.get("url")
# 增加timeout的配置项,单位为秒
timeout = config.get("timeout", 300)
self.timeout = int(timeout) if timeout else 300
param_defaults = {
"max_tokens": (500, int),
"temperature": (0.7, lambda x: round(float(x), 1)),
"top_p": (1.0, lambda x: round(float(x), 1)),
"frequency_penalty": (0, lambda x: round(float(x), 1))
"frequency_penalty": (0, lambda x: round(float(x), 1)),
}
for param, (default, converter) in param_defaults.items():
value = config.get(param)
try:
setattr(self, param, converter(value) if value not in (None, "") else default)
setattr(
self,
param,
converter(value) if value not in (None, "") else default,
)
except (ValueError, TypeError):
setattr(self, param, default)
logger.debug(
f"意图识别参数初始化: {self.temperature}, {self.max_tokens}, {self.top_p}, {self.frequency_penalty}")
f"意图识别参数初始化: {self.temperature}, {self.max_tokens}, {self.top_p}, {self.frequency_penalty}"
)
check_model_key("LLM", self.api_key)
self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url)
model_key_msg = check_model_key("LLM", self.api_key)
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url, timeout=httpx.Timeout(self.timeout))
def response(self, session_id, dialogue, **kwargs):
try:
@@ -46,7 +57,9 @@ class LLMProvider(LLMProviderBase):
max_tokens=kwargs.get("max_tokens", self.max_tokens),
temperature=kwargs.get("temperature", self.temperature),
top_p=kwargs.get("top_p", self.top_p),
frequency_penalty=kwargs.get("frequency_penalty", self.frequency_penalty),
frequency_penalty=kwargs.get(
"frequency_penalty", self.frequency_penalty
),
)
is_active = True
@@ -84,12 +97,14 @@ class LLMProvider(LLMProviderBase):
for chunk in stream:
# 检查是否存在有效的choice且content不为空
if getattr(chunk, "choices", None):
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls
yield chunk.choices[0].delta.content, chunk.choices[
0
].delta.tool_calls
# 存在 CompletionUsage 消息时,生成 Token 消耗 log
elif isinstance(getattr(chunk, 'usage', None), CompletionUsage):
usage_info = getattr(chunk, 'usage', None)
elif isinstance(getattr(chunk, "usage", None), CompletionUsage):
usage_info = getattr(chunk, "usage", None)
logger.bind(tag=TAG).info(
f"Token 消耗:输入 {getattr(usage_info, 'prompt_tokens', '未知')}"
f"Token 消耗:输入 {getattr(usage_info, 'prompt_tokens', '未知')}"
f"输出 {getattr(usage_info, 'completion_tokens', '未知')}"
f"共计 {getattr(usage_info, 'total_tokens', '未知')}"
)
@@ -12,10 +12,6 @@ class MemoryProviderBase(ABC):
def set_llm(self, llm):
self.llm = llm
# 获取模型名称和类型信息
model_name = getattr(llm, "model_name", str(llm.__class__.__name__))
# 记录更详细的日志
logger.bind(tag=TAG).info(f"记忆总结设置LLM: {model_name}")
@abstractmethod
async def save_memory(self, msgs):
@@ -12,12 +12,14 @@ class MemoryProvider(MemoryProviderBase):
super().__init__(config)
self.api_key = config.get("api_key", "")
self.api_version = config.get("api_version", "v1.1")
have_key = check_model_key("Mem0ai", self.api_key)
if not have_key:
model_key_msg = check_model_key("Mem0ai", self.api_key)
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
self.use_mem0 = False
return
else:
self.use_mem0 = True
try:
self.client = MemoryClient(api_key=self.api_key)
logger.bind(tag=TAG).info("成功连接到 Mem0ai 服务")
@@ -5,6 +5,7 @@ import os
import yaml
from config.config_loader import get_project_dir
from config.manage_api_client import save_mem_local_short
from core.utils.util import check_model_key
short_term_memory_prompt = """
@@ -145,6 +146,10 @@ class MemoryProvider(MemoryProviderBase):
# 打印使用的模型信息
model_info = getattr(self.llm, "model_name", str(self.llm.__class__.__name__))
logger.bind(tag=TAG).debug(f"使用记忆保存模型: {model_info}")
api_key = getattr(self.llm, "api_key", None)
memory_key_msg = check_model_key("记忆总结专用LLM", api_key)
if memory_key_msg:
logger.bind(tag=TAG).error(memory_key_msg)
if self.llm is None:
logger.bind(tag=TAG).error("LLM is not set for memory provider")
return None
@@ -0,0 +1 @@
@@ -0,0 +1,6 @@
"""基础工具定义模块"""
from .tool_types import ToolType, ToolDefinition
from .tool_executor import ToolExecutor
__all__ = ["ToolType", "ToolDefinition", "ToolExecutor"]
@@ -0,0 +1,27 @@
"""工具执行器基类定义"""
from abc import ABC, abstractmethod
from typing import Dict, Any
from .tool_types import ToolDefinition
from plugins_func.register import ActionResponse
class ToolExecutor(ABC):
"""工具执行器抽象基类"""
@abstractmethod
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行工具调用"""
pass
@abstractmethod
def get_tools(self) -> Dict[str, ToolDefinition]:
"""获取该执行器管理的所有工具"""
pass
@abstractmethod
def has_tool(self, tool_name: str) -> bool:
"""检查是否有指定工具"""
pass
@@ -0,0 +1,27 @@
"""工具系统的类型定义"""
from enum import Enum
from dataclasses import dataclass
from typing import Any, Dict, Optional
from plugins_func.register import Action
class ToolType(Enum):
"""工具类型枚举"""
SERVER_PLUGIN = "server_plugin" # 服务端插件
SERVER_MCP = "server_mcp" # 服务端MCP
DEVICE_IOT = "device_iot" # 设备端IoT
DEVICE_MCP = "device_mcp" # 设备端MCP
MCP_ENDPOINT = "mcp_endpoint" # MCP接入点
@dataclass
class ToolDefinition:
"""工具定义"""
name: str # 工具名称
description: Dict[str, Any] # 工具描述(OpenAI函数调用格式)
tool_type: ToolType # 工具类型
parameters: Optional[Dict[str, Any]] = None # 额外参数
@@ -0,0 +1,12 @@
"""设备端IoT工具模块"""
from .iot_descriptor import IotDescriptor
from .iot_handler import handleIotDescriptors, handleIotStatus
from .iot_executor import DeviceIoTExecutor
__all__ = [
"IotDescriptor",
"handleIotDescriptors",
"handleIotStatus",
"DeviceIoTExecutor",
]
@@ -0,0 +1,46 @@
"""IoT设备描述符定义"""
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class IotDescriptor:
"""IoT设备描述符"""
def __init__(self, name, description, properties, methods):
self.name = name
self.description = description
self.properties = []
self.methods = []
# 根据描述创建属性
if properties is not None:
for key, value in properties.items():
property_item = {}
property_item["name"] = key
property_item["description"] = value["description"]
if value["type"] == "number":
property_item["value"] = 0
elif value["type"] == "boolean":
property_item["value"] = False
else:
property_item["value"] = ""
self.properties.append(property_item)
# 根据描述创建方法
if methods is not None:
for key, value in methods.items():
method = {}
method["description"] = value["description"]
method["name"] = key
# 检查方法是否有参数
if "parameters" in value:
method["parameters"] = {}
for k, v in value["parameters"].items():
method["parameters"][k] = {
"description": v["description"],
"type": v["type"],
}
self.methods.append(method)
@@ -0,0 +1,238 @@
"""设备端IoT工具执行器"""
import json
import asyncio
from typing import Dict, Any
from ..base import ToolType, ToolDefinition, ToolExecutor
from plugins_func.register import Action, ActionResponse
class DeviceIoTExecutor(ToolExecutor):
"""设备端IoT工具执行器"""
def __init__(self, conn):
self.conn = conn
self.iot_tools: Dict[str, ToolDefinition] = {}
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行设备端IoT工具"""
if not self.has_tool(tool_name):
return ActionResponse(
action=Action.NOTFOUND, response=f"IoT工具 {tool_name} 不存在"
)
try:
# 解析工具名称,获取设备名和操作类型
if tool_name.startswith("get_"):
# 查询操作:get_devicename_property
parts = tool_name.split("_", 2)
if len(parts) >= 3:
device_name = parts[1]
property_name = parts[2]
value = await self._get_iot_status(device_name, property_name)
if value is not None:
# 处理响应模板
response_success = arguments.get(
"response_success", "查询成功:{value}"
)
response = response_success.replace("{value}", str(value))
return ActionResponse(
action=Action.RESPONSE,
response=response,
)
else:
response_failure = arguments.get(
"response_failure", f"无法获取{device_name}的状态"
)
return ActionResponse(
action=Action.ERROR, response=response_failure
)
else:
# 控制操作:devicename_method
parts = tool_name.split("_", 1)
if len(parts) >= 2:
device_name = parts[0]
method_name = parts[1]
# 提取控制参数(排除响应参数)
control_params = {
k: v
for k, v in arguments.items()
if k not in ["response_success", "response_failure"]
}
# 发送IoT控制命令
await self._send_iot_command(
device_name, method_name, control_params
)
# 等待状态更新
await asyncio.sleep(0.1)
response_success = arguments.get("response_success", "操作成功")
# 处理响应中的占位符
for param_name, param_value in control_params.items():
placeholder = "{" + param_name + "}"
if placeholder in response_success:
response_success = response_success.replace(
placeholder, str(param_value)
)
if "{value}" in response_success:
response_success = response_success.replace(
"{value}", str(param_value)
)
break
return ActionResponse(
action=Action.REQLLM,
result=response_success,
)
return ActionResponse(action=Action.ERROR, response="无法解析IoT工具名称")
except Exception as e:
response_failure = arguments.get("response_failure", "操作失败")
return ActionResponse(action=Action.ERROR, response=response_failure)
async def _get_iot_status(self, device_name: str, property_name: str):
"""获取IoT设备状态"""
for key, value in self.conn.iot_descriptors.items():
if key.lower() == device_name.lower():
for property_item in value.properties:
if property_item["name"].lower() == property_name.lower():
return property_item["value"]
return None
async def _send_iot_command(
self, device_name: str, method_name: str, parameters: Dict[str, Any]
):
"""发送IoT控制命令"""
for key, value in self.conn.iot_descriptors.items():
if key.lower() == device_name.lower():
for method in value.methods:
if method["name"].lower() == method_name.lower():
command = {
"name": key,
"method": method["name"],
}
if parameters:
command["parameters"] = parameters
send_message = json.dumps(
{"type": "iot", "commands": [command]}
)
await self.conn.websocket.send(send_message)
return
raise Exception(f"未找到设备{device_name}的方法{method_name}")
def register_iot_tools(self, descriptors: list):
"""注册IoT工具"""
for descriptor in descriptors:
device_name = descriptor["name"]
device_desc = descriptor["description"]
# 注册查询工具
if "properties" in descriptor:
for prop_name, prop_info in descriptor["properties"].items():
tool_name = f"get_{device_name.lower()}_{prop_name.lower()}"
tool_desc = {
"type": "function",
"function": {
"name": tool_name,
"description": f"查询{device_desc}{prop_info['description']}",
"parameters": {
"type": "object",
"properties": {
"response_success": {
"type": "string",
"description": f"查询成功时的友好回复,必须使用{{value}}作为占位符表示查询到的值",
},
"response_failure": {
"type": "string",
"description": f"查询失败时的友好回复",
},
},
"required": ["response_success", "response_failure"],
},
},
}
self.iot_tools[tool_name] = ToolDefinition(
name=tool_name,
description=tool_desc,
tool_type=ToolType.DEVICE_IOT,
)
# 注册控制工具
if "methods" in descriptor:
for method_name, method_info in descriptor["methods"].items():
tool_name = f"{device_name.lower()}_{method_name.lower()}"
# 构建参数
parameters = {}
required_params = []
# 添加方法的原始参数
if "parameters" in method_info:
parameters.update(
{
param_name: {
"type": param_info["type"],
"description": param_info["description"],
}
for param_name, param_info in method_info[
"parameters"
].items()
}
)
required_params.extend(method_info["parameters"].keys())
# 添加响应参数
parameters.update(
{
"response_success": {
"type": "string",
"description": "操作成功时的友好回复",
},
"response_failure": {
"type": "string",
"description": "操作失败时的友好回复",
},
}
)
required_params.extend(["response_success", "response_failure"])
tool_desc = {
"type": "function",
"function": {
"name": tool_name,
"description": f"{device_desc} - {method_info['description']}",
"parameters": {
"type": "object",
"properties": parameters,
"required": required_params,
},
},
}
self.iot_tools[tool_name] = ToolDefinition(
name=tool_name,
description=tool_desc,
tool_type=ToolType.DEVICE_IOT,
)
def get_tools(self) -> Dict[str, ToolDefinition]:
"""获取所有设备端IoT工具"""
return self.iot_tools.copy()
def has_tool(self, tool_name: str) -> bool:
"""检查是否有指定的设备端IoT工具"""
return tool_name in self.iot_tools
@@ -0,0 +1,83 @@
"""IoT设备支持模块,提供IoT设备描述符和状态处理"""
import asyncio
from config.logger import setup_logging
from .iot_descriptor import IotDescriptor
TAG = __name__
logger = setup_logging()
async def handleIotDescriptors(conn, descriptors):
"""处理物联网描述"""
wait_max_time = 5
while (
not hasattr(conn, "func_handler")
or 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).debug("连接对象没有func_handler")
return
functions_changed = False
for descriptor in descriptors:
# 如果descriptor没有properties和methods,则直接跳过
if "properties" not in descriptor and "methods" not in descriptor:
continue
# 处理缺失properties的情况
if "properties" not in descriptor:
descriptor["properties"] = {}
# 从methods中提取所有参数作为properties
if "methods" in descriptor:
for method_name, method_info in descriptor["methods"].items():
if "parameters" in method_info:
for param_name, param_info in method_info["parameters"].items():
# 将参数信息转换为属性信息
descriptor["properties"][param_name] = {
"description": param_info["description"],
"type": param_info["type"],
}
# 创建IOT设备描述符
iot_descriptor = IotDescriptor(
descriptor["name"],
descriptor["description"],
descriptor["properties"],
descriptor["methods"],
)
conn.iot_descriptors[descriptor["name"]] = iot_descriptor
functions_changed = True
# 如果注册了新函数,更新function描述列表
if functions_changed and hasattr(conn, "func_handler"):
# 注册IoT工具到统一工具处理器
await conn.func_handler.register_iot_tools(descriptors)
conn.func_handler.current_support_functions()
async def handleIotStatus(conn, states):
"""处理物联网状态"""
for state in states:
for key, value in conn.iot_descriptors.items():
if key == state["name"]:
for property_item in value.properties:
for k, v in state["state"].items():
if property_item["name"] == k:
if type(v) != type(property_item["value"]):
logger.bind(tag=TAG).error(
f"属性{property_item['name']}的值类型不匹配"
)
break
else:
property_item["value"] = v
logger.bind(tag=TAG).info(
f"物联网状态更新: {key} , {property_item['name']} = {v}"
)
break
break
@@ -0,0 +1,21 @@
"""设备端MCP工具模块"""
from .mcp_client import MCPClient
from .mcp_handler import (
send_mcp_message,
handle_mcp_message,
send_mcp_initialize_message,
send_mcp_tools_list_request,
call_mcp_tool,
)
from .mcp_executor import DeviceMCPExecutor
__all__ = [
"MCPClient",
"send_mcp_message",
"handle_mcp_message",
"send_mcp_initialize_message",
"send_mcp_tools_list_request",
"call_mcp_tool",
"DeviceMCPExecutor",
]
@@ -0,0 +1,93 @@
"""设备端MCP客户端定义"""
import asyncio
from concurrent.futures import Future
from core.utils.util import sanitize_tool_name
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class MCPClient:
"""设备端MCP客户端,用于管理MCP状态和工具"""
def __init__(self):
self.tools = {} # sanitized_name -> tool_data
self.name_mapping = {}
self.ready = False
self.call_results = {} # To store Futures for tool call responses
self.next_id = 1
self.lock = asyncio.Lock()
self._cached_available_tools = None # Cache for get_available_tools
def has_tool(self, name: str) -> bool:
return name in self.tools
def get_available_tools(self) -> list:
# Check if the cache is valid
if self._cached_available_tools is not None:
return self._cached_available_tools
# If cache is not valid, regenerate the list
result = []
for tool_name, tool_data in self.tools.items():
function_def = {
"name": tool_name,
"description": tool_data["description"],
"parameters": {
"type": tool_data["inputSchema"].get("type", "object"),
"properties": tool_data["inputSchema"].get("properties", {}),
"required": tool_data["inputSchema"].get("required", []),
},
}
result.append({"type": "function", "function": function_def})
self._cached_available_tools = result # Store the generated list in cache
return result
async def is_ready(self) -> bool:
async with self.lock:
return self.ready
async def set_ready(self, status: bool):
async with self.lock:
self.ready = status
async def add_tool(self, tool_data: dict):
async with self.lock:
sanitized_name = sanitize_tool_name(tool_data["name"])
self.tools[sanitized_name] = tool_data
self.name_mapping[sanitized_name] = tool_data["name"]
self._cached_available_tools = (
None # Invalidate the cache when a tool is added
)
async def get_next_id(self) -> int:
async with self.lock:
current_id = self.next_id
self.next_id += 1
return current_id
async def register_call_result_future(self, id: int, future: Future):
async with self.lock:
self.call_results[id] = future
async def resolve_call_result(self, id: int, result: any):
async with self.lock:
if id in self.call_results:
future = self.call_results.pop(id)
if not future.done():
future.set_result(result)
async def reject_call_result(self, id: int, exception: Exception):
async with self.lock:
if id in self.call_results:
future = self.call_results.pop(id)
if not future.done():
future.set_exception(exception)
async def cleanup_call_result(self, id: int):
async with self.lock:
if id in self.call_results:
self.call_results.pop(id)
@@ -0,0 +1,89 @@
"""设备端MCP工具执行器"""
from typing import Dict, Any
from ..base import ToolType, ToolDefinition, ToolExecutor
from plugins_func.register import Action, ActionResponse
from .mcp_handler import call_mcp_tool
class DeviceMCPExecutor(ToolExecutor):
"""设备端MCP工具执行器"""
def __init__(self, conn):
self.conn = conn
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行设备端MCP工具"""
if not hasattr(conn, "mcp_client") or not conn.mcp_client:
return ActionResponse(
action=Action.ERROR,
response="设备端MCP客户端未初始化",
)
if not await conn.mcp_client.is_ready():
return ActionResponse(
action=Action.ERROR,
response="设备端MCP客户端未准备就绪",
)
try:
# 转换参数为JSON字符串
import json
args_str = json.dumps(arguments) if arguments else "{}"
# 调用设备端MCP工具
result = await call_mcp_tool(conn, conn.mcp_client, tool_name, args_str)
resultJson = None
if isinstance(result, str):
try:
resultJson = json.loads(result)
except Exception as e:
pass
# 视觉大模型不经过二次LLM处理
if (
resultJson is not None
and isinstance(resultJson, dict)
and "action" in resultJson
):
return ActionResponse(
action=Action[resultJson["action"]],
response=resultJson.get("response", ""),
)
return ActionResponse(action=Action.REQLLM, result=str(result))
except ValueError as e:
return ActionResponse(action=Action.NOTFOUND, response=str(e))
except Exception as e:
return ActionResponse(action=Action.ERROR, response=str(e))
def get_tools(self) -> Dict[str, ToolDefinition]:
"""获取所有设备端MCP工具"""
if not hasattr(self.conn, "mcp_client") or not self.conn.mcp_client:
return {}
tools = {}
mcp_tools = self.conn.mcp_client.get_available_tools()
for tool in mcp_tools:
func_def = tool.get("function", {})
tool_name = func_def.get("name", "")
if tool_name:
tools[tool_name] = ToolDefinition(
name=tool_name, description=tool, tool_type=ToolType.DEVICE_MCP
)
return tools
def has_tool(self, tool_name: str) -> bool:
"""检查是否有指定的设备端MCP工具"""
if not hasattr(self.conn, "mcp_client") or not self.conn.mcp_client:
return False
return self.conn.mcp_client.has_tool(tool_name)
@@ -1,14 +1,19 @@
"""设备端MCP客户端支持模块"""
import json
import asyncio
import re
from concurrent.futures import Future
from core.utils.util import get_vision_url, sanitize_tool_name
from core.utils.auth import AuthToken
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class MCPClient:
"""MCPClient,用于管理MCP状态和工具"""
"""设备端MCP客户端,用于管理MCP状态和工具"""
def __init__(self):
self.tools = {} # sanitized_name -> tool_data
@@ -94,24 +99,24 @@ class MCPClient:
async def send_mcp_message(conn, payload: dict):
"""Helper to send MCP messages, encapsulating common logic."""
if not conn.features.get("mcp"):
conn.logger.bind(tag=TAG).warning("客户端不支持MCP,无法发送MCP消息")
logger.bind(tag=TAG).warning("客户端不支持MCP,无法发送MCP消息")
return
message = json.dumps({"type": "mcp", "payload": payload})
try:
await conn.websocket.send(message)
conn.logger.bind(tag=TAG).info(f"成功发送MCP消息: {message}")
logger.bind(tag=TAG).info(f"成功发送MCP消息: {message}")
except Exception as e:
conn.logger.bind(tag=TAG).error(f"发送MCP消息失败: {e}")
logger.bind(tag=TAG).error(f"发送MCP消息失败: {e}")
async def handle_mcp_message(conn, mcp_client: MCPClient, payload: dict):
"""处理MCP消息,包括初始化、工具列表和工具调用响应等"""
conn.logger.bind(tag=TAG).info(f"处理MCP消息: {payload}")
logger.bind(tag=TAG).info(f"处理MCP消息: {str(payload)[:100]}")
if not isinstance(payload, dict):
conn.logger.bind(tag=TAG).error("MCP消息缺少payload字段或格式错误")
logger.bind(tag=TAG).error("MCP消息缺少payload字段或格式错误")
return
# Handle result
@@ -121,32 +126,32 @@ async def handle_mcp_message(conn, mcp_client: MCPClient, payload: dict):
# Check for tool call response first
if msg_id in mcp_client.call_results:
conn.logger.bind(tag=TAG).debug(
logger.bind(tag=TAG).debug(
f"收到工具调用响应,ID: {msg_id}, 结果: {result}"
)
await mcp_client.resolve_call_result(msg_id, result)
return
if msg_id == 1: # mcpInitializeID
conn.logger.bind(tag=TAG).debug("收到MCP初始化响应")
logger.bind(tag=TAG).debug("收到MCP初始化响应")
server_info = result.get("serverInfo")
if isinstance(server_info, dict):
name = server_info.get("name")
version = server_info.get("version")
conn.logger.bind(tag=TAG).info(
logger.bind(tag=TAG).info(
f"客户端MCP服务器信息: name={name}, version={version}"
)
return
elif msg_id == 2: # mcpToolsListID
conn.logger.bind(tag=TAG).debug("收到MCP工具列表响应")
logger.bind(tag=TAG).debug("收到MCP工具列表响应")
if isinstance(result, dict) and "tools" in result:
tools_data = result["tools"]
if not isinstance(tools_data, list):
conn.logger.bind(tag=TAG).error("工具列表格式错误")
logger.bind(tag=TAG).error("工具列表格式错误")
return
conn.logger.bind(tag=TAG).info(
logger.bind(tag=TAG).info(
f"客户端设备支持的工具数量: {len(tools_data)}"
)
@@ -172,7 +177,7 @@ async def handle_mcp_message(conn, mcp_client: MCPClient, payload: dict):
"inputSchema": input_schema,
}
await mcp_client.add_tool(new_tool)
conn.logger.bind(tag=TAG).debug(f"客户端工具 #{i+1}: {name}")
logger.bind(tag=TAG).debug(f"客户端工具 #{i+1}: {name}")
# 替换所有工具描述中的工具名称
for tool_data in mcp_client.tools.values():
@@ -190,24 +195,27 @@ async def handle_mcp_message(conn, mcp_client: MCPClient, payload: dict):
next_cursor = result.get("nextCursor", "")
if next_cursor:
conn.logger.bind(tag=TAG).info(
f"有更多工具,nextCursor: {next_cursor}"
)
logger.bind(tag=TAG).info(f"有更多工具,nextCursor: {next_cursor}")
await send_mcp_tools_list_continue_request(conn, next_cursor)
else:
await mcp_client.set_ready(True)
conn.logger.bind(tag=TAG).info("所有工具已获取,MCP客户端准备就绪")
logger.bind(tag=TAG).info("所有工具已获取,MCP客户端准备就绪")
# 刷新工具缓存,确保MCP工具被包含在函数列表中
if hasattr(conn, "func_handler") and conn.func_handler:
conn.func_handler.tool_manager.refresh_tools()
conn.func_handler.current_support_functions()
return
# Handle method calls (requests from the client)
elif "method" in payload:
method = payload["method"]
conn.logger.bind(tag=TAG).info(f"收到MCP客户端请求: {method}")
logger.bind(tag=TAG).info(f"收到MCP客户端请求: {method}")
elif "error" in payload:
error_data = payload["error"]
error_msg = error_data.get("message", "未知错误")
conn.logger.bind(tag=TAG).error(f"收到MCP错误响应: {error_msg}")
logger.bind(tag=TAG).error(f"收到MCP错误响应: {error_msg}")
msg_id = int(payload.get("id", 0))
if msg_id in mcp_client.call_results:
@@ -216,9 +224,6 @@ async def handle_mcp_message(conn, mcp_client: MCPClient, payload: dict):
)
# --- Outgoing MCP Messages ---
async def send_mcp_initialize_message(conn):
"""发送MCP初始化消息"""
@@ -250,7 +255,7 @@ async def send_mcp_initialize_message(conn):
},
},
}
conn.logger.bind(tag=TAG).info("发送MCP初始化消息")
logger.bind(tag=TAG).info("发送MCP初始化消息")
await send_mcp_message(conn, payload)
@@ -261,7 +266,7 @@ async def send_mcp_tools_list_request(conn):
"id": 2, # mcpToolsListID
"method": "tools/list",
}
conn.logger.bind(tag=TAG).debug("发送MCP工具列表请求")
logger.bind(tag=TAG).debug("发送MCP工具列表请求")
await send_mcp_message(conn, payload)
@@ -273,7 +278,7 @@ async def send_mcp_tools_list_continue_request(conn, cursor: str):
"method": "tools/list",
"params": {"cursor": cursor},
}
conn.logger.bind(tag=TAG).info(f"发送带cursor的MCP工具列表请求: {cursor}")
logger.bind(tag=TAG).info(f"发送带cursor的MCP工具列表请求: {cursor}")
await send_mcp_message(conn, payload)
@@ -307,8 +312,6 @@ async def call_mcp_tool(
# 如果解析失败,尝试合并多个JSON对象
try:
# 使用正则表达式匹配所有JSON对象
import re
json_objects = re.findall(r"\{[^{}]*\}", args)
if len(json_objects) > 1:
# 合并所有JSON对象
@@ -327,7 +330,7 @@ async def call_mcp_tool(
else:
raise ValueError(f"参数JSON解析失败: {args}")
except Exception as e:
conn.logger.bind(tag=TAG).error(
logger.bind(tag=TAG).error(
f"参数JSON解析失败: {str(e)}, 原始参数: {args}"
)
raise ValueError(f"参数JSON解析失败: {str(e)}")
@@ -353,15 +356,13 @@ async def call_mcp_tool(
"params": {"name": actual_name, "arguments": arguments},
}
conn.logger.bind(tag=TAG).info(
f"发送客户端mcp工具调用请求: {actual_name},参数: {args}"
)
logger.bind(tag=TAG).info(f"发送客户端mcp工具调用请求: {actual_name},参数: {args}")
await send_mcp_message(conn, payload)
try:
# Wait for response or timeout
raw_result = await asyncio.wait_for(result_future, timeout=timeout)
conn.logger.bind(tag=TAG).info(
logger.bind(tag=TAG).info(
f"客户端mcp工具调用 {actual_name} 成功,原始结果: {raw_result}"
)
@@ -0,0 +1,21 @@
"""MCP接入点工具模块"""
from .mcp_endpoint_executor import MCPEndpointExecutor
from .mcp_endpoint_client import MCPEndpointClient
from .mcp_endpoint_handler import (
connect_mcp_endpoint,
send_mcp_endpoint_initialize,
send_mcp_endpoint_notification,
send_mcp_endpoint_tools_list,
call_mcp_endpoint_tool,
)
__all__ = [
"MCPEndpointExecutor",
"MCPEndpointClient",
"connect_mcp_endpoint",
"send_mcp_endpoint_initialize",
"send_mcp_endpoint_notification",
"send_mcp_endpoint_tools_list",
"call_mcp_endpoint_tool",
]
@@ -0,0 +1,112 @@
"""MCP接入点客户端定义"""
import asyncio
from concurrent.futures import Future
from core.utils.util import sanitize_tool_name
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class MCPEndpointClient:
"""MCP接入点客户端,用于管理MCP接入点状态和工具"""
def __init__(self, conn=None):
self.conn = conn
self.tools = {} # sanitized_name -> tool_data
self.name_mapping = {}
self.ready = False
self.call_results = {} # To store Futures for tool call responses
self.next_id = 1
self.lock = asyncio.Lock()
self._cached_available_tools = None # Cache for get_available_tools
self.websocket = None # WebSocket连接
def has_tool(self, name: str) -> bool:
return name in self.tools
def get_available_tools(self) -> list:
# Check if the cache is valid
if self._cached_available_tools is not None:
return self._cached_available_tools
# If cache is not valid, regenerate the list
result = []
for tool_name, tool_data in self.tools.items():
function_def = {
"name": tool_name,
"description": tool_data["description"],
"parameters": {
"type": tool_data["inputSchema"].get("type", "object"),
"properties": tool_data["inputSchema"].get("properties", {}),
"required": tool_data["inputSchema"].get("required", []),
},
}
result.append({"type": "function", "function": function_def})
self._cached_available_tools = result # Store the generated list in cache
return result
async def is_ready(self) -> bool:
async with self.lock:
return self.ready
async def set_ready(self, status: bool):
async with self.lock:
self.ready = status
async def add_tool(self, tool_data: dict):
async with self.lock:
sanitized_name = sanitize_tool_name(tool_data["name"])
self.tools[sanitized_name] = tool_data
self.name_mapping[sanitized_name] = tool_data["name"]
self._cached_available_tools = (
None # Invalidate the cache when a tool is added
)
async def get_next_id(self) -> int:
async with self.lock:
current_id = self.next_id
self.next_id += 1
return current_id
async def register_call_result_future(self, id: int, future: Future):
async with self.lock:
self.call_results[id] = future
async def resolve_call_result(self, id: int, result: any):
async with self.lock:
if id in self.call_results:
future = self.call_results.pop(id)
if not future.done():
future.set_result(result)
async def reject_call_result(self, id: int, exception: Exception):
async with self.lock:
if id in self.call_results:
future = self.call_results.pop(id)
if not future.done():
future.set_exception(exception)
async def cleanup_call_result(self, id: int):
async with self.lock:
if id in self.call_results:
self.call_results.pop(id)
def set_websocket(self, websocket):
"""设置WebSocket连接"""
self.websocket = websocket
async def send_message(self, message: str):
"""发送消息到MCP接入点"""
if self.websocket:
await self.websocket.send(message)
else:
raise RuntimeError("WebSocket连接未建立")
async def close(self):
"""关闭WebSocket连接"""
if self.websocket:
await self.websocket.close()
self.websocket = None
@@ -0,0 +1,97 @@
"""MCP接入点工具执行器"""
from typing import Dict, Any
from ..base import ToolType, ToolDefinition, ToolExecutor
from plugins_func.register import Action, ActionResponse
from .mcp_endpoint_handler import call_mcp_endpoint_tool
class MCPEndpointExecutor(ToolExecutor):
"""MCP接入点工具执行器"""
def __init__(self, conn):
self.conn = conn
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行MCP接入点工具"""
if not hasattr(conn, "mcp_endpoint_client") or not conn.mcp_endpoint_client:
return ActionResponse(
action=Action.ERROR,
response="MCP接入点客户端未初始化",
)
if not await conn.mcp_endpoint_client.is_ready():
return ActionResponse(
action=Action.ERROR,
response="MCP接入点客户端未准备就绪",
)
try:
# 转换参数为JSON字符串
import json
args_str = json.dumps(arguments) if arguments else "{}"
# 调用MCP接入点工具
result = await call_mcp_endpoint_tool(
conn.mcp_endpoint_client, tool_name, args_str
)
resultJson = None
if isinstance(result, str):
try:
resultJson = json.loads(result)
except Exception as e:
pass
# 视觉大模型不经过二次LLM处理
if (
resultJson is not None
and isinstance(resultJson, dict)
and "action" in resultJson
):
return ActionResponse(
action=Action[resultJson["action"]],
response=resultJson.get("response", ""),
)
return ActionResponse(action=Action.REQLLM, result=str(result))
except ValueError as e:
return ActionResponse(action=Action.NOTFOUND, response=str(e))
except Exception as e:
return ActionResponse(action=Action.ERROR, response=str(e))
def get_tools(self) -> Dict[str, ToolDefinition]:
"""获取所有MCP接入点工具"""
if (
not hasattr(self.conn, "mcp_endpoint_client")
or not self.conn.mcp_endpoint_client
):
return {}
tools = {}
mcp_tools = self.conn.mcp_endpoint_client.get_available_tools()
for tool in mcp_tools:
func_def = tool.get("function", {})
tool_name = func_def.get("name", "")
if tool_name:
tools[tool_name] = ToolDefinition(
name=tool_name, description=tool, tool_type=ToolType.MCP_ENDPOINT
)
return tools
def has_tool(self, tool_name: str) -> bool:
"""检查是否有指定的MCP接入点工具"""
if (
not hasattr(self.conn, "mcp_endpoint_client")
or not self.conn.mcp_endpoint_client
):
return False
return self.conn.mcp_endpoint_client.has_tool(tool_name)
@@ -0,0 +1,390 @@
"""MCP接入点处理器"""
import json
import asyncio
import re
import websockets
from config.logger import setup_logging
from .mcp_endpoint_client import MCPEndpointClient
TAG = __name__
logger = setup_logging()
async def connect_mcp_endpoint(mcp_endpoint_url: str, conn=None) -> MCPEndpointClient:
"""连接到MCP接入点"""
if not mcp_endpoint_url or "你的" in mcp_endpoint_url or mcp_endpoint_url == "null":
return None
try:
websocket = await websockets.connect(mcp_endpoint_url)
mcp_client = MCPEndpointClient(conn)
mcp_client.set_websocket(websocket)
# 启动消息监听器
asyncio.create_task(_message_listener(mcp_client))
# 发送初始化消息
await send_mcp_endpoint_initialize(mcp_client)
# 发送初始化完成通知
await send_mcp_endpoint_notification(mcp_client, "notifications/initialized")
# 获取工具列表
await send_mcp_endpoint_tools_list(mcp_client)
logger.bind(tag=TAG).info("MCP接入点连接成功")
return mcp_client
except Exception as e:
logger.bind(tag=TAG).error(f"连接MCP接入点失败: {e}")
return None
async def _message_listener(mcp_client: MCPEndpointClient):
"""监听MCP接入点消息"""
try:
async for message in mcp_client.websocket:
await handle_mcp_endpoint_message(mcp_client, message)
except websockets.exceptions.ConnectionClosed:
logger.bind(tag=TAG).info("MCP接入点连接已关闭")
except Exception as e:
logger.bind(tag=TAG).error(f"MCP接入点消息监听器错误: {e}")
finally:
await mcp_client.set_ready(False)
async def handle_mcp_endpoint_message(mcp_client: MCPEndpointClient, message: str):
"""处理MCP接入点消息"""
try:
payload = json.loads(message)
logger.bind(tag=TAG).debug(f"收到MCP接入点消息: {payload}")
if not isinstance(payload, dict):
logger.bind(tag=TAG).error("MCP接入点消息格式错误")
return
# Handle result
if "result" in payload:
result = payload["result"]
# 安全地获取消息ID,如果为None则使用0
msg_id_raw = payload.get("id")
msg_id = int(msg_id_raw) if msg_id_raw is not None else 0
# Check for tool call response first
if msg_id in mcp_client.call_results:
logger.bind(tag=TAG).debug(
f"收到工具调用响应,ID: {msg_id}, 结果: {result}"
)
await mcp_client.resolve_call_result(msg_id, result)
return
if msg_id == 1: # mcpInitializeID
logger.bind(tag=TAG).debug("收到MCP接入点初始化响应")
if result is not None and isinstance(result, dict):
server_info = result.get("serverInfo")
if isinstance(server_info, dict):
name = server_info.get("name")
version = server_info.get("version")
logger.bind(tag=TAG).info(
f"MCP接入点服务器信息: name={name}, version={version}"
)
else:
logger.bind(tag=TAG).warning(
"MCP接入点初始化响应结果为空或格式错误"
)
return
elif msg_id == 2: # mcpToolsListID
logger.bind(tag=TAG).debug("收到MCP接入点工具列表响应")
if (
result is not None
and isinstance(result, dict)
and "tools" in result
):
tools_data = result["tools"]
if not isinstance(tools_data, list):
logger.bind(tag=TAG).error("工具列表格式错误")
return
logger.bind(tag=TAG).info(
f"MCP接入点支持的工具数量: {len(tools_data)}"
)
for i, tool in enumerate(tools_data):
if not isinstance(tool, dict):
continue
name = tool.get("name", "")
description = tool.get("description", "")
input_schema = {
"type": "object",
"properties": {},
"required": [],
}
if "inputSchema" in tool and isinstance(
tool["inputSchema"], dict
):
schema = tool["inputSchema"]
input_schema["type"] = schema.get("type", "object")
input_schema["properties"] = schema.get("properties", {})
input_schema["required"] = [
s
for s in schema.get("required", [])
if isinstance(s, str)
]
new_tool = {
"name": name,
"description": description,
"inputSchema": input_schema,
}
await mcp_client.add_tool(new_tool)
logger.bind(tag=TAG).debug(f"MCP接入点工具 #{i+1}: {name}")
# 替换所有工具描述中的工具名称
for tool_data in mcp_client.tools.values():
if "description" in tool_data:
description = tool_data["description"]
# 遍历所有工具名称进行替换
for (
sanitized_name,
original_name,
) in mcp_client.name_mapping.items():
description = description.replace(
original_name, sanitized_name
)
tool_data["description"] = description
next_cursor = (
result.get("nextCursor", "") if result is not None else ""
)
if next_cursor:
logger.bind(tag=TAG).info(
f"有更多工具,nextCursor: {next_cursor}"
)
await send_mcp_endpoint_tools_list_continue(
mcp_client, next_cursor
)
else:
await mcp_client.set_ready(True)
logger.bind(tag=TAG).info(
"所有MCP接入点工具已获取,客户端准备就绪"
)
# 刷新工具缓存,确保MCP接入点工具被包含在函数列表中
if (
hasattr(mcp_client, "conn")
and mcp_client.conn
and hasattr(mcp_client.conn, "func_handler")
and mcp_client.conn.func_handler
):
mcp_client.conn.func_handler.tool_manager.refresh_tools()
mcp_client.conn.func_handler.current_support_functions()
logger.bind(tag=TAG).info(
f"MCP接入点工具获取完成,共 {len(mcp_client.tools)} 个工具"
)
else:
logger.bind(tag=TAG).warning(
"MCP接入点工具列表响应结果为空或格式错误"
)
return
# Handle method calls (requests from the endpoint)
elif "method" in payload:
method = payload["method"]
logger.bind(tag=TAG).info(f"收到MCP接入点请求: {method}")
elif "error" in payload:
error_data = payload["error"]
error_msg = error_data.get("message", "未知错误")
logger.bind(tag=TAG).error(f"收到MCP接入点错误响应: {error_msg}")
# 安全地获取消息ID,如果为None则使用0
msg_id_raw = payload.get("id")
msg_id = int(msg_id_raw) if msg_id_raw is not None else 0
if msg_id in mcp_client.call_results:
await mcp_client.reject_call_result(
msg_id, Exception(f"MCP接入点错误: {error_msg}")
)
except json.JSONDecodeError as e:
logger.bind(tag=TAG).error(f"MCP接入点消息JSON解析失败: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"处理MCP接入点消息时出错: {e}")
import traceback
logger.bind(tag=TAG).error(f"错误详情: {traceback.format_exc()}")
async def send_mcp_endpoint_initialize(mcp_client: MCPEndpointClient):
"""发送MCP接入点初始化消息"""
payload = {
"jsonrpc": "2.0",
"id": 1, # mcpInitializeID
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {
"roots": {"listChanged": True},
"sampling": {},
},
"clientInfo": {
"name": "XiaozhiMCPEndpointClient",
"version": "1.0.0",
},
},
}
message = json.dumps(payload)
logger.bind(tag=TAG).info("发送MCP接入点初始化消息")
await mcp_client.send_message(message)
async def send_mcp_endpoint_notification(mcp_client: MCPEndpointClient, method: str):
"""发送MCP接入点通知消息"""
payload = {
"jsonrpc": "2.0",
"method": method,
"params": {},
}
message = json.dumps(payload)
logger.bind(tag=TAG).debug(f"发送MCP接入点通知: {method}")
await mcp_client.send_message(message)
async def send_mcp_endpoint_tools_list(mcp_client: MCPEndpointClient):
"""发送MCP接入点工具列表请求"""
payload = {
"jsonrpc": "2.0",
"id": 2, # mcpToolsListID
"method": "tools/list",
}
message = json.dumps(payload)
logger.bind(tag=TAG).debug("发送MCP接入点工具列表请求")
await mcp_client.send_message(message)
async def send_mcp_endpoint_tools_list_continue(
mcp_client: MCPEndpointClient, cursor: str
):
"""发送带有cursor的MCP接入点工具列表请求"""
payload = {
"jsonrpc": "2.0",
"id": 2, # mcpToolsListID (same ID for continuation)
"method": "tools/list",
"params": {"cursor": cursor},
}
message = json.dumps(payload)
logger.bind(tag=TAG).info(f"发送带cursor的MCP接入点工具列表请求: {cursor}")
await mcp_client.send_message(message)
async def call_mcp_endpoint_tool(
mcp_client: MCPEndpointClient, tool_name: str, args: str = "{}", timeout: int = 30
):
"""
调用指定的MCP接入点工具,并等待响应
"""
if not await mcp_client.is_ready():
raise RuntimeError("MCP接入点客户端尚未准备就绪")
if not mcp_client.has_tool(tool_name):
raise ValueError(f"工具 {tool_name} 不存在")
tool_call_id = await mcp_client.get_next_id()
result_future = asyncio.Future()
await mcp_client.register_call_result_future(tool_call_id, result_future)
# 处理参数
try:
if isinstance(args, str):
# 确保字符串是有效的JSON
if not args.strip():
arguments = {}
else:
try:
# 尝试直接解析
arguments = json.loads(args)
except json.JSONDecodeError:
# 如果解析失败,尝试合并多个JSON对象
try:
# 使用正则表达式匹配所有JSON对象
json_objects = re.findall(r"\{[^{}]*\}", args)
if len(json_objects) > 1:
# 合并所有JSON对象
merged_dict = {}
for json_str in json_objects:
try:
obj = json.loads(json_str)
if isinstance(obj, dict):
merged_dict.update(obj)
except json.JSONDecodeError:
continue
if merged_dict:
arguments = merged_dict
else:
raise ValueError(f"无法解析任何有效的JSON对象: {args}")
else:
raise ValueError(f"参数JSON解析失败: {args}")
except Exception as e:
logger.bind(tag=TAG).error(
f"参数JSON解析失败: {str(e)}, 原始参数: {args}"
)
raise ValueError(f"参数JSON解析失败: {str(e)}")
elif isinstance(args, dict):
arguments = args
else:
raise ValueError(f"参数类型错误,期望字符串或字典,实际类型: {type(args)}")
# 确保参数是字典类型
if not isinstance(arguments, dict):
raise ValueError(f"参数必须是字典类型,实际类型: {type(arguments)}")
except Exception as e:
if not isinstance(e, ValueError):
raise ValueError(f"参数处理失败: {str(e)}")
raise e
actual_name = mcp_client.name_mapping.get(tool_name, tool_name)
payload = {
"jsonrpc": "2.0",
"id": tool_call_id,
"method": "tools/call",
"params": {"name": actual_name, "arguments": arguments},
}
message = json.dumps(payload)
logger.bind(tag=TAG).info(f"发送MCP接入点工具调用请求: {actual_name},参数: {args}")
await mcp_client.send_message(message)
try:
# Wait for response or timeout
raw_result = await asyncio.wait_for(result_future, timeout=timeout)
logger.bind(tag=TAG).info(
f"MCP接入点工具调用 {actual_name} 成功,原始结果: {raw_result}"
)
if isinstance(raw_result, dict):
if raw_result.get("isError") is True:
error_msg = raw_result.get(
"error", "工具调用返回错误,但未提供具体错误信息"
)
raise RuntimeError(f"工具调用错误: {error_msg}")
content = raw_result.get("content")
if isinstance(content, list) and len(content) > 0:
if isinstance(content[0], dict) and "text" in content[0]:
# 直接返回文本内容,不进行JSON解析
return content[0]["text"]
# 如果结果不是预期的格式,将其转换为字符串
return str(raw_result)
except asyncio.TimeoutError:
await mcp_client.cleanup_call_result(tool_call_id)
raise TimeoutError("工具调用请求超时")
except Exception as e:
await mcp_client.cleanup_call_result(tool_call_id)
raise e
@@ -0,0 +1,7 @@
"""服务端MCP工具模块"""
from .mcp_manager import ServerMCPManager
from .mcp_executor import ServerMCPExecutor
from .mcp_client import ServerMCPClient
__all__ = ["ServerMCPManager", "ServerMCPExecutor", "ServerMCPClient"]
@@ -1,7 +1,12 @@
"""服务端MCP客户端"""
from __future__ import annotations
from datetime import timedelta
import asyncio, os, shutil, concurrent.futures
import asyncio
import os
import shutil
import concurrent.futures
from contextlib import AsyncExitStack
from typing import Optional, List, Dict, Any
@@ -14,8 +19,15 @@ from core.utils.util import sanitize_tool_name
TAG = __name__
class MCPClient:
class ServerMCPClient:
"""服务端MCP客户端,用于连接和管理MCP服务"""
def __init__(self, config: Dict[str, Any]):
"""初始化服务端MCP客户端
Args:
config: MCP服务配置字典
"""
self.logger = setup_logging()
self.config = config
@@ -24,21 +36,26 @@ class MCPClient:
self._shutdown_evt = asyncio.Event()
self.session: Optional[ClientSession] = None
self.tools: List = [] # original tool objects
self.tools: List = [] # 原始工具对象
self.tools_dict: Dict[str, Any] = {}
self.name_mapping: Dict[str, str] = {}
async def initialize(self):
"""初始化MCP客户端连接"""
if self._worker_task:
return
self._worker_task = asyncio.create_task(self._worker(), name="MCPClientWorker")
self._worker_task = asyncio.create_task(
self._worker(), name="ServerMCPClientWorker"
)
await self._ready_evt.wait()
self.logger.bind(tag=TAG).info(
f"Connected, tools = {[name for name in self.name_mapping.values()]}"
f"服务端MCP客户端已连接,可用工具: {[name for name in self.name_mapping.values()]}"
)
async def cleanup(self):
"""清理MCP客户端资源"""
if not self._worker_task:
return
@@ -46,14 +63,27 @@ class MCPClient:
try:
await asyncio.wait_for(self._worker_task, timeout=20)
except (asyncio.TimeoutError, Exception) as e:
self.logger.bind(tag=TAG).error(f"worker shutdown err: {e}")
self.logger.bind(tag=TAG).error(f"服务端MCP客户端关闭错误: {e}")
finally:
self._worker_task = None
def has_tool(self, name: str) -> bool:
"""检查是否包含指定工具
Args:
name: 工具名称
Returns:
bool: 是否包含该工具
"""
return name in self.tools_dict
def get_available_tools(self):
def get_available_tools(self) -> List[Dict[str, Any]]:
"""获取所有可用工具的定义
Returns:
List[Dict[str, Any]]: 工具定义列表
"""
return [
{
"type": "function",
@@ -66,9 +96,21 @@ class MCPClient:
for name, tool in self.tools_dict.items()
]
async def call_tool(self, name: str, args: dict):
async def call_tool(self, name: str, args: dict) -> Any:
"""调用指定工具
Args:
name: 工具名称
args: 工具参数
Returns:
Any: 工具执行结果
Raises:
RuntimeError: 客户端未初始化时抛出
"""
if not self.session:
raise RuntimeError("MCPClient not initialized")
raise RuntimeError("服务端MCP客户端未初始化")
real_name = self.name_mapping.get(name, name)
loop = self._worker_task.get_loop()
@@ -80,7 +122,29 @@ class MCPClient:
fut: concurrent.futures.Future = asyncio.run_coroutine_threadsafe(coro, loop)
return await asyncio.wrap_future(fut)
def is_connected(self) -> bool:
"""检查MCP客户端是否连接正常
Returns:
bool: 如果客户端已连接并正常工作返回True否则返回False
"""
# 检查工作任务是否存在
if self._worker_task is None:
return False
# 检查工作任务是否已经完成或取消
if self._worker_task.done():
return False
# 检查会话是否存在
if self.session is None:
return False
# 所有检查都通过,连接正常
return True
async def _worker(self):
"""MCP客户端工作协程"""
async with AsyncExitStack() as stack:
try:
# 建立 StdioClient
@@ -100,6 +164,7 @@ class MCPClient:
stdio_client(params)
)
read_stream, write_stream = stdio_r, stdio_w
# 建立SSEClient
elif "url" in self.config:
if "API_ACCESS_TOKEN" in self.config:
@@ -114,7 +179,7 @@ class MCPClient:
read_stream, write_stream = sse_r, sse_w
else:
raise ValueError("MCPClient config must include 'command' or 'url'")
raise ValueError("MCP客户端配置必须包含'command''url'")
self.session = await stack.enter_async_context(
ClientSession(
@@ -138,6 +203,6 @@ class MCPClient:
await self._shutdown_evt.wait()
except Exception as e:
self.logger.bind(tag=TAG).error(f"worker error: {e}")
self.logger.bind(tag=TAG).error(f"服务端MCP客户端工作协程错误: {e}")
self._ready_evt.set()
raise
@@ -0,0 +1,89 @@
"""服务端MCP工具执行器"""
from typing import Dict, Any, Optional
from ..base import ToolType, ToolDefinition, ToolExecutor
from plugins_func.register import Action, ActionResponse
from .mcp_manager import ServerMCPManager
class ServerMCPExecutor(ToolExecutor):
"""服务端MCP工具执行器"""
def __init__(self, conn):
self.conn = conn
self.mcp_manager: Optional[ServerMCPManager] = None
self._initialized = False
async def initialize(self):
"""初始化MCP管理器"""
if not self._initialized:
self.mcp_manager = ServerMCPManager(self.conn)
await self.mcp_manager.initialize_servers()
self._initialized = True
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行服务端MCP工具"""
if not self._initialized or not self.mcp_manager:
return ActionResponse(
action=Action.ERROR,
response="MCP管理器未初始化",
)
try:
# 移除mcp_前缀(如果有)
actual_tool_name = tool_name
if tool_name.startswith("mcp_"):
actual_tool_name = tool_name[4:]
result = await self.mcp_manager.execute_tool(actual_tool_name, arguments)
return ActionResponse(action=Action.REQLLM, result=str(result))
except ValueError as e:
return ActionResponse(
action=Action.NOTFOUND,
response=str(e),
)
except Exception as e:
return ActionResponse(
action=Action.ERROR,
response=str(e),
)
def get_tools(self) -> Dict[str, ToolDefinition]:
"""获取所有服务端MCP工具"""
if not self._initialized or not self.mcp_manager:
return {}
tools = {}
mcp_tools = self.mcp_manager.get_all_tools()
for tool in mcp_tools:
func_def = tool.get("function", {})
tool_name = func_def.get("name", "")
if tool_name == "":
continue
tools[tool_name] = ToolDefinition(
name=tool_name, description=tool, tool_type=ToolType.SERVER_MCP
)
return tools
def has_tool(self, tool_name: str) -> bool:
"""检查是否有指定的服务端MCP工具"""
if not self._initialized or not self.mcp_manager:
return False
# 移除mcp_前缀(如果有)
actual_tool_name = tool_name
if tool_name.startswith("mcp_"):
actual_tool_name = tool_name[4:]
return self.mcp_manager.is_mcp_tool(actual_tool_name)
async def cleanup(self):
"""清理MCP连接"""
if self.mcp_manager:
await self.mcp_manager.cleanup_all()
@@ -0,0 +1,158 @@
"""服务端MCP管理器"""
import asyncio
import os
import json
from typing import Dict, Any, List
from config.config_loader import get_project_dir
from config.logger import setup_logging
from .mcp_client import ServerMCPClient
TAG = __name__
logger = setup_logging()
class ServerMCPManager:
"""管理多个服务端MCP服务的集中管理器"""
def __init__(self, conn) -> None:
"""初始化MCP管理器"""
self.conn = conn
self.config_path = get_project_dir() + "data/.mcp_server_settings.json"
if not os.path.exists(self.config_path):
self.config_path = ""
logger.bind(tag=TAG).warning(
f"请检查mcp服务配置文件:data/.mcp_server_settings.json"
)
self.clients: Dict[str, ServerMCPClient] = {}
self.tools = []
def load_config(self) -> Dict[str, Any]:
"""加载MCP服务配置"""
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:
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") and not srv_config.get("url"):
logger.bind(tag=TAG).warning(
f"Skipping server {name}: neither command nor url specified"
)
continue
try:
# 初始化服务端MCP客户端
logger.bind(tag=TAG).info(f"初始化服务端MCP客户端: {name}")
client = ServerMCPClient(srv_config)
await client.initialize()
self.clients[name] = client
client_tools = client.get_available_tools()
self.tools.extend(client_tools)
except Exception as e:
logger.bind(tag=TAG).error(
f"Failed to initialize MCP server {name}: {e}"
)
# 输出当前支持的服务端MCP工具列表
if hasattr(self.conn, "func_handler") and self.conn.func_handler:
self.conn.func_handler.current_support_functions()
def get_all_tools(self) -> List[Dict[str, Any]]:
"""获取所有服务的工具function定义"""
return self.tools
def is_mcp_tool(self, tool_name: str) -> bool:
"""检查是否是MCP工具"""
for tool in self.tools:
if (
tool.get("function") is not 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:
"""执行工具调用,失败时会尝试重新连接"""
logger.bind(tag=TAG).info(f"执行服务端MCP工具 {tool_name},参数: {arguments}")
max_retries = 3 # 最大重试次数
retry_interval = 2 # 重试间隔(秒)
# 找到对应的客户端
client_name = None
target_client = None
for name, client in self.clients.items():
if client.has_tool(tool_name):
client_name = name
target_client = client
break
if not target_client:
raise ValueError(f"工具 {tool_name} 在任意MCP服务中未找到")
# 带重试机制的工具调用
for attempt in range(max_retries):
try:
return await target_client.call_tool(tool_name, arguments)
except Exception as e:
# 最后一次尝试失败时直接抛出异常
if attempt == max_retries - 1:
raise
logger.bind(tag=TAG).warning(
f"执行工具 {tool_name} 失败 (尝试 {attempt+1}/{max_retries}): {e}"
)
# 尝试重新连接
logger.bind(tag=TAG).info(
f"重试前尝试重新连接 MCP 客户端 {client_name}"
)
try:
# 关闭旧的连接
await target_client.cleanup()
# 重新初始化客户端
config = self.load_config()
if client_name in config:
client = ServerMCPClient(config[client_name])
await client.initialize()
self.clients[client_name] = client
target_client = client
logger.bind(tag=TAG).info(
f"成功重新连接 MCP 客户端: {client_name}"
)
else:
logger.bind(tag=TAG).error(
f"Cannot reconnect MCP client {client_name}: config not found"
)
except Exception as reconnect_error:
logger.bind(tag=TAG).error(
f"Failed to reconnect MCP client {client_name}: {reconnect_error}"
)
# 等待一段时间再重试
await asyncio.sleep(retry_interval)
async def cleanup_all(self) -> None:
"""关闭所有 MCP客户端"""
for name, client in list(self.clients.items()):
try:
if hasattr(client, "cleanup"):
await asyncio.wait_for(client.cleanup(), timeout=20)
logger.bind(tag=TAG).info(f"服务端MCP客户端已关闭: {name}")
except (asyncio.TimeoutError, Exception) as e:
logger.bind(tag=TAG).error(f"关闭服务端MCP客户端 {name} 时出错: {e}")
self.clients.clear()
@@ -0,0 +1,5 @@
"""服务端插件工具模块"""
from .plugin_executor import ServerPluginExecutor
__all__ = ["ServerPluginExecutor"]
@@ -0,0 +1,84 @@
"""服务端插件工具执行器"""
from typing import Dict, Any
from ..base import ToolType, ToolDefinition, ToolExecutor
from plugins_func.register import all_function_registry, Action, ActionResponse
class ServerPluginExecutor(ToolExecutor):
"""服务端插件工具执行器"""
def __init__(self, conn):
self.conn = conn
self.config = conn.config
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行服务端插件工具"""
func_item = all_function_registry.get(tool_name)
if not func_item:
return ActionResponse(
action=Action.NOTFOUND, response=f"插件函数 {tool_name} 不存在"
)
try:
# 根据工具类型决定如何调用
if hasattr(func_item, "type"):
func_type = func_item.type
if func_type.code in [4, 5]: # SYSTEM_CTL, IOT_CTL (需要conn参数)
result = func_item.func(conn, **arguments)
elif func_type.code == 2: # WAIT
result = func_item.func(**arguments)
elif func_type.code == 3: # CHANGE_SYS_PROMPT
result = func_item.func(conn, **arguments)
else:
result = func_item.func(**arguments)
else:
# 默认不传conn参数
result = func_item.func(**arguments)
return result
except Exception as e:
return ActionResponse(
action=Action.ERROR,
response=str(e),
)
def get_tools(self) -> Dict[str, ToolDefinition]:
"""获取所有注册的服务端插件工具"""
tools = {}
# 获取必要的函数
necessary_functions = ["handle_exit_intent", "get_time", "get_lunar"]
# 获取配置中的函数
config_functions = self.config["Intent"][
self.config["selected_module"]["Intent"]
].get("functions", [])
# 转换为列表
if not isinstance(config_functions, list):
try:
config_functions = list(config_functions)
except TypeError:
config_functions = []
# 合并所有需要的函数
all_required_functions = list(set(necessary_functions + config_functions))
for func_name in all_required_functions:
func_item = all_function_registry.get(func_name)
if func_item:
tools[func_name] = ToolDefinition(
name=func_name,
description=func_item.description,
tool_type=ToolType.SERVER_PLUGIN,
)
return tools
def has_tool(self, tool_name: str) -> bool:
"""检查是否有指定的服务端插件工具"""
return tool_name in all_function_registry
@@ -0,0 +1,235 @@
"""统一工具处理器"""
import json
from typing import Dict, List, Any, Optional
from config.logger import setup_logging
from plugins_func.loadplugins import auto_import_modules
from .base import ToolType
from plugins_func.register import Action, ActionResponse
from .unified_tool_manager import ToolManager
from .server_plugins import ServerPluginExecutor
from .server_mcp import ServerMCPExecutor
from .device_iot import DeviceIoTExecutor
from .device_mcp import DeviceMCPExecutor
from .mcp_endpoint import MCPEndpointExecutor
class UnifiedToolHandler:
"""统一工具处理器"""
def __init__(self, conn):
self.conn = conn
self.config = conn.config
self.logger = setup_logging()
# 创建工具管理器
self.tool_manager = ToolManager(conn)
# 创建各类执行器
self.server_plugin_executor = ServerPluginExecutor(conn)
self.server_mcp_executor = ServerMCPExecutor(conn)
self.device_iot_executor = DeviceIoTExecutor(conn)
self.device_mcp_executor = DeviceMCPExecutor(conn)
self.mcp_endpoint_executor = MCPEndpointExecutor(conn)
# 注册执行器
self.tool_manager.register_executor(
ToolType.SERVER_PLUGIN, self.server_plugin_executor
)
self.tool_manager.register_executor(
ToolType.SERVER_MCP, self.server_mcp_executor
)
self.tool_manager.register_executor(
ToolType.DEVICE_IOT, self.device_iot_executor
)
self.tool_manager.register_executor(
ToolType.DEVICE_MCP, self.device_mcp_executor
)
self.tool_manager.register_executor(
ToolType.MCP_ENDPOINT, self.mcp_endpoint_executor
)
# 初始化标志
self.finish_init = False
async def _initialize(self):
"""异步初始化"""
try:
# 自动导入插件模块
auto_import_modules("plugins_func.functions")
# 初始化服务端MCP
await self.server_mcp_executor.initialize()
# 初始化MCP接入点
await self._initialize_mcp_endpoint()
# 初始化Home Assistant(如果需要)
self._initialize_home_assistant()
self.finish_init = True
self.logger.info("统一工具处理器初始化完成")
# 输出当前支持的所有工具列表
self.current_support_functions()
except Exception as e:
self.logger.error(f"统一工具处理器初始化失败: {e}")
async def _initialize_mcp_endpoint(self):
"""初始化MCP接入点"""
try:
from .mcp_endpoint import connect_mcp_endpoint
# 从配置中获取MCP接入点URL
mcp_endpoint_url = self.config.get("mcp_endpoint", "")
if (
mcp_endpoint_url
and "你的" not in mcp_endpoint_url
and mcp_endpoint_url != "null"
):
self.logger.info(f"正在初始化MCP接入点: {mcp_endpoint_url}")
mcp_endpoint_client = await connect_mcp_endpoint(
mcp_endpoint_url, self.conn
)
if mcp_endpoint_client:
# 将MCP接入点客户端保存到连接对象中
self.conn.mcp_endpoint_client = mcp_endpoint_client
self.logger.info("MCP接入点初始化成功")
else:
self.logger.warning("MCP接入点初始化失败")
except Exception as e:
self.logger.error(f"初始化MCP接入点失败: {e}")
def _initialize_home_assistant(self):
"""初始化Home Assistant提示词"""
try:
from plugins_func.functions.hass_init import append_devices_to_prompt
append_devices_to_prompt(self.conn)
except ImportError:
pass # 忽略导入错误
except Exception as e:
self.logger.error(f"初始化Home Assistant失败: {e}")
def get_functions(self) -> List[Dict[str, Any]]:
"""获取所有工具的函数描述"""
return self.tool_manager.get_function_descriptions()
def current_support_functions(self) -> List[str]:
"""获取当前支持的函数名称列表"""
func_names = self.tool_manager.get_supported_tool_names()
self.logger.info(f"当前支持的函数列表: {func_names}")
return func_names
def upload_functions_desc(self):
"""刷新函数描述列表"""
self.tool_manager.refresh_tools()
self.logger.info("函数描述列表已刷新")
def has_tool(self, tool_name: str) -> bool:
"""检查是否有指定工具"""
return self.tool_manager.has_tool(tool_name)
async def handle_llm_function_call(
self, conn, function_call_data: Dict[str, Any]
) -> Optional[ActionResponse]:
"""处理LLM函数调用"""
try:
# 处理多函数调用
if "function_calls" in function_call_data:
responses = []
for call in function_call_data["function_calls"]:
result = await self.tool_manager.execute_tool(
call["name"], call.get("arguments", {})
)
responses.append(result)
return self._combine_responses(responses)
# 处理单函数调用
function_name = function_call_data["name"]
arguments = function_call_data.get("arguments", {})
# 如果arguments是字符串,尝试解析为JSON
if isinstance(arguments, str):
try:
arguments = json.loads(arguments) if arguments else {}
except json.JSONDecodeError:
self.logger.error(f"无法解析函数参数: {arguments}")
return ActionResponse(
action=Action.ERROR,
response="无法解析函数参数",
)
self.logger.debug(f"调用函数: {function_name}, 参数: {arguments}")
# 执行工具调用
result = await self.tool_manager.execute_tool(function_name, arguments)
return result
except Exception as e:
self.logger.error(f"处理function call错误: {e}")
return ActionResponse(action=Action.ERROR, response=str(e))
def _combine_responses(self, responses: List[ActionResponse]) -> ActionResponse:
"""合并多个函数调用的响应"""
if not responses:
return ActionResponse(action=Action.NONE, response="无响应")
# 如果有任何错误,返回第一个错误
for response in responses:
if response.action == Action.ERROR:
return response
# 合并所有成功的响应
contents = []
responses_text = []
for response in responses:
if response.content:
contents.append(response.content)
if response.response:
responses_text.append(response.response)
# 确定最终的动作类型
final_action = Action.RESPONSE
for response in responses:
if response.action == Action.REQLLM:
final_action = Action.REQLLM
break
return ActionResponse(
action=final_action,
result="; ".join(contents) if contents else None,
response="; ".join(responses_text) if responses_text else None,
)
async def register_iot_tools(self, descriptors: List[Dict[str, Any]]):
"""注册IoT设备工具"""
self.device_iot_executor.register_iot_tools(descriptors)
self.tool_manager.refresh_tools()
self.logger.info(f"注册了{len(descriptors)}个IoT设备的工具")
def get_tool_statistics(self) -> Dict[str, int]:
"""获取工具统计信息"""
return self.tool_manager.get_tool_statistics()
async def cleanup(self):
"""清理资源"""
try:
await self.server_mcp_executor.cleanup()
# 清理MCP接入点连接
if (
hasattr(self.conn, "mcp_endpoint_client")
and self.conn.mcp_endpoint_client
):
await self.conn.mcp_endpoint_client.close()
self.logger.info("工具处理器清理完成")
except Exception as e:
self.logger.error(f"工具处理器清理失败: {e}")
@@ -0,0 +1,124 @@
"""统一工具管理器"""
from typing import Dict, List, Optional, Any
from config.logger import setup_logging
from plugins_func.register import Action, ActionResponse
from .base import ToolType, ToolDefinition, ToolExecutor
class ToolManager:
"""统一工具管理器,管理所有类型的工具"""
def __init__(self, conn):
self.conn = conn
self.logger = setup_logging()
self.executors: Dict[ToolType, ToolExecutor] = {}
self._cached_tools: Optional[Dict[str, ToolDefinition]] = None
self._cached_function_descriptions: Optional[List[Dict[str, Any]]] = None
def register_executor(self, tool_type: ToolType, executor: ToolExecutor):
"""注册工具执行器"""
self.executors[tool_type] = executor
self._invalidate_cache()
self.logger.info(f"注册工具执行器: {tool_type.value}")
def _invalidate_cache(self):
"""使缓存失效"""
self._cached_tools = None
self._cached_function_descriptions = None
def get_all_tools(self) -> Dict[str, ToolDefinition]:
"""获取所有工具定义"""
if self._cached_tools is not None:
return self._cached_tools
all_tools = {}
for tool_type, executor in self.executors.items():
try:
tools = executor.get_tools()
for name, definition in tools.items():
if name in all_tools:
self.logger.warning(f"工具名称冲突: {name}")
all_tools[name] = definition
except Exception as e:
self.logger.error(f"获取{tool_type.value}工具时出错: {e}")
self._cached_tools = all_tools
return all_tools
def get_function_descriptions(self) -> List[Dict[str, Any]]:
"""获取所有工具的函数描述(OpenAI格式)"""
if self._cached_function_descriptions is not None:
return self._cached_function_descriptions
descriptions = []
tools = self.get_all_tools()
for tool_definition in tools.values():
descriptions.append(tool_definition.description)
self._cached_function_descriptions = descriptions
return descriptions
def has_tool(self, tool_name: str) -> bool:
"""检查是否存在指定工具"""
tools = self.get_all_tools()
return tool_name in tools
def get_tool_type(self, tool_name: str) -> Optional[ToolType]:
"""获取工具类型"""
tools = self.get_all_tools()
tool_def = tools.get(tool_name)
return tool_def.tool_type if tool_def else None
async def execute_tool(
self, tool_name: str, arguments: Dict[str, Any]
) -> ActionResponse:
"""执行工具调用"""
try:
# 查找工具类型
tool_type = self.get_tool_type(tool_name)
if not tool_type:
return ActionResponse(
action=Action.NOTFOUND,
response=f"工具 {tool_name} 不存在",
)
# 获取对应的执行器
executor = self.executors.get(tool_type)
if not executor:
return ActionResponse(
action=Action.ERROR,
response=f"工具类型 {tool_type.value} 的执行器未注册",
)
# 执行工具
self.logger.info(f"执行工具: {tool_name},参数: {arguments}")
result = await executor.execute(self.conn, tool_name, arguments)
self.logger.debug(f"工具执行结果: {result}")
return result
except Exception as e:
self.logger.error(f"执行工具 {tool_name} 时出错: {e}")
return ActionResponse(action=Action.ERROR, response=str(e))
def get_supported_tool_names(self) -> List[str]:
"""获取所有支持的工具名称"""
tools = self.get_all_tools()
return list(tools.keys())
def refresh_tools(self):
"""刷新工具缓存"""
self._invalidate_cache()
self.logger.info("工具缓存已刷新")
def get_tool_statistics(self) -> Dict[str, int]:
"""获取工具统计信息"""
stats = {}
for tool_type, executor in self.executors.items():
try:
tools = executor.get_tools()
stats[tool_type.value] = len(tools)
except Exception as e:
self.logger.error(f"获取{tool_type.value}工具统计时出错: {e}")
stats[tool_type.value] = 0
return stats
@@ -43,7 +43,6 @@ class TTSProviderBase(ABC):
self.tts_text_buff = []
self.punctuations = (
"",
".",
"",
"?",
"",
@@ -59,7 +58,6 @@ class TTSProviderBase(ABC):
"",
",",
"",
".",
"",
"?",
"",
@@ -171,7 +169,7 @@ class TTSProviderBase(ABC):
)
)
# 对于单句的文本,进行分段处理
segments = re.split(r'([。!?!?;\n])', content_detail)
segments = re.split(r"([。!?!?;\n])", content_detail)
for seg in segments:
self.tts_text_queue.put(
TTSMessageDTO(
@@ -37,7 +37,9 @@ class TTSProvider(TTSProviderBase):
self.api_url = config.get("api_url")
self.authorization = config.get("authorization")
self.header = {"Authorization": f"{self.authorization}{self.access_token}"}
check_model_key("TTS", self.access_token)
model_key_msg = check_model_key("TTS", self.access_token)
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
async def text_to_speak(self, text, output_file):
request_json = {
@@ -94,8 +94,9 @@ class TTSProvider(TTSProviderBase):
self.format = config.get("response_format", "wav")
self.audio_file_type = config.get("response_format", "wav")
self.api_key = config.get("api_key", "YOUR_API_KEY")
have_key = check_model_key("FishSpeech TTS", self.api_key)
if not have_key:
model_key_msg = check_model_key("FishSpeech TTS", self.api_key)
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
return
self.normalize = str(config.get("normalize", True)).lower() in (
"true",
@@ -12,6 +12,8 @@ from core.utils.util import check_model_key
from core.providers.tts.base import TTSProviderBase
from core.handle.abortHandle import handleAbortMessage
from core.providers.tts.dto.dto import SentenceType, ContentType, InterfaceType
from asyncio import Task
TAG = __name__
logger = setup_logging()
@@ -141,6 +143,7 @@ class TTSProvider(TTSProviderBase):
super().__init__(config, delete_audio_file)
self.ws = None
self.interface_type = InterfaceType.DUAL_STREAM
self._monitor_task = None # 监听任务引用
self.appId = config.get("appid")
self.access_token = config.get("access_token")
self.cluster = config.get("cluster")
@@ -157,7 +160,9 @@ class TTSProvider(TTSProviderBase):
self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
sample_rate=16000, channels=1, frame_size_ms=60
)
check_model_key("TTS", self.access_token)
model_key_msg = check_model_key("TTS", self.access_token)
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
async def open_audio_channels(self, conn):
try:
@@ -202,6 +207,10 @@ class TTSProvider(TTSProviderBase):
if message.sentence_type == SentenceType.FIRST:
# 初始化参数
try:
if not getattr(self.conn, "sentence_id", None):
self.conn.sentence_id = uuid.uuid4().hex
logger.bind(tag=TAG).info(f"自动生成新的 会话ID: {self.conn.sentence_id}")
logger.bind(tag=TAG).info("开始启动TTS会话...")
future = asyncio.run_coroutine_threadsafe(
self.start_session(self.conn.sentence_id),
@@ -264,10 +273,9 @@ class TTSProvider(TTSProviderBase):
try:
# 建立新连接
if self.ws is None:
await handleAbortMessage(self.conn)
logger.bind(tag=TAG).error(f"WebSocket连接不存在,终止发送文本")
logger.bind(tag=TAG).warning(f"WebSocket连接不存在,终止发送文本")
return
# 过滤Markdown
filtered_text = MarkdownCleaner.clean_markdown(text)
@@ -287,6 +295,25 @@ class TTSProvider(TTSProviderBase):
async def start_session(self, session_id):
logger.bind(tag=TAG).info(f"开始会话~~{session_id}")
try:
task = self._monitor_task
if (
task is not None
and isinstance(task, Task)
and not task.done()
):
logger.bind(tag=TAG).info("等待上一个监听任务结束...")
if self.ws is not None:
logger.bind(tag=TAG).info("强制关闭上一个WebSocket连接以唤醒监听任务...")
try:
await self.ws.close()
except Exception as e:
logger.bind(tag=TAG).warning(f"关闭上一个ws异常: {e}")
self.ws = None
try:
await asyncio.wait_for(task, timeout=8)
except Exception as e:
logger.bind(tag=TAG).warning(f"等待监听任务异常: {e}")
self._monitor_task = None
# 建立新连接
await self._ensure_connection()
@@ -457,6 +484,8 @@ class TTSProvider(TTSProviderBase):
except:
pass
self.ws = None
# 监听任务退出时清理引用
self._monitor_task = None
async def send_event(
self,
@@ -166,7 +166,9 @@ class TTSProvider(TTSProviderBase):
) as resp:
if resp.status != 200:
logger.error(f"TTS请求失败: {resp.status}, {await resp.text()}")
logger.bind(tag=TAG).error(
f"TTS请求失败: {resp.status}, {await resp.text()}"
)
self.tts_audio_queue.put((SentenceType.LAST, [], None))
return
@@ -229,7 +231,7 @@ class TTSProvider(TTSProviderBase):
self._process_before_stop_play_files()
except Exception as e:
logger.error(f"TTS请求异常: {e}")
logger.bind(tag=TAG).error(f"TTS请求异常: {e}")
self.tts_audio_queue.put((SentenceType.LAST, [], None))
def to_tts(self, text: str) -> list:
@@ -263,7 +265,7 @@ class TTSProvider(TTSProviderBase):
self.api_url, params=params, headers=headers, timeout=5
) as response:
if response.status_code != 200:
logger.error(
logger.bind(tag=TAG).error(
f"TTS请求失败: {response.status_code}, {response.text}"
)
return []
@@ -299,5 +301,5 @@ class TTSProvider(TTSProviderBase):
return opus_datas
except Exception as e:
logger.error(f"TTS请求异常: {e}")
logger.bind(tag=TAG).error(f"TTS请求异常: {e}")
return []
@@ -25,7 +25,9 @@ class TTSProvider(TTSProviderBase):
self.speed = float(speed) if speed else 1.0
self.output_file = config.get("output_dir", "tmp/")
check_model_key("TTS", self.api_key)
model_key_msg = check_model_key("TTS", self.api_key)
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
async def text_to_speak(self, text, output_file):
headers = {
@@ -35,6 +35,10 @@ class VADProvider(VADProviderBase):
pcm_frame = self.decoder.decode(opus_packet, 960)
conn.client_audio_buffer.extend(pcm_frame) # 将新数据加入缓冲区
# 确保帧计数器存在
if not hasattr(conn, "client_voice_frame_count"):
conn.client_voice_frame_count = 0
# 处理缓冲区中的完整帧(每次处理512采样点)
client_have_voice = False
while len(conn.client_audio_buffer) >= 512 * 2:
@@ -50,18 +54,24 @@ class VADProvider(VADProviderBase):
# 检测语音活动
with torch.no_grad():
speech_prob = self.model(audio_tensor, 16000).item()
client_have_voice = speech_prob >= self.vad_threshold
is_voice = speech_prob >= self.vad_threshold
if is_voice:
conn.client_voice_frame_count += 1
else:
conn.client_voice_frame_count = 0
# 只有连续4帧检测到语音才认为有语音
client_have_voice = conn.client_voice_frame_count >= 4
# 如果之前有声音,但本次没有声音,且与上次有声音的时间差已经超过了静默阈值,则认为已经说完一句话
if conn.client_have_voice and not client_have_voice:
stop_duration = (
time.time() * 1000 - conn.client_have_voice_last_time
)
stop_duration = time.time() * 1000 - conn.last_activity_time
if stop_duration >= self.silence_threshold_ms:
conn.client_voice_stop = True
if client_have_voice:
conn.client_have_voice = True
conn.client_have_voice_last_time = time.time() * 1000
conn.last_activity_time = time.time() * 1000
return client_have_voice
except opuslib_next.OpusError as e:
@@ -34,7 +34,9 @@ class VLLMProvider(VLLMProviderBase):
except (ValueError, TypeError):
setattr(self, param, default)
check_model_key("VLLM", self.api_key)
model_key_msg = check_model_key("VLLM", self.api_key)
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url)
def response(self, question, base64_image):
+92 -9
View File
@@ -1,12 +1,80 @@
import jwt
import time
import json
import os
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
from typing import Tuple, Optional
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.backends import default_backend
import base64
class AuthToken:
def __init__(self, secret_key: str):
self.secret_key = secret_key
self.secret_key = secret_key.encode() # 转换为字节
# 从密钥派生固定长度的加密密钥 (32字节 for AES-256)
self.encryption_key = self._derive_key(32)
def _derive_key(self, length: int) -> bytes:
"""派生固定长度的密钥"""
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
# 使用固定盐值(实际生产环境应使用随机盐)
salt = b"fixed_salt_placeholder" # 生产环境应改为随机生成
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=length,
salt=salt,
iterations=100000,
backend=default_backend(),
)
return kdf.derive(self.secret_key)
def _encrypt_payload(self, payload: dict) -> str:
"""使用AES-GCM加密整个payload"""
# 将payload转换为JSON字符串
payload_json = json.dumps(payload)
# 生成随机IV
iv = os.urandom(12)
# 创建加密器
cipher = Cipher(
algorithms.AES(self.encryption_key),
modes.GCM(iv),
backend=default_backend(),
)
encryptor = cipher.encryptor()
# 加密并生成标签
ciphertext = encryptor.update(payload_json.encode()) + encryptor.finalize()
tag = encryptor.tag
# 组合 IV + 密文 + 标签
encrypted_data = iv + ciphertext + tag
return base64.urlsafe_b64encode(encrypted_data).decode()
def _decrypt_payload(self, encrypted_data: str) -> dict:
"""解密AES-GCM加密的payload"""
# 解码Base64
data = base64.urlsafe_b64decode(encrypted_data.encode())
# 拆分组件
iv = data[:12]
tag = data[-16:]
ciphertext = data[12:-16]
# 创建解密器
cipher = Cipher(
algorithms.AES(self.encryption_key),
modes.GCM(iv, tag),
backend=default_backend(),
)
decryptor = cipher.decryptor()
# 解密
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
return json.loads(plaintext.decode())
def generate_token(self, device_id: str) -> str:
"""
@@ -17,11 +85,17 @@ class AuthToken:
# 设置过期时间为1小时后
expire_time = datetime.now(timezone.utc) + timedelta(hours=1)
# 创建payload
# 创建原始payload
payload = {"device_id": device_id, "exp": expire_time.timestamp()}
# 加密整个payload
encrypted_payload = self._encrypt_payload(payload)
# 创建外层payload,包含加密数据
outer_payload = {"data": encrypted_payload}
# 使用JWT进行编码
token = jwt.encode(payload, self.secret_key, algorithm="HS256")
token = jwt.encode(outer_payload, self.secret_key, algorithm="HS256")
return token
def verify_token(self, token: str) -> Tuple[bool, Optional[str]]:
@@ -31,13 +105,22 @@ class AuthToken:
:return: (是否有效, 设备ID)
"""
try:
# 解码token
payload = jwt.decode(token, self.secret_key, algorithms=["HS256"])
# 先验证外层JWT(签名和过期时间)
outer_payload = jwt.decode(token, self.secret_key, algorithms=["HS256"])
# 检查是否过期
if payload["exp"] < time.time():
# 解密内层payload
inner_payload = self._decrypt_payload(outer_payload["data"])
# 再次检查过期时间(双重验证)
if inner_payload["exp"] < time.time():
return False, None
return True, payload["device_id"]
return True, inner_payload["device_id"]
except jwt.InvalidTokenError:
return False, None
except json.JSONDecodeError:
return False, None
except Exception as e: # 捕获其他可能的错误
print(f"Token verification failed: {str(e)}")
return False, None
+7 -7
View File
@@ -186,10 +186,8 @@ def remove_punctuation_and_length(text):
def check_model_key(modelType, modelKey):
if "" in modelKey:
raise ValueError(
"你还没配置" + modelType + "的密钥,请检查一下所使用的LLM是否配置了密钥"
)
return True
return f"配置错误: {modelType} 的 API key 未设置,当前值为: {modelKey}"
return None
def parse_string_to_list(value, separator=";"):
@@ -785,7 +783,9 @@ def audio_bytes_to_data(audio_bytes, file_type, is_opus=True):
return p3.decode_opus_from_bytes(audio_bytes)
else:
# 其他格式用pydub
audio = AudioSegment.from_file(BytesIO(audio_bytes), format=file_type, parameters=["-nostdin"])
audio = AudioSegment.from_file(
BytesIO(audio_bytes), format=file_type, parameters=["-nostdin"]
)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
duration = len(audio) / 1000.0
raw_data = audio.raw_data
@@ -838,11 +838,11 @@ def opus_datas_to_wav_bytes(opus_datas, sample_rate=16000, channels=1):
pcm = decoder.decode(opus_frame, frame_size)
pcm_datas.append(pcm)
pcm_bytes = b''.join(pcm_datas)
pcm_bytes = b"".join(pcm_datas)
# 写入wav字节流
wav_buffer = BytesIO()
with wave.open(wav_buffer, 'wb') as wf:
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(channels)
wf.setsampwidth(2) # 16bit
wf.setframerate(sample_rate)
+2 -1
View File
@@ -52,7 +52,7 @@ services:
volumes:
# 配置文件目录
- ./uploadfile:/uploadfile
# 数据库模块
xiaozhi-esp32-server-db:
image: mysql:latest
container_name: xiaozhi-esp32-server-db
@@ -73,6 +73,7 @@ services:
- MYSQL_ROOT_PASSWORD=123456
- MYSQL_DATABASE=xiaozhi_esp32_server
- MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci"
# redis模块
xiaozhi-esp32-server-redis:
image: redis
expose:
@@ -120,16 +120,16 @@ def map_category(category_text):
# 类别映射字典,目前支持社会、国际、财经新闻,如需更多类型,参见配置文件
category_map = {
# 社会新闻
"社会": "society",
"社会新闻": "society",
"社会": "society_rss_url",
"社会新闻": "society_rss_url",
# 国际新闻
"国际": "world",
"国际新闻": "world",
"国际": "world_rss_url",
"国际新闻": "world_rss_url",
# 财经新闻
"财经": "finance",
"财经新闻": "finance",
"金融": "finance",
"经济": "finance",
"财经": "finance_rss_url",
"财经新闻": "finance_rss_url",
"金融": "finance_rss_url",
"经济": "finance_rss_url",
}
# 转换为小写并去除空格
@@ -205,8 +205,8 @@ def get_news_from_chinanews(
# 如果提供了类别,尝试从配置中获取对应的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]
if mapped_category and mapped_category in rss_config:
rss_url = rss_config[mapped_category]
logger.bind(tag=TAG).info(
f"获取新闻: 原始类别={category}, 映射类别={mapped_category}, URL={rss_url}"
@@ -8,38 +8,61 @@ from markitdown import MarkItDown
TAG = __name__
logger = setup_logging()
# 新闻来源字典,包含名称和对应的API ID
NEWS_SOURCES = {
# 默认新闻来源字典,当配置中没有指定时使用
DEFAULT_NEWS_SOURCES = {
"thepaper": "澎湃新闻",
"baidu": "百度热搜",
"cls-depth": "财联社",
}
# 动态生成新闻源描述
def generate_news_sources_description():
sources_desc = []
for source_id, source_name in NEWS_SOURCES.items():
sources_desc.append(f"{source_name}({source_id})")
return "".join(sources_desc)
def get_news_sources_from_config(conn):
"""从配置中获取新闻源字典"""
try:
# 尝试从插件配置中获取新闻源
if (conn.config.get("plugins") and
conn.config["plugins"].get("get_news_from_newsnow") and
conn.config["plugins"]["get_news_from_newsnow"].get("news_sources")):
# 如果配置中是字符串,尝试解析JSON
news_sources_config = conn.config["plugins"]["get_news_from_newsnow"]["news_sources"]
if isinstance(news_sources_config, str):
try:
return json.loads(news_sources_config)
except json.JSONDecodeError:
logger.bind(tag=TAG).warning("新闻源配置JSON格式错误,使用默认配置")
return DEFAULT_NEWS_SOURCES
elif isinstance(news_sources_config, dict):
return news_sources_config
else:
logger.bind(tag=TAG).warning("新闻源配置格式错误,使用默认配置")
return DEFAULT_NEWS_SOURCES
else:
logger.bind(tag=TAG).debug("未找到新闻源配置,使用默认配置")
return DEFAULT_NEWS_SOURCES
except Exception as e:
logger.bind(tag=TAG).error(f"获取新闻源配置失败: {e},使用默认配置")
return DEFAULT_NEWS_SOURCES
# 静态函数描述,使用默认新闻源生成描述
GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = {
"type": "function",
"function": {
"name": "get_news_from_newsnow",
"description": (
"获取最新新闻,随机选择一条新闻进行播报。"
f"用户可以选择不同的新闻源,如{generate_news_sources_description()}等。"
"用户可以选择不同的新闻源,如澎湃新闻(thepaper)、百度热搜(baidu)、财联社(cls-depth)等。"
"如果没有指定,默认从澎湃新闻获取。"
"用户可以要求获取详细内容,此时会获取新闻的详细内容。"
"注意:实际可用的新闻源取决于系统配置。"
),
"parameters": {
"type": "object",
"properties": {
"source": {
"type": "string",
"description": f"新闻源,例如{generate_news_sources_description()}等。可选参数,如果不提供则使用默认新闻源",
"description": "新闻源,例如thepaper、baidu、cls-depth等。可选参数,如果不提供则使用默认新闻源",
},
"detail": {
"type": "boolean",
@@ -115,6 +138,9 @@ def get_news_from_newsnow(
):
"""获取新闻并随机选择一条进行播报,或获取上一条新闻的详细内容"""
try:
# 获取当前配置的新闻源
news_sources = get_news_sources_from_config(conn)
# 如果detail为True,获取上一条新闻的详细内容
detail = str(detail).lower() == "true"
if detail:
@@ -132,7 +158,7 @@ def get_news_from_newsnow(
url = conn.last_newsnow_link.get("url")
title = conn.last_newsnow_link.get("title", "未知标题")
source_id = conn.last_newsnow_link.get("source_id", "thepaper")
source_name = NEWS_SOURCES.get(source_id, "未知来源")
source_name = news_sources.get(source_id, "未知来源")
if not url or url == "#":
return ActionResponse(
@@ -167,11 +193,11 @@ def get_news_from_newsnow(
# 否则,获取新闻列表并随机选择一条
# 验证新闻源是否有效,如果无效则使用默认源
if source not in NEWS_SOURCES:
if source not in news_sources:
logger.bind(tag=TAG).warning(f"无效的新闻源: {source},使用默认源thepaper")
source = "thepaper"
source_name = NEWS_SOURCES.get(source, "澎湃新闻")
source_name = news_sources.get(source, "澎湃新闻")
logger.bind(tag=TAG).info(f"获取新闻: 新闻源={source}({source_name})")
# 获取新闻列表
@@ -1,157 +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()
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_speaker_volume_or_screen_brightness",
"description": (
"用户想要获取或者设置设备的音量/亮度大小,或者用户觉得声音/亮度过高或过低,或者用户想提高或降低音量/亮度。\n"
"**严格限制**:仅当用户明确操作 **Speaker(音量)或Screen(亮度)** 时才能调用此函数!\n"
"对于其他设备(如AC、Battery、Switch等),请不要调用此函数,而是继续正常的对话。\n\n"
"示例:\n"
"- 用户说『现在亮度多少』 → 调用函数:device_type: Screen, action: get\n"
"- 用户说『设置音量为50』 → 调用函数:device_type: Speaker, action: set, value: 50\n"
"- 用户说『亮度太高了』 → 调用函数:device_type: Screen, action: lower\n"
"- 用户说『调大音量』 → 调用函数:device_type: Speaker, action: raise\n\n"
"**拒绝调用示例**(应继续对话而非调用本函数):\n"
"- 用户说『空调调低一度』 → 不调用(设备类型为AC)\n"
"- 用户说『开关灯』 → 不调用(设备类型为Switch)\n"
"- 用户说『电量多少』 → 不调用(设备类型为Battery)\n"
),
"parameters": {
"type": "object",
"properties": {
"device_type": {
"type": "string",
"description": "设备类型,**严格限定为Speaker(音量)或Screen(亮度)**,其他设备类型禁止调用此函数",
"enum": ["Speaker", "Screen"],
},
"action": {
"type": "string",
"description": "动作名称,可选值:get(获取),set(设置),raise(提高),lower(降低)",
},
"value": {
"type": "integer",
"description": "值大小,可选值:0-100之间的整数",
},
},
"required": ["device_type", "action"],
},
},
}
@register_function(
"handle_speaker_volume_or_screen_brightness",
handle_device_function_desc,
ToolType.IOT_CTL,
)
def handle_speaker_volume_or_screen_brightness(
conn, device_type: str, action: str, value: int = None
):
# 检查value是否为中文值
if (
value is not None
and isinstance(value, str)
and any("\u4e00" <= char <= "\u9fff" for char in str(value))
):
raise Exception(
f"请直接告诉我要将{'音量' if device_type=='Speaker' else '亮度'}调整成多少"
)
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,
)
@@ -17,57 +17,78 @@ hass_get_state_function_desc = {
"properties": {
"entity_id": {
"type": "string",
"description": "需要操作的设备id,homeassistant里的entity_id"
"description": "需要操作的设备id,homeassistant里的entity_id",
}
},
"required": ["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=''):
def hass_get_state(conn, entity_id=""):
try:
future = asyncio.run_coroutine_threadsafe(
handle_hass_get_state(conn, entity_id),
conn.loop
handle_hass_get_state(conn, entity_id), conn.loop
)
ha_response = future.result()
return ActionResponse( Action.REQLLM, ha_response , None )
return ActionResponse(Action.REQLLM, ha_response, None)
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']
ha_config = initialize_hass_handler(conn)
api_key = ha_config.get("api_key")
base_url = ha_config.get("base_url")
url = f"{base_url}/api/states/{entity_id}"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
response = requests.get(url, headers=headers)
if response.status_code == 200:
responsetext = '设备状态:' + response.json()['state'] + ' '
responsetext = "设备状态:" + response.json()["state"] + " "
logger.bind(tag=TAG).info(f"api返回内容: {response.json()}")
if 'media_title' in response.json()['attributes']:
responsetext = responsetext+ '正在播放的是:'+str(response.json()['attributes']['media_title'])+' '
if 'volume_level' in response.json()['attributes']:
responsetext = responsetext+ '音量是:'+str(response.json()['attributes']['volume_level'])+' '
if 'color_temp_kelvin' in response.json()['attributes']:
responsetext = responsetext+ '色温是:'+str(response.json()['attributes']['color_temp_kelvin'])+' '
if 'rgb_color' in response.json()['attributes']:
responsetext = responsetext+ 'rgb颜色是:'+str(response.json()['attributes']['rgb_color'])+' '
if 'brightness' in response.json()['attributes']:
responsetext = responsetext+ '亮度是:'+str(response.json()['attributes']['brightness'])+' '
if "media_title" in response.json()["attributes"]:
responsetext = (
responsetext
+ "正在播放的是:"
+ str(response.json()["attributes"]["media_title"])
+ " "
)
if "volume_level" in response.json()["attributes"]:
responsetext = (
responsetext
+ "音量是:"
+ str(response.json()["attributes"]["volume_level"])
+ " "
)
if "color_temp_kelvin" in response.json()["attributes"]:
responsetext = (
responsetext
+ "色温是:"
+ str(response.json()["attributes"]["color_temp_kelvin"])
+ " "
)
if "rgb_color" in response.json()["attributes"]:
responsetext = (
responsetext
+ "rgb颜色是:"
+ str(response.json()["attributes"]["rgb_color"])
+ " "
)
if "brightness" in response.json()["attributes"]:
responsetext = (
responsetext
+ "亮度是:"
+ str(response.json()["attributes"]["brightness"])
+ " "
)
logger.bind(tag=TAG).info(f"查询返回内容: {responsetext}")
return responsetext
#return response.json()['attributes']
#response.attributes
# return response.json()['attributes']
# response.attributes
else:
return f"切换失败,错误码: {response.status_code}"
@@ -4,40 +4,49 @@ from core.utils.util import check_model_key
TAG = __name__
logger = setup_logging()
HASS_CACHE = {}
def append_devices_to_prompt(conn):
if conn.intent_type == "function_call":
funcs = conn.config["Intent"][conn.config["selected_module"]["Intent"]].get(
"functions", []
)
config_source = (
"home_assistant"
if conn.config["plugins"].get("home_assistant")
else "hass_get_state"
)
if "hass_get_state" in funcs or "hass_set_state" in funcs:
prompt = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n"
devices = conn.config["plugins"]["home_assistant"].get("devices", [])
if len(devices) == 0:
return
for device in devices:
prompt += device + "\n"
conn.prompt += prompt
deviceStr = conn.config["plugins"].get(config_source, {}).get("devices", "")
conn.prompt += prompt + deviceStr + "\n"
# 更新提示词
conn.dialogue.update_system_message(conn.prompt)
def initialize_hass_handler(conn):
global HASS_CACHE
if HASS_CACHE == {}:
if conn.load_function_plugin:
funcs = conn.config["Intent"][conn.config["selected_module"]["Intent"]].get(
"functions", []
)
if "hass_get_state" in funcs or "hass_set_state" in funcs:
HASS_CACHE["base_url"] = conn.config["plugins"]["home_assistant"].get(
"base_url"
)
HASS_CACHE["api_key"] = conn.config["plugins"]["home_assistant"].get(
"api_key"
)
ha_config = {}
if not conn.load_function_plugin:
return ha_config
check_model_key("home_assistant", HASS_CACHE["api_key"])
return HASS_CACHE
# 确定配置来源
config_source = (
"home_assistant"
if conn.config["plugins"].get("home_assistant")
else "hass_get_state"
)
if not conn.config["plugins"].get(config_source):
return ha_config
# 统一获取配置
plugin_config = conn.config["plugins"][config_source]
ha_config["base_url"] = plugin_config.get("base_url")
ha_config["api_key"] = plugin_config.get("api_key")
# 统一检查API密钥
model_key_msg = check_model_key("home_assistant", ha_config.get("api_key"))
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
return ha_config
@@ -17,46 +17,43 @@ hass_play_music_function_desc = {
"properties": {
"media_content_id": {
"type": "string",
"description": "可以是音乐或有声书的专辑名称、歌曲名、演唱者,如果未指定就填random"
"description": "可以是音乐或有声书的专辑名称、歌曲名、演唱者,如果未指定就填random",
},
"entity_id": {
"type": "string",
"description": "需要操作的音箱的设备id,homeassistant里的entity_id,media_player开头"
}
"description": "需要操作的音箱的设备id,homeassistant里的entity_id,media_player开头",
},
},
"required": ["media_content_id", "entity_id"]
}
}
"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'):
@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
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)
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']
ha_config = initialize_hass_handler(conn)
api_key = ha_config.get("api_key")
base_url = ha_config.get("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
}
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}的音乐"
@@ -20,40 +20,40 @@ hass_set_state_function_desc = {
"properties": {
"type": {
"type": "string",
"description": "需要操作的动作,打开设备:turn_on,关闭设备:turn_off,增加亮度:brightness_up,降低亮度:brightness_down,设置亮度:brightness_value,增加音量:volume_up,降低音量:volume_down,设置音量:volume_set,设置色温:set_kelvin,设置颜色:set_color,设备暂停:pause,设备继续:continue,静音/取消静音:volume_mute"
"description": "需要操作的动作,打开设备:turn_on,关闭设备:turn_off,增加亮度:brightness_up,降低亮度:brightness_down,设置亮度:brightness_value,增加音量:volume_up,降低音量:volume_down,设置音量:volume_set,设置色温:set_kelvin,设置颜色:set_color,设备暂停:pause,设备继续:continue,静音/取消静音:volume_mute",
},
"input": {
"type": "integer",
"description": "只有在设置音量,设置亮度时候才需要,有效值为1-100,对应音量和亮度的1%-100%"
"description": "只有在设置音量,设置亮度时候才需要,有效值为1-100,对应音量和亮度的1%-100%",
},
"is_muted": {
"type": "string",
"description": "只有在设置静音操作时才需要,设置静音的时候该值为true,取消静音时该值为false"
"description": "只有在设置静音操作时才需要,设置静音的时候该值为true,取消静音时该值为false",
},
"rgb_color": {
"type": "array",
"items": {"type": "integer"},
"description": "只有在设置颜色时需要,这里填目标颜色的rgb值",
},
"rgb_color":{
"type":"list",
"description": "只有在设置颜色时需要,这里填目标颜色的rgb值"
}
},
"required": ["type"]
"required": ["type"],
},
"entity_id": {
"type": "string",
"description": "需要操作的设备id,homeassistant里的entity_id"
}
"description": "需要操作的设备id,homeassistant里的entity_id",
},
},
"required": ["state", "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={}):
@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
handle_hass_set_state(conn, entity_id, state), conn.loop
)
ha_response = future.result()
return ActionResponse(Action.REQLLM, ha_response, None)
@@ -62,21 +62,21 @@ def hass_set_state(conn, entity_id='', state={}):
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']
'''
ha_config = initialize_hass_handler(conn)
api_key = ha_config.get("api_key")
base_url = ha_config.get("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':
action = ""
arg = ""
value = ""
if state["type"] == "turn_on":
description = "设备已打开"
if domain == "cover":
action = "open_cover"
@@ -84,91 +84,87 @@ async def handle_hass_set_state(conn, entity_id, state):
action = "start"
else:
action = "turn_on"
elif state['type'] == 'turn_off':
elif state["type"] == "turn_off":
description = "设备已关闭"
if domain == 'cover':
if domain == "cover":
action = "close_cover"
elif domain == 'vacuum':
elif domain == "vacuum":
action = "stop"
else:
action = "turn_off"
elif state['type'] == 'brightness_up':
elif state["type"] == "brightness_up":
description = "灯光已调亮"
action = 'turn_on'
arg = 'brightness_step_pct'
action = "turn_on"
arg = "brightness_step_pct"
value = 10
elif state['type'] == 'brightness_down':
elif state["type"] == "brightness_down":
description = "灯光已调暗"
action = 'turn_on'
arg = 'brightness_step_pct'
action = "turn_on"
arg = "brightness_step_pct"
value = -10
elif state['type'] == 'brightness_value':
elif state["type"] == "brightness_value":
description = f"亮度已调整到{state['input']}"
action = 'turn_on'
arg = 'brightness_pct'
value = state['input']
elif state['type'] == 'set_color':
action = "turn_on"
arg = "brightness_pct"
value = state["input"]
elif state["type"] == "set_color":
description = f"颜色已调整到{state['rgb_color']}"
action = 'turn_on'
arg = 'rgb_color'
value = state['rgb_color']
elif state['type'] == 'set_kelvin':
action = "turn_on"
arg = "rgb_color"
value = state["rgb_color"]
elif state["type"] == "set_kelvin":
description = f"色温已调整到{state['input']}K"
action = 'turn_on'
arg = 'kelvin'
value = state['input']
elif state['type'] == 'volume_up':
action = "turn_on"
arg = "kelvin"
value = state["input"]
elif state["type"] == "volume_up":
description = "音量已调大"
action = state['type']
elif state['type'] == 'volume_down':
action = state["type"]
elif state["type"] == "volume_down":
description = "音量已调小"
action = state['type']
elif state['type'] == 'volume_set':
action = state["type"]
elif state["type"] == "volume_set":
description = f"音量已调整到{state['input']}"
action = state['type']
arg = 'volume_level'
value = state['input']
if state['input'] >= 1:
value = state['input']/100
elif state['type'] == 'volume_mute':
action = state["type"]
arg = "volume_level"
value = state["input"]
if state["input"] >= 1:
value = state["input"] / 100
elif state["type"] == "volume_mute":
description = f"设备已静音"
action = state['type']
arg = 'is_volume_muted'
value = state['is_muted']
elif state['type'] == 'pause':
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':
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'
if domain == "media_player":
action = "media_play"
if domain == "vacuum":
action = "start"
else:
return f"{domain} {state.type}功能尚未支持"
if arg == '':
if arg == "":
data = {
"entity_id": entity_id,
}
else:
data = {
"entity_id": entity_id,
arg: value
}
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"
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
response = requests.post(url, headers=headers, json=data)
logger.bind(tag=TAG).info(f"设置状态:{description},url:{url},return_code:{response.status_code}")
logger.bind(tag=TAG).info(
f"设置状态:{description},url:{url},return_code:{response.status_code}"
)
if response.status_code == 200:
return description
else:
@@ -1,56 +0,0 @@
from plugins_func.register import register_function, ToolType, ActionResponse, Action
plugin_loader_function_desc = {
"type": "function",
"function": {
"name": "plugin_loader",
"description": "当用户想加载或卸载插件/function时,调用此函数:支持的插件列表为[plugins]",
"parameters": {
"type": "object",
"properties": {
"oper": {"type": "string", "description": "load or unload"},
"name": {"type": "string", "description": "要加载或卸载的插件名字"},
},
"required": ["oper", "name"],
},
},
}
@register_function("plugin_loader", plugin_loader_function_desc, ToolType.SYSTEM_CTL)
def plugin_loader(conn, oper: str, name: str):
"""插件加载"""
if oper not in ["load", "unload"]:
return ActionResponse(
action=Action.RESPONSE, result="插件操作失败", response="不支持的操作"
)
cur_support = conn.func_handler.current_support_functions()
if oper == "load":
if name in cur_support:
return ActionResponse(
action=Action.RESPONSE,
result="插件加载失败",
response=f"{name}插件已加载,无需重复加载",
)
func = conn.func_handler.function_registry.register_function(name)
if not func:
return ActionResponse(
action=Action.RESPONSE, result="插件加载失败", response="插件未找到"
)
res = f"{name}插件加载成功"
else:
if name not in cur_support:
return ActionResponse(
action=Action.RESPONSE,
result="插件卸载失败",
response=f"{name}插件未加载",
)
bOK = conn.func_handler.function_registry.unregister_function(name)
if not bOK:
return ActionResponse(
action=Action.RESPONSE, result="插件卸载失败", response="插件未找到"
)
res = f"{name}插件卸载成功"
conn.func_handler.upload_functions_desc()
return ActionResponse(action=Action.RESPONSE, result="插件操作成功", response=res)
+1 -1
View File
@@ -35,7 +35,7 @@ class Action(Enum):
class ActionResponse:
def __init__(self, action: Action, result, response):
def __init__(self, action: Action, result=None, response=None):
self.action = action # 动作类型
self.result = result # 动作产生的结果
self.response = response # 直接回复的内容
+1 -1
View File
@@ -30,7 +30,7 @@ baidu-aip==4.16.13
chardet==5.2.0
aioconsole==0.8.1
markitdown==0.1.1
mcp-proxy==0.6.0
mcp-proxy==0.8.0
PyJWT==2.8.0
psutil==7.0.0
portalocker==2.10.1