Compare commits

..
55 Commits
Author SHA1 Message Date
hrzandGitHub d892212583 Update version (#1765)
* update:发布0.6.3版本

* update:升级sherpa_onnx版本
2025-07-05 23:58:47 +08:00
hrzandGitHub 089acd357d update:发布0.6.3版本 (#1764) 2025-07-05 23:50:26 +08:00
hrzandGitHub d14f6937fa Merge pull request #1753 from myifeng/main
客户端连接成功时不主动发送hello
2025-07-05 20:05:51 +08:00
hrzandGitHub d74813ec80 Update connection.py 2025-07-05 20:05:36 +08:00
hrzandGitHub 1cfba1aa52 update: 单模块部署也能使用mcp接入点 (#1763)
* 优化:OTA接口需要适应最小示例参数 #1757

* update:修复智能体选择的模型不存在导致的500错误 #1752

* update: 单模块部署也能使用mcp接入点
2025-07-05 19:46:04 +08:00
hrzandGitHub 37ecd3da1e 增加docker部署时url地址的说明 (#1754)
* update:优化mcp工具较多时的现实

* update:修改版本号

* update:docker部署manager-api,填写备注
2025-07-04 17:02:19 +08:00
myifeng 22e9e4c352 客户端连接成功时不主动发送hello 2025-07-04 15:27:38 +08:00
hrzandGitHub 04b132816c update:修改版本号
* update:优化mcp工具较多时的现实

* update:修改版本号
2025-07-01 18:16:21 +08:00
hrzandGitHub f08ed1bbb7 Merge pull request #1729 from xinnan-tech/fix-vllm
update:优化mcp工具较多时的显示
2025-07-01 17:31:58 +08:00
欣南科技andGitHub d59a769084 Merge pull request #1728 from xinnan-tech/redis-password
update:允许在docker-compose文件里设置redis密码
2025-07-01 16:35:20 +08:00
hrz 7a8fe1074c update:允许在docker-compose文件里设置redis密码 2025-07-01 16:34:37 +08:00
欣南科技andGitHub 3fc256ed9a Merge pull request #1727 from xinnan-tech/mcp-update
update:支持中文名称的mcp工具
2025-07-01 16:16:03 +08:00
hrz 18eed4417c update:支持中文名称的mcp工具 2025-07-01 16:07:35 +08:00
欣南科技andGitHub 20adbadc02 Merge pull request #1718 from xinnan-tech/newsnow
可配置新闻来源
2025-06-30 17:13:13 +08:00
hrz c68384be69 update:区分本地化部署和非本地化部署tts的配置字段 2025-06-30 16:56:56 +08:00
hrz 7619979046 update:简化新闻源配置 2025-06-30 14:24:46 +08:00
hrzandGitHub 54ccae8665 Merge pull request #1518 from shane04111/add_local_tts_change_voice
update: 添加本地tts支援面板的"角色音色"
2025-06-30 10:48:15 +08:00
hrzandGitHub 3015b01740 Merge branch 'newsnow' into add_local_tts_change_voice 2025-06-30 10:48:06 +08:00
hrzandGitHub 43d47a4f09 Merge pull request #1552 from liammazy/main
增加timeout配置项,单位为秒
2025-06-30 10:46:50 +08:00
hrzandGitHub 96abc34a92 Merge branch 'newsnow' into main 2025-06-30 10:44:39 +08:00
hrzandGitHub 26553b8875 Update openai.py 2025-06-30 10:41:15 +08:00
hrzandGitHub 5e3187e36b Merge pull request #1673 from goxofy/main
update: newsnow 插件支持前端配置新闻源  && fix issues/1658
2025-06-30 10:10:28 +08:00
hrzandGitHub eba8c3122f Merge branch 'newsnow' into main 2025-06-30 10:10:19 +08:00
hrzandGitHub f1a3f39782 Merge pull request #1712 from ifhaveif/main
🐞 fix: 只清空当前数据库,不清空整个数据库
2025-06-30 10:04:01 +08:00
xiongyonghui 0880f78002 🐞 fix: 只清空当前数据库,不清空整个数据库
-- emptyAll.lua
1、当redis被多个项目使用时,清空了其它项目的库(影响其它项目)
2025-06-30 00:17:39 +08:00
欣南科技andGitHub 1c16d497f9 Merge pull request #1706 from xinnan-tech/update-doc
update:增加MCP接入点的视频demo
2025-06-28 20:36:44 +08:00
hrz faaae95197 update:增加MCP接入点的视频demo 2025-06-28 20:36:05 +08:00
TinKandGitHub eeb5ed920a Merge pull request #10 from goxofy/manual-add-device
fix
2025-06-26 16:29:29 +08:00
Tink 3af453c2df fix 2025-06-26 16:26:45 +08:00
TinKandGitHub 90aad4f131 Merge pull request #9 from goxofy/manual-add-device
fix
2025-06-26 16:24:55 +08:00
Tink f69b1b34f1 fix 2025-06-26 16:22:21 +08:00
TinKandGitHub ece2c4b49f Merge pull request #8 from goxofy/manual-add-device
fix
2025-06-26 16:07:57 +08:00
Tink 033c8f9bee fix 2025-06-26 16:06:36 +08:00
TinKandGitHub 1b9ca95a0e Merge pull request #7 from goxofy/manual-add-device
UpdateWrapper
2025-06-26 16:00:31 +08:00
Tink 7e86a9f8a9 UpdateWrapper 2025-06-26 15:54:26 +08:00
TinKandGitHub 9dee744498 Merge pull request #6 from goxofy/manual-add-device
fix last_connected_at
2025-06-26 15:35:51 +08:00
Tink 32fa71e8f1 fix last_connected_at 2025-06-26 15:24:57 +08:00
TinKandGitHub 717386f10a Merge pull request #5 from goxofy/manual-add-device
fix id in ai_device
2025-06-26 14:14:07 +08:00
Tink fe071fe41a fix id in ai_device 2025-06-26 14:13:29 +08:00
TinKandGitHub 560a7d083b Merge pull request #4 from goxofy/manual-add-device
manualAddDevice
2025-06-26 13:41:46 +08:00
Tink 4428c1a298 manualAddDevice 2025-06-26 13:40:01 +08:00
TinKandGitHub 760fd2fdc5 Merge pull request #3 from goxofy/manual-add-device
手动添加设备增加后端接口
2025-06-26 13:37:06 +08:00
Tink 84c11a281a 手动添加设备增加后端接口 2025-06-26 13:36:11 +08:00
TinKandGitHub ad682ca0c4 Merge pull request #2 from goxofy/manual-add-device
fix RequestService
2025-06-26 12:27:46 +08:00
Tink 2a349ca9ef fix RequestService 2025-06-26 12:25:19 +08:00
TinKandGitHub 1f3c90e9e5 Merge pull request #1 from goxofy/manual-add-device
manual-add-device
2025-06-26 11:16:23 +08:00
Tink 6ec7a1fe09 manual-add-device 2025-06-26 11:09:17 +08:00
Tink 78dc266eee change newsnow plugin doc 2025-06-25 16:49:54 +08:00
Tink 8828f8401d newsnow 插件支持前端配置新闻源 2025-06-25 16:32:49 +08:00
shane0411 0ca1a6286c Merge remote-tracking branch 'upstream/main' into add_local_tts_change_voice 2025-06-12 12:36:13 +08:00
shane0411 a4aea694d4 Merge remote-tracking branch 'upstream/main' into add_local_tts_change_voice 2025-06-11 14:29:35 +08:00
Liam Mazy 0f31bfc78f 增加timeout配置项,单位为秒 2025-06-11 14:05:54 +08:00
shane0411 052d8c8962 feat: 添加音频路径音频文本的提示 2025-06-09 19:36:57 +08:00
shane0411 2eb5539c9b feat: 新增音频路径音频文本欄位,提供給本地tts選取音色
https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/1503#issuecomment-2954952028
2025-06-09 19:18:58 +08:00
shane0411 be6fba5963 feat: 添加本地tts支援面板的"角色音色" 2025-06-09 15:20:27 +08:00
46 changed files with 1083 additions and 292 deletions
+8 -3
View File
@@ -144,6 +144,11 @@
</a> </a>
</td> </td>
<td> <td>
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
<picture>
<img alt="MCP接入点" src="docs/images/demo13.png" />
</picture>
</a>
</td> </td>
<td> <td>
</td> </td>
@@ -170,8 +175,8 @@
#### 🚀 部署方式选择 #### 🚀 部署方式选择
| 部署方式 | 特点 | 适用场景 | 部署文档 | 配置要求 | 视频教程 | | 部署方式 | 特点 | 适用场景 | 部署文档 | 配置要求 | 视频教程 |
|---------|------|---------|---------|---------|---------| |---------|------|---------|---------|---------|---------|
| **最简化安装** | 智能对话、IOT、MCP、视觉感知,数据存储在配置文件 | 低配置环境,无需数据库 | [①Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②源码部署](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - | | **最简化安装** | 智能对话、IOT、MCP、视觉感知 | 低配置环境,数据存储在配置文件,无需数据库 | [①Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②源码部署](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - |
| **全模块安装** | 智能对话、IOT、MCP、视觉感知、OTA、智控台,数据存储在数据库 | 完整功能体验 |[①Docker版](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②源码部署](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) | | **全模块安装** | 智能对话、IOT、MCP接入点、视觉感知、OTA、智控台 | 完整功能体验,数据存储在数据库 |[①Docker版](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②源码部署](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据 > 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据
@@ -226,7 +231,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
| 视觉感知系统 | 支持多种VLLM(视觉大模型),实现多模态交互 | | 视觉感知系统 | 支持多种VLLM(视觉大模型),实现多模态交互 |
| 意图识别系统 | 支持LLM意图识别、Function Call函数调用,提供插件化意图处理机制 | | 意图识别系统 | 支持LLM意图识别、Function Call函数调用,提供插件化意图处理机制 |
| 记忆系统 | 支持本地短期记忆、mem0ai接口记忆,具备记忆总结功能 | | 记忆系统 | 支持本地短期记忆、mem0ai接口记忆,具备记忆总结功能 |
| IOT/MCP控制协议 | 支持设备注册管理、智能控制接口,同时支持IOT、MCP控制协议 | | 工具调用 | 支持客户端IOT协议、客户MCP协议、服务端MCP协议、MCP接入点协议、自定义工具函数 |
| 管理后台 | 提供Web管理界面,支持用户管理、系统配置和设备管理 | | 管理后台 | 提供Web管理界面,支持用户管理、系统配置和设备管理 |
| 测试工具 | 提供性能测试工具、视觉模型测试工具和音频交互测试工具 | | 测试工具 | 提供性能测试工具、视觉模型测试工具和音频交互测试工具 |
| 部署支持 | 支持Docker部署和本地部署,提供完整的配置文件管理 | | 部署支持 | 支持Docker部署和本地部署,提供完整的配置文件管理 |
+3 -3
View File
@@ -42,9 +42,9 @@ http {
proxy_set_header Referer $http_referer; proxy_set_header Referer $http_referer;
proxy_set_header Cookie $http_cookie; proxy_set_header Cookie $http_cookie;
proxy_connect_timeout 10; proxy_connect_timeout 15;
proxy_send_timeout 10; proxy_send_timeout 15;
proxy_read_timeout 10; proxy_read_timeout 15;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+1
View File
@@ -6,6 +6,7 @@ java -jar /app/xiaozhi-esp32-api.jar \
--spring.datasource.druid.username=${SPRING_DATASOURCE_DRUID_USERNAME} \ --spring.datasource.druid.username=${SPRING_DATASOURCE_DRUID_USERNAME} \
--spring.datasource.druid.password=${SPRING_DATASOURCE_DRUID_PASSWORD} \ --spring.datasource.druid.password=${SPRING_DATASOURCE_DRUID_PASSWORD} \
--spring.data.redis.host=${SPRING_DATA_REDIS_HOST} \ --spring.data.redis.host=${SPRING_DATA_REDIS_HOST} \
--spring.data.redis.password=${SPRING_DATA_REDIS_PASSWORD} \
--spring.data.redis.port=${SPRING_DATA_REDIS_PORT} & --spring.data.redis.port=${SPRING_DATA_REDIS_PORT} &
# 启动Nginx(前台运行保持容器存活) # 启动Nginx(前台运行保持容器存活)
Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

+125
View File
@@ -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`说明配置成功了。
+42 -82
View File
@@ -1,92 +1,36 @@
# MCP 接入点部署使用指南 # MCP 接入点使用指南
本教程包含2个部分 本教程以虾哥开源的mcp计算器功能为示例,介绍如何将自己自定义的mcp服务接入到自己的接入点里。
- 1、如何开启mcp接入点
- 2、如何为智能体接入一个简单的mcp功能,如计算器功能
部署的前提条件: 本教程的前提是,你的`xiaozhi-server`已经启用了mcp接入点功能,如果你还没启用,可以先根据[这个教程](./mcp-endpoint-enable.md)启用。
- 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。
# 如何为智能体接入一个简单的mcp功能,如计算器功能 # 如何为智能体接入一个简单的mcp功能,如计算器功能
如果以上步骤顺利,你可以进入智能体管理,点击`配置角色`,在`意图识别`的右边,有一个`编辑功能`的按钮。 ### 如果你是全模块部署
如果你是全模块部署,你可以进入智控台,智能体管理,点击`配置角色`,在`意图识别`的右边,有一个`编辑功能`的按钮。
点击这个按钮。在弹出的页面里,位于底部,会有`MCP接入点`,正常来说,会显示这个智能体的`MCP接入点地址`,接下来,我们来给这个智能体扩展一个基于MCP技术的计算器的功能。 点击这个按钮。在弹出的页面里,位于底部,会有`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计算器项目代码 ## 第一步 下载虾哥MCP计算器项目代码
浏览器打开虾哥写的[计算器项目](https://github.com/78/mcp-calculator) 浏览器打开虾哥写的[计算器项目](https://github.com/78/mcp-calculator)
@@ -114,13 +58,13 @@ pip install -r requirements.txt
例如我的智能体的mcp地址是 例如我的智能体的mcp地址是
``` ```
ws://192.168.4.7:8004/mcp_endpoint/mcp/?token=abc ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc
``` ```
开始输入命令 开始输入命令
```bash ```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 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'`,说明设备将可以根据意图识别,调用计算器这个工具。
+105
View File
@@ -0,0 +1,105 @@
# get_news_from_newsnow 插件新闻源配置指南
## 概述
`get_news_from_newsnow` 插件现在支持通过Web管理界面动态配置新闻源,不再需要修改代码。用户可以在智控台中为每个智能体配置不同的新闻源。
## 配置方式
### 1. 通过Web管理界面配置(推荐)
1. 登录智控台
2. 进入"角色配置"页面
3. 选择要配置的智能体
4. 点击"编辑功能"按钮
5. 在右侧参数配置区域找到"newsnow新闻聚合"插件
6. 在"新闻源配置"字段中输入分号分隔的中文名称
### 2. 配置文件方式
`config.yaml` 中配置:
```yaml
plugins:
get_news_from_newsnow:
url: "https://newsnow.busiyi.world/api/s?id="
news_sources: "澎湃新闻;百度热搜;财联社;微博;抖音"
```
## 新闻源配置格式
新闻源配置使用分号分隔的中文名称,格式为:
```
中文名称1;中文名称2;中文名称3
```
### 配置示例
```
澎湃新闻;百度热搜;财联社;微博;抖音;知乎;36氪
```
## 支持的新闻源
插件支持以下新闻源的中文名称:
- 澎湃新闻
- 百度热搜
- 财联社
- 微博
- 抖音
- 知乎
- 36氪
- 华尔街见闻
- IT之家
- 今日头条
- 虎扑
- 哔哩哔哩
- 快手
- 雪球
- 格隆汇
- 法布财经
- 金十数据
- 牛客
- 少数派
- 稀土掘金
- 凤凰网
- 虫部落
- 联合早报
- 酷安
- 远景论坛
- 参考消息
- 卫星通讯社
- 百度贴吧
- 靠谱新闻
- 以及更多...
## 默认配置
如果未配置新闻源,插件将使用以下默认配置:
```
澎湃新闻;百度热搜;财联社
```
## 使用说明
1. **配置新闻源**:在Web界面或配置文件中设置新闻源的中文名称,用分号分隔
2. **调用插件**:用户可以说"播报新闻"或"获取新闻"
3. **指定新闻源**:用户可以说"播报澎湃新闻"或"获取百度热搜"
4. **获取详情**:用户可以说"详细介绍这条新闻"
## 工作原理
1. 插件接受中文名称作为参数(如"澎湃新闻")
2. 根据配置的新闻源列表,将中文名称转换为对应的英文ID(如"thepaper"
3. 使用英文ID调用API获取新闻数据
4. 返回新闻内容给用户
## 注意事项
1. 配置的中文名称必须与 CHANNEL_MAP 中定义的名称完全一致
2. 配置更改后需要重启服务或重新加载配置
3. 如果配置的新闻源无效,插件会自动使用默认新闻源
4. 多个新闻源之间使用英文分号(;)分隔,不要使用中文分号(;)
@@ -232,7 +232,7 @@ public interface Constant {
/** /**
* 版本号 * 版本号
*/ */
public static final String VERSION = "0.6.1"; public static final String VERSION = "0.6.3";
/** /**
* 无效固件URL * 无效固件URL
@@ -19,6 +19,8 @@ import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService; import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService; import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
/** /**
* {@link AgentChatHistoryBizService} impl * {@link AgentChatHistoryBizService} impl
@@ -35,6 +37,7 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
private final AgentChatHistoryService agentChatHistoryService; private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService; private final AgentChatAudioService agentChatAudioService;
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
private final DeviceService deviceService;
/** /**
* 处理聊天记录上报,包括文件上传和相关信息记录 * 处理聊天记录上报,包括文件上传和相关信息记录
@@ -68,6 +71,15 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
// 更新设备最后对话时间 // 更新设备最后对话时间
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date()); redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
// 更新设备最后连接时间
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device != null) {
deviceService.updateDeviceConnectionInfo(agentId, device.getId(), null);
} else {
log.warn("聊天记录上报时,未找到mac地址为 {} 的设备", macAddress);
}
return Boolean.TRUE; return Boolean.TRUE;
} }
@@ -5,6 +5,7 @@ import java.net.URISyntaxException;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -18,7 +19,6 @@ import xiaozhi.common.utils.AESUtils;
import xiaozhi.common.utils.HashEncryptionUtil; import xiaozhi.common.utils.HashEncryptionUtil;
import xiaozhi.common.utils.JsonUtils; import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.dto.McpJsonRpcRequest; import xiaozhi.modules.agent.dto.McpJsonRpcRequest;
import xiaozhi.modules.agent.dto.McpJsonRpcResponse;
import xiaozhi.modules.agent.service.AgentMcpAccessPointService; import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.utils.WebSocketClientManager; import xiaozhi.modules.sys.utils.WebSocketClientManager;
@@ -66,24 +66,42 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
new WebSocketClientManager.Builder() new WebSocketClientManager.Builder()
.uri(wsUrl) .uri(wsUrl)
.connectTimeout(5, TimeUnit.SECONDS) .connectTimeout(5, TimeUnit.SECONDS)
.maxSessionDuration(20, TimeUnit.SECONDS))) { .maxSessionDuration(9, TimeUnit.SECONDS))) {
// 发送初始化通知 // 发送初始化消息
McpJsonRpcRequest initRequest = new McpJsonRpcRequest("notifications/initialized"); McpJsonRpcRequest initializeRequest = new McpJsonRpcRequest("initialize",
client.sendJson(initRequest); Map.of(
"protocolVersion", "2024-11-05",
"capabilities", Map.of(
"roots", Map.of("listChanged", false),
"sampling", Map.of()),
"clientInfo", Map.of(
"name", "xz-mcp-broker",
"version", "0.0.1")),
1);
client.sendJson(initializeRequest);
// 等待初始化响应
Thread.sleep(200);
// 发送初始化完成通知
// 对于通知类型的消息,手动构建JSON以避免包含null字段
String notificationJson = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}";
client.sendText(notificationJson);
// 等待 0.2 秒 // 等待 0.2 秒
Thread.sleep(200); Thread.sleep(200);
// 发送工具列表请求 // 发送工具列表请求
McpJsonRpcRequest toolsRequest = new McpJsonRpcRequest("tools/list", null, 1); McpJsonRpcRequest toolsRequest = new McpJsonRpcRequest("tools/list", null, 2);
client.sendJson(toolsRequest); client.sendJson(toolsRequest);
// 监听响应,直到收到包含 id=1 的响应 // 监听响应,直到收到包含 id=2 的响应tools/list响应)
List<String> responses = client.listener(response -> { List<String> responses = client.listener(response -> {
try { try {
McpJsonRpcResponse jsonResponse = JsonUtils.parseObject(response, McpJsonRpcResponse.class); // 先尝试解析为通用JSON对象来获取id
return jsonResponse != null && Integer.valueOf(1).equals(jsonResponse.getId()); Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"));
} catch (Exception e) { } catch (Exception e) {
log.warn("解析响应失败: {}", response, e); log.warn("解析响应失败: {}", response, e);
return false; return false;
@@ -93,14 +111,23 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 处理响应 // 处理响应
for (String response : responses) { for (String response : responses) {
try { try {
McpJsonRpcResponse jsonResponse = JsonUtils.parseObject(response, McpJsonRpcResponse.class); // 先解析为通用JSON对象
if (jsonResponse != null && Integer.valueOf(1).equals(jsonResponse.getId()) Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
&& jsonResponse.getResult() != null && jsonResponse.getResult().getTools() != null) { if (jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"))) {
// 检查是否有result字段
// 提取工具名称列表 Object resultObj = jsonMap.get("result");
return java.util.Arrays.stream(jsonResponse.getResult().getTools()) if (resultObj instanceof Map) {
.map(McpJsonRpcResponse.McpTool::getName) Map<String, Object> resultMap = (Map<String, Object>) resultObj;
.collect(Collectors.toList()); Object toolsObj = resultMap.get("tools");
if (toolsObj instanceof List) {
List<Map<String, Object>> toolsList = (List<Map<String, Object>>) toolsObj;
// 提取工具名称列表
return toolsList.stream()
.map(tool -> (String) tool.get("name"))
.filter(name -> name != null)
.collect(Collectors.toList());
}
}
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("处理工具列表响应失败: {}", response, e); log.warn("处理工具列表响应失败: {}", response, e);
@@ -72,6 +72,8 @@ public class ConfigServiceImpl implements ConfigService {
null, null,
null, null,
null, null,
null,
null,
agent.getVadModelId(), agent.getVadModelId(),
agent.getAsrModelId(), agent.getAsrModelId(),
null, null,
@@ -108,9 +110,13 @@ public class ConfigServiceImpl implements ConfigService {
} }
// 获取音色信息 // 获取音色信息
String voice = null; String voice = null;
String referenceAudio = null;
String referenceText = null;
TimbreDetailsVO timbre = timbreService.get(agent.getTtsVoiceId()); TimbreDetailsVO timbre = timbreService.get(agent.getTtsVoiceId());
if (timbre != null) { if (timbre != null) {
voice = timbre.getTtsVoice(); voice = timbre.getTtsVoice();
referenceAudio = timbre.getReferenceAudio();
referenceText = timbre.getReferenceText();
} }
// 构建返回数据 // 构建返回数据
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
@@ -163,6 +169,8 @@ public class ConfigServiceImpl implements ConfigService {
agent.getSystemPrompt(), agent.getSystemPrompt(),
agent.getSummaryMemory(), agent.getSummaryMemory(),
voice, voice,
referenceAudio,
referenceText,
agent.getVadModelId(), agent.getVadModelId(),
agent.getAsrModelId(), agent.getAsrModelId(),
agent.getLlmModelId(), agent.getLlmModelId(),
@@ -179,7 +187,7 @@ public class ConfigServiceImpl implements ConfigService {
/** /**
* 构建配置信息 * 构建配置信息
* *
* @param paramsList 系统参数列表 * @param config 系统参数列表
* @return 配置信息 * @return 配置信息
*/ */
private Object buildConfig(Map<String, Object> config) { private Object buildConfig(Map<String, Object> config) {
@@ -250,21 +258,25 @@ public class ConfigServiceImpl implements ConfigService {
/** /**
* 构建模块配置 * 构建模块配置
* *
* @param prompt 提示词 * @param prompt 提示词
* @param voice 音色 * @param voice 音色
* @param vadModelId VAD模型ID * @param referenceAudio 参考音频路径
* @param asrModelId ASR模型ID * @param referenceText 参考文本
* @param llmModelId LLM模型ID * @param vadModelId VAD模型ID
* @param ttsModelId TTS模型ID * @param asrModelId ASR模型ID
* @param memModelId 记忆模型ID * @param llmModelId LLM模型ID
* @param intentModelId 意图模型ID * @param ttsModelId TTS模型ID
* @param result 结果Map * @param memModelId 记忆模型ID
* @param intentModelId 意图模型ID
* @param result 结果Map
*/ */
private void buildModuleConfig( private void buildModuleConfig(
String assistantName, String assistantName,
String prompt, String prompt,
String summaryMemory, String summaryMemory,
String voice, String voice,
String referenceAudio,
String referenceText,
String vadModelId, String vadModelId,
String asrModelId, String asrModelId,
String llmModelId, String llmModelId,
@@ -286,12 +298,20 @@ public class ConfigServiceImpl implements ConfigService {
continue; continue;
} }
ModelConfigEntity model = modelConfigService.getModelById(modelIds[i], isCache); ModelConfigEntity model = modelConfigService.getModelById(modelIds[i], isCache);
if (model == null) {
continue;
}
Map<String, Object> typeConfig = new HashMap<>(); Map<String, Object> typeConfig = new HashMap<>();
if (model.getConfigJson() != null) { if (model.getConfigJson() != null) {
typeConfig.put(model.getId(), model.getConfigJson()); typeConfig.put(model.getId(), model.getConfigJson());
// 如果是TTS类型,添加private_voice属性 // 如果是TTS类型,添加private_voice属性
if ("TTS".equals(modelTypes[i]) && voice != null) { if ("TTS".equals(modelTypes[i])) {
((Map<String, Object>) model.getConfigJson()).put("private_voice", voice); if (voice != null)
((Map<String, Object>) model.getConfigJson()).put("private_voice", voice);
if (referenceAudio != null)
((Map<String, Object>) model.getConfigJson()).put("ref_audio", referenceAudio);
if (referenceText != null)
((Map<String, Object>) model.getConfigJson()).put("ref_text", referenceText);
} }
// 如果是Intent类型,且type=intent_llm,则给他添加附加模型 // 如果是Intent类型,且type=intent_llm,则给他添加附加模型
if ("Intent".equals(modelTypes[i])) { if ("Intent".equals(modelTypes[i])) {
@@ -25,6 +25,7 @@ import xiaozhi.common.utils.Result;
import xiaozhi.modules.device.dto.DeviceRegisterDTO; import xiaozhi.modules.device.dto.DeviceRegisterDTO;
import xiaozhi.modules.device.dto.DeviceUnBindDTO; import xiaozhi.modules.device.dto.DeviceUnBindDTO;
import xiaozhi.modules.device.dto.DeviceUpdateDTO; import xiaozhi.modules.device.dto.DeviceUpdateDTO;
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
import xiaozhi.modules.device.entity.DeviceEntity; import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService; import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.security.user.SecurityUser; import xiaozhi.modules.security.user.SecurityUser;
@@ -99,4 +100,13 @@ public class DeviceController {
deviceService.updateById(entity); deviceService.updateById(entity);
return new Result<Void>(); return new Result<Void>();
} }
@PostMapping("/manual-add")
@Operation(summary = "手动添加设备")
@RequiresPermissions("sys:role:normal")
public Result<Void> manualAddDevice(@RequestBody @Valid DeviceManualAddDTO dto) {
UserDetail user = SecurityUser.getUser();
deviceService.manualAddDevice(user.getId(), dto);
return new Result<>();
}
} }
@@ -51,13 +51,12 @@ public class OTAController {
if (StringUtils.isBlank(clientId)) { if (StringUtils.isBlank(clientId)) {
clientId = deviceId; clientId = deviceId;
} }
String macAddress = deviceReportReqDTO.getMacAddress(); boolean macAddressValid = isMacAddressValid(deviceId);
boolean macAddressValid = isMacAddressValid(macAddress);
// 设备Id和Mac地址应是一致的, 并且必须需要application字段 // 设备Id和Mac地址应是一致的, 并且必须需要application字段
if (!deviceId.equals(macAddress) || !macAddressValid || deviceReportReqDTO.getApplication() == null) { if (!macAddressValid) {
return createResponse(DeviceReportRespDTO.createError("Invalid OTA request")); return createResponse(DeviceReportRespDTO.createError("Invalid device ID"));
} }
return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO)); return createResponse(deviceService.checkDeviceActive(deviceId, clientId, deviceReportReqDTO));
} }
@Operation(summary = "设备快速检查激活状态") @Operation(summary = "设备快速检查激活状态")
@@ -0,0 +1,11 @@
package xiaozhi.modules.device.dto;
import lombok.Data;
@Data
public class DeviceManualAddDTO {
private String agentId;
private String board; // 设备型号
private String appVersion; // 固件版本
private String macAddress; // Mac地址
}
@@ -8,6 +8,7 @@ import xiaozhi.common.service.BaseService;
import xiaozhi.modules.device.dto.DevicePageUserDTO; import xiaozhi.modules.device.dto.DevicePageUserDTO;
import xiaozhi.modules.device.dto.DeviceReportReqDTO; import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO; import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
import xiaozhi.modules.device.entity.DeviceEntity; import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.vo.UserShowDeviceListVO; import xiaozhi.modules.device.vo.UserShowDeviceListVO;
@@ -87,5 +88,14 @@ public interface DeviceService extends BaseService<DeviceEntity> {
*/ */
Date getLatestLastConnectionTime(String agentId); Date getLatestLastConnectionTime(String agentId);
/**
* 手动添加设备
*/
void manualAddDevice(Long userId, DeviceManualAddDTO dto);
/**
* 更新设备连接信息
*/
void updateDeviceConnectionInfo(String agentId, String deviceId, String appVersion);
} }
@@ -44,6 +44,7 @@ import xiaozhi.modules.device.vo.UserShowDeviceListVO;
import xiaozhi.modules.security.user.SecurityUser; import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.service.SysUserUtilService; import xiaozhi.modules.sys.service.SysUserUtilService;
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
@Slf4j @Slf4j
@Service @Service
@@ -410,4 +411,30 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
} }
return 0; return 0;
} }
@Override
public void manualAddDevice(Long userId, DeviceManualAddDTO dto) {
// 检查mac是否已存在
QueryWrapper<DeviceEntity> wrapper = new QueryWrapper<>();
wrapper.eq("mac_address", dto.getMacAddress());
DeviceEntity exist = baseDao.selectOne(wrapper);
if (exist != null) {
throw new RenException("该Mac地址已存在");
}
Date now = new Date();
DeviceEntity entity = new DeviceEntity();
entity.setId(dto.getMacAddress());
entity.setUserId(userId);
entity.setAgentId(dto.getAgentId());
entity.setBoard(dto.getBoard());
entity.setAppVersion(dto.getAppVersion());
entity.setMacAddress(dto.getMacAddress());
entity.setCreateDate(now);
entity.setUpdateDate(now);
entity.setLastConnectedAt(now);
entity.setCreator(userId);
entity.setUpdater(userId);
entity.setAutoUpdate(1);
baseDao.insert(entity);
}
} }
@@ -218,7 +218,7 @@ public class SysParamsController {
if (response.getStatusCode() != HttpStatus.OK) { if (response.getStatusCode() != HttpStatus.OK) {
throw new RenException("MCP接口访问失败,状态码:" + response.getStatusCode()); throw new RenException("MCP接口访问失败,状态码:" + response.getStatusCode());
} }
// 检查响应内容是否包含OTA相关信息 // 检查响应内容是否包含mcp相关信息
String body = response.getBody(); String body = response.getBody();
if (body == null || !body.contains("success")) { if (body == null || !body.contains("success")) {
throw new RenException("MCP接口返回内容格式不正确,可能不是一个真实的MCP接口"); throw new RenException("MCP接口返回内容格式不正确,可能不是一个真实的MCP接口");
@@ -26,6 +26,12 @@ public class TimbreDataDTO {
@Schema(description = "备注") @Schema(description = "备注")
private String remark; private String remark;
@Schema(description = "参考音频路径")
private String referenceAudio;
@Schema(description = "參考文本")
private String referenceText;
@Schema(description = "排序") @Schema(description = "排序")
@Min(value = 0, message = "{sort.number}") @Min(value = 0, message = "{sort.number}")
private long sort; private long sort;
@@ -34,6 +34,12 @@ public class TimbreEntity {
@Schema(description = "备注") @Schema(description = "备注")
private String remark; private String remark;
@Schema(description = "参考音频路径")
private String referenceAudio;
@Schema(description = "參考文本")
private String referenceText;
@Schema(description = "排序") @Schema(description = "排序")
private long sort; private long sort;
@@ -25,6 +25,12 @@ public class TimbreDetailsVO implements Serializable {
@Schema(description = "备注") @Schema(description = "备注")
private String remark; private String remark;
@Schema(description = "参考音频路径")
private String referenceAudio;
@Schema(description = "參考文本")
private String referenceText;
@Schema(description = "排序") @Schema(description = "排序")
private long sort; private long sort;
@@ -0,0 +1,3 @@
ALTER TABLE `ai_tts_voice`
ADD COLUMN `reference_audio` VARCHAR(500) DEFAULT NULL COMMENT '参考音频路径' AFTER `remark`,
ADD COLUMN `reference_text` VARCHAR(500) DEFAULT NULL COMMENT '参考文本' AFTER `reference_audio`;
@@ -0,0 +1,18 @@
-- 更新现有的 get_news_from_newsnow 插件配置
UPDATE ai_model_provider
SET fields = JSON_ARRAY(
JSON_OBJECT(
'key', 'url',
'type', 'string',
'label', '接口地址',
'default', 'https://newsnow.busiyi.world/api/s?id='
),
JSON_OBJECT(
'key', 'news_sources',
'type', 'string',
'label', '新闻源配置',
'default', '澎湃新闻;百度热搜;财联社'
)
)
WHERE provider_code = 'get_news_from_newsnow'
AND model_type = 'Plugin';
@@ -205,6 +205,13 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202506080955.sql path: classpath:db/changelog/202506080955.sql
- changeSet:
id: 202506091720
author: shane0411
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202506091720.sql
- changeSet: - changeSet:
id: 202506161101 id: 202506161101
author: hrz author: hrz
@@ -219,6 +226,13 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202506191643.sql path: classpath:db/changelog/202506191643.sql
- changeSet:
id: 202506251620
author: Tink
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202506251620.sql
- changeSet: - changeSet:
id: 202506261637 id: 202506261637
author: hrz author: hrz
@@ -1 +1 @@
redis.call('FLUSHALL') redis.call('FLUSHDB')
+1 -1
View File
@@ -7,6 +7,7 @@ import model from './module/model.js'
import ota from './module/ota.js' import ota from './module/ota.js'
import timbre from "./module/timbre.js" import timbre from "./module/timbre.js"
import user from './module/user.js' import user from './module/user.js'
/** /**
* 接口地址 * 接口地址
* 开发时自动读取使用.env.development文件 * 开发时自动读取使用.env.development文件
@@ -22,7 +23,6 @@ export function getServiceUrl() {
return DEV_API_SERVICE return DEV_API_SERVICE
} }
/** request服务封装 */ /** request服务封装 */
export default { export default {
getServiceUrl, getServiceUrl,
@@ -68,4 +68,21 @@ export default {
}) })
}).send() }).send()
}, },
// 手动添加设备
manualAddDevice(params, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/device/manual-add`)
.method('POST')
.data(params)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail((err) => {
console.error('手动添加设备失败:', err);
RequestService.reAjaxFun(() => {
this.manualAddDevice(params, callback);
});
}).send();
},
} }
@@ -34,6 +34,8 @@ export default {
languages: params.languageType, languages: params.languageType,
name: params.voiceName, name: params.voiceName,
remark: params.remark, remark: params.remark,
referenceAudio: params.referenceAudio,
referenceText: params.referenceText,
sort: params.sort, sort: params.sort,
ttsModelId: params.ttsModelId, ttsModelId: params.ttsModelId,
ttsVoice: params.voiceCode, ttsVoice: params.voiceCode,
@@ -75,6 +77,8 @@ export default {
languages: params.languageType, languages: params.languageType,
name: params.voiceName, name: params.voiceName,
remark: params.remark, remark: params.remark,
referenceAudio: params.referenceAudio,
referenceText: params.referenceText,
ttsModelId: params.ttsModelId, ttsModelId: params.ttsModelId,
ttsVoice: params.voiceCode, ttsVoice: params.voiceCode,
voiceDemo: params.voiceDemo || '' voiceDemo: params.voiceDemo || ''
@@ -110,8 +110,10 @@
<div class="url-header"> <div class="url-header">
<div class="address-desc"> <div class="address-desc">
<span>以下是智能体的MCP接入点地址</span> <span>以下是智能体的MCP接入点地址</span>
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-enable.md"
target="_blank" class="doc-link">如何部署MCP接入点</a> &nbsp;&nbsp;|&nbsp;&nbsp;
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md" <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md"
target="_blank" class="doc-link">查看接入点使用文档</a> target="_blank" class="doc-link">如何接入MCP功能</a> &nbsp;
</div> </div>
</div> </div>
<el-input v-model="mcpUrl" readonly class="url-input"> <el-input v-model="mcpUrl" readonly class="url-input">
@@ -660,6 +662,7 @@ export default {
.mcp-left, .mcp-left,
.mcp-right { .mcp-right {
flex: 1; flex: 1;
padding-bottom: 50px;
} }
.url-header { .url-header {
@@ -0,0 +1,158 @@
<template>
<el-dialog title="手动添加设备" :visible="visible" @close="handleClose" width="30%" center>
<div class="dialog-content">
<el-form :model="deviceForm" :rules="rules" ref="deviceForm" label-width="100px">
<el-form-item label="设备型号" prop="board">
<el-select v-model="deviceForm.board" placeholder="请选择设备型号" style="width: 100%">
<el-option
v-for="item in firmwareTypes"
:key="item.key"
:label="item.name"
:value="item.key">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="固件版本" prop="appVersion">
<el-input v-model="deviceForm.appVersion" placeholder="请输入固件版本"></el-input>
</el-form-item>
<el-form-item label="Mac地址" prop="macAddress">
<el-input v-model="deviceForm.macAddress" placeholder="请输入Mac地址"></el-input>
</el-form-item>
</el-form>
</div>
<div style="display: flex;margin: 15px 15px;gap: 7px;">
<div class="dialog-btn" @click="submitForm">确定</div>
<div class="dialog-btn cancel-btn" @click="cancel">取消</div>
</div>
</el-dialog>
</template>
<script>
import Api from '@/apis/api';
export default {
name: 'ManualAddDeviceDialog',
props: {
visible: { type: Boolean, required: true },
agentId: { type: String, required: true }
},
data() {
// MAC地址验证规则
const validateMac = (rule, value, callback) => {
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/;
if (!value) {
callback(new Error('请输入Mac地址'));
} else if (!macRegex.test(value)) {
callback(new Error('请输入正确的Mac地址格式,例如:00:1A:2B:3C:4D:5E'));
} else {
callback();
}
};
return {
deviceForm: {
board: '',
appVersion: '',
macAddress: ''
},
firmwareTypes: [],
rules: {
board: [
{ required: true, message: '请选择设备型号', trigger: 'change' }
],
appVersion: [
{ required: true, message: '请输入固件版本', trigger: 'blur' }
],
macAddress: [
{ required: true, validator: validateMac, trigger: 'blur' }
]
}
}
},
created() {
this.getFirmwareTypes();
},
methods: {
async getFirmwareTypes() {
try {
const res = await Api.dict.getDictDataByType('FIRMWARE_TYPE');
this.firmwareTypes = res.data;
} catch (error) {
console.error('获取固件类型失败:', error);
this.$message.error(error.message || '获取固件类型失败');
}
},
submitForm() {
this.$refs.deviceForm.validate((valid) => {
if (valid) {
this.addDevice();
}
});
},
addDevice() {
const params = {
agentId: this.agentId,
...this.deviceForm
};
Api.device.manualAddDevice(params, ({ data }) => {
if (data.code === 0) {
this.$message.success('设备添加成功');
this.$emit('refresh');
this.closeDialog();
} else {
this.$message.error(data.msg || '添加失败');
}
});
},
closeDialog() {
this.$emit('update:visible', false);
this.$refs.deviceForm.resetFields();
},
cancel() {
this.closeDialog();
},
handleClose() {
this.closeDialog();
}
}
}
</script>
<style scoped>
.dialog-content {
padding: 0 20px;
}
.dialog-btn {
cursor: pointer;
flex: 1;
border-radius: 23px;
background: #5778ff;
height: 40px;
font-weight: 500;
font-size: 12px;
color: #fff;
line-height: 40px;
text-align: center;
}
.cancel-btn {
background: #e6ebff;
border: 1px solid #adbdff;
color: #5778ff;
}
::v-deep .el-dialog {
border-radius: 15px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
::v-deep .el-dialog__body {
padding: 20px 6px;
}
::v-deep .el-form-item {
margin-bottom: 20px;
}
</style>
+118 -106
View File
@@ -1,79 +1,66 @@
<template> <template>
<el-dialog <el-dialog :visible.sync="localVisible" width="90%" @close="handleClose" :show-close="false" :append-to-body="true"
:visible.sync="localVisible" :close-on-click-modal="true">
width="85%"
@close="handleClose"
:show-close="false"
:append-to-body="true"
:close-on-click-modal="true">
<button class="custom-close-btn" @click="handleClose"> <button class="custom-close-btn" @click="handleClose">
× ×
</button> </button>
<div class="scroll-wrapper"> <div class="scroll-wrapper">
<div <div class="table-container" ref="tableContainer" @scroll="handleScroll">
class="table-container" <el-table v-loading="loading" :data="filteredTtsModels" style="width: 100%;" class="data-table"
ref="tableContainer" header-row-class-name="table-header" :fit="true" element-loading-text="拼命加载中"
@scroll="handleScroll"> element-loading-spinner="el-icon-loading" element-loading-background="rgba(0, 0, 0, 0.8)">
<el-table
v-loading="loading"
:data="filteredTtsModels"
style="width: 100%;"
class="data-table"
header-row-class-name="table-header"
:fit="true"
element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(0, 0, 0, 0.8)">
<el-table-column label="选择" width="50" align="center"> <el-table-column label="选择" width="50" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox> <el-checkbox v-model="scope.row.selected"></el-checkbox>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="音色编码" align="center" min-width="50"> <el-table-column label="音色编码" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-input v-if="scope.row.editing" v-model="scope.row.voiceCode"></el-input> <el-input v-if="scope.row.editing" v-model="scope.row.voiceCode"></el-input>
<span v-else>{{ scope.row.voiceCode }}</span> <span v-else>{{ scope.row.voiceCode }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="音色名称" align="center" min-width="50"> <el-table-column label="音色名称" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-input v-if="scope.row.editing" v-model="scope.row.voiceName"></el-input> <el-input v-if="scope.row.editing" v-model="scope.row.voiceName"></el-input>
<span v-else>{{ scope.row.voiceName }}</span> <span v-else>{{ scope.row.voiceName }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="语言类型" align="center" min-width="50"> <el-table-column label="语言类型" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-input v-if="scope.row.editing" v-model="scope.row.languageType"></el-input> <el-input v-if="scope.row.editing" v-model="scope.row.languageType"></el-input>
<span v-else>{{ scope.row.languageType }}</span> <span v-else>{{ scope.row.languageType }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="试听" align="center" min-width="100px" class-name="audio-column"> <el-table-column v-if="!showReferenceColumns" label="试听" align="center" class-name="audio-column">
<template slot-scope="scope"> <template slot-scope="scope">
<div class="custom-audio-container"> <div class="custom-audio-container">
<el-input <el-input v-if="scope.row.editing" v-model="scope.row.voiceDemo" placeholder="请输入MP3地址"
v-if="scope.row.editing" class="audio-input">
v-model="scope.row.voiceDemo"
placeholder="请输入MP3地址"
size="mini"
class="audio-input">
</el-input> </el-input>
<AudioPlayer v-else-if="isValidAudioUrl(scope.row.voiceDemo)" :audioUrl="scope.row.voiceDemo"/> <AudioPlayer v-else-if="isValidAudioUrl(scope.row.voiceDemo)" :audioUrl="scope.row.voiceDemo" />
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="备注" align="center"> <el-table-column v-if="!showReferenceColumns" label="备注" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-input <el-input v-if="scope.row.editing" type="textarea" :rows="1" autosize v-model="scope.row.remark"
v-if="scope.row.editing" placeholder="这里是备注" class="remark-input"></el-input>
type="textarea"
:rows="1"
autosize
v-model="scope.row.remark"
placeholder="这里是备注"
class="remark-input"></el-input>
<span v-else>{{ scope.row.remark }}</span> <span v-else>{{ scope.row.remark }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="showReferenceColumns" label="克隆音频路径" align="center">
<template slot-scope="scope">
<el-input v-if="scope.row.editing" v-model="scope.row.referenceAudio" placeholder="这里是克隆音频路径"></el-input>
<span v-else>{{ scope.row.referenceAudio }}</span>
</template>
</el-table-column>
<el-table-column v-if="showReferenceColumns" label="克隆音频文本" align="center">
<template slot-scope="scope">
<el-input v-if="scope.row.editing" v-model="scope.row.referenceText" placeholder="这里是克隆音频对应文本"></el-input>
<span v-else>{{ scope.row.referenceText }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="150"> <el-table-column label="操作" align="center" width="150">
<template slot-scope="scope"> <template slot-scope="scope">
<template v-if="!scope.row.editing"> <template v-if="!scope.row.editing">
@@ -84,12 +71,7 @@
删除 删除
</el-button> </el-button>
</template> </template>
<el-button <el-button v-else type="success" size="mini" @click="saveEdit(scope.row)" class="save-Tts">保存
v-else
type="success"
size="mini"
@click="saveEdit(scope.row)"
class="save-Tts">保存
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -110,21 +92,19 @@
<el-button type="primary" size="mini" @click="addNew" style="background: #5bc98c;border: None;"> <el-button type="primary" size="mini" @click="addNew" style="background: #5bc98c;border: None;">
新增 新增
</el-button> </el-button>
<el-button type="primary" <el-button type="primary" size="mini" @click="deleteRow(filteredTtsModels.filter(row => row.selected))"
size="mini" style="background: red;border:None">删除
@click="deleteRow(filteredTtsModels.filter(row => row.selected))"
style="background: red;border:None">删除
</el-button> </el-button>
</div> </div>
</el-dialog> </el-dialog>
</template> </template>
<script> <script>
import AudioPlayer from './AudioPlayer.vue'
import Api from "@/apis/api"; import Api from "@/apis/api";
import AudioPlayer from './AudioPlayer.vue';
export default { export default {
components: {AudioPlayer}, components: { AudioPlayer },
props: { props: {
visible: { visible: {
type: Boolean, type: Boolean,
@@ -133,6 +113,10 @@ export default {
ttsModelId: { ttsModelId: {
type: String, type: String,
required: true required: true
},
modelConfig: {
type: Object,
default: null
} }
}, },
data() { data() {
@@ -151,6 +135,7 @@ export default {
selectAll: false, selectAll: false,
selectedRows: [], selectedRows: [],
loading: false, loading: false,
showReferenceColumns: false, // 控制是否显示参考列
}; };
}, },
watch: { watch: {
@@ -158,12 +143,19 @@ export default {
this.localVisible = newVal; this.localVisible = newVal;
if (newVal) { if (newVal) {
this.currentPage = 1; this.currentPage = 1;
this.updateShowReferenceColumns(); // 更新显示状态
this.loadData(); // 对话框显示时加载数据 this.loadData(); // 对话框显示时加载数据
this.$nextTick(() => { this.$nextTick(() => {
this.updateScrollbar(); this.updateScrollbar();
}); });
} }
}, },
modelConfig: {
handler(newVal) {
this.updateShowReferenceColumns();
},
immediate: true
},
filteredTtsModels() { filteredTtsModels() {
this.$nextTick(() => { this.$nextTick(() => {
this.updateScrollbar(); this.updateScrollbar();
@@ -173,7 +165,7 @@ export default {
computed: { computed: {
filteredTtsModels() { filteredTtsModels() {
return this.ttsModels.filter(model => return this.ttsModels.filter(model =>
model.voiceName.toLowerCase().includes(this.searchQuery.toLowerCase()) model.voiceName.toLowerCase().includes(this.searchQuery.toLowerCase())
); );
} }
}, },
@@ -189,6 +181,16 @@ export default {
window.removeEventListener('mousemove', this.handleDrag); window.removeEventListener('mousemove', this.handleDrag);
}, },
methods: { methods: {
// 更新是否显示参考列
updateShowReferenceColumns() {
if (this.modelConfig && this.modelConfig.configJson) {
const providerType = this.modelConfig.configJson.type;
this.showReferenceColumns = ['fishspeech', 'gpt_sovits_v2', 'gpt_sovits_v3'].includes(providerType);
} else {
this.showReferenceColumns = false;
}
},
loadData() { loadData() {
this.loading = true; this.loading = true;
const params = { const params = {
@@ -206,6 +208,8 @@ export default {
voiceName: item.name || '未命名音色', voiceName: item.name || '未命名音色',
languageType: item.languages || '', languageType: item.languages || '',
remark: item.remark || '', remark: item.remark || '',
referenceAudio: item.referenceAudio || '',
referenceText: item.referenceText || '',
voiceDemo: item.voiceDemo || '', voiceDemo: item.voiceDemo || '',
selected: false, selected: false,
editing: false, editing: false,
@@ -237,6 +241,7 @@ export default {
this.total = 0; this.total = 0;
this.selectAll = false; this.selectAll = false;
this.searchQuery = ''; this.searchQuery = '';
this.showReferenceColumns = false;
this.localVisible = false; this.localVisible = false;
this.$emit('update:visible', false); this.$emit('update:visible', false);
@@ -249,7 +254,7 @@ export default {
if (!container || !scrollbarThumb || !scrollbarTrack) return; if (!container || !scrollbarThumb || !scrollbarTrack) return;
const {scrollHeight, clientHeight} = container; const { scrollHeight, clientHeight } = container;
const trackHeight = scrollbarTrack.clientHeight; const trackHeight = scrollbarTrack.clientHeight;
const thumbHeight = Math.max((clientHeight / scrollHeight) * trackHeight, 20); const thumbHeight = Math.max((clientHeight / scrollHeight) * trackHeight, 20);
@@ -264,7 +269,7 @@ export default {
if (!container || !scrollbarThumb || !scrollbarTrack) return; if (!container || !scrollbarThumb || !scrollbarTrack) return;
const {scrollHeight, clientHeight, scrollTop} = container; const { scrollHeight, clientHeight, scrollTop } = container;
const trackHeight = scrollbarTrack.clientHeight; const trackHeight = scrollbarTrack.clientHeight;
const thumbHeight = scrollbarThumb.clientHeight; const thumbHeight = scrollbarThumb.clientHeight;
const maxTop = trackHeight - thumbHeight; const maxTop = trackHeight - thumbHeight;
@@ -332,7 +337,7 @@ export default {
startEdit(row) { startEdit(row) {
row.editing = true; row.editing = true;
this.$set(row, 'originalData', {...row}); this.$set(row, 'originalData', { ...row });
}, },
saveEdit(row) { saveEdit(row) {
@@ -356,6 +361,12 @@ export default {
sort: row.sort sort: row.sort
}; };
// 只有在显示参考列的情况下才添加参考字段
if (this.showReferenceColumns) {
params.referenceAudio = row.referenceAudio;
params.referenceText = row.referenceText;
}
let res; let res;
if (row.id) { if (row.id) {
// 已有ID,执行更新操作 // 已有ID,执行更新操作
@@ -423,8 +434,8 @@ export default {
} }
const maxSort = this.ttsModels.length > 0 const maxSort = this.ttsModels.length > 0
? Math.max(...this.ttsModels.map(item => Number(item.sort) || 0)) ? Math.max(...this.ttsModels.map(item => Number(item.sort) || 0))
: 0; : 0;
const newRow = { const newRow = {
voiceCode: '', voiceCode: '',
@@ -432,6 +443,8 @@ export default {
languageType: '中文', languageType: '中文',
voiceDemo: '', voiceDemo: '',
remark: '', remark: '',
referenceAudio: '',
referenceText: '',
selected: false, selected: false,
editing: true, editing: true,
sort: maxSort + 1 sort: maxSort + 1
@@ -441,56 +454,56 @@ export default {
}, },
deleteRow(row) { deleteRow(row) {
// 处理单个音色或音色数组 // 处理单个音色或音色数组
const voices = Array.isArray(row) ? row : [row]; const voices = Array.isArray(row) ? row : [row];
if (Array.isArray(row) && row.length === 0) { if (Array.isArray(row) && row.length === 0) {
this.$message.warning("请先选择需要删除的音色"); this.$message.warning("请先选择需要删除的音色");
return; return;
}
const voiceCount = voices.length;
this.$confirm(`确定要删除选中的${voiceCount}个音色吗?`, "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
distinguishCancelAndClose: true
}).then(() => {
const ids = voices.map(voice => voice.id);
if (ids.some(id => !id)) {
this.$message.error("存在无效的音色ID");
return;
} }
Api.timbre.deleteVoice(ids, ({ data }) => {
const voiceCount = voices.length; if (data.code === 0) {
this.$confirm(`确定要删除选中的${voiceCount}个音色吗?`, "警告", { this.$message.success({
confirmButtonText: "确定", message: `成功删除${voiceCount}个参数`,
cancelButtonText: "取消", showClose: true
type: "warning", });
distinguishCancelAndClose: true this.loadData(); // 刷新参数列表
}).then(() => { } else {
const ids = voices.map(voice => voice.id); this.$message.error({
if (ids.some(id => !id)) { message: data.msg || '删除失败,请重试',
this.$message.error("存在无效的音色ID"); showClose: true
return; });
} }
Api.timbre.deleteVoice(ids, ({data}) => {
if (data.code === 0) {
this.$message.success({
message: `成功删除${voiceCount}个参数`,
showClose: true
});
this.loadData(); // 刷新参数列表
} else {
this.$message.error({
message: data.msg || '删除失败,请重试',
showClose: true
});
}
}); });
}).catch(action => { }).catch(action => {
if (action === 'cancel') { if (action === 'cancel') {
this.$message({ this.$message({
type: 'info', type: 'info',
message: '已取消删除操作', message: '已取消删除操作',
duration: 1000 duration: 1000
}); });
} else { } else {
this.$message({ this.$message({
type: 'info', type: 'info',
message: '操作已关闭', message: '操作已关闭',
duration: 1000 duration: 1000
}); });
} }
}); });
}, },
@@ -502,7 +515,6 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
::v-deep .el-dialog { ::v-deep .el-dialog {
border-radius: 8px !important; border-radius: 8px !important;
overflow: hidden; overflow: hidden;
@@ -695,6 +707,7 @@ export default {
white-space: pre-wrap !important; white-space: pre-wrap !important;
word-break: break-all !important; word-break: break-all !important;
} }
/* 按钮组定位调整 */ /* 按钮组定位调整 */
.action-buttons { .action-buttons {
position: static; position: static;
@@ -719,5 +732,4 @@ export default {
flex-shrink: 0; flex-shrink: 0;
margin: 2px !important; margin: 2px !important;
} }
</style> </style>
@@ -77,7 +77,10 @@
{{ isAllSelected ? '取消全选' : '全选' }} {{ isAllSelected ? '取消全选' : '全选' }}
</el-button> </el-button>
<el-button type="success" size="mini" class="add-device-btn" @click="handleAddDevice"> <el-button type="success" size="mini" class="add-device-btn" @click="handleAddDevice">
新增 验证码绑定
</el-button>
<el-button type="success" size="mini" class="add-device-btn" @click="handleManualAddDevice">
手动添加
</el-button> </el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteSelected">解绑</el-button> <el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteSelected">解绑</el-button>
</div> </div>
@@ -103,6 +106,8 @@
<AddDeviceDialog :visible.sync="addDeviceDialogVisible" :agent-id="currentAgentId" <AddDeviceDialog :visible.sync="addDeviceDialogVisible" :agent-id="currentAgentId"
@refresh="fetchBindDevices(currentAgentId)"/> @refresh="fetchBindDevices(currentAgentId)"/>
<ManualAddDeviceDialog :visible.sync="manualAddDeviceDialogVisible" :agent-id="currentAgentId"
@refresh="fetchBindDevices(currentAgentId)"/>
</div> </div>
</template> </template>
@@ -110,13 +115,19 @@
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
import AddDeviceDialog from "@/components/AddDeviceDialog.vue"; import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
import ManualAddDeviceDialog from "@/components/ManualAddDeviceDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
export default { export default {
components: {HeaderBar, AddDeviceDialog}, components: {
HeaderBar,
AddDeviceDialog,
ManualAddDeviceDialog
},
data() { data() {
return { return {
addDeviceDialogVisible: false, addDeviceDialogVisible: false,
manualAddDeviceDialogVisible: false,
selectedDevices: [], selectedDevices: [],
isAllSelected: false, isAllSelected: false,
searchKeyword: "", searchKeyword: "",
@@ -252,6 +263,9 @@ export default {
handleAddDevice() { handleAddDevice() {
this.addDeviceDialogVisible = true; this.addDeviceDialogVisible = true;
}, },
handleManualAddDevice() {
this.manualAddDeviceDialogVisible = true;
},
submitRemark(row) { submitRemark(row) {
if (row._submitting) return; if (row._submitting) return;
+4 -2
View File
@@ -128,7 +128,7 @@
<ModelEditDialog :modelType="activeTab" :visible.sync="editDialogVisible" :modelData="editModelData" <ModelEditDialog :modelType="activeTab" :visible.sync="editDialogVisible" :modelData="editModelData"
@save="handleModelSave" /> @save="handleModelSave" />
<TtsModel :visible.sync="ttsDialogVisible" :ttsModelId="selectedTtsModelId" /> <TtsModel :visible.sync="ttsDialogVisible" :ttsModelId="selectedTtsModelId" :modelConfig="selectedModelConfig" />
<AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" /> <AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" />
</div> </div>
<el-footer> <el-footer>
@@ -162,7 +162,8 @@ export default {
total: 0, total: 0,
selectedModels: [], selectedModels: [],
isAllSelected: false, isAllSelected: false,
loading: false loading: false,
selectedModelConfig: {}
}; };
}, },
@@ -211,6 +212,7 @@ export default {
}, },
openTtsDialog(row) { openTtsDialog(row) {
this.selectedTtsModelId = row.id; this.selectedTtsModelId = row.id;
this.selectedModelConfig = row;
this.ttsDialogVisible = true; this.ttsDialogVisible = true;
}, },
headerCellClassName({ column, columnIndex }) { headerCellClassName({ column, columnIndex }) {
+12 -1
View File
@@ -5,7 +5,7 @@ import asyncio
from aioconsole import ainput from aioconsole import ainput
from config.settings import load_config from config.settings import load_config
from config.logger import setup_logging 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.http_server import SimpleHttpServer
from core.websocket_server import WebSocketServer from core.websocket_server import WebSocketServer
from core.utils.util import check_ffmpeg_installed from core.utils.util import check_ffmpeg_installed
@@ -77,6 +77,17 @@ async def main():
get_local_ip(), get_local_ip(),
port, 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配置,使用安全的默认值
websocket_port = 8000 websocket_port = 8000
+9 -6
View File
@@ -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地址 mcp_endpoint: 你的接入点 websocket地址
# 插件的基础配置 # 插件的基础配置
plugins: plugins:
@@ -121,7 +122,9 @@ plugins:
society_rss_url: "https://www.chinanews.com.cn/rss/society.xml" society_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
world_rss_url: "https://www.chinanews.com.cn/rss/world.xml" world_rss_url: "https://www.chinanews.com.cn/rss/world.xml"
finance_rss_url: "https://www.chinanews.com.cn/rss/finance.xml" finance_rss_url: "https://www.chinanews.com.cn/rss/finance.xml"
get_news_from_newsnow: {"url": "https://newsnow.busiyi.world/api/s?id="} get_news_from_newsnow:
url: "https://newsnow.busiyi.world/api/s?id="
news_sources: "澎湃新闻;百度热搜;财联社"
home_assistant: home_assistant:
devices: devices:
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1 - 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
@@ -159,7 +162,7 @@ end_prompt:
enable: true # 是否开启结束语 enable: true # 是否开启结束语
# 结束语 # 结束语
prompt: | prompt: |
请你以时间过得真快未来头,用富有感情、依依不舍的话来结束这场对话吧! 请你以"时间过得真快"未来头,用富有感情、依依不舍的话来结束这场对话吧!
# 具体处理时选择的模块(The module selected for specific processing) # 具体处理时选择的模块(The module selected for specific processing)
selected_module: selected_module:
@@ -196,7 +199,7 @@ Intent:
# 如果你的不想使用selected_module.LLM意图识别,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM # 如果你的不想使用selected_module.LLM意图识别,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
llm: ChatGLMLLM llm: ChatGLMLLM
# plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用 # plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用
# 系统默认已经记载handle_exit_intent(退出识别)”、“play_music(音乐播放)插件,请勿重复加载 # 系统默认已经记载"handle_exit_intent(退出识别)"、"play_music(音乐播放)"插件,请勿重复加载
# 下面是加载查天气、角色切换、加载查新闻的插件示例 # 下面是加载查天气、角色切换、加载查新闻的插件示例
functions: functions:
- get_weather - get_weather
@@ -206,7 +209,7 @@ Intent:
# 不需要动type # 不需要动type
type: function_call type: function_call
# plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用 # plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用
# 系统默认已经记载handle_exit_intent(退出识别)”、“play_music(音乐播放)插件,请勿重复加载 # 系统默认已经记载"handle_exit_intent(退出识别)"、"play_music(音乐播放)"插件,请勿重复加载
# 下面是加载查天气、角色切换、加载查新闻的插件示例 # 下面是加载查天气、角色切换、加载查新闻的插件示例
functions: functions:
- change_role - change_role
@@ -766,4 +769,4 @@ TTS:
# 支持声音克隆,可自行上传音频,填入voice参数,voice参数为空时,使用默认声音 # 支持声音克隆,可自行上传音频,填入voice参数,voice参数为空时,使用默认声音
access_token: "U4YdYXVfpwWnk2t5Gp822zWPCuORyeJL" access_token: "U4YdYXVfpwWnk2t5Gp822zWPCuORyeJL"
voice: "OUeAo1mhq6IBExi" voice: "OUeAo1mhq6IBExi"
output_dir: tmp/ output_dir: tmp/
+1 -1
View File
@@ -5,7 +5,7 @@ from config.config_loader import load_config
from config.settings import check_config_file from config.settings import check_config_file
from datetime import datetime from datetime import datetime
SERVER_VERSION = "0.6.1" SERVER_VERSION = "0.6.3"
_logger_initialized = False _logger_initialized = False
+1
View File
@@ -19,6 +19,7 @@ server:
vision_explain: http://你的ip或者域名:端口号/mcp/vision/explain vision_explain: http://你的ip或者域名:端口号/mcp/vision/explain
manager-api: manager-api:
# 你的manager-api的地址,最好使用局域网ip # 你的manager-api的地址,最好使用局域网ip
# 如果使用docker部署,请使用填写成 http://xiaozhi-esp32-server-web:8002/xiaozhi
url: http://127.0.0.1:8002/xiaozhi url: http://127.0.0.1:8002/xiaozhi
# 你的manager-api的token,就是刚才复制出来的server.secret # 你的manager-api的token,就是刚才复制出来的server.secret
secret: 你的server.secret值 secret: 你的server.secret值
+87 -12
View File
@@ -193,7 +193,6 @@ class ConnectionHandler:
self.welcome_msg = self.config["xiaozhi"] self.welcome_msg = self.config["xiaozhi"]
self.welcome_msg["session_id"] = self.session_id self.welcome_msg["session_id"] = self.session_id
await self.websocket.send(json.dumps(self.welcome_msg))
# 获取差异化配置 # 获取差异化配置
self._initialize_private_config() self._initialize_private_config()
@@ -214,7 +213,17 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"Connection error: {str(e)}-{stack_trace}") self.logger.bind(tag=TAG).error(f"Connection error: {str(e)}-{stack_trace}")
return return
finally: 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): async def _save_and_close(self, ws):
"""保存记忆并关闭连接""" """保存记忆并关闭连接"""
@@ -232,7 +241,10 @@ class ConnectionHandler:
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}") self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
finally: finally:
loop.close() try:
loop.close()
except Exception:
pass
# 启动线程保存记忆,不等待完成 # 启动线程保存记忆,不等待完成
threading.Thread(target=save_memory_task, daemon=True).start() threading.Thread(target=save_memory_task, daemon=True).start()
@@ -240,7 +252,12 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}") self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
finally: 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): async def _route_message(self, message):
"""消息路由""" """消息路由"""
@@ -837,13 +854,22 @@ class ConnectionHandler:
"""资源清理方法""" """资源清理方法"""
try: try:
# 取消超时任务 # 取消超时任务
if self.timeout_task: if self.timeout_task and not self.timeout_task.done():
self.timeout_task.cancel() self.timeout_task.cancel()
try:
await self.timeout_task
except asyncio.CancelledError:
pass
self.timeout_task = None self.timeout_task = None
# 清理工具处理器资源 # 清理工具处理器资源
if hasattr(self, "func_handler") and self.func_handler: 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: if self.stop_event:
@@ -853,19 +879,58 @@ class ConnectionHandler:
self.clear_queues() self.clear_queues()
# 关闭WebSocket连接 # 关闭WebSocket连接
if ws: try:
await ws.close() if ws:
elif self.websocket: # 安全地检查WebSocket状态并关闭
await self.websocket.close() 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: 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.executor = None
self.logger.bind(tag=TAG).info("连接资源已释放") self.logger.bind(tag=TAG).info("连接资源已释放")
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"关闭连接时出错: {e}") self.logger.bind(tag=TAG).error(f"关闭连接时出错: {e}")
finally:
# 确保停止事件被设置
if self.stop_event:
self.stop_event.set()
def clear_queues(self): def clear_queues(self):
"""清空所有任务队列""" """清空所有任务队列"""
@@ -922,9 +987,19 @@ class ConnectionHandler:
): ):
if not self.stop_event.is_set(): if not self.stop_event.is_set():
self.logger.bind(tag=TAG).info("连接超时,准备关闭") 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 break
# 每10秒检查一次,避免过于频繁 # 每10秒检查一次,避免过于频繁
await asyncio.sleep(10) await asyncio.sleep(10)
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}") self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}")
finally:
self.logger.bind(tag=TAG).info("超时检查任务已退出")
@@ -1,3 +1,4 @@
import httpx
import openai import openai
from openai.types import CompletionUsage from openai.types import CompletionUsage
from config.logger import setup_logging from config.logger import setup_logging
@@ -16,6 +17,9 @@ class LLMProvider(LLMProviderBase):
self.base_url = config.get("base_url") self.base_url = config.get("base_url")
else: else:
self.base_url = config.get("url") self.base_url = config.get("url")
# 增加timeout的配置项,单位为秒
timeout = config.get("timeout", 300)
self.timeout = int(timeout) if timeout else 300
param_defaults = { param_defaults = {
"max_tokens": (500, int), "max_tokens": (500, int),
@@ -42,7 +46,7 @@ class LLMProvider(LLMProviderBase):
model_key_msg = check_model_key("LLM", self.api_key) model_key_msg = check_model_key("LLM", self.api_key)
if model_key_msg: if model_key_msg:
logger.bind(tag=TAG).error(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) 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): def response(self, session_id, dialogue, **kwargs):
try: try:
@@ -85,8 +85,12 @@ class TTSProvider(TTSProviderBase):
self.reference_id = ( self.reference_id = (
None if not config.get("reference_id") else config.get("reference_id") None if not config.get("reference_id") else config.get("reference_id")
) )
self.reference_audio = parse_string_to_list(config.get("reference_audio")) self.reference_audio = parse_string_to_list(
self.reference_text = parse_string_to_list(config.get("reference_text")) config.get('ref_audio')if config.get('ref_audio') else config.get("reference_audio")
)
self.reference_text = parse_string_to_list(
config.get('ref_text')if config.get('ref_text') else config.get("reference_text")
)
self.format = config.get("response_format", "wav") self.format = config.get("response_format", "wav")
self.audio_file_type = config.get("response_format", "wav") self.audio_file_type = config.get("response_format", "wav")
self.api_key = config.get("api_key", "YOUR_API_KEY") self.api_key = config.get("api_key", "YOUR_API_KEY")
@@ -12,8 +12,8 @@ class TTSProvider(TTSProviderBase):
super().__init__(config, delete_audio_file) super().__init__(config, delete_audio_file)
self.url = config.get("url") self.url = config.get("url")
self.text_lang = config.get("text_lang", "zh") self.text_lang = config.get("text_lang", "zh")
self.ref_audio_path = config.get("ref_audio_path") self.ref_audio_path = config.get('ref_audio') if config.get('ref_audio') else config.get("ref_audio_path")
self.prompt_text = config.get("prompt_text") self.prompt_text = config.get('ref_text') if config.get('ref_text') else config.get("prompt_text")
self.prompt_lang = config.get("prompt_lang", "zh") self.prompt_lang = config.get("prompt_lang", "zh")
# 处理空字符串的情况 # 处理空字符串的情况
@@ -11,8 +11,8 @@ class TTSProvider(TTSProviderBase):
def __init__(self, config, delete_audio_file): def __init__(self, config, delete_audio_file):
super().__init__(config, delete_audio_file) super().__init__(config, delete_audio_file)
self.url = config.get("url") self.url = config.get("url")
self.refer_wav_path = config.get("refer_wav_path") self.refer_wav_path = config.get('ref_audio')if config.get('ref_audio') else config.get("refer_wav_path")
self.prompt_text = config.get("prompt_text") self.prompt_text = config.get('ref_text')if config.get('ref_text') else config.get("prompt_text")
self.prompt_language = config.get("prompt_language") self.prompt_language = config.get("prompt_language")
self.text_language = config.get("text_language", "audo") self.text_language = config.get("text_language", "audo")
+27 -1
View File
@@ -980,4 +980,30 @@ def is_valid_image_file(file_data: bytes) -> bool:
def sanitize_tool_name(name: str) -> str: def sanitize_tool_name(name: str) -> str:
"""Sanitize tool names for OpenAI compatibility.""" """Sanitize tool names for OpenAI compatibility."""
return re.sub(r"[^a-zA-Z0-9_-]", "_", name) # 支持中文、英文字母、数字、下划线和连字符
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
@@ -57,8 +57,25 @@ class WebSocketServer:
self.active_connections.add(handler) self.active_connections.add(handler)
try: try:
await handler.handle_connection(websocket) await handler.handle_connection(websocket)
except Exception as e:
self.logger.bind(tag=TAG).error(f"处理连接时出错: {e}")
finally: finally:
# 确保从活动连接集合中移除
self.active_connections.discard(handler) 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): async def _http_response(self, websocket, request_headers):
# 检查是否为 WebSocket 升级请求 # 检查是否为 WebSocket 升级请求
@@ -48,6 +48,7 @@ services:
- SPRING_DATASOURCE_DRUID_USERNAME=root - SPRING_DATASOURCE_DRUID_USERNAME=root
- SPRING_DATASOURCE_DRUID_PASSWORD=123456 - SPRING_DATASOURCE_DRUID_PASSWORD=123456
- SPRING_DATA_REDIS_HOST=xiaozhi-esp32-server-redis - SPRING_DATA_REDIS_HOST=xiaozhi-esp32-server-redis
- SPRING_DATA_REDIS_PASSWORD=
- SPRING_DATA_REDIS_PORT=6379 - SPRING_DATA_REDIS_PORT=6379
volumes: volumes:
# 配置文件目录 # 配置文件目录
@@ -8,30 +8,90 @@ from markitdown import MarkItDown
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
# 新闻来源字典,包含名称和对应的API ID CHANNEL_MAP = {
NEWS_SOURCES = { "V2EX": "v2ex-share",
"thepaper": "澎湃新闻", "知乎": "zhihu",
"baidu": "百度热搜", "微博": "weibo",
"cls-depth": "财联社", "联合早报": "zaobao",
"酷安": "coolapk",
"MKTNews": "mktnews-flash",
"华尔街见闻": "wallstreetcn-quick",
"36氪": "36kr-quick",
"抖音": "douyin",
"虎扑": "hupu",
"百度贴吧": "tieba",
"今日头条": "toutiao",
"IT之家": "ithome",
"澎湃新闻": "thepaper",
"卫星通讯社": "sputniknewscn",
"参考消息": "cankaoxiaoxi",
"远景论坛": "pcbeta-windows11",
"财联社": "cls-depth",
"雪球": "xueqiu-hotstock",
"格隆汇": "gelonghui",
"法布财经": "fastbull-express",
"Solidot": "solidot",
"Hacker News": "hackernews",
"Product Hunt": "producthunt",
"Github": "github-trending-today",
"哔哩哔哩": "bilibili-hot-search",
"快手": "kuaishou",
"靠谱新闻": "kaopu",
"金十数据": "jin10",
"百度热搜": "baidu",
"牛客": "nowcoder",
"少数派": "sspai",
"稀土掘金": "juejin",
"凤凰网": "ifeng",
"虫部落": "chongbuluo-latest",
} }
# 动态生成新闻源描述 # 默认新闻来源字典,当配置中没有指定时使用
def generate_news_sources_description(): DEFAULT_NEWS_SOURCES = "澎湃新闻;百度热搜;财联社"
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")
):
# 获取配置的新闻源字符串
news_sources_config = conn.config["plugins"]["get_news_from_newsnow"][
"news_sources"
]
if isinstance(news_sources_config, str) and news_sources_config.strip():
logger.bind(tag=TAG).debug(f"使用配置的新闻源: {news_sources_config}")
return news_sources_config
else:
logger.bind(tag=TAG).warning("新闻源配置为空或格式错误,使用默认配置")
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
# 从CHANNEL_MAP获取所有可用的新闻源名称
available_sources = list(CHANNEL_MAP.keys())
example_sources_str = "".join(available_sources)
GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = { GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = {
"type": "function", "type": "function",
"function": { "function": {
"name": "get_news_from_newsnow", "name": "get_news_from_newsnow",
"description": ( "description": (
"获取最新新闻,随机选择一条新闻进行播报。" "获取最新新闻,随机选择一条新闻进行播报。"
f"用户可以选择不同的新闻源,{generate_news_sources_description()}等。" f"用户可以选择不同的新闻源,标准的名称是:{example_sources_str}"
"如果没有指定,默认从澎湃新闻获取。" "例如用户要求百度新闻,其实就是百度热搜。如果没有指定,默认从澎湃新闻获取。"
"用户可以要求获取详细内容,此时会获取新闻的详细内容。" "用户可以要求获取详细内容,此时会获取新闻的详细内容。"
), ),
"parameters": { "parameters": {
@@ -39,7 +99,7 @@ GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = {
"properties": { "properties": {
"source": { "source": {
"type": "string", "type": "string",
"description": f"新闻源,例如{generate_news_sources_description()}等。可选参数,如果不提供则使用默认新闻源", "description": f"新闻源的标准中文名称,例如{example_sources_str}等。可选参数,如果不提供则使用默认新闻源",
}, },
"detail": { "detail": {
"type": "boolean", "type": "boolean",
@@ -111,10 +171,13 @@ def fetch_news_detail(url):
ToolType.SYSTEM_CTL, ToolType.SYSTEM_CTL,
) )
def get_news_from_newsnow( def get_news_from_newsnow(
conn, source: str = "thepaper", detail: bool = False, lang: str = "zh_CN" conn, source: str = "澎湃新闻", detail: bool = False, lang: str = "zh_CN"
): ):
"""获取新闻并随机选择一条进行播报,或获取上一条新闻的详细内容""" """获取新闻并随机选择一条进行播报,或获取上一条新闻的详细内容"""
try: try:
# 获取当前配置的新闻源
news_sources = get_news_sources_from_config(conn)
# 如果detail为True,获取上一条新闻的详细内容 # 如果detail为True,获取上一条新闻的详细内容
detail = str(detail).lower() == "true" detail = str(detail).lower() == "true"
if detail: if detail:
@@ -132,7 +195,7 @@ def get_news_from_newsnow(
url = conn.last_newsnow_link.get("url") url = conn.last_newsnow_link.get("url")
title = conn.last_newsnow_link.get("title", "未知标题") title = conn.last_newsnow_link.get("title", "未知标题")
source_id = conn.last_newsnow_link.get("source_id", "thepaper") source_id = conn.last_newsnow_link.get("source_id", "thepaper")
source_name = NEWS_SOURCES.get(source_id, "未知来源") source_name = CHANNEL_MAP.get(source_id, "未知来源")
if not url or url == "#": if not url or url == "#":
return ActionResponse( return ActionResponse(
@@ -166,21 +229,32 @@ def get_news_from_newsnow(
return ActionResponse(Action.REQLLM, detail_report, None) return ActionResponse(Action.REQLLM, detail_report, None)
# 否则,获取新闻列表并随机选择一条 # 否则,获取新闻列表并随机选择一条
# 验证新闻源是否有效,如果无效则使用默认源 # 将中文名称转换为英文ID
if source not in NEWS_SOURCES: english_source_id = None
logger.bind(tag=TAG).warning(f"无效的新闻源: {source},使用默认源thepaper")
source = "thepaper"
source_name = NEWS_SOURCES.get(source, "澎湃新闻") # 检查输入的中文名称是否在配置的新闻源中
logger.bind(tag=TAG).info(f"获取新闻: 新闻源={source}({source_name})") news_sources_list = [
name.strip() for name in news_sources.split(";") if name.strip()
]
if source in news_sources_list:
# 如果输入的中文名称在配置的新闻源中,在 CHANNEL_MAP 中查找对应的英文ID
english_source_id = CHANNEL_MAP.get(source)
# 如果找不到对应的英文ID,使用默认源
if not english_source_id:
logger.bind(tag=TAG).warning(f"无效的新闻源: {source},使用默认源澎湃新闻")
english_source_id = "thepaper"
source = "澎湃新闻"
logger.bind(tag=TAG).info(f"获取新闻: 新闻源={source}({english_source_id})")
# 获取新闻列表 # 获取新闻列表
news_items = fetch_news_from_api(conn, source) news_items = fetch_news_from_api(conn, english_source_id)
if not news_items: if not news_items:
return ActionResponse( return ActionResponse(
Action.REQLLM, Action.REQLLM,
f"抱歉,未能从{source_name}获取到新闻信息,请稍后再试或尝试其他新闻源。", f"抱歉,未能从{source}获取到新闻信息,请稍后再试或尝试其他新闻源。",
None, None,
) )
@@ -193,14 +267,14 @@ def get_news_from_newsnow(
conn.last_newsnow_link = { conn.last_newsnow_link = {
"url": selected_news.get("url", "#"), "url": selected_news.get("url", "#"),
"title": selected_news.get("title", "未知标题"), "title": selected_news.get("title", "未知标题"),
"source_id": source, "source_id": english_source_id,
} }
# 构建新闻报告 # 构建新闻报告
news_report = ( news_report = (
f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n" f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n"
f"新闻标题: {selected_news['title']}\n" f"新闻标题: {selected_news['title']}\n"
# f"新闻来源: {source_name}\n" # f"新闻来源: {source}\n"
f"(请以自然、流畅的方式向用户播报这条新闻标题," f"(请以自然、流畅的方式向用户播报这条新闻标题,"
f"提示用户可以要求获取详细内容,此时会获取新闻的详细内容。)" f"提示用户可以要求获取详细内容,此时会获取新闻的详细内容。)"
) )
+1 -1
View File
@@ -21,7 +21,7 @@ cozepy==0.12.0
mem0ai==0.1.62 mem0ai==0.1.62
bs4==0.0.2 bs4==0.0.2
modelscope==1.23.2 modelscope==1.23.2
sherpa_onnx==1.12.0 sherpa_onnx==1.12.4
mcp==1.8.1 mcp==1.8.1
cnlunar==0.2.0 cnlunar==0.2.0
PySocks==1.7.1 PySocks==1.7.1