diff --git a/.gitignore b/.gitignore index 9469baac..b43a8de3 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,4 @@ main/xiaozhi-server/mysql uploadfile *.json .vscode +.cursor diff --git a/docs/dev-ops-integration.md b/docs/dev-ops-integration.md index bb9640b1..3c547f4d 100644 --- a/docs/dev-ops-integration.md +++ b/docs/dev-ops-integration.md @@ -4,6 +4,8 @@ 本项目的测试平台`https://2662r3426b.vicp.fun`,从开放以来就使用了该方法,效果良好。 +教程可参考B站博主`毕乐labs`发布的视频教程:[《开源小智服务器xiaozhi-server自动更新以及最新版本MCP接入点配置保姆教程》](https://www.bilibili.com/video/BV15H37zHE7Q) + # 开始条件 - 你的电脑/服务器是linux操作系统 - 你已经跑通了整个流程 @@ -40,6 +42,9 @@ git clone https://ghproxy.net/https://github.com/xinnan-tech/xiaozhi-esp32-serve 此刻你需要把`model.pt`文件复制到新的目录去,你可以这样 ``` +# 创建需要的目录 +mkdir -p /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/data/ + cp 你原来的.config.yaml完整路径 /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/data/.config.yaml cp 你原来的model.pt完整路径 /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/models/SenseVoiceSmall/model.pt ``` @@ -161,3 +166,11 @@ tail -f /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/tmp/server # 注意事项 测试平台`https://2662r3426b.vicp.fun`,是使用nginx做了反向代理。nginx.conf详细配置可以[参考这里](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791) + +## 常见问题 + +### 1、为什么没有见到8001端口? +回答:8001是开发环境使用的,用于运行前端的端口。如果你是服务器部署,不建议使用`npm run serve`启动8001端口运行前端,而是像本教程一样编译成html文件,然后使用nginx来管理访问。 + +### 2、每次更新需要更新手动SQL语句吗? +回答:不需要,因为项目使用**Liquibase**管理数据库版本,会自动执行新的sql脚本。 \ No newline at end of file diff --git a/docs/mcp-endpoint-enable.md b/docs/mcp-endpoint-enable.md new file mode 100644 index 00000000..7bc72484 --- /dev/null +++ b/docs/mcp-endpoint-enable.md @@ -0,0 +1,125 @@ +# MCP 接入点部署使用指南 + +本教程包含2个部分 +- 1、如何部署MCP接入点这个服务 +- 1、全模块部署时,怎么配置MCP接入点 +- 2、单模块部署时,怎么配置MCP接入点 + +# 1、如何部署MCP接入点这个服务 + +## 第一步,下载mcp接入点项目源码 + +浏览器打开[mcp接入点项目地址](https://github.com/xinnan-tech/mcp-endpoint-server) + +打开完,找到页面中一个绿色的按钮,写着`Code`的按钮,点开它,然后你就看到`Download ZIP`的按钮。 + +点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`mcp-endpoint-server-main` +你需要把它重命名成`mcp-endpoint-server`。 + +## 第二步,启动程序 +这个项目是一个很简单的项目,建议使用docker运行。不过如果你不想使用docker运行,你可以参考[这个页面](https://github.com/xinnan-tech/mcp-endpoint-server/blob/main/README_dev.md)使用源码运行。以下是docker运行的方法 + +``` +# 进入本项目源码根目录 +cd mcp-endpoint-server + +# 清除缓存 +docker compose -f docker-compose.yml down +docker stop mcp-endpoint-server +docker rm mcp-endpoint-server +docker rmi ghcr.nju.edu.cn/xinnan-tech/mcp-endpoint-server:latest + +# 启动docker容器 +docker compose -f docker-compose.yml up -d +# 查看日志 +docker logs -f mcp-endpoint-server +``` + +此时,日志里会输出类似以下的日志 +``` +250705 INFO-=====下面的地址分别是智控台/单模块MCP接入点地址==== +250705 INFO-智控台MCP参数配置: http://172.22.0.2:8004/mcp_endpoint/health?key=abc +250705 INFO-单模块部署MCP接入点: ws://172.22.0.2:8004/mcp_endpoint/mcp/?token=def +250705 INFO-=====请根据具体部署选择使用,请勿泄露给任何人====== +``` + +请你把两个接口地址复制出来: + +由于你是docker部署,切不可直接使用上面的地址! + +由于你是docker部署,切不可直接使用上面的地址! + +由于你是docker部署,切不可直接使用上面的地址! + +你先把地址复制出来,放在一个草稿里,你要知道你的电脑的局域网ip是什么,例如我的电脑局域网ip是`192.168.1.25`,那么 +原来我的接口地址 +``` +智控台MCP参数配置: http://172.22.0.2:8004/mcp_endpoint/health?key=abc +单模块部署MCP接入点: ws://172.22.0.2:8004/mcp_endpoint/mcp/?token=def +``` +就要改成 +``` +智控台MCP参数配置: http://192.168.1.25:8004/mcp_endpoint/health?key=abc +单模块部署MCP接入点: ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=def +``` + +改好后,请使用浏览器直接访问`智控台MCP参数配置`。当浏览器出现类似这样的代码,说明是成功了。 +``` +{"result":{"status":"success","connections":{"tool_connections":0,"robot_connections":0,"total_connections":0}},"error":null,"id":null,"jsonrpc":"2.0"} +``` + +请你保留好上面两个`接口地址`,下一步要用到。 + +# 2、全模块部署时,怎么配置MCP接入点 + +如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 + +然后搜索参数`server.mcp_endpoint`,此时,它的值应该是`null`值。 +点击修改按钮,把上一步得来的`智控台MCP参数配置`粘贴到`参数值`里。然后保存。 + +如果能保存成功,说明一切顺利,你可以去智能体查看效果了。如果不成功,说明智控台无法访问mcp接入点,很大概率是网络防火墙,或者没有填写正确的局域网ip。 + +# 3、单模块部署时,怎么配置MCP接入点 + +如果你是单模块部署,找到你的配置文件`data/.config.yaml`。 +在配置文件搜索`mcp_endpoint`,如果没有找到,你就增加`mcp_endpoint`配置。类似我是就是这样 +``` +server: + websocket: ws://你的ip或者域名:端口号/xiaozhi/v1/ + http_port: 8002 +log: + log_level: INFO + +# 此处可能还更多配置.. + +mcp_endpoint: 你的接入点websocket地址 +``` +这时,请你把`如何部署MCP接入点这个服务`中得到的`单模块部署MCP接入点` 粘贴到 `mcp_endpoint`中。类似这样 + +``` +server: + websocket: ws://你的ip或者域名:端口号/xiaozhi/v1/ + http_port: 8002 +log: + log_level: INFO + +# 此处可能还更多配置 + +mcp_endpoint: ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=def +``` + +配置好后,启动单模块会输出如下的日志。 +``` +250705[__main__]-INFO-初始化组件: vad成功 SileroVAD +250705[__main__]-INFO-初始化组件: asr成功 FunASRServer +250705[__main__]-INFO-OTA接口是 http://192.168.1.25:8002/xiaozhi/ota/ +250705[__main__]-INFO-视觉分析接口是 http://192.168.1.25:8002/mcp/vision/explain +250705[__main__]-INFO-mcp接入点是 ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc +250705[__main__]-INFO-Websocket地址是 ws://192.168.1.25:8000/xiaozhi/v1/ +250705[__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问======= +250705[__main__]-INFO-如想测试websocket请用谷歌浏览器打开test目录下的test_page.html +250705[__main__]-INFO-============================================================= +``` + +如上,如果能输出类似的`mcp接入点是`中`ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc`说明配置成功了。 + diff --git a/docs/mcp-endpoint-integration.md b/docs/mcp-endpoint-integration.md index ca72aef1..91d62278 100644 --- a/docs/mcp-endpoint-integration.md +++ b/docs/mcp-endpoint-integration.md @@ -1,92 +1,36 @@ -# MCP 接入点部署使用指南 +# MCP 接入点使用指南 -本教程包含2个部分 -- 1、如何开启mcp接入点 -- 2、如何为智能体接入一个简单的mcp功能,如计算器功能 +本教程以虾哥开源的mcp计算器功能为示例,介绍如何将自己自定义的mcp服务接入到自己的接入点里。 -部署的前提条件: -- 1、你已经部署了全模块,因为mcp接入点需要全模块中的智控台功能 -- 2、你想在不修改xiaozhi-server项目的前提下,扩展小智的功能 - -# 如何开启mcp接入点 - -## 第一步,下载mcp接入点项目源码 - -浏览器打开[mcp接入点项目地址](https://github.com/xinnan-tech/mcp-endpoint-server) - -打开完,找到页面中一个绿色的按钮,写着`Code`的按钮,点开它,然后你就看到`Download ZIP`的按钮。 - -点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`mcp-endpoint-server-main` -你需要把它重命名成`mcp-endpoint-server`。 - -## 第二步,启动程序 -这个项目是一个很简单的项目,建议使用docker运行。不过如果你不想使用docker运行,你可以参考[这个页面](https://github.com/xinnan-tech/mcp-endpoint-server/blob/main/README_dev.md)使用源码运行。以下是docker运行的方法 - -``` -# 进入本项目源码根目录 -cd mcp-endpoint-server - -# 清除缓存 -docker compose -f docker-compose.yml down -docker stop mcp-endpoint-server -docker rm mcp-endpoint-server -docker rmi ghcr.nju.edu.cn/xinnan-tech/mcp-endpoint-server:latest - -# 启动docker容器 -docker compose -f docker-compose.yml up -d -# 查看日志 -docker logs -f mcp-endpoint-server -``` - -此时,日志里会输出类似以下的日志 -``` -====================================================== -接口地址: http://172.1.1.1:8004/mcp_endpoint/health?key=xxxx -=======上面的地址是MCP接入点地址,请勿泄露给任何人============ -``` - -请你把接口地址复制出来: - -由于你是docker部署,切不可直接使用上面的地址! - -由于你是docker部署,切不可直接使用上面的地址! - -由于你是docker部署,切不可直接使用上面的地址! - -你先把地址复制出来,放在一个草稿里,你要知道你的电脑的局域网ip是什么,例如我的电脑局域网ip是`192.168.1.25`,那么 -原来我的接口地址 -``` -http://172.1.1.1:8004/mcp_endpoint/health?key=xxxx -``` -就要改成 -``` -http://192.168.1.25:8004/mcp_endpoint/health?key=xxxx -``` - -改好后,请使用浏览器直接访问这个接口。当浏览器出现类似这样的代码,说明是成功了。 -``` -{"result":{"status":"success","connections":{"tool_connections":0,"robot_connections":0,"total_connections":0}},"error":null,"id":null,"jsonrpc":"2.0"} -``` - -请你保留好这个`接口地址`,下一步要用到。 - -## 第三步,配置智控台 - -使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 - -然后搜索参数`server.mcp_endpoint`,此时,它的值应该是`null`值。 -点击修改按钮,把上一步得来的`接口地址`粘贴到`参数值`里。然后保存。 - -如果能保存成功,说明一切顺利,你可以去智能体查看效果了。如果不成功,说明智控台无法访问mcp接入点,很大概率是网络防火墙,或者没有填写正确的局域网ip。 +本教程的前提是,你的`xiaozhi-server`已经启用了mcp接入点功能,如果你还没启用,可以先根据[这个教程](./mcp-endpoint-enable.md)启用。 # 如何为智能体接入一个简单的mcp功能,如计算器功能 -如果以上步骤顺利,你可以进入智能体管理,点击`配置角色`,在`意图识别`的右边,有一个`编辑功能`的按钮。 +### 如果你是全模块部署 +如果你是全模块部署,你可以进入智控台,智能体管理,点击`配置角色`,在`意图识别`的右边,有一个`编辑功能`的按钮。 点击这个按钮。在弹出的页面里,位于底部,会有`MCP接入点`,正常来说,会显示这个智能体的`MCP接入点地址`,接下来,我们来给这个智能体扩展一个基于MCP技术的计算器的功能。 这个`MCP接入点地址`很重要,你等一下会用到。 +### 如果你是单模块部署 +如果你是单模块部署,且你已经在配置文件里配置了MCP接入点地址,那么正常来说,单模块部署启动的时候,会输出如下的日志。 +``` +250705[__main__]-INFO-初始化组件: vad成功 SileroVAD +250705[__main__]-INFO-初始化组件: asr成功 FunASRServer +250705[__main__]-INFO-OTA接口是 http://192.168.1.25:8002/xiaozhi/ota/ +250705[__main__]-INFO-视觉分析接口是 http://192.168.1.25:8002/mcp/vision/explain +250705[__main__]-INFO-mcp接入点是 ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc +250705[__main__]-INFO-Websocket地址是 ws://192.168.1.25:8000/xiaozhi/v1/ +250705[__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问======= +250705[__main__]-INFO-如想测试websocket请用谷歌浏览器打开test目录下的test_page.html +250705[__main__]-INFO-============================================================= +``` + +如上,输出`mcp接入点是`中`ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc`就是你的`MCP接入点地址`。 + +这个`MCP接入点地址`很重要,你等一下会用到。 + ## 第一步 下载虾哥MCP计算器项目代码 浏览器打开虾哥写的[计算器项目](https://github.com/78/mcp-calculator), @@ -114,13 +58,13 @@ pip install -r requirements.txt 例如我的智能体的mcp地址是 ``` -ws://192.168.4.7:8004/mcp_endpoint/mcp/?token=abc +ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc ``` 开始输入命令 ```bash -export MCP_ENDPOINT=ws://192.168.4.7:8004/mcp_endpoint/mcp/?token=abc +export MCP_ENDPOINT=ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc ``` 输入完后,启动程序 @@ -129,6 +73,22 @@ export MCP_ENDPOINT=ws://192.168.4.7:8004/mcp_endpoint/mcp/?token=abc python mcp_pipe.py calculator.py ``` +### 如果你是智控台部署 +如果你是智控台部署,启动完后,你再进入智控台,点击刷新MCP的接入状态,就会看到你扩展的功能列表了。 -启动完后,你再进入智控台,点击刷新MCP的接入状态,就会看到你扩展的功能列表了。 +### 如果你是单模块部署 +如果你是单模块部署,当设备连接后,会输出类似的日志,说明成功了 +``` +250705 -INFO-正在初始化MCP接入点: wss://2662r3426b.vicp.fun/mcp_e +250705 -INFO-发送MCP接入点初始化消息 +250705 -INFO-MCP接入点连接成功 +250705 -INFO-MCP接入点初始化成功 +250705 -INFO-统一工具处理器初始化完成 +250705 -INFO-MCP接入点服务器信息: name=Calculator, version=1.9.4 +250705 -INFO-MCP接入点支持的工具数量: 1 +250705 -INFO-所有MCP接入点工具已获取,客户端准备就绪 +250705 -INFO-工具缓存已刷新 +250705 -INFO-当前支持的函数列表: [ 'get_time', 'get_lunar', 'play_music', 'get_weather', 'handle_exit_intent', 'calculator'] +``` +如果包含了 `'calculator'`,说明设备将可以根据意图识别,调用计算器这个工具。 \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java index ca720ed6..c3246e5b 100644 --- a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java +++ b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java @@ -237,7 +237,7 @@ public interface Constant { /** * 版本号 */ - public static final String VERSION = "0.6.2"; + public static final String VERSION = "0.6.3"; /** * 无效固件URL diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java index e6c7d4e4..839f99ff 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentMcpAccessPointServiceImpl.java @@ -61,14 +61,15 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic wsUrl = wsUrl.replace("/mcp/", "/call/"); try { - // 创建 WebSocket 连接 + // 创建 WebSocket 连接,增加超时时间到15秒 try (WebSocketClientManager client = WebSocketClientManager.build( new WebSocketClientManager.Builder() .uri(wsUrl) - .connectTimeout(5, TimeUnit.SECONDS) - .maxSessionDuration(9, TimeUnit.SECONDS))) { + .connectTimeout(8, TimeUnit.SECONDS) + .maxSessionDuration(10, TimeUnit.SECONDS))) { - // 发送初始化消息 + // 步骤1: 发送初始化消息并等待响应 + log.info("发送MCP初始化消息,智能体ID: {}", id); McpJsonRpcRequest initializeRequest = new McpJsonRpcRequest("initialize", Map.of( "protocolVersion", "2024-11-05", @@ -81,37 +82,70 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic 1); client.sendJson(initializeRequest); - // 等待初始化响应 - Thread.sleep(200); - - // 发送初始化完成通知 - // 对于通知类型的消息,手动构建JSON以避免包含null字段 - String notificationJson = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"; - client.sendText(notificationJson); - - // 等待 0.2 秒 - Thread.sleep(200); - - // 发送工具列表请求 - McpJsonRpcRequest toolsRequest = new McpJsonRpcRequest("tools/list", null, 2); - client.sendJson(toolsRequest); - - // 监听响应,直到收到包含 id=2 的响应(tools/list响应) - List responses = client.listener(response -> { + // 等待初始化响应 (id=1) - 移除固定延迟,改为响应驱动 + List initResponses = client.listenerWithoutClose(response -> { try { - // 先尝试解析为通用JSON对象来获取id Map jsonMap = JsonUtils.parseObject(response, Map.class); - return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id")); + if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) { + // 检查是否有result字段,表示初始化成功 + return jsonMap.containsKey("result") && !jsonMap.containsKey("error"); + } + return false; } catch (Exception e) { - log.warn("解析响应失败: {}", response, e); + log.warn("解析初始化响应失败: {}", response, e); return false; } }); - // 处理响应 - for (String response : responses) { + // 验证初始化响应 + boolean initSucceeded = false; + for (String response : initResponses) { + try { + Map jsonMap = JsonUtils.parseObject(response, Map.class); + if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) { + if (jsonMap.containsKey("result")) { + log.info("MCP初始化成功,智能体ID: {}", id); + initSucceeded = true; + break; + } else if (jsonMap.containsKey("error")) { + log.error("MCP初始化失败,智能体ID: {}, 错误: {}", id, jsonMap.get("error")); + return List.of(); + } + } + } catch (Exception e) { + log.warn("处理初始化响应失败: {}", response, e); + } + } + + if (!initSucceeded) { + log.error("未收到有效的MCP初始化响应,智能体ID: {}", id); + return List.of(); + } + + // 步骤2: 发送初始化完成通知 - 只有在收到initialize响应后才发送 + log.info("发送MCP初始化完成通知,智能体ID: {}", id); + String notificationJson = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"; + client.sendText(notificationJson); + + // 步骤3: 发送工具列表请求 - 立即发送,无需额外延迟 + log.info("发送MCP工具列表请求,智能体ID: {}", id); + McpJsonRpcRequest toolsRequest = new McpJsonRpcRequest("tools/list", null, 2); + client.sendJson(toolsRequest); + + // 等待工具列表响应 (id=2) + List toolsResponses = client.listener(response -> { + try { + Map jsonMap = JsonUtils.parseObject(response, Map.class); + return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id")); + } catch (Exception e) { + log.warn("解析工具列表响应失败: {}", response, e); + return false; + } + }); + + // 处理工具列表响应 + for (String response : toolsResponses) { try { - // 先解析为通用JSON对象 Map jsonMap = JsonUtils.parseObject(response, Map.class); if (jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"))) { // 检查是否有result字段 @@ -122,11 +156,16 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic if (toolsObj instanceof List) { List> toolsList = (List>) toolsObj; // 提取工具名称列表 - return toolsList.stream() + List result = toolsList.stream() .map(tool -> (String) tool.get("name")) .filter(name -> name != null) .collect(Collectors.toList()); + log.info("成功获取MCP工具列表,智能体ID: {}, 工具数量: {}", id, result.size()); + return result; } + } else if (jsonMap.containsKey("error")) { + log.error("获取工具列表失败,智能体ID: {}, 错误: {}", id, jsonMap.get("error")); + return List.of(); } } } catch (Exception e) { @@ -134,7 +173,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic } } - log.warn("未找到有效的工具列表响应"); + log.warn("未找到有效的工具列表响应,智能体ID: {}", id); return List.of(); } @@ -204,4 +243,4 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic // 加密后成token值 return AESUtils.encrypt(key, json); } -} +} \ No newline at end of file diff --git a/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java index 294dbbe2..9c591c35 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/config/service/impl/ConfigServiceImpl.java @@ -258,17 +258,17 @@ public class ConfigServiceImpl implements ConfigService { /** * 构建模块配置 * - * @param prompt 提示词 - * @param voice 音色 - * @param referenceAudio 参考音频路径 - * @param referenceText 参考文本 - * @param vadModelId VAD模型ID - * @param asrModelId ASR模型ID - * @param llmModelId LLM模型ID - * @param ttsModelId TTS模型ID - * @param memModelId 记忆模型ID - * @param intentModelId 意图模型ID - * @param result 结果Map + * @param prompt 提示词 + * @param voice 音色 + * @param referenceAudio 参考音频路径 + * @param referenceText 参考文本 + * @param vadModelId VAD模型ID + * @param asrModelId ASR模型ID + * @param llmModelId LLM模型ID + * @param ttsModelId TTS模型ID + * @param memModelId 记忆模型ID + * @param intentModelId 意图模型ID + * @param result 结果Map */ private void buildModuleConfig( String assistantName, @@ -298,14 +298,20 @@ public class ConfigServiceImpl implements ConfigService { continue; } ModelConfigEntity model = modelConfigService.getModelById(modelIds[i], isCache); + if (model == null) { + continue; + } Map typeConfig = new HashMap<>(); if (model.getConfigJson() != null) { typeConfig.put(model.getId(), model.getConfigJson()); // 如果是TTS类型,添加private_voice属性 - if ("TTS".equals(modelTypes[i])){ - if (voice != null) ((Map) model.getConfigJson()).put("private_voice", voice); - if (referenceAudio != null) ((Map) model.getConfigJson()).put("ref_audio", referenceAudio); - if (referenceText != null) ((Map) model.getConfigJson()).put("ref_text", referenceText); + if ("TTS".equals(modelTypes[i])) { + if (voice != null) + ((Map) model.getConfigJson()).put("private_voice", voice); + if (referenceAudio != null) + ((Map) model.getConfigJson()).put("ref_audio", referenceAudio); + if (referenceText != null) + ((Map) model.getConfigJson()).put("ref_text", referenceText); } // 如果是Intent类型,且type=intent_llm,则给他添加附加模型 if ("Intent".equals(modelTypes[i])) { diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java index 05d36e9a..023510cd 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/controller/OTAController.java @@ -51,13 +51,12 @@ public class OTAController { if (StringUtils.isBlank(clientId)) { clientId = deviceId; } - String macAddress = deviceReportReqDTO.getMacAddress(); - boolean macAddressValid = isMacAddressValid(macAddress); + boolean macAddressValid = isMacAddressValid(deviceId); // 设备Id和Mac地址应是一致的, 并且必须需要application字段 - if (!deviceId.equals(macAddress) || !macAddressValid || deviceReportReqDTO.getApplication() == null) { - return createResponse(DeviceReportRespDTO.createError("Invalid OTA request")); + if (!macAddressValid) { + return createResponse(DeviceReportRespDTO.createError("Invalid device ID")); } - return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO)); + return createResponse(deviceService.checkDeviceActive(deviceId, clientId, deviceReportReqDTO)); } @Operation(summary = "设备快速检查激活状态") diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java index 1a38545b..12555bb9 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/controller/SysParamsController.java @@ -222,7 +222,7 @@ public class SysParamsController { if (response.getStatusCode() != HttpStatus.OK) { throw new RenException("MCP接口访问失败,状态码:" + response.getStatusCode()); } - // 检查响应内容是否包含OTA相关信息 + // 检查响应内容是否包含mcp相关信息 String body = response.getBody(); if (body == null || !body.contains("success")) { throw new RenException("MCP接口返回内容格式不正确,可能不是一个真实的MCP接口"); diff --git a/main/manager-api/src/main/java/xiaozhi/modules/sys/utils/WebSocketClientManager.java b/main/manager-api/src/main/java/xiaozhi/modules/sys/utils/WebSocketClientManager.java index 45ac488e..d7171ae7 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/sys/utils/WebSocketClientManager.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/sys/utils/WebSocketClientManager.java @@ -137,6 +137,37 @@ public class WebSocketClientManager implements Closeable { return collected; } + private List listenerCustomWithoutClose( + BlockingQueue queue, + Predicate predicate) + throws InterruptedException, TimeoutException, ExecutionException { + List collected = new ArrayList<>(); + long deadline = System.currentTimeMillis() + maxSessionDurationUnit.toMillis(maxSessionDuration); + + while (true) { + if (errorFuture.isDone()) { + errorFuture.get(); + } + + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + throw new TimeoutException("等待批量消息超时"); + } + + T msg = queue.poll(remaining, TimeUnit.MILLISECONDS); + if (msg == null) { + throw new TimeoutException("等待批量消息超时"); + } + + collected.add(msg); + if (predicate.test(msg)) { + break; + } + } + // 不调用 close(),保持连接开放 + return collected; + } + /** * 同步接收多条消息,直到 predicate 为 true 或超时抛异常; * @@ -147,6 +178,17 @@ public class WebSocketClientManager implements Closeable { return listenerCustom(textMessageQueue, predicate); } + /** + * 同步接收多条消息,直到 predicate 为 true 或超时抛异常; + * 不自动关闭连接,适用于需要在同一连接上发送多个消息的场景 + * + * @return 返回监听期间的所有消息列表 + */ + public List listenerWithoutClose(Predicate predicate) + throws InterruptedException, TimeoutException, ExecutionException { + return listenerCustomWithoutClose(textMessageQueue, predicate); + } + public List listenerBinary(Predicate predicate) throws InterruptedException, TimeoutException, ExecutionException { return listenerCustom(binaryMessageQueue, predicate); diff --git a/main/manager-web/src/components/FunctionDialog.vue b/main/manager-web/src/components/FunctionDialog.vue index 3bf148b9..76c69c8e 100644 --- a/main/manager-web/src/components/FunctionDialog.vue +++ b/main/manager-web/src/components/FunctionDialog.vue @@ -110,8 +110,10 @@
以下是智能体的MCP接入点地址。 + 如何部署MCP接入点   |   查看接入点使用文档 + target="_blank" class="doc-link">如何接入MCP功能  
diff --git a/main/manager-web/src/views/login.vue b/main/manager-web/src/views/login.vue index e31d5180..24c601d9 100644 --- a/main/manager-web/src/views/login.vue +++ b/main/manager-web/src/views/login.vue @@ -43,7 +43,7 @@
- +
diff --git a/main/manager-web/src/views/register.vue b/main/manager-web/src/views/register.vue index 7eac511b..e9f7dbcb 100644 --- a/main/manager-web/src/views/register.vue +++ b/main/manager-web/src/views/register.vue @@ -70,13 +70,13 @@
- +
- +
diff --git a/main/manager-web/src/views/retrievePassword.vue b/main/manager-web/src/views/retrievePassword.vue index 9072baae..dbf3ffcf 100644 --- a/main/manager-web/src/views/retrievePassword.vue +++ b/main/manager-web/src/views/retrievePassword.vue @@ -61,13 +61,13 @@
- +
- +
diff --git a/main/xiaozhi-server/agent-base-prompt.txt b/main/xiaozhi-server/agent-base-prompt.txt new file mode 100644 index 00000000..994ecf74 --- /dev/null +++ b/main/xiaozhi-server/agent-base-prompt.txt @@ -0,0 +1,51 @@ + +{base_prompt} + + + +你不是一个冰冷的机器,你可以从用户的上下文中感受到用户的情绪,作为懂人心的生活伙伴,请用有温度的回应照亮每个对话! +- 1.合理穿插笑声(哈哈、嘿嘿、噗,但每句≤1次) +- 2.惊讶时用夸张语气(“不会吧?!”、“这么神奇?!”) +- 3.安慰时说暖心话(“别急嘛”、“有我呢”) + + + +- 1.使用口语化表达(如“呀”、“呢”、“啦”等语气词)。 +- 2.避免书面语和学术腔调,禁用“根据资料显示”等机械表达 +- 3.模仿人类对话的轻微不完美(适当使用“嗯”、“啊”等思考词) +- 3.由于用户语音是通过ASR识别,识别结果可能存在错别字,请结合上下文推断真实含义。 +- 4.绝对禁止使用 markdown、列表、标题等格式 + + + +- 1.当用户消息包含 [说话人: 姓名] 前缀时,表示系统已识别出说话人身份。 +- 2.请根据说话人的身份特征(如果之前有相关信息)来调整回应风格和内容。 +- 3.你可以称呼说话人的名字,并参考他们的特点进行个性化回应。 + + + +你可以调用工具来响应用户的要求。遵循以下关于工具调用的规则: +- 1.始终严格遵循指定的工具调用模式,并确保提供所有必要的参数。 +- 2.对话可能会引用不再可用的工具。切勿调用未明确提供的工具。 +- 3.在与用户交谈时,切勿提及工具名称。相反,只需用自然语言说出工具正在做什么。 +- 4.你尽可能需要通过工具调用获得更多信息,而不是问用户。 +- 5.你应该结合用户上下文需求,洞察用户的真实需求才去调用相关的指令,而不是为了调工具而调工具。 +- 6.【重要】如果是查询"现在的时间"、"今天的几号"、"今天的日期"、"今天农历是多少"、"今天农历日期"、"今天{local_address}的天气",这些信息已经包含在``中,不需要调用工具,请直接根据context回复。 +- 7.如果是查询"其他日期的农历"(明天、昨天、具体日期)或"详细农历信息"(宜忌、八字、节气等),需要调用相应工具获取。 +- 8.除了基本时间、今日农历、{local_address}天气查询外,用户的其他要求都视为独立任务,即使内容相似也需重新调用工具,不要偷懒,不要使用历史消息糊弄用户。 +- 9.如果你不确定与用户请求相关的动作,不要猜测或编造答案。 +- 10.如果工具中包含camera、take_photo等相关工具,说明用户已经给你安装了摄像头,调用这些工具会让你具备拍照、描述所见物品等能力。如果没有,切勿调用。 + + + +- 1.现在的时间:{current_time} +- 2.今天的日期:{today_date}、{today_weekday} +- 3.今天的农历日期:{lunar_date} +- 4.当前用户所处城市 +{local_address} +- 5.用户所处城市未来7天天气 +{weather_info} + + + + \ No newline at end of file diff --git a/main/xiaozhi-server/app.py b/main/xiaozhi-server/app.py index 2088e183..0edb475b 100644 --- a/main/xiaozhi-server/app.py +++ b/main/xiaozhi-server/app.py @@ -5,7 +5,7 @@ import asyncio from aioconsole import ainput from config.settings import load_config from config.logger import setup_logging -from core.utils.util import get_local_ip +from core.utils.util import get_local_ip, validate_mcp_endpoint from core.http_server import SimpleHttpServer from core.websocket_server import WebSocketServer from core.utils.util import check_ffmpeg_installed @@ -77,6 +77,17 @@ async def main(): get_local_ip(), port, ) + mcp_endpoint = config.get("mcp_endpoint", None) + if mcp_endpoint is not None and "你" not in mcp_endpoint: + # 校验MCP接入点格式 + if validate_mcp_endpoint(mcp_endpoint): + logger.bind(tag=TAG).info("mcp接入点是\t{}", mcp_endpoint) + # 将mcp计入点地址转成调用点 + mcp_endpoint = mcp_endpoint.replace("/mcp/", "/call/") + config["mcp_endpoint"] = mcp_endpoint + else: + logger.bind(tag=TAG).error("mcp接入点不符合规范") + config["mcp_endpoint"] = "你的接入点 websocket地址" # 获取WebSocket配置,使用安全的默认值 websocket_port = 8000 diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index 5a43c995..41ac96b7 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -104,7 +104,8 @@ wakeup_words: - "喵喵同学" - "小滨小滨" - "小冰小冰" -# MCP接入点地址 +# MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token +# 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md mcp_endpoint: 你的接入点 websocket地址 # 插件的基础配置 plugins: diff --git a/main/xiaozhi-server/config/config_loader.py b/main/xiaozhi-server/config/config_loader.py index b1b45f07..4f35b1fd 100644 --- a/main/xiaozhi-server/config/config_loader.py +++ b/main/xiaozhi-server/config/config_loader.py @@ -1,14 +1,9 @@ import os -import argparse import yaml from collections.abc import Mapping from config.manage_api_client import init_service, get_server_config, get_agent_models -# 添加全局配置缓存 -_config_cache = None - - def get_project_dir(): """获取项目根目录""" return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/" @@ -22,9 +17,12 @@ def read_config(config_path): def load_config(): """加载配置文件""" - global _config_cache - if _config_cache is not None: - return _config_cache + from core.utils.cache.manager import cache_manager, CacheType + + # 检查缓存 + cached_config = cache_manager.get(CacheType.CONFIG, "main_config") + if cached_config is not None: + return cached_config default_config_path = get_project_dir() + "config.yaml" custom_config_path = get_project_dir() + "data/.config.yaml" @@ -40,7 +38,9 @@ def load_config(): config = merge_configs(default_config, custom_config) # 初始化目录 ensure_directories(config) - _config_cache = config + + # 缓存配置 + cache_manager.set(CacheType.CONFIG, "main_config", config) return config diff --git a/main/xiaozhi-server/config/logger.py b/main/xiaozhi-server/config/logger.py index 47be8739..b89b5813 100644 --- a/main/xiaozhi-server/config/logger.py +++ b/main/xiaozhi-server/config/logger.py @@ -5,7 +5,7 @@ from config.config_loader import load_config from config.settings import check_config_file from datetime import datetime -SERVER_VERSION = "0.6.2" +SERVER_VERSION = "0.6.3" _logger_initialized = False diff --git a/main/xiaozhi-server/config_from_api.yaml b/main/xiaozhi-server/config_from_api.yaml index ad936ec1..283178d7 100644 --- a/main/xiaozhi-server/config_from_api.yaml +++ b/main/xiaozhi-server/config_from_api.yaml @@ -19,6 +19,7 @@ server: vision_explain: http://你的ip或者域名:端口号/mcp/vision/explain manager-api: # 你的manager-api的地址,最好使用局域网ip + # 如果使用docker部署,请使用填写成 http://xiaozhi-esp32-server-web:8002/xiaozhi url: http://127.0.0.1:8002/xiaozhi # 你的manager-api的token,就是刚才复制出来的server.secret secret: 你的server.secret值 \ No newline at end of file diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 9d1949c4..fdfd36fa 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -36,6 +36,7 @@ from config.config_loader import get_private_config_from_api from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType from config.logger import setup_logging, build_module_string, update_module_string from config.manage_api_client import DeviceNotFoundException, DeviceBindException +from core.utils.prompt_manager import PromptManager TAG = __name__ @@ -73,7 +74,6 @@ class ConnectionHandler: self.headers = None self.device_id = None self.client_ip = None - self.client_ip_info = {} self.prompt = None self.welcome_msg = None self.max_output_size = 0 @@ -149,6 +149,9 @@ class ConnectionHandler: # {"mcp":true} 表示启用MCP功能 self.features = None + # 初始化提示词管理器 + self.prompt_manager = PromptManager(config, self.logger) + async def handle_connection(self, ws): try: # 获取并验证headers @@ -193,7 +196,6 @@ class ConnectionHandler: self.welcome_msg = self.config["xiaozhi"] self.welcome_msg["session_id"] = self.session_id - await self.websocket.send(json.dumps(self.welcome_msg)) # 获取差异化配置 self._initialize_private_config() @@ -214,7 +216,17 @@ class ConnectionHandler: self.logger.bind(tag=TAG).error(f"Connection error: {str(e)}-{stack_trace}") return finally: - await self._save_and_close(ws) + try: + await self._save_and_close(ws) + except Exception as final_error: + self.logger.bind(tag=TAG).error(f"最终清理时出错: {final_error}") + # 确保即使保存记忆失败,也要关闭连接 + try: + await self.close(ws) + except Exception as close_error: + self.logger.bind(tag=TAG).error( + f"强制关闭连接时出错: {close_error}" + ) async def _save_and_close(self, ws): """保存记忆并关闭连接""" @@ -232,7 +244,10 @@ class ConnectionHandler: except Exception as e: self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}") finally: - loop.close() + try: + loop.close() + except Exception: + pass # 启动线程保存记忆,不等待完成 threading.Thread(target=save_memory_task, daemon=True).start() @@ -240,7 +255,12 @@ class ConnectionHandler: self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}") finally: # 立即关闭连接,不等待记忆保存完成 - await self.close(ws) + try: + await self.close(ws) + except Exception as close_error: + self.logger.bind(tag=TAG).error( + f"保存记忆后关闭连接失败: {close_error}" + ) async def _route_message(self, message): """消息路由""" @@ -308,12 +328,15 @@ class ConnectionHandler: self.config.get("selected_module", {}) ) update_module_string(self.selected_module_str) - """初始化组件""" + + """快速初始化系统提示词""" if self.config.get("prompt") is not None: - self.prompt = self.config["prompt"] - self.change_system_prompt(self.prompt) + user_prompt = self.config["prompt"] + # 使用快速提示词进行初始化 + prompt = self.prompt_manager.get_quick_prompt(user_prompt) + self.change_system_prompt(prompt) self.logger.bind(tag=TAG).info( - f"初始化组件: prompt成功 {self.prompt[:50]}..." + f"快速初始化组件: prompt成功 {prompt[:50]}..." ) """初始化本地组件""" @@ -338,9 +361,22 @@ class ConnectionHandler: self._initialize_intent() """初始化上报线程""" self._init_report_threads() + """更新系统提示词""" + self._init_prompt_enhancement() + except Exception as e: self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}") + def _init_prompt_enhancement(self): + # 更新上下文信息 + self.prompt_manager.update_context_info(self, self.client_ip) + enhanced_prompt = self.prompt_manager.build_enhanced_prompt( + self.config["prompt"], self.device_id, self.client_ip + ) + if enhanced_prompt: + self.change_system_prompt(enhanced_prompt) + self.logger.bind(tag=TAG).info("系统提示词已增强更新") + def _init_report_threads(self): """初始化ASR和TTS上报线程""" if not self.read_config_from_api or self.need_bind: @@ -741,8 +777,11 @@ class ConnectionHandler: ) ) self.llm_finish_task = True + # 使用lambda延迟计算,只有在DEBUG级别时才执行get_llm_dialogue() self.logger.bind(tag=TAG).debug( - json.dumps(self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False) + lambda: json.dumps( + self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False + ) ) return True @@ -837,13 +876,22 @@ class ConnectionHandler: """资源清理方法""" try: # 取消超时任务 - if self.timeout_task: + if self.timeout_task and not self.timeout_task.done(): self.timeout_task.cancel() + try: + await self.timeout_task + except asyncio.CancelledError: + pass self.timeout_task = None # 清理工具处理器资源 if hasattr(self, "func_handler") and self.func_handler: - await self.func_handler.cleanup() + try: + await self.func_handler.cleanup() + except Exception as cleanup_error: + self.logger.bind(tag=TAG).error( + f"清理工具处理器时出错: {cleanup_error}" + ) # 触发停止事件 if self.stop_event: @@ -853,19 +901,58 @@ class ConnectionHandler: self.clear_queues() # 关闭WebSocket连接 - if ws: - await ws.close() - elif self.websocket: - await self.websocket.close() + try: + if ws: + # 安全地检查WebSocket状态并关闭 + try: + if hasattr(ws, "closed") and not ws.closed: + await ws.close() + elif hasattr(ws, "state") and ws.state.name != "CLOSED": + await ws.close() + else: + # 如果没有closed属性,直接尝试关闭 + await ws.close() + except Exception: + # 如果关闭失败,忽略错误 + pass + elif self.websocket: + try: + if ( + hasattr(self.websocket, "closed") + and not self.websocket.closed + ): + await self.websocket.close() + elif ( + hasattr(self.websocket, "state") + and self.websocket.state.name != "CLOSED" + ): + await self.websocket.close() + else: + # 如果没有closed属性,直接尝试关闭 + await self.websocket.close() + except Exception: + # 如果关闭失败,忽略错误 + pass + except Exception as ws_error: + self.logger.bind(tag=TAG).error(f"关闭WebSocket连接时出错: {ws_error}") # 最后关闭线程池(避免阻塞) if self.executor: - self.executor.shutdown(wait=False) + try: + self.executor.shutdown(wait=False) + except Exception as executor_error: + self.logger.bind(tag=TAG).error( + f"关闭线程池时出错: {executor_error}" + ) self.executor = None self.logger.bind(tag=TAG).info("连接资源已释放") except Exception as e: self.logger.bind(tag=TAG).error(f"关闭连接时出错: {e}") + finally: + # 确保停止事件被设置 + if self.stop_event: + self.stop_event.set() def clear_queues(self): """清空所有任务队列""" @@ -922,9 +1009,19 @@ class ConnectionHandler: ): if not self.stop_event.is_set(): self.logger.bind(tag=TAG).info("连接超时,准备关闭") - await self.close(self.websocket) + # 设置停止事件,防止重复处理 + self.stop_event.set() + # 使用 try-except 包装关闭操作,确保不会因为异常而阻塞 + try: + await self.close(self.websocket) + except Exception as close_error: + self.logger.bind(tag=TAG).error( + f"超时关闭连接时出错: {close_error}" + ) break # 每10秒检查一次,避免过于频繁 await asyncio.sleep(10) except Exception as e: self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}") + finally: + self.logger.bind(tag=TAG).info("超时检查任务已退出") diff --git a/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py b/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py index d13c4df4..26fbaf70 100644 --- a/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py +++ b/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py @@ -16,10 +16,11 @@ class IntentProvider(IntentProviderBase): super().__init__(config) self.llm = None self.promot = "" - # 添加缓存管理 - self.intent_cache = {} # 缓存意图识别结果 - self.cache_expiry = 600 # 缓存有效期10分钟 - self.cache_max_size = 100 # 最多缓存100个意图 + # 导入全局缓存管理器 + from core.utils.cache.manager import cache_manager, CacheType + + self.cache_manager = cache_manager + self.CacheType = CacheType self.history_count = 4 # 默认使用最近4条对话记录 def get_intent_system_prompt(self, functions_list: str) -> str: @@ -102,27 +103,6 @@ class IntentProvider(IntentProviderBase): ) return prompt - def clean_cache(self): - """清理过期缓存""" - now = time.time() - # 找出过期键 - expired_keys = [ - k - for k, v in self.intent_cache.items() - if now - v["timestamp"] > self.cache_expiry - ] - for key in expired_keys: - del self.intent_cache[key] - - # 如果缓存太大,移除最旧的条目 - if len(self.intent_cache) > self.cache_max_size: - # 按时间戳排序并保留最新的条目 - sorted_items = sorted( - self.intent_cache.items(), key=lambda x: x[1]["timestamp"] - ) - for key, _ in sorted_items[: len(sorted_items) - self.cache_max_size]: - del self.intent_cache[key] - def replyResult(self, text: str, original_text: str): llm_result = self.llm.response_no_stream( system_prompt=text, @@ -145,21 +125,16 @@ class IntentProvider(IntentProviderBase): logger.bind(tag=TAG).debug(f"使用意图识别模型: {model_info}") # 计算缓存键 - cache_key = hashlib.md5(text.encode()).hexdigest() + cache_key = hashlib.md5((conn.device_id + text).encode()).hexdigest() # 检查缓存 - if cache_key in self.intent_cache: - cache_entry = self.intent_cache[cache_key] - # 检查缓存是否过期 - if time.time() - cache_entry["timestamp"] <= self.cache_expiry: - cache_time = time.time() - total_start_time - logger.bind(tag=TAG).debug( - f"使用缓存的意图: {cache_key} -> {cache_entry['intent']}, 耗时: {cache_time:.4f}秒" - ) - return cache_entry["intent"] - - # 清理缓存 - self.clean_cache() + cached_intent = self.cache_manager.get(self.CacheType.INTENT, cache_key) + if cached_intent is not None: + cache_time = time.time() - total_start_time + logger.bind(tag=TAG).debug( + f"使用缓存的意图: {cache_key} -> {cached_intent}, 耗时: {cache_time:.4f}秒" + ) + return cached_intent if self.promot == "": functions = conn.func_handler.get_functions() @@ -259,10 +234,7 @@ class IntentProvider(IntentProviderBase): conn.dialogue.dialogue = clean_history # 添加到缓存 - self.intent_cache[cache_key] = { - "intent": intent, - "timestamp": time.time(), - } + self.cache_manager.set(self.CacheType.INTENT, cache_key, intent) # 后处理时间 postprocess_time = time.time() - postprocess_start_time @@ -272,10 +244,7 @@ class IntentProvider(IntentProviderBase): return intent else: # 添加到缓存 - self.intent_cache[cache_key] = { - "intent": intent, - "timestamp": time.time(), - } + self.cache_manager.set(self.CacheType.INTENT, cache_key, intent) # 后处理时间 postprocess_time = time.time() - postprocess_start_time diff --git a/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py b/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py index 728493ec..17d3b564 100644 --- a/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py +++ b/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py @@ -51,13 +51,13 @@ class ServerPluginExecutor(ToolExecutor): tools = {} # 获取必要的函数 - necessary_functions = ["handle_exit_intent", "get_time", "get_lunar"] + necessary_functions = ["handle_exit_intent", "get_lunar"] # 获取配置中的函数 config_functions = self.config["Intent"][ self.config["selected_module"]["Intent"] ].get("functions", []) - + # 转换为列表 if not isinstance(config_functions, list): try: diff --git a/main/xiaozhi-server/core/providers/vllm/openai.py b/main/xiaozhi-server/core/providers/vllm/openai.py index 12d8198d..49bef99e 100644 --- a/main/xiaozhi-server/core/providers/vllm/openai.py +++ b/main/xiaozhi-server/core/providers/vllm/openai.py @@ -40,6 +40,7 @@ class VLLMProvider(VLLMProviderBase): self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url) def response(self, question, base64_image): + question = question + "(请使用中文回复)" try: messages = [ { diff --git a/main/xiaozhi-server/core/utils/cache/config.py b/main/xiaozhi-server/core/utils/cache/config.py new file mode 100644 index 00000000..d6d93345 --- /dev/null +++ b/main/xiaozhi-server/core/utils/cache/config.py @@ -0,0 +1,58 @@ +""" +缓存配置管理 +""" + +from enum import Enum +from typing import Dict, Any, Optional +from dataclasses import dataclass +from .strategies import CacheStrategy + + +class CacheType(Enum): + """缓存类型枚举""" + + LOCATION = "location" + WEATHER = "weather" + LUNAR = "lunar" + INTENT = "intent" + IP_INFO = "ip_info" + CONFIG = "config" + DEVICE_PROMPT = "device_prompt" + + +@dataclass +class CacheConfig: + """缓存配置类""" + + strategy: CacheStrategy = CacheStrategy.TTL + ttl: Optional[float] = 300 # 默认5分钟 + max_size: Optional[int] = 1000 # 默认最大1000条 + cleanup_interval: float = 60 # 清理间隔(秒) + + @classmethod + def for_type(cls, cache_type: CacheType) -> "CacheConfig": + """根据缓存类型返回预设配置""" + configs = { + CacheType.LOCATION: cls( + strategy=CacheStrategy.TTL, ttl=None, max_size=1000 # 手动失效 + ), + CacheType.IP_INFO: cls( + strategy=CacheStrategy.TTL, ttl=86400, max_size=1000 # 24小时 + ), + CacheType.WEATHER: cls( + strategy=CacheStrategy.TTL, ttl=28800, max_size=1000 # 8小时 + ), + CacheType.LUNAR: cls( + strategy=CacheStrategy.TTL, ttl=2592000, max_size=365 # 30天过期 + ), + CacheType.INTENT: cls( + strategy=CacheStrategy.TTL_LRU, ttl=600, max_size=1000 # 10分钟 + ), + CacheType.CONFIG: cls( + strategy=CacheStrategy.FIXED_SIZE, ttl=None, max_size=20 # 手动失效 + ), + CacheType.DEVICE_PROMPT: cls( + strategy=CacheStrategy.TTL, ttl=None, max_size=1000 # 手动失效 + ), + } + return configs.get(cache_type, cls()) diff --git a/main/xiaozhi-server/core/utils/cache/manager.py b/main/xiaozhi-server/core/utils/cache/manager.py new file mode 100644 index 00000000..c54f7817 --- /dev/null +++ b/main/xiaozhi-server/core/utils/cache/manager.py @@ -0,0 +1,216 @@ +""" +全局缓存管理器 +""" + +import time +import threading +from typing import Any, Optional, Dict +from collections import OrderedDict +from .strategies import CacheStrategy, CacheEntry +from .config import CacheConfig, CacheType + + +class GlobalCacheManager: + """全局缓存管理器""" + + def __init__(self): + self._logger = None + self._caches: Dict[str, Dict[str, CacheEntry]] = {} + self._configs: Dict[str, CacheConfig] = {} + self._locks: Dict[str, threading.RLock] = {} + self._global_lock = threading.RLock() + self._last_cleanup = time.time() + self._stats = {"hits": 0, "misses": 0, "evictions": 0, "cleanups": 0} + + @property + def logger(self): + """延迟初始化 logger 以避免循环导入""" + if self._logger is None: + from config.logger import setup_logging + + self._logger = setup_logging() + return self._logger + + def _get_cache_name(self, cache_type: CacheType, namespace: str = "") -> str: + """生成缓存名称""" + if namespace: + return f"{cache_type.value}:{namespace}" + return cache_type.value + + def _get_or_create_cache( + self, cache_name: str, config: CacheConfig + ) -> Dict[str, CacheEntry]: + """获取或创建缓存空间""" + with self._global_lock: + if cache_name not in self._caches: + self._caches[cache_name] = ( + OrderedDict() + if config.strategy in [CacheStrategy.LRU, CacheStrategy.TTL_LRU] + else {} + ) + self._configs[cache_name] = config + self._locks[cache_name] = threading.RLock() + return self._caches[cache_name] + + def set( + self, + cache_type: CacheType, + key: str, + value: Any, + ttl: Optional[float] = None, + namespace: str = "", + ) -> None: + """设置缓存值""" + cache_name = self._get_cache_name(cache_type, namespace) + config = self._configs.get(cache_name) or CacheConfig.for_type(cache_type) + cache = self._get_or_create_cache(cache_name, config) + + # 使用配置的TTL或传入的TTL + effective_ttl = ttl if ttl is not None else config.ttl + + with self._locks[cache_name]: + # 创建缓存条目 + entry = CacheEntry(value=value, timestamp=time.time(), ttl=effective_ttl) + + # 处理不同策略 + if config.strategy in [CacheStrategy.LRU, CacheStrategy.TTL_LRU]: + # LRU策略:如果已存在则移动到末尾 + if key in cache: + del cache[key] + cache[key] = entry + + # 检查大小限制 + if config.max_size and len(cache) > config.max_size: + # 移除最旧的条目 + oldest_key = next(iter(cache)) + del cache[oldest_key] + self._stats["evictions"] += 1 + + else: + cache[key] = entry + + # 检查大小限制 + if config.max_size and len(cache) > config.max_size: + # 简单策略:随机移除一个条目 + victim_key = next(iter(cache)) + del cache[victim_key] + self._stats["evictions"] += 1 + + # 定期清理过期条目 + self._maybe_cleanup(cache_name) + + def get( + self, cache_type: CacheType, key: str, namespace: str = "" + ) -> Optional[Any]: + """获取缓存值""" + cache_name = self._get_cache_name(cache_type, namespace) + + if cache_name not in self._caches: + self._stats["misses"] += 1 + return None + + cache = self._caches[cache_name] + config = self._configs[cache_name] + + with self._locks[cache_name]: + if key not in cache: + self._stats["misses"] += 1 + return None + + entry = cache[key] + + # 检查过期 + if entry.is_expired(): + del cache[key] + self._stats["misses"] += 1 + return None + + # 更新访问信息 + entry.touch() + + # LRU策略:移动到末尾 + if config.strategy in [CacheStrategy.LRU, CacheStrategy.TTL_LRU]: + del cache[key] + cache[key] = entry + + self._stats["hits"] += 1 + return entry.value + + def delete(self, cache_type: CacheType, key: str, namespace: str = "") -> bool: + """删除缓存条目""" + cache_name = self._get_cache_name(cache_type, namespace) + + if cache_name not in self._caches: + return False + + cache = self._caches[cache_name] + + with self._locks[cache_name]: + if key in cache: + del cache[key] + return True + return False + + def clear(self, cache_type: CacheType, namespace: str = "") -> None: + """清空指定缓存""" + cache_name = self._get_cache_name(cache_type, namespace) + + if cache_name not in self._caches: + return + + with self._locks[cache_name]: + self._caches[cache_name].clear() + + def invalidate_pattern( + self, cache_type: CacheType, pattern: str, namespace: str = "" + ) -> int: + """按模式失效缓存条目""" + cache_name = self._get_cache_name(cache_type, namespace) + + if cache_name not in self._caches: + return 0 + + cache = self._caches[cache_name] + deleted_count = 0 + + with self._locks[cache_name]: + keys_to_delete = [key for key in cache.keys() if pattern in key] + for key in keys_to_delete: + del cache[key] + deleted_count += 1 + + return deleted_count + + def _cleanup_expired(self, cache_name: str) -> int: + """清理过期条目""" + if cache_name not in self._caches: + return 0 + + cache = self._caches[cache_name] + deleted_count = 0 + + with self._locks[cache_name]: + expired_keys = [key for key, entry in cache.items() if entry.is_expired()] + for key in expired_keys: + del cache[key] + deleted_count += 1 + + return deleted_count + + def _maybe_cleanup(self, cache_name: str): + """定期清理检查""" + config = self._configs.get(cache_name) + if not config: + return + + now = time.time() + if now - self._last_cleanup > config.cleanup_interval: + self._last_cleanup = now + deleted = self._cleanup_expired(cache_name) + if deleted > 0: + self._stats["cleanups"] += 1 + self.logger.debug(f"清理缓存 {cache_name}: 删除 {deleted} 个过期条目") + + +# 创建全局缓存管理器实例 +cache_manager = GlobalCacheManager() diff --git a/main/xiaozhi-server/core/utils/cache/strategies.py b/main/xiaozhi-server/core/utils/cache/strategies.py new file mode 100644 index 00000000..13327ca7 --- /dev/null +++ b/main/xiaozhi-server/core/utils/cache/strategies.py @@ -0,0 +1,43 @@ +""" +缓存策略和数据结构定义 +""" + +import time +from enum import Enum +from typing import Any, Optional +from dataclasses import dataclass + + +class CacheStrategy(Enum): + """缓存策略枚举""" + + TTL = "ttl" # 基于时间过期 + LRU = "lru" # 最近最少使用 + FIXED_SIZE = "fixed_size" # 固定大小 + TTL_LRU = "ttl_lru" # TTL + LRU混合策略 + + +@dataclass +class CacheEntry: + """缓存条目数据结构""" + + value: Any + timestamp: float + ttl: Optional[float] = None # 生存时间(秒) + access_count: int = 0 + last_access: float = None + + def __post_init__(self): + if self.last_access is None: + self.last_access = self.timestamp + + def is_expired(self) -> bool: + """检查是否过期""" + if self.ttl is None: + return False + return time.time() - self.timestamp > self.ttl + + def touch(self): + """更新访问时间和计数""" + self.last_access = time.time() + self.access_count += 1 diff --git a/main/xiaozhi-server/core/utils/dialogue.py b/main/xiaozhi-server/core/utils/dialogue.py index 2ee30d4a..15eb06f3 100644 --- a/main/xiaozhi-server/core/utils/dialogue.py +++ b/main/xiaozhi-server/core/utils/dialogue.py @@ -1,4 +1,5 @@ import uuid +import re from typing import List, Dict from datetime import datetime @@ -74,9 +75,12 @@ class Dialogue: ) if system_message: - enhanced_system_prompt = ( - f"{system_message.content}\n\n" - f"以下是用户的历史记忆:\n```\n{memory_str}\n```" + # 使用正则表达式匹配 标签,不管中间有什么内容 + enhanced_system_prompt = re.sub( + r".*?", + f"\n{memory_str}\n", + system_message.content, + flags=re.DOTALL, ) dialogue.append({"role": "system", "content": enhanced_system_prompt}) diff --git a/main/xiaozhi-server/core/utils/prompt_manager.py b/main/xiaozhi-server/core/utils/prompt_manager.py new file mode 100644 index 00000000..dae840bf --- /dev/null +++ b/main/xiaozhi-server/core/utils/prompt_manager.py @@ -0,0 +1,219 @@ +""" +系统提示词管理器模块 +负责管理和更新系统提示词,包括快速初始化和异步增强功能 +""" + +import os +import cnlunar +from typing import Dict, Any +from config.logger import setup_logging + +TAG = __name__ + +WEEKDAY_MAP = { + "Monday": "星期一", + "Tuesday": "星期二", + "Wednesday": "星期三", + "Thursday": "星期四", + "Friday": "星期五", + "Saturday": "星期六", + "Sunday": "星期日", +} + + +class PromptManager: + """系统提示词管理器,负责管理和更新系统提示词""" + + def __init__(self, config: Dict[str, Any], logger=None): + self.config = config + self.logger = logger or setup_logging() + self.base_prompt_template = None + self.last_update_time = 0 + + # 导入全局缓存管理器 + from core.utils.cache.manager import cache_manager, CacheType + + self.cache_manager = cache_manager + self.CacheType = CacheType + + self._load_base_template() + + def _load_base_template(self): + """加载基础提示词模板""" + try: + template_path = "agent-base-prompt.txt" + cache_key = f"prompt_template:{template_path}" + + # 先从缓存获取 + cached_template = self.cache_manager.get(self.CacheType.CONFIG, cache_key) + if cached_template is not None: + self.base_prompt_template = cached_template + self.logger.bind(tag=TAG).debug("从缓存加载基础提示词模板") + return + + # 缓存未命中,从文件读取 + if os.path.exists(template_path): + with open(template_path, "r", encoding="utf-8") as f: + template_content = f.read() + + # 存入缓存(CONFIG类型默认不自动过期,需要手动失效) + self.cache_manager.set( + self.CacheType.CONFIG, cache_key, template_content + ) + self.base_prompt_template = template_content + self.logger.bind(tag=TAG).debug("成功加载基础提示词模板并缓存") + else: + self.logger.bind(tag=TAG).warning("未找到agent-base-prompt.txt文件") + except Exception as e: + self.logger.bind(tag=TAG).error(f"加载提示词模板失败: {e}") + + def get_quick_prompt(self, user_prompt: str, device_id: str = None) -> str: + """快速获取系统提示词(使用用户配置)""" + device_cache_key = f"device_prompt:{device_id}" + cached_device_prompt = self.cache_manager.get( + self.CacheType.DEVICE_PROMPT, device_cache_key + ) + if cached_device_prompt is not None: + self.logger.bind(tag=TAG).debug(f"使用设备 {device_id} 的缓存提示词") + return cached_device_prompt + else: + self.logger.bind(tag=TAG).debug( + f"设备 {device_id} 无缓存提示词,使用传入的提示词" + ) + + # 使用传入的提示词并缓存(如果有设备ID) + if device_id: + device_cache_key = f"device_prompt:{device_id}" + self.cache_manager.set(self.CacheType.CONFIG, device_cache_key, user_prompt) + self.logger.bind(tag=TAG).debug(f"设备 {device_id} 的提示词已缓存") + + self.logger.bind(tag=TAG).info(f"使用快速提示词: {user_prompt[:50]}...") + return user_prompt + + def _get_current_time_info(self) -> tuple: + """获取当前时间信息""" + from datetime import datetime + + now = datetime.now() + current_time = now.strftime("%H:%M") + today_date = now.strftime("%Y-%m-%d") + today_weekday = WEEKDAY_MAP[now.strftime("%A")] + today_lunar = cnlunar.Lunar(now, godType="8char") + lunar_date = "%s年%s%s\n" % ( + today_lunar.lunarYearCn, + today_lunar.lunarMonthCn[:-1], + today_lunar.lunarDayCn, + ) + + return current_time, today_date, today_weekday, lunar_date + + def _get_location_info(self, client_ip: str) -> str: + """获取位置信息""" + try: + # 先从缓存获取 + cached_location = self.cache_manager.get(self.CacheType.LOCATION, client_ip) + if cached_location is not None: + return cached_location + + # 缓存未命中,调用API获取 + from core.utils.util import get_ip_info + + ip_info = get_ip_info(client_ip, self.logger) + city = ip_info.get("city", "未知位置") + location = f"{city}" + + # 存入缓存 + self.cache_manager.set(self.CacheType.LOCATION, client_ip, location) + return location + except Exception as e: + self.logger.bind(tag=TAG).error(f"获取位置信息失败: {e}") + return "未知位置" + + def _get_weather_info(self, conn, location: str) -> str: + """获取天气信息""" + try: + # 先从缓存获取 + cached_weather = self.cache_manager.get(self.CacheType.WEATHER, location) + if cached_weather is not None: + return cached_weather + + # 缓存未命中,调用get_weather函数获取 + from plugins_func.functions.get_weather import get_weather + from plugins_func.register import ActionResponse + + # 调用get_weather函数 + result = get_weather(conn, location=location, lang="zh_CN") + if isinstance(result, ActionResponse): + weather_report = result.result + self.cache_manager.set(self.CacheType.WEATHER, location, weather_report) + return weather_report + return "天气信息获取失败" + + except Exception as e: + self.logger.bind(tag=TAG).error(f"获取天气信息失败: {e}") + return "天气信息获取失败" + + def update_context_info(self, conn, client_ip: str): + """同步更新上下文信息""" + try: + # 获取位置信息(使用全局缓存) + local_address = self._get_location_info(client_ip) + # 获取天气信息(使用全局缓存) + self._get_weather_info(conn, local_address) + self.logger.bind(tag=TAG).info(f"上下文信息更新完成") + + except Exception as e: + self.logger.bind(tag=TAG).error(f"更新上下文信息失败: {e}") + + def build_enhanced_prompt( + self, user_prompt: str, device_id: str, client_ip: str = None + ) -> str: + """构建增强的系统提示词""" + if not self.base_prompt_template: + return user_prompt + + try: + # 获取最新的时间信息(不缓存) + current_time, today_date, today_weekday, lunar_date = ( + self._get_current_time_info() + ) + + # 获取缓存的上下文信息 + local_address = "" + weather_info = "" + + if client_ip: + # 获取位置信息(从全局缓存) + local_address = ( + self.cache_manager.get(self.CacheType.LOCATION, client_ip) or "" + ) + + # 获取天气信息(从全局缓存) + if local_address: + weather_info = ( + self.cache_manager.get(self.CacheType.WEATHER, local_address) + or "" + ) + + # 替换模板变量 + enhanced_prompt = self.base_prompt_template.format( + base_prompt=user_prompt, + current_time=current_time, + today_date=today_date, + today_weekday=today_weekday, + lunar_date=lunar_date, + local_address=local_address, + weather_info=weather_info, + ) + device_cache_key = f"device_prompt:{device_id}" + self.cache_manager.set( + self.CacheType.DEVICE_PROMPT, device_cache_key, enhanced_prompt + ) + self.logger.bind(tag=TAG).info( + f"构建增强提示词成功,长度: {len(enhanced_prompt)}" + ) + return enhanced_prompt + + except Exception as e: + self.logger.bind(tag=TAG).error(f"构建增强提示词失败: {e}") + return user_prompt diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py index 35417abc..bc778558 100644 --- a/main/xiaozhi-server/core/utils/util.py +++ b/main/xiaozhi-server/core/utils/util.py @@ -96,11 +96,23 @@ def is_private_ip(ip_addr): def get_ip_info(ip_addr, logger): try: + # 导入全局缓存管理器 + from core.utils.cache.manager import cache_manager, CacheType + + # 先从缓存获取 + cached_ip_info = cache_manager.get(CacheType.IP_INFO, ip_addr) + if cached_ip_info is not None: + return cached_ip_info + + # 缓存未命中,调用API if is_private_ip(ip_addr): ip_addr = "" url = f"https://whois.pconline.com.cn/ipJson.jsp?json=true&ip={ip_addr}" resp = requests.get(url).json() ip_info = {"city": resp.get("city")} + + # 存入缓存 + cache_manager.set(CacheType.IP_INFO, ip_addr, ip_info) return ip_info except Exception as e: logger.bind(tag=TAG).error(f"Error getting client ip info: {e}") @@ -982,3 +994,28 @@ def sanitize_tool_name(name: str) -> str: """Sanitize tool names for OpenAI compatibility.""" # 支持中文、英文字母、数字、下划线和连字符 return re.sub(r"[^a-zA-Z0-9_\-\u4e00-\u9fff]", "_", name) + + +def validate_mcp_endpoint(mcp_endpoint: str) -> bool: + """ + 校验MCP接入点格式 + + Args: + mcp_endpoint: MCP接入点字符串 + + Returns: + bool: 是否有效 + """ + # 1. 检查是否以ws开头 + if not mcp_endpoint.startswith("ws"): + return False + + # 2. 检查是否包含key、call字样 + if "key" in mcp_endpoint.lower() or "call" in mcp_endpoint.lower(): + return False + + # 3. 检查是否包含/mcp/字样 + if "/mcp/" not in mcp_endpoint: + return False + + return True diff --git a/main/xiaozhi-server/core/websocket_server.py b/main/xiaozhi-server/core/websocket_server.py index 1a1ba4c0..09ba7961 100644 --- a/main/xiaozhi-server/core/websocket_server.py +++ b/main/xiaozhi-server/core/websocket_server.py @@ -57,8 +57,25 @@ class WebSocketServer: self.active_connections.add(handler) try: await handler.handle_connection(websocket) + except Exception as e: + self.logger.bind(tag=TAG).error(f"处理连接时出错: {e}") finally: + # 确保从活动连接集合中移除 self.active_connections.discard(handler) + # 强制关闭连接(如果还没有关闭的话) + try: + # 安全地检查WebSocket状态并关闭 + if hasattr(websocket, "closed") and not websocket.closed: + await websocket.close() + elif hasattr(websocket, "state") and websocket.state.name != "CLOSED": + await websocket.close() + else: + # 如果没有closed属性,直接尝试关闭 + await websocket.close() + except Exception as close_error: + self.logger.bind(tag=TAG).error( + f"服务器端强制关闭连接时出错: {close_error}" + ) async def _http_response(self, websocket, request_headers): # 检查是否为 WebSocket 升级请求 diff --git a/main/xiaozhi-server/plugins_func/functions/get_time.py b/main/xiaozhi-server/plugins_func/functions/get_time.py index 44732bba..766e19fd 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_time.py +++ b/main/xiaozhi-server/plugins_func/functions/get_time.py @@ -2,59 +2,27 @@ from datetime import datetime import cnlunar from plugins_func.register import register_function, ToolType, ActionResponse, Action -# 添加星期映射字典 -WEEKDAY_MAP = { - "Monday": "星期一", - "Tuesday": "星期二", - "Wednesday": "星期三", - "Thursday": "星期四", - "Friday": "星期五", - "Saturday": "星期六", - "Sunday": "星期日", -} - -get_time_function_desc = { - "type": "function", - "function": { - "name": "get_time", - "description": "获取今天日期或者当前时间信息", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, -} - - -@register_function("get_time", get_time_function_desc, ToolType.WAIT) -def get_time(): - """ - 获取当前的日期时间信息 - """ - now = datetime.now() - current_time = now.strftime("%H:%M:%S") - current_date = now.strftime("%Y-%m-%d") - current_weekday = WEEKDAY_MAP[now.strftime("%A")] - response_text = ( - f"当前日期: {current_date},当前时间: {current_time}, {current_weekday}" - ) - - return ActionResponse(Action.REQLLM, response_text, None) - - get_lunar_function_desc = { "type": "function", "function": { "name": "get_lunar", "description": ( - "用于获取今天的阴历/农历和黄历信息。" + "用于具体日期的阴历/农历和黄历信息。" "用户可以指定查询内容,如:阴历日期、天干地支、节气、生肖、星座、八字、宜忌等。" "如果没有指定查询内容,则默认查询干支年和农历日期。" + "对于'今天农历是多少'、'今天农历日期'这样的基本查询,请直接使用context中的信息,不要调用此工具。" ), "parameters": { "type": "object", "properties": { + "date": { + "type": "string", + "description": "要查询的日期,格式为YYYY-MM-DD,例如2024-01-01。如果不提供,则使用当前日期", + }, "query": { "type": "string", "description": "要查询的内容,例如阴历日期、天干地支、节日、节气、生肖、星座、八字、宜忌等", - } + }, }, "required": [], }, @@ -63,23 +31,41 @@ get_lunar_function_desc = { @register_function("get_lunar", get_lunar_function_desc, ToolType.WAIT) -def get_lunar(query=None): +def get_lunar(date=None, query=None): """ 用于获取当前的阴历/农历,和天干地支、节气、生肖、星座、八字、宜忌等黄历信息 """ - now = datetime.now() - current_time = now.strftime("%H:%M:%S") + from core.utils.cache.manager import cache_manager, CacheType + + # 如果提供了日期参数,则使用指定日期;否则使用当前日期 + if date: + try: + now = datetime.strptime(date, "%Y-%m-%d") + except ValueError: + return ActionResponse( + Action.REQLLM, + f"日期格式错误,请使用YYYY-MM-DD格式,例如:2024-01-01", + None, + ) + else: + now = datetime.now() + current_date = now.strftime("%Y-%m-%d") - current_weekday = WEEKDAY_MAP[now.strftime("%A")] # 如果 query 为 None,则使用默认文本 if query is None: query = "默认查询干支年和农历日期" + + # 尝试从缓存获取农历信息 + lunar_cache_key = f"lunar_info_{current_date}" + cached_lunar_info = cache_manager.get(CacheType.LUNAR, lunar_cache_key) + if cached_lunar_info: + return ActionResponse(Action.REQLLM, cached_lunar_info, None) + response_text = f"根据以下信息回应用户的查询请求,并提供与{query}相关的信息:\n" lunar = cnlunar.Lunar(now, godType="8char") response_text += ( - f"当前公历日期: {current_date},当前时间: {current_time},{current_weekday}\n" "农历信息:\n" "%s年%s%s\n" % (lunar.lunarYearCn, lunar.lunarMonthCn[:-1], lunar.lunarDayCn) + "干支: %s年 %s月 %s日\n" % (lunar.year8Char, lunar.month8Char, lunar.day8Char) @@ -135,4 +121,7 @@ def get_lunar(query=None): + "(默认返回干支年和农历日期;仅在要求查询宜忌信息时才返回本日宜忌)" ) + # 缓存农历信息 + cache_manager.set(CacheType.LUNAR, lunar_cache_key, response_text) + return ActionResponse(Action.REQLLM, response_text, None) diff --git a/main/xiaozhi-server/plugins_func/functions/get_weather.py b/main/xiaozhi-server/plugins_func/functions/get_weather.py index 75c15c7b..a3af6c03 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_weather.py +++ b/main/xiaozhi-server/plugins_func/functions/get_weather.py @@ -151,20 +151,44 @@ def parse_weather_info(soup): @register_function("get_weather", GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL) def get_weather(conn, location: str = None, lang: str = "zh_CN"): - api_host = conn.config["plugins"]["get_weather"].get("api_host", "mj7p3y7naa.re.qweatherapi.com") - api_key = conn.config["plugins"]["get_weather"].get("api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da") + from core.utils.cache.manager import cache_manager, CacheType + + api_host = conn.config["plugins"]["get_weather"].get( + "api_host", "mj7p3y7naa.re.qweatherapi.com" + ) + api_key = conn.config["plugins"]["get_weather"].get( + "api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da" + ) default_location = conn.config["plugins"]["get_weather"]["default_location"] client_ip = conn.client_ip + # 优先使用用户提供的location参数 if not location: # 通过客户端IP解析城市 if client_ip: - # 动态解析IP对应的城市信息 - ip_info = get_ip_info(client_ip, logger) - location = ip_info.get("city") if ip_info and "city" in ip_info else None + # 先从缓存获取IP对应的城市信息 + cached_ip_info = cache_manager.get(CacheType.IP_INFO, client_ip) + if cached_ip_info: + location = cached_ip_info.get("city") + else: + # 缓存未命中,调用API获取 + ip_info = get_ip_info(client_ip, logger) + if ip_info: + cache_manager.set(CacheType.IP_INFO, client_ip, ip_info) + location = ip_info.get("city") + + if not location: + location = default_location else: - # 若IP解析失败或无IP,使用默认位置 + # 若无IP,使用默认位置 location = default_location + # 尝试从缓存获取完整天气报告 + weather_cache_key = f"full_weather_{location}_{lang}" + cached_weather_report = cache_manager.get(CacheType.WEATHER, weather_cache_key) + if cached_weather_report: + return ActionResponse(Action.REQLLM, cached_weather_report, None) + + # 缓存未命中,获取实时天气数据 city_info = fetch_city_info(location, api_key, api_host) if not city_info: return ActionResponse( @@ -192,4 +216,7 @@ def get_weather(conn, location: str = None, lang: str = "zh_CN"): # 提示语 weather_report += "\n(如需某一天的具体天气,请告诉我日期)" + # 缓存完整的天气报告 + cache_manager.set(CacheType.WEATHER, weather_cache_key, weather_report) + return ActionResponse(Action.REQLLM, weather_report, None) diff --git a/main/xiaozhi-server/requirements.txt b/main/xiaozhi-server/requirements.txt index a60ad689..4ec8b625 100755 --- a/main/xiaozhi-server/requirements.txt +++ b/main/xiaozhi-server/requirements.txt @@ -21,7 +21,7 @@ cozepy==0.12.0 mem0ai==0.1.62 bs4==0.0.2 modelscope==1.23.2 -sherpa_onnx==1.12.0 +sherpa_onnx==1.12.4 mcp==1.8.1 cnlunar==0.2.0 PySocks==1.7.1