Compare commits

...
94 Commits
Author SHA1 Message Date
欣南科技andGitHub 2d5ef3011a Merge pull request #1028 from xinnan-tech/manager-api-model-delete
update:单Server部署时,docker-compose开放8002端口
2025-04-27 22:50:39 +08:00
hrz 63dab6d224 update:单Server部署时,docker-compose开放8002端口 2025-04-27 22:49:14 +08:00
CGDandGitHub 38dd76dfb3 Merge pull request #1026 from xinnan-tech/emote_switching
Emote switching
2025-04-27 22:20:04 +08:00
fe642de526 update:删除模型前检查是否有引用 (#1027)
* fix:DoubaoASR去掉转wav环节

* update:删除模型前检查是否有引用

* update:去除print

---------

Co-authored-by: MakerZorky <1053714527zhq@gmail.com>
2025-04-27 22:08:30 +08:00
hrz f4b7eefc38 update:去除print 2025-04-27 22:07:36 +08:00
hrz 5bbbad02b4 update:删除模型前检查是否有引用 2025-04-27 21:02:58 +08:00
Sakura-RanChen bd85dd5faf 增加“表情切换”英文适配,优化输出文本切割逻辑 2025-04-27 17:57:58 +08:00
hrzandGitHub d91ba3d219 Merge pull request #1020 from MakerZorky/main
fix:DoubaoASR去掉pcm转wav环节,优化响应速度
2025-04-27 16:49:38 +08:00
MakerZorky 7992c40815 fix:DoubaoASR去掉转wav环节 2025-04-27 15:11:50 +08:00
hrzandGitHub 574b01db97 Merge pull request #1011 from xinnan-tech/simple_ota_server
为最简化安装环境提供OTA基础信息接口
2025-04-27 13:55:51 +08:00
hrz 2d6049b7bd update:优化说明文档 2025-04-27 13:55:04 +08:00
hrz 96d0f2e6f9 update:更新教程 2025-04-27 13:34:17 +08:00
hrz b46bd4d0dd update:兼容全模块部署,不启动自带的OTA 服务 2025-04-27 13:07:14 +08:00
hrzandGitHub 411fc46189 Merge pull request #1006 from weinaike/main
fix:修复tts多次尝试逻辑
2025-04-27 11:16:10 +08:00
Sakura-RanChen 48427e330f 根据文本情绪权重进行表情切换处理,积极情绪优先 2025-04-27 11:15:58 +08:00
王华侨 5ddc2451c1 更新部署信息 2025-04-26 22:08:52 +08:00
王华侨 e357cad2bb 为最简化安装环境提供OTA基础信息接口 2025-04-26 21:56:41 +08:00
hrzandGitHub 80be8b2eaa Hot fix (#1007)
* update:优化代码

* fix:补回server.websocket配置

* update:修正版本号
2025-04-26 09:12:23 +08:00
wnk 9a5aaea8ca fix:
tts未及时响应,多次尝试逻辑修复。(删除空文件,达成While的条件)
2025-04-26 07:04:07 +08:00
hrzandGitHub 7d585615a9 update:细化版本对比 (#1004) 2025-04-25 23:58:43 +08:00
hrzandGitHub 3f3375dcac Auto ota update (#1001)
* update:修改ai_ota表编码

* update:更新教程

* add:增加OTA地址的配置

* update:将设备默认设置成自动升级

* update:设备管理可以设置某个设备是否自动升级

* update:判断客户端是否启动了自动升级

* update:校验OTA地址
2025-04-25 18:23:43 +08:00
hrzandGitHub f555eae653 Merge pull request #999 from xinnan-tech/auto-ota-update
Auto ota update
2025-04-25 17:56:47 +08:00
CGDandGitHub e805a6e037 Merge pull request #981 from xinnan-tech/aboutHandle-modify
打断时增加tts和音频任务清理
2025-04-25 09:58:15 +08:00
欣南科技andGitHub 3ed82ed7bb Merge pull request #982 from xinnan-tech/auto-ota-update
update:设置同类型OTA固件,只保留一个
2025-04-25 09:51:00 +08:00
hrz d638e512c9 update:设置同类型OTA固件,只保留一个 2025-04-25 09:48:25 +08:00
Sakura-RanChen 204b966935 打断时增加tts和音频任务清理 2025-04-25 09:34:22 +08:00
欣南科技andGitHub 8318ba5e55 Merge pull request #978 from xinnan-tech/auto-ota-update
update:OTA自动升级
2025-04-24 18:37:37 +08:00
hrz d39c595828 update:优化获取新固件方法 2025-04-24 18:36:04 +08:00
hrz 04154162c6 update:OTA自动升级 2025-04-24 18:27:38 +08:00
hrz 47f8e0f04c update:检验LLM密钥 2025-04-24 15:32:28 +08:00
hrz 7e4ada5f4b update:检验LLM密钥 2025-04-24 15:22:28 +08:00
hrz b0b4f87848 update:天气插件优化 2025-04-24 14:58:35 +08:00
欣南科技andGitHub d9085b82bf Merge pull request #967 from xinnan-tech/model-config-ui
update:优化模型配置左侧栏显示
2025-04-24 12:17:01 +08:00
hrz 8e5770ff59 修复tts名字过长 2025-04-24 12:13:42 +08:00
Sakura-RanChenandGitHub beb420c69d Merge pull request #968 from xinnan-tech/web-vue-ota-modify
Web vue ota modify
2025-04-24 11:54:08 +08:00
Junsen HuangandGitHub 1d8b898924 fix: index.js
修复router没有设置base
2025-04-24 11:46:29 +08:00
Junsen HuangandGitHub b5b4c5ced7 fix: vue.config.js
修复publicPath空串问题、默认是"/"
2025-04-24 11:45:24 +08:00
CGD b4e9d77895 update:修改了表格样式,增加了滑动条等,使其与其他页面基本对齐一致 2025-04-24 11:21:09 +08:00
hrzandGitHub 21a08b3c7a Merge pull request #933 from laride/feat/volces-ai-gateway
添加火山引擎边缘大模型网关 LLM/TTS
2025-04-24 11:19:28 +08:00
hrzandGitHub 6977d6bf73 Merge branch 'model-config-ui' into feat/volces-ai-gateway 2025-04-24 11:19:19 +08:00
hrzandGitHub 39f81dd0c0 Merge pull request #944 from laride/feat/add-public-path-env
添加前端部署路径环境变量
2025-04-24 11:16:20 +08:00
hrz b3191f5e0b update:优化tts异常捕获 2025-04-24 10:58:42 +08:00
hrzandGitHub 78f2f21142 Merge pull request #934 from taiping520/main
fix: 修复tts失败不重试问题
2025-04-24 10:43:32 +08:00
hrz 497fc1d1ae update:优化模型配置左侧栏显示 2025-04-24 10:41:14 +08:00
taiping520andGitHub d83e1c6fb2 Merge branch 'xinnan-tech:main' into main 2025-04-24 09:50:24 +08:00
CGD c5218d485a update:添加"加载中"样式,修改分页器样式 2025-04-24 09:33:46 +08:00
Sakura-RanChenandGitHub 19cbd3b8da Merge pull request #965 from xinnan-tech/py_test
Py test
2025-04-24 09:20:16 +08:00
CGD c0c5630bfb update:修改"获取天气"的逻辑顺序 2025-04-24 09:12:56 +08:00
CGD 969b5fcfbb update:"默认提示词"取消用户位置 2025-04-24 09:11:58 +08:00
欣南科技andGitHub 0739fa2195 Merge pull request #964 from xinnan-tech/update-fix
fix:取消docker版上传ota固件时默认的文件大小限制
2025-04-23 23:24:21 +08:00
hrz 3c62adab3c fix:取消docker版上传ota固件时默认的文件大小限制 2025-04-23 23:23:25 +08:00
欣南科技andGitHub edc601c35e Merge pull request #962 from xinnan-tech/update-fix
update:让alibl、等非原生llm使用functional时不卡顿
2025-04-23 21:30:40 +08:00
hrz 951370eee7 update:让alibl、等非原生llm使用function时不卡顿 2025-04-23 21:29:42 +08:00
hrzandGitHub fa81f4a150 Merge pull request #959 from TinsFox/main
update: 修改智控台登录地址为8002
2025-04-23 21:10:21 +08:00
TinsFox e6a90c40fb update: 修改智控台登录地址为8002 2025-04-23 20:25:04 +08:00
欣南科技andGitHub 2e0a1c00fa Merge pull request #955 from xinnan-tech/update-readme
update:增加HomeAssistant教程
2025-04-23 18:18:45 +08:00
hrz 109754fac9 update:增加HomeAssistant教程 2025-04-23 18:17:56 +08:00
taiping520andGitHub c3fbff3d7a Merge branch 'xinnan-tech:main' into main 2025-04-23 16:44:27 +08:00
欣南科技andGitHub 6dd967fe24 udpate:更新编译文档 (#948) 2025-04-23 11:49:24 +08:00
hrz f0bfdba944 udpate:更新编译文档 2025-04-23 11:48:20 +08:00
Liu Ruichao 7f76b5f4ee add PUBLIC_PATH env 2025-04-23 10:49:50 +08:00
taiping520andGitHub 35f4d8f15a Merge branch 'xinnan-tech:main' into main 2025-04-23 09:45:05 +08:00
hrzandGitHub 783f17e9e8 update:补充修改 OTA 固件信息接口 (#940)
* update:修复server.websocket配置占用bug

* update:补充修改 OTA 固件信息接口
2025-04-23 00:24:19 +08:00
hrzandGitHub 9e305c2840 update:修复server.websocket配置占用bug (#939) 2025-04-23 00:04:21 +08:00
78fa5d3110 add:ota管理与固件上传与下载 (#931)
* 新增ota管理与固件上传于下载

* feat: 支持通过OTA更新websocket地址

* update:合并最新代码

* update:优化OTA增删改查

* update:一个临时下载链接最多只能下载3次,防止盗链和流量攻击

* update:OTA固件增删改查

---------

Co-authored-by: kevin1sMe <linjiang1205@gmail.com>
Co-authored-by: hrz <1710360675@qq.com>
2025-04-22 23:36:23 +08:00
Liu Ruichao d57482d966 feat: add Volces AI Gateway LLM/TTS 2025-04-22 17:04:29 +08:00
sczx-win10 3d7c2aff4b Merge remote-tracking branch 'origin/main' 2025-04-22 16:58:29 +08:00
sczx-win10 3757c5dde7 fix: 修复tts失败不重试问题 2025-04-22 16:44:45 +08:00
Sakura-RanChenandGitHub 13c0a966ec Merge pull request #928 from xinnan-tech/web-vue-device-fixed
update:设备管理页面样式调整
2025-04-22 11:34:52 +08:00
CGD d987619946 update:设备管理页面样式调整 2025-04-22 11:33:17 +08:00
Sakura-RanChenandGitHub 8fb9498fe6 Merge pull request #927 from xinnan-tech/web-vue-height-fixed
Web vue height fixed
2025-04-22 10:18:19 +08:00
CGD b8f64a6693 update:更换设备管理页面的表格“选择”样式 2025-04-22 10:05:06 +08:00
CGD e31bb047bf update:将参数管理页面和用户管理页面的高度调整为固定 2025-04-22 09:27:14 +08:00
Sakura-RanChenandGitHub 6897b813a6 Merge pull request #919 from xinnan-tech/web-vue-loading
Web vue loading
2025-04-22 09:02:24 +08:00
hrzandGitHub 83f86406b5 update:更新固件下载源码路径 (#923) 2025-04-21 21:08:15 +08:00
dde237ff0d update:增加dashscope依赖 (#920)
* update:增加腾讯TTS、腾讯ASR供应器

* update:增加dashscope依赖

---------

Co-authored-by: 欣南科技 <huangrongzhuang@xin-nan.com>
2025-04-21 17:55:27 +08:00
hrzandGitHub 6a4887e252 Merge pull request #916 from laride/fix/empty-chunk
处理 OpenAI 格式响应中 `choices` 为空的 chunk
2025-04-21 17:41:04 +08:00
d945b94910 update:增加腾讯TTS、腾讯ASR供应器 (#918)
Co-authored-by: 欣南科技 <huangrongzhuang@xin-nan.com>
2025-04-21 17:35:10 +08:00
CGD bc7dcf791d update:获取"设备管理"数据时添加loading状态,修改“添加智能体”的错误字眼 2025-04-21 16:24:53 +08:00
CGD 66c71fe3b7 update:音色管理页面加载音色配置时添加loading状态 2025-04-21 15:23:27 +08:00
larideandGitHub 16a7aeec3b fix: handle empty choices in some chunk of OpenAI streaming response 2025-04-21 14:24:47 +08:00
Sakura-RanChenandGitHub 94605345e5 Merge pull request #914 from xinnan-tech/web-table-loading
Web table loading
2025-04-21 11:40:47 +08:00
CGD ec9fa32c81 update:添加多个组件"保存"时的loading状态 2025-04-21 11:36:10 +08:00
CGDandGitHub 264aa15281 Merge pull request #913 from xinnan-tech/web-role-exit
增加“角色配置”页面返回按钮,智能体管理骨架屏加载显示
2025-04-21 11:26:39 +08:00
Ran_Chen 180a6ff824 增加“角色配置”页面返回按钮,智能体管理骨架屏加载显示 2025-04-21 10:24:04 +08:00
CGD 5904ee1571 update:添加“模型配置”、“用户管理”、“参数管理”页面“获取列表”时的加载状态 2025-04-21 10:02:50 +08:00
hrzandGitHub bc99341f85 Merge pull request #909 from xinnan-tech/ota-activate
update:输入验证码后设备端快速刷新结果
2025-04-20 21:15:00 +08:00
hrzandGitHub 8b9174b67e update:绑定设备时,板子类型优先获取品牌商版名称 (#903)
* update:Server端兼容老设备缺失"client-id"请求头

* update:优化iot日志

* update:绑定设备时,板子类型优先获取品牌商版名称

* update:更新版本号
2025-04-20 01:40:21 +08:00
hrzandGitHub 6c04a6cf69 update:Server端兼容老设备缺失"client-id"请求头 (#902)
* update:Server端兼容老设备缺失"client-id"请求头

* update:优化iot日志

* update:绑定设备时,板子类型优先获取品牌商版名称
2025-04-20 01:28:01 +08:00
hrzandGitHub 14a63c4b97 update:补齐ddos攻击提示音 (#896) 2025-04-19 13:24:03 +08:00
hrzandGitHub 098d13a34b update:更新0.3.7 (#892) 2025-04-19 00:11:12 +08:00
488f247744 update:增加单台设备每天最多聊天字数,防止被ddos
* 添加清空redis所有库的接口
--AdminController.java 添加了清除所有的接口
--RedisUtils.java 添加了清除redis所有key的方法,redisTemplate提供的清空方法已经被标记为弃用了,所有选择用执行lua脚本方式

* fix:修复使用本地配置时忘记附带提示词

* fix:修复意图识别插件名称格式bug

* update:版本升级后强制刷新redis

* update:增加单台设备每天最多聊天字数,防止被ddos

---------

Co-authored-by: 剑雨 <2375294554@qq.com>
2025-04-18 23:39:56 +08:00
hrzandGitHub c74dbf03cd fix:修复意图识别插件名称格式bug (#885)
* fix:修复使用本地配置时忘记附带提示词

* fix:修复意图识别插件名称格式bug
2025-04-18 16:39:18 +08:00
hrzandGitHub 1f836c3235 fix:修复使用本地配置时忘记附带提示词 (#880) 2025-04-18 11:38:31 +08:00
106 changed files with 4326 additions and 1189 deletions
+2
View File
@@ -158,8 +158,10 @@ my_wakeup_words.mp3
!main/xiaozhi-server/config/assets/bind_code.wav !main/xiaozhi-server/config/assets/bind_code.wav
!main/xiaozhi-server/config/assets/bind_not_found.wav !main/xiaozhi-server/config/assets/bind_not_found.wav
!main/xiaozhi-server/config/assets/bind_code/*.wav !main/xiaozhi-server/config/assets/bind_code/*.wav
!main/xiaozhi-server/config/assets/max_output_size.wav
main/manager-api/.vscode main/manager-api/.vscode
# Ignore webpack cache directory # Ignore webpack cache directory
main/manager-web/.webpack_cache/ main/manager-web/.webpack_cache/
main/xiaozhi-server/mysql main/xiaozhi-server/mysql
uploadfile
+1 -1
View File
@@ -23,7 +23,7 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# 配置Nginx # 配置Nginx
COPY docs/docker/nginx.conf /etc/nginx/conf.d/default.conf COPY docs/docker/nginx.conf /etc/nginx/nginx.conf
# 复制前端构建产物 # 复制前端构建产物
COPY --from=web-builder /app/dist /usr/share/nginx/html COPY --from=web-builder /app/dist /usr/share/nginx/html
+5 -2
View File
@@ -6,7 +6,7 @@
<p align="center"> <p align="center">
本项目为开源智能硬件项目 本项目为开源智能硬件项目
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a>提供后端服务<br/> <a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a>提供后端服务<br/>
根据<a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">小智通信协议</a>使用Python实现<br/> 根据<a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">小智通信协议</a>使用Python、Java、Vue实现<br/>
帮助您快速搭建小智服务器 帮助您快速搭建小智服务器
</p> </p>
@@ -15,7 +15,8 @@
· 简体中文 · 简体中文
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">更新日志</a> · <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">更新日志</a>
· <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a> · <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues ">反馈问题</a> · <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">反馈问题</a>
· <a href="./docs/FAQ.md">常见问题</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases"> <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
@@ -147,6 +148,8 @@ server:
## 部署文档 ## 部署文档
![Banners](docs/images/banner2.png)
本项目提供两种部署方式,请根据您的具体需求选择: 本项目提供两种部署方式,请根据您的具体需求选择:
#### 🚀 部署方式选择 #### 🚀 部署方式选择
+11 -6
View File
@@ -276,21 +276,26 @@ LLM:
如果你能看到,类似以下日志,则是本项目服务启动成功的标志。 如果你能看到,类似以下日志,则是本项目服务启动成功的标志。
``` ```
25-02-23 12:01:09[core.websocket_server] - INFO - Server is running at ws://xxx.xx.xx.xx:8000/xiaozhi/v1/ 250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-OTA接口是 http://192.168.4.123:8002/xiaozhi/ota/
25-02-23 12:01:09[core.websocket_server] - INFO - =======上面的地址是websocket协议地址,请勿用浏览器访问======= 250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-Websocket地址是 ws://192.168.4.123:8000/xiaozhi/v1/
25-02-23 12:01:09[core.websocket_server] - INFO - 如想测试websocket请用谷歌浏览器打开test目录下的test_page.html 250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问=======
25-02-23 12:01:09[core.websocket_server] - INFO - ======================================================= 250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-如想测试websocket请用谷歌浏览器打开test目录下的test_page.html
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-=======================================================
``` ```
正常来说,如果您是通过源码运行本项目,日志会有你的接口地址信息。 正常来说,如果您是通过源码运行本项目,日志会有你的接口地址信息。
但是如果你用docker部署,那么你的日志里给出的接口地址信息就不是真实的接口地址。 但是如果你用docker部署,那么你的日志里给出的接口地址信息就不是真实的接口地址。
最正确的方法,是根据电脑的局域网IP来确定你的接口地址。 最正确的方法,是根据电脑的局域网IP来确定你的接口地址。
如果你的电脑的局域网IP比如是`192.168.1.25`,那么你的接口地址就是:`ws://192.168.1.25:8000/xiaozhi/v1/` 如果你的电脑的局域网IP比如是`192.168.1.25`,那么你的接口地址就是:`ws://192.168.1.25:8000/xiaozhi/v1/`,对应的OTA地址就是:`http://192.168.1.25:8002/xiaozhi/ota/`
这个信息很有用的,后面`编译esp32固件`需要用到。 这个信息很有用的,后面`编译esp32固件`需要用到。
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。 接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
1、 [编译自己的esp32固件](firmware-build.md)了。
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
以下是一些常见问题,供参考: 以下是一些常见问题,供参考:
+38 -12
View File
@@ -122,11 +122,11 @@ http://localhost:8002/xiaozhi/doc.html
这时,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8002 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。 这时,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8002 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。
接下来要做件重要的事情: 接下来要做件重要的事情:
### 第一件重要的事情 ### 第一件重要的事情
使用超级管理员账号,登录系统,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值` 使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值`
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。 `server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
@@ -138,7 +138,12 @@ manager-api:
secret: 你的server.secret值 secret: 你的server.secret值
``` ```
1、把你刚才从`智控台`复制过来的`server.secret``参数值`复制到`.config.yaml`文件里的`secret`里。 1、把你刚才从`智控台`复制过来的`server.secret``参数值`复制到`.config.yaml`文件里的`secret`里。
2、注意,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
2、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
3、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
4、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
类似这样的效果 类似这样的效果
``` ```
@@ -151,7 +156,7 @@ manager-api:
### 第二件重要的事情 ### 第二件重要的事情
使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮, 使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。 弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
## 5.重启xiaozhi-esp32-server ## 5.重启xiaozhi-esp32-server
@@ -164,7 +169,7 @@ docker logs -f xiaozhi-esp32-server
如果你能看到,类似以下日志,则是Server启动成功的标志。 如果你能看到,类似以下日志,则是Server启动成功的标志。
``` ```
25-02-23 12:01:09[core.websocket_server] - INFO - Server is running at ws://xxx.xx.xx.xx:8000/xiaozhi/v1/ 25-02-23 12:01:09[core.websocket_server] - INFO - Websocket地址是 ws://xxx.xx.xx.xx:8000/xiaozhi/v1/
25-02-23 12:01:09[core.websocket_server] - INFO - =======上面的地址是websocket协议地址,请勿用浏览器访问======= 25-02-23 12:01:09[core.websocket_server] - INFO - =======上面的地址是websocket协议地址,请勿用浏览器访问=======
25-02-23 12:01:09[core.websocket_server] - INFO - 如想测试websocket请用谷歌浏览器打开test目录下的test_page.html 25-02-23 12:01:09[core.websocket_server] - INFO - 如想测试websocket请用谷歌浏览器打开test目录下的test_page.html
25-02-23 12:01:09[core.websocket_server] - INFO - ======================================================= 25-02-23 12:01:09[core.websocket_server] - INFO - =======================================================
@@ -182,7 +187,17 @@ Websocket接口:
ws://你电脑局域网的ip:8000/xiaozhi/v1/ ws://你电脑局域网的ip:8000/xiaozhi/v1/
``` ```
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。 ### 第三件重要的事情
使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到参数编码是`server.websocket`,输入你的`Websocket接口`
使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到数编码是`server.ota`,输入你的`OTA接口`
接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
1、 [编译自己的esp32固件](firmware-build.md)了。
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
# 方式二:本地源码运行全模块 # 方式二:本地源码运行全模块
@@ -278,13 +293,13 @@ npm run serve
运行成功后,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8001 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。 运行成功后,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8001 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮, 重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。 弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮, 重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。 弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮, 重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。 弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
## 5.安装Python环境 ## 5.安装Python环境
@@ -349,7 +364,7 @@ pip install -r requirements.txt
## 8.配置项目文件 ## 8.配置项目文件
使用超级管理员账号,登录智控台 http://127.0.0.1:8001 ,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值` 使用超级管理员账号,登录智控台 ,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值`
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。 `server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
@@ -390,7 +405,7 @@ python app.py
25-02-23 12:01:09[core.websocket_server] - INFO - ======================================================= 25-02-23 12:01:09[core.websocket_server] - INFO - =======================================================
``` ```
由于你是全模块部署,因此你有两个重要的接口需要写入到esp32中 由于你是全模块部署,因此你有两个重要的接口。
OTA接口: OTA接口:
``` ```
@@ -402,7 +417,18 @@ Websocket接口:
ws://你电脑局域网的ip:8000/xiaozhi/v1/ ws://你电脑局域网的ip:8000/xiaozhi/v1/
``` ```
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了 请你务必把以上两个接口地址写入到智控台中:他们将会影响websocket地址发放和自动升级功能
1、使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到参数编码是`server.websocket`,输入你的`Websocket接口`
2、使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到数编码是`server.ota`,输入你的`OTA接口`
接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
1、 [编译自己的esp32固件](firmware-build.md)了。
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
# 常见问题 # 常见问题
+1 -35
View File
@@ -102,41 +102,7 @@ VAD:
### 7、我想通过小智控制电灯、空调、远程开关机等操作 💡 ### 7、我想通过小智控制电灯、空调、远程开关机等操作 💡
本项目,支持以工具调用的方式控制HomeAssistant设备 参考教程[ESP32设备与HomeAssistant集成指南](./homeassistant-integration.md)
1、首先选择一款支持function call支持的LLM,例如`ChatGLMLLM`
2、在配置文件中,将 `selected_module.Intent` 设置为 `function_call`
3、登录`HomeAssistant`,点击`左下角个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key。
在配置文件中,配置好你的home assistant的`devices`(被控制的设备)和`api_key``base_url`等信息。例如:
``` yaml
plugins
home_assistant:
devices:
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
- 卧室,台灯,switch.iot_cn_831898993_socn1_on_p_2_1
base_url: http://你的homeassistant地址:8123
api_key: 你的home assistant api访问令牌
```
最后,允许function_call 插件在配置文件中启用`hass_get_state`(必须)、`hass_set_state`(必须)、`hass_play_music`(不想用ha听音乐可以不启动),例如:
``` yaml
Intent:
...
function_call:
type: nointent
functions:
- change_role
- get_weather
- get_news
- hass_get_state
- hass_set_state
- hass_play_music
```
### 8、更多问题,可联系我们反馈 💬 ### 8、更多问题,可联系我们反馈 💬
+47 -20
View File
@@ -1,26 +1,53 @@
server { user root;
listen 8002; worker_processes 4;
server_name localhost;
# 静态资源服务(Vue项目) events {
location / { worker_connections 1024;
root /usr/share/nginx/html; }
try_files $uri $uri/ /index.html;
}
# API反向代理(Java项目) http {
location /xiaozhi/ { include mime.types;
proxy_pass http://127.0.0.1:8003; default_type application/octet-stream;
proxy_set_header Host $host; sendfile on;
proxy_cookie_path /api/ /; keepalive_timeout 300;
proxy_set_header Referer $http_referer; client_header_timeout 180s;
proxy_set_header Cookie $http_cookie; client_body_timeout 180s;
client_max_body_size 1024M;
proxy_connect_timeout 10; gzip on;
proxy_send_timeout 10; gzip_buffers 32 4K;
proxy_read_timeout 10; gzip_comp_level 6;
gzip_min_length 100;
gzip_types application/javascript text/css text/xml image/jpeg image/gif image/png;
gzip_disable "MSIE [1-6]\.";
gzip_vary on;
proxy_set_header X-Real-IP $remote_addr; server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 无域名访问,就用localhost
server_name localhost;
# 80端口
listen 8002;
# 转发到编译后到web目录
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
# 转发到manager-api
location /xiaozhi/ {
proxy_pass http://127.0.0.1:8003;
proxy_set_header Host $host;
proxy_cookie_path /manager/ /;
proxy_set_header Referer $http_referer;
proxy_set_header Cookie $http_cookie;
proxy_connect_timeout 10;
proxy_send_timeout 10;
proxy_read_timeout 10;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
} }
} }
+59 -53
View File
@@ -1,67 +1,79 @@
# 编译esp32固件 # esp32固件编译
## 第1步 配置环境 ## 第1步 准备你的ota地址
如果你,使用的是本项目0.3.12版本,不管是简单Server部署还是全模块部署,都会有ota地址。
由于简单Server部署和全模块部署的OTA地址设置方式不一样,请你选择下面的具体方式:
### 如果你用的是简单Server部署
此刻,请你用浏览器打开你的ota地址,例如我的ota地址
```
http://192.168.1.25:8002/xiaozhi/ota/
```
如果显示“OTA接口运行正常,向设备发送的websocket地址是:ws://xxx:8000/xiaozhi/v1/
你可以使用项目自带的`test_page.html`测试一下,是否能连上ota页面输出的websocket地址。
如果访问不到,你需要到配置文件`.config.yaml`里修改`server.websocket`的地址,重启后再重新测试,直到`test_page.html`能正常访问。
成功后,请往下进行第2步
### 如果你用的是全模块部署
此刻,请你用浏览器打开你的ota地址,例如我的ota地址
```
http://192.168.1.25:8002/xiaozhi/ota/
```
如果显示“OTA接口运行正常,websocket集群数量:X”。那就往下进行2步。
如果显示“OTA接口运行不正常”,大概是你还没在`智控台`配置`Websocket`地址。那就:
- 1、使用超级管理员登录智控台
- 2、顶部菜单点击`参数管理`
- 3、在列表中找到`server.websocket`项目,输入你的`Websocket`地址。例如我的就是
```
ws://192.168.1.25:8000/xiaozhi/v1/
```
配置完后,再使用浏览器刷新你的ota接口地址,看看是不是正常了。如果还不正常就,就再次确认一下Websocket是否正常启动,是否配置了Websocket地址。
## 第2步 配置环境
先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf) 先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
## 第2步 打开配置文件 ## 第3步 打开配置文件
配置好编译环境后,下载虾哥[xiaozhi-esp32](https://github.com/78/xiaozhi-esp32)项目源码,进入虾哥项目,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。 配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
## 第3步 修改WEBSOCKET地址 从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32)。
找到`WEBSOCKET_URL``default`的内容,把`wss://api.tenclass.net/xiaozhi/v1/`
改成你自己的地址,例如,我的接口地址是`ws://192.168.1.25:8000/xiaozhi/v1/`,就把内容改成这个。
修改前: 下载后,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "wss://api.tenclass.net/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
修改后(示例)
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "ws://192.168.1.25:8000/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
## 第4步 修改OTA地址 ## 第4步 修改OTA地址
如果你是全模块部署本项目,就修改OTA接口,如果你只是部署了8000端口的xiaozhi-server,可以继续沿用虾哥团队的OTA接口。如果你不修改OTA接口,请直接忽略本第4步,直接看第5步
找到`OTA_VERSION_URL``default`的内容,把`https://api.tenclass.net/xiaozhi/ota/` 找到`OTA_URL``default`的内容,把`https://api.tenclass.net/xiaozhi/ota/`
改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8002/xiaozhi/ota/`,就把内容改成这个。 改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8002/xiaozhi/ota/`,就把内容改成这个。
修改前: 修改前:
``` ```
config OTA_VERSION_URL config OTA_URL
string "OTA Version URL" string "Default OTA URL"
default "https://api.tenclass.net/xiaozhi/ota/" default "https://api.tenclass.net/xiaozhi/ota/"
help help
The application will access this URL to check for updates. The application will access this URL to check for new firmwares and server address.
``` ```
修改后: 修改后:
``` ```
config OTA_VERSION_URL config OTA_URL
string "OTA Version URL" string "Default OTA URL"
default "http://192.168.1.25:8002/xiaozhi/ota/" default "http://192.168.1.25:8002/xiaozhi/ota/"
help help
The application will access this URL to check for updates. The application will access this URL to check for new firmwares and server address.
``` ```
## 第5步 设置编译参数 ## 第4步 设置编译参数
设置编译参数 设置编译参数
@@ -82,32 +94,26 @@ idf.py menuconfig
![图片](images/build_setting02.png) ![图片](images/build_setting02.png)
## 第6步 编译固件 ## 第5步 编译固件
``` ```
idf.py build idf.py build
``` ```
如果是vscode安装的idf可以使用`F1`或者`ctrl+shift+p`,输入idf然后直接选择进行编译 ## 第6步 打包bin固件
还可以直接进行烧录不用接下来的操作
<img src="./images/vscode_idf.png" width="500px"/>
## 第7步 打包bin固件
``` ```
cd scripts cd scripts
python release.py python release.py
``` ```
编译成功后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin` 上面的打包命令执行完成后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin`
这个`merged-binary.bin`就是要烧录到硬件上的固件文件。 这个`merged-binary.bin`就是要烧录到硬件上的固件文件。
注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin` 注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin`
,对你没有太大影响,请继续。 ,对你没有太大影响,请继续。
## 第8步 烧录固件 ## 第7步 烧录固件
将esp32设备连接电脑,使用chrome浏览器,打开以下网址 将esp32设备连接电脑,使用chrome浏览器,打开以下网址
``` ```
+53
View File
@@ -0,0 +1,53 @@
# 基于虾哥编译好的固件配置自定义服务器
## 第1步 确认版本
烧录虾哥已经编译好的1.6.1版本固件
## 第2步 准备你的ota地址
如果你按照教程使用的是全模块部署,就应该会有ota地址。
此刻,请你用浏览器打开你的ota地址,例如我的ota地址
```
https://2662r3426b.vicp.fun/xiaozhi/ota/
```
如果显示“OTA接口运行正常,websocket集群数量:X”。那就往下。
如果显示“OTA接口运行不正常”,大概是你还没在`智控台`配置`Websocket`地址。那就:
- 1、使用超级管理员登录智控台
- 2、顶部菜单点击`参数管理`
- 3、在列表中找到`server.websocket`项目,输入你的`Websocket`地址。例如我的就是
```
wss://2662r3426b.vicp.fun/xiaozhi/v1/
```
配置完后,再使用浏览器刷新你的ota接口地址,看看是不是正常了。如果还不正常就,就再次确认一下Websocket是否正常启动,是否配置了Websocket地址。
## 第3步 进入配网模式
进入机器的配网模式,在页面顶部,点击“高级选项”,在里面输入你服务器的`ota`地址,点击保存。重启设备
## 第4步 唤醒小智,查看日志输出
唤醒小智,看看日志是不是正常输出。
## 常见问题
以下是一些常见问题,供参考:
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
[3、TTS 经常失败,经常超时](./FAQ.md)
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
[5、如何提高小智对话响应速度?](./FAQ.md)
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
+111
View File
@@ -0,0 +1,111 @@
# ESP32设备与HomeAssistant集成指南
## 简介
本文档将指导您如何将ESP32设备与HomeAssistant进行集成。
## 前提条件
- 已安装并配置好`HomeAssistant`
- 已经在HomeAssistant中集成`Xiaomi Home`,并将米家的设备导入进`HomeAssistant`
- `xiaozhi-esp32-server智控台`能正常使用
- 本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用
## 网络环境
1、我的`HomeAssistant`部署在下面,版本是`2025.3.4`
```
http://192.168.4.7:8123
```
2、我的`xiaozhi-esp32-server智控台``HomeAssistant`部署在同一台机器的另一个端口,版本是`0.3.10`
```
http://192.168.4.7:8002
```
## 配置步骤
### 1. 登录`HomeAssistant`整理需要控制的设备清单
登录`HomeAssistant`,点击`左下角的设置`,然后进入`设备与服务`,再点击顶部的`实体`
然后在实体中搜索你相关控制的开关,结果出来后,在列表中,点击其中一个结果,这是会出现一个开关的界面。
在开关的界面,我们尝试点击开关,看看是开发会随着我们的点击开/关。如果能操作,说明是正常联网的。
接着在开关面板找到设置按钮,点击后,可以查看这个开关的`实体标识符`
我们打开一个记事本,按照这样格式整理一条数据:
位置+英文逗号+设备名称+英文逗号+`实体标识符`+英文分号
例如,我在公司,我有一个玩具灯,他的标识符是switch.cuco_cn_460494544_cp1_on_p_2_1,那么就这个写这一条数据
```
公司,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;
```
当然最后我可能要操作两个灯,我的最终的结果是:
```
公司,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;
公司,台灯,switch.iot_cn_831898993_socn1_on_p_2_1;
```
这段字符,我们成为“设备清单字符”需要保存好,等一下有用。
### 2. 登录`HomeAssistant`拿到开发密钥
登录`HomeAssistant`,点击`左下角个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key。
### 3. 登录`智控台`
使用管理员账号,登录`智控台`。点击顶部菜单`参数管理`,搜索`plugins.home_assistant.`,会有三条结果出来
编辑`plugins.home_assistant.devices`,把刚才整理的设备清单字符粘贴进去。
编辑`plugins.home_assistant.base_url`,把你部署的`HomeAssistant`接口地址粘贴进去,我粘贴进去的地址是这样的
```
http://192.168.4.7:8123
```
编辑`plugins.home_assistant.api_key`,把你从`HomeAssistant`复制过来的密钥,粘贴进去
### 4. 设置`意图识别`函数
在智控台,点击顶部菜单“模型配置”,在左侧栏,找到“意图识别”,找到id为`Intent_function_call`的意图,点击编辑
然后在弹框中,在原来的基础上追加两个函数:“hass_get_state”和“hass_set_state”
修改前
```
change_role;get_weather;get_news;play_music
```
修改后
```
change_role;get_weather;get_news;play_music;hass_get_state;hass_set_state
```
### 6. 手动重启xiaozhi-server
重启xiaozhi-server程序
### 5. 确认角色配置是否设置了函数意图识别
在智控台,点击顶部菜单“智能体管理”,找到设备所在的智能体,点击“配置角色”
确认意图识别(Intent),是否选择“函数调用意图识别”
### 6. 唤醒设别进行控制
尝试和esp32说,“打开XXX灯”
@@ -0,0 +1,17 @@
package xiaozhi.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
/**
* RestTemplate配置
*/
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
@@ -84,6 +84,16 @@ public interface Constant {
*/ */
String SERVER_SECRET = "server.secret"; String SERVER_SECRET = "server.secret";
/**
* websocket地址
*/
String SERVER_WEBSOCKET = "server.websocket";
/**
* ota地址
*/
String SERVER_OTA = "server.ota";
/** /**
* 是否允许用户注册 * 是否允许用户注册
*/ */
@@ -163,4 +173,9 @@ public interface Constant {
return value; return value;
} }
} }
/**
* 版本号
*/
public static final String VERSION = "0.3.12";
} }
@@ -75,4 +75,25 @@ public class RedisKeys {
public static String getTimbreDetailsKey(String id) { public static String getTimbreDetailsKey(String id) {
return "timbre:details:" + id; return "timbre:details:" + id;
} }
/**
* 获取版本号Key
*/
public static String getVersionKey() {
return "sys:version";
}
/**
* OTA固件ID的Key
*/
public static String getOtaIdKey(String uuid) {
return "ota:id:" + uuid;
}
/**
* OTA固件下载次数的Key
*/
public static String getOtaDownloadCountKey(String uuid) {
return "ota:download:count:" + uuid;
}
} }
@@ -1,11 +1,14 @@
package xiaozhi.common.redis; package xiaozhi.common.redis;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -27,7 +30,7 @@ public class RedisUtils {
/** /**
* 过期时长为1小时,单位:秒 * 过期时长为1小时,单位:秒
*/ */
public final static long HOUR_ONE_EXPIRE = 60 * 60 * 1L; public final static long HOUR_ONE_EXPIRE = (long) 60 * 60;
/** /**
* 过期时长为6小时,单位:秒 * 过期时长为6小时,单位:秒
*/ */
@@ -124,4 +127,24 @@ public class RedisUtils {
public Object rightPop(String key) { public Object rightPop(String key) {
return redisTemplate.opsForList().rightPop(key); return redisTemplate.opsForList().rightPop(key);
} }
/**
* 清空所有 Redis 数据库中的所有键
*/
public void emptyAll() {
// Lua 脚本 FLUSHALL是redis清空所有库的命令
String luaScript ="redis.call('FLUSHALL')";
// 创建 DefaultRedisScript 对象
DefaultRedisScript<Void> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(luaScript); // 设置 Lua 脚本内容
redisScript.setResultType(Void.class); // 设置返回值类型
// 执行 Lua 脚本
List<String> keys = Collections.emptyList(); // 如果脚本不依赖 key,可以传入空列表
redisTemplate.execute(redisScript, keys);
}
} }
@@ -5,6 +5,9 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.DependsOn;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.config.service.ConfigService; import xiaozhi.modules.config.service.ConfigService;
import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysParamsService;
@@ -18,8 +21,20 @@ public class SystemInitConfig {
@Autowired @Autowired
private ConfigService configService; private ConfigService configService;
@Autowired
private RedisUtils redisUtils;
@PostConstruct @PostConstruct
public void init() { public void init() {
// 检查版本号
String redisVersion = (String) redisUtils.get(RedisKeys.getVersionKey());
if (!Constant.VERSION.equals(redisVersion)) {
// 如果版本不一致,清空Redis
redisUtils.emptyAll();
// 存储新版本号
redisUtils.set(RedisKeys.getVersionKey(), Constant.VERSION);
}
sysParamsService.initServerSecret(); sysParamsService.initServerSecret();
configService.getConfig(false); configService.getConfig(false);
} }
@@ -91,6 +91,7 @@ public class ConfigServiceImpl implements ConfigService {
} }
throw new RenException(ErrorCode.OTA_DEVICE_NOT_FOUND, "not found device"); throw new RenException(ErrorCode.OTA_DEVICE_NOT_FOUND, "not found device");
} }
// 获取智能体信息 // 获取智能体信息
AgentEntity agent = agentService.getAgentById(device.getAgentId()); AgentEntity agent = agentService.getAgentById(device.getAgentId());
if (agent == null) { if (agent == null) {
@@ -104,7 +105,9 @@ public class ConfigServiceImpl implements ConfigService {
} }
// 构建返回数据 // 构建返回数据
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
// 获取单台设备每天最多输出字数
String deviceMaxOutputSize = sysParamsService.getValue("device_max_output_size", true);
result.put("device_max_output_size", deviceMaxOutputSize);
// 如果客户端已实例化模型,则不返回 // 如果客户端已实例化模型,则不返回
String alreadySelectedVadModelId = (String) selectedModule.get("VAD"); String alreadySelectedVadModelId = (String) selectedModule.get("VAD");
if (alreadySelectedVadModelId != null && alreadySelectedVadModelId.equals(agent.getVadModelId())) { if (alreadySelectedVadModelId != null && alreadySelectedVadModelId.equals(agent.getVadModelId())) {
@@ -267,6 +270,12 @@ public class ConfigServiceImpl implements ConfigService {
if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) { if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) {
intentLLMModelId = null; intentLLMModelId = null;
} }
} else if ("function_call".equals(map.get("type"))) {
String functionStr = (String) map.get("functions");
if (StringUtils.isNotBlank(functionStr)) {
String[] functions = functionStr.split("\\;");
map.put("functions", functions);
}
} }
} }
// 如果是LLM类型,且intentLLMModelId不为空,则添加附加模型 // 如果是LLM类型,且intentLLMModelId不为空,则添加附加模型
@@ -282,7 +291,7 @@ public class ConfigServiceImpl implements ConfigService {
result.put("selected_module", selectedModule); result.put("selected_module", selectedModule);
if (StringUtils.isNotBlank(prompt)) { if (StringUtils.isNotBlank(prompt)) {
prompt = prompt.replace("{{assistant_name}}", "小智"); prompt = prompt.replace("{{assistant_name}}", StringUtils.isBlank(assistantName) ? "小智" : assistantName);
} }
result.put("prompt", prompt); result.put("prompt", prompt);
} }
@@ -7,6 +7,7 @@ import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -79,4 +80,16 @@ public class DeviceController {
return new Result<Void>(); return new Result<Void>();
} }
@PutMapping("/enableOta/{id}/{status}")
@Operation(summary = "启用/关闭OTA自动升级")
@RequiresPermissions("sys:role:normal")
public Result<Void> enableOtaUpgrade(@PathVariable String id, @PathVariable Integer status) {
DeviceEntity entity = deviceService.selectById(id);
if (entity == null) {
return new Result<Void>().error("设备不存在");
}
entity.setAutoUpdate(status);
deviceService.updateById(entity);
return new Result<Void>();
}
} }
@@ -15,25 +15,32 @@ import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
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.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService; import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.device.utils.NetworkUtil; import xiaozhi.modules.device.utils.NetworkUtil;
import xiaozhi.modules.sys.service.SysParamsService;
@Tag(name = "设备管理", description = "OTA 相关接口") @Tag(name = "设备管理", description = "OTA 相关接口")
@Slf4j
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/ota/") @RequestMapping("/ota/")
public class OTAController { public class OTAController {
private final DeviceService deviceService; private final DeviceService deviceService;
private final SysParamsService sysParamsService;
@Operation(summary = "检查 OTA 版本和设备激活状态") @Operation(summary = "OTA版本和设备激活状态检查")
@PostMapping @PostMapping
public ResponseEntity<String> checkOTAVersion( public ResponseEntity<String> checkOTAVersion(
@RequestBody DeviceReportReqDTO deviceReportReqDTO, @RequestBody DeviceReportReqDTO deviceReportReqDTO,
@@ -54,9 +61,33 @@ public class OTAController {
return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO)); return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO));
} }
@Operation(summary = "设备快速检查激活状态")
@PostMapping("activate")
public ResponseEntity<String> activateDevice(
@Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Device-Id") String deviceId,
@Parameter(name = "Client-Id", description = "客户端标识", required = false, in = ParameterIn.HEADER) @RequestHeader(value = "Client-Id", required = false) String clientId) {
if (StringUtils.isBlank(deviceId)) {
return ResponseEntity.status(202).build();
}
DeviceEntity device = deviceService.getDeviceByMacAddress(deviceId);
if (device == null) {
return ResponseEntity.status(202).build();
}
return ResponseEntity.ok("success");
}
@GetMapping @GetMapping
public ResponseEntity<String> getOTAPrompt() { @Hidden
return createResponse(DeviceReportRespDTO.createError("请提交正确的ota参数")); public ResponseEntity<String> getOTA() {
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
return ResponseEntity.ok("OTA接口不正常,缺少websocket地址,请登录智控台,在参数管理找到【server.websocket】配置");
}
String otaUrl = sysParamsService.getValue(Constant.SERVER_OTA, true);
if (StringUtils.isBlank(otaUrl) || otaUrl.equals("null")) {
return ResponseEntity.ok("OTA接口不正常,缺少ota地址,请登录智控台,在参数管理找到【server.ota】配置");
}
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
} }
@SneakyThrows @SneakyThrows
@@ -0,0 +1,291 @@
package xiaozhi.modules.device.controller;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.UUID;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.device.entity.OtaEntity;
import xiaozhi.modules.device.service.OtaService;
@Tag(name = "设备管理", description = "OTA 相关接口")
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/otaMag")
public class OTAMagController {
private static final Logger logger = LoggerFactory.getLogger(OTAController.class);
private final OtaService otaService;
private final RedisUtils redisUtils;
@GetMapping
@Operation(summary = "分页查询 OTA 固件信息")
@Parameters({
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true)
})
@RequiresPermissions("sys:role:superAdmin")
public Result<PageData<OtaEntity>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
ValidatorUtils.validateEntity(params);
PageData<OtaEntity> page = otaService.page(params);
return new Result<PageData<OtaEntity>>().ok(page);
}
@GetMapping("{id}")
@Operation(summary = "信息 OTA 固件信息")
@RequiresPermissions("sys:role:superAdmin")
public Result<OtaEntity> get(@PathVariable("id") String id) {
OtaEntity data = otaService.selectById(id);
return new Result<OtaEntity>().ok(data);
}
@PostMapping
@Operation(summary = "保存 OTA 固件信息")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> save(@RequestBody OtaEntity entity) {
if (entity == null) {
return new Result<Void>().error("固件信息不能为空");
}
if (StringUtils.isBlank(entity.getFirmwareName())) {
return new Result<Void>().error("固件名称不能为空");
}
if (StringUtils.isBlank(entity.getType())) {
return new Result<Void>().error("固件类型不能为空");
}
if (StringUtils.isBlank(entity.getVersion())) {
return new Result<Void>().error("版本号不能为空");
}
try {
otaService.save(entity);
return new Result<Void>();
} catch (RuntimeException e) {
return new Result<Void>().error(e.getMessage());
}
}
@DeleteMapping("/{id}")
@Operation(summary = "OTA 删除")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> delete(@PathVariable("id") String[] ids) {
if (ids == null || ids.length == 0) {
return new Result<Void>().error("删除的固件ID不能为空");
}
otaService.delete(ids);
return new Result<Void>();
}
@PutMapping("/{id}")
@Operation(summary = "修改 OTA 固件信息")
@RequiresPermissions("sys:role:superAdmin")
public Result<?> update(@PathVariable("id") String id, @RequestBody OtaEntity entity) {
if (entity == null) {
return new Result<>().error("固件信息不能为空");
}
entity.setId(id);
try {
otaService.update(entity);
return new Result<>();
} catch (RuntimeException e) {
return new Result<>().error(e.getMessage());
}
}
@GetMapping("/getDownloadUrl/{id}")
@Operation(summary = "获取 OTA 固件下载链接")
@RequiresPermissions("sys:role:superAdmin")
public Result<String> getDownloadUrl(@PathVariable("id") String id) {
String uuid = UUID.randomUUID().toString();
redisUtils.set(RedisKeys.getOtaIdKey(uuid), id);
return new Result<String>().ok(uuid);
}
@GetMapping("/download/{uuid}")
@Operation(summary = "下载固件文件")
public ResponseEntity<byte[]> downloadFirmware(@PathVariable("uuid") String uuid) {
String id = (String) redisUtils.get(RedisKeys.getOtaIdKey(uuid));
if (StringUtils.isBlank(id)) {
return ResponseEntity.notFound().build();
}
// 检查下载次数
String downloadCountKey = RedisKeys.getOtaDownloadCountKey(uuid);
Integer downloadCount = (Integer) redisUtils.get(downloadCountKey);
if (downloadCount == null) {
downloadCount = 0;
}
// 如果下载次数超过3次,返回404
if (downloadCount >= 3) {
redisUtils.delete(downloadCountKey);
redisUtils.delete(RedisKeys.getOtaIdKey(uuid));
logger.warn("Download limit exceeded for UUID: {}", uuid);
return ResponseEntity.notFound().build();
}
redisUtils.set(downloadCountKey, downloadCount + 1);
try {
// 获取固件信息
OtaEntity otaEntity = otaService.selectById(id);
if (otaEntity == null || StringUtils.isBlank(otaEntity.getFirmwarePath())) {
logger.warn("Firmware not found or path is empty for ID: {}", id);
return ResponseEntity.notFound().build();
}
// 获取文件路径 - 确保路径是绝对路径或正确的相对路径
String firmwarePath = otaEntity.getFirmwarePath();
Path path;
// 检查是否是绝对路径
if (Paths.get(firmwarePath).isAbsolute()) {
path = Paths.get(firmwarePath);
} else {
// 如果是相对路径,则从当前工作目录解析
path = Paths.get(System.getProperty("user.dir"), firmwarePath);
}
logger.info("Attempting to download firmware for ID: {}, DB path: {}, resolved path: {}",
id, firmwarePath, path.toAbsolutePath());
if (!Files.exists(path) || !Files.isRegularFile(path)) {
// 尝试直接从firmware目录下查找文件名
String fileName = new File(firmwarePath).getName();
Path altPath = Paths.get(System.getProperty("user.dir"), "firmware", fileName);
logger.info("File not found at primary path, trying alternative path: {}", altPath.toAbsolutePath());
if (Files.exists(altPath) && Files.isRegularFile(altPath)) {
path = altPath;
} else {
logger.error("Firmware file not found at either path: {} or {}",
path.toAbsolutePath(), altPath.toAbsolutePath());
return ResponseEntity.notFound().build();
}
}
// 读取文件内容
byte[] fileContent = Files.readAllBytes(path);
// 设置响应头
String originalFilename = otaEntity.getType() + "_" + otaEntity.getVersion();
if (firmwarePath.contains(".")) {
String extension = firmwarePath.substring(firmwarePath.lastIndexOf("."));
originalFilename += extension;
}
// 清理文件名,移除不安全字符
String safeFilename = originalFilename.replaceAll("[^a-zA-Z0-9._-]", "_");
logger.info("Providing download for firmware ID: {}, filename: {}, size: {} bytes",
id, safeFilename, fileContent.length);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeFilename + "\"")
.body(fileContent);
} catch (IOException e) {
logger.error("Error reading firmware file for ID: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
} catch (Exception e) {
logger.error("Unexpected error during firmware download for ID: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/upload")
@Operation(summary = "上传固件文件")
@RequiresPermissions("sys:role:superAdmin")
public Result<String> uploadFirmware(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return new Result<String>().error("上传文件不能为空");
}
// 检查文件扩展名
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
return new Result<String>().error("文件名不能为空");
}
String extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase();
if (!extension.equals(".bin") && !extension.equals(".apk")) {
return new Result<String>().error("只允许上传.bin和.apk格式的文件");
}
try {
// 计算文件的MD5值
String md5 = calculateMD5(file);
// 设置存储路径
String uploadDir = "uploadfile";
Path uploadPath = Paths.get(uploadDir);
// 如果目录不存在,创建目录
if (!Files.exists(uploadPath)) {
Files.createDirectories(uploadPath);
}
// 使用MD5作为文件名,固定使用.bin扩展名
String uniqueFileName = md5 + extension;
Path filePath = uploadPath.resolve(uniqueFileName);
// 检查文件是否已存在
if (Files.exists(filePath)) {
return new Result<String>().ok(filePath.toString());
}
// 保存文件
Files.copy(file.getInputStream(), filePath);
// 返回文件路径
return new Result<String>().ok(filePath.toString());
} catch (IOException | NoSuchAlgorithmException e) {
return new Result<String>().error("文件上传失败:" + e.getMessage());
}
}
private String calculateMD5(MultipartFile file) throws IOException, NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(file.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
@@ -0,0 +1,15 @@
package xiaozhi.modules.device.dao;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.device.entity.OtaEntity;
/**
* OTA固件管理
*/
@Mapper
public interface OtaDao extends BaseMapper<OtaEntity> {
}
@@ -20,6 +20,9 @@ public class DeviceReportRespDTO {
@Schema(description = "固件版本信息") @Schema(description = "固件版本信息")
private Firmware firmware; private Firmware firmware;
@Schema(description = "WebSocket配置")
private Websocket websocket;
@Getter @Getter
@Setter @Setter
public static class Firmware { public static class Firmware {
@@ -44,6 +47,8 @@ public class DeviceReportRespDTO {
@Schema(description = "激活码信息: 激活地址") @Schema(description = "激活码信息: 激活地址")
private String message; private String message;
@Schema(description = "挑战码")
private String challenge;
} }
@Getter @Getter
@@ -58,4 +63,11 @@ public class DeviceReportRespDTO {
@Schema(description = "时区偏移量,单位为分钟") @Schema(description = "时区偏移量,单位为分钟")
private Integer timezone_offset; private Integer timezone_offset;
} }
@Getter
@Setter
public static class Websocket {
@Schema(description = "WebSocket服务器地址")
private String url;
}
} }
@@ -0,0 +1,61 @@
package xiaozhi.modules.device.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("ai_ota")
@Schema(description = "固件信息")
public class OtaEntity {
@TableId(type = IdType.ASSIGN_UUID)
@Schema(description = "ID")
private String id;
@Schema(description = "固件名称")
private String firmwareName;
@Schema(description = "固件类型")
private String type;
@Schema(description = "版本号")
private String version;
@Schema(description = "文件大小(字节)")
private Long size;
@Schema(description = "备注/说明")
private String remark;
@Schema(description = "固件路径")
private String firmwarePath;
@Schema(description = "排序")
private Integer sort;
@Schema(description = "更新者")
@TableField(fill = FieldFill.UPDATE)
private Long updater;
@Schema(description = "更新时间")
@TableField(fill = FieldFill.UPDATE)
private Date updateDate;
@Schema(description = "创建者")
@TableField(fill = FieldFill.INSERT)
private Long creator;
@Schema(description = "创建时间")
@TableField(fill = FieldFill.INSERT)
private Date createDate;
}
@@ -3,18 +3,14 @@ package xiaozhi.modules.device.service;
import java.util.List; import java.util.List;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
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.entity.DeviceEntity; import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.vo.UserShowDeviceListVO; import xiaozhi.modules.device.vo.UserShowDeviceListVO;
public interface DeviceService { public interface DeviceService extends BaseService<DeviceEntity> {
/**
* 根据Mac地址获取设备信息
*/
DeviceEntity getDeviceById(String macAddress);
/** /**
* 检查设备是否激活 * 检查设备是否激活
@@ -0,0 +1,22 @@
package xiaozhi.modules.device.service;
import java.util.Map;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.device.entity.OtaEntity;
/**
* OTA固件管理
*/
public interface OtaService extends BaseService<OtaEntity> {
PageData<OtaEntity> page(Map<String, Object> params);
boolean save(OtaEntity entity);
void update(OtaEntity entity);
void delete(String[] ids);
OtaEntity getLatestOta(String type);
}
@@ -6,21 +6,26 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TimeZone; import java.util.TimeZone;
import java.util.concurrent.TimeUnit; import java.util.UUID;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant; import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.user.UserDetail; import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
@@ -30,39 +35,24 @@ 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.entity.DeviceEntity; import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.entity.OtaEntity;
import xiaozhi.modules.device.service.DeviceService; import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.device.service.OtaService;
import xiaozhi.modules.device.vo.UserShowDeviceListVO; 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;
@Slf4j
@Service @Service
@AllArgsConstructor
public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity> implements DeviceService { public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity> implements DeviceService {
private final DeviceDao deviceDao; private final DeviceDao deviceDao;
private final SysUserUtilService sysUserUtilService; private final SysUserUtilService sysUserUtilService;
private final SysParamsService sysParamsService;
private final String frontedUrl; private final RedisUtils redisUtils;
private final OtaService otaService;
private final RedisTemplate<String, Object> redisTemplate;
// 添加构造函数来初始化 deviceMapper
public DeviceServiceImpl(DeviceDao deviceDao, SysUserUtilService sysUserUtilService,
SysParamsService sysParamsService,
RedisTemplate<String, Object> redisTemplate) {
this.deviceDao = deviceDao;
this.sysUserUtilService = sysUserUtilService;
this.frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
this.redisTemplate = redisTemplate;
}
@Override
public DeviceEntity getDeviceById(String deviceId) {
LambdaQueryWrapper<DeviceEntity> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(DeviceEntity::getId, deviceId);
return deviceDao.selectOne(queryWrapper);
}
@Override @Override
public Boolean deviceActivation(String agentId, String activationCode) { public Boolean deviceActivation(String agentId, String activationCode) {
@@ -70,14 +60,14 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
throw new RenException("激活码不能为空"); throw new RenException("激活码不能为空");
} }
String deviceKey = "ota:activation:code:" + activationCode; String deviceKey = "ota:activation:code:" + activationCode;
Object cacheDeviceId = redisTemplate.opsForValue().get(deviceKey); Object cacheDeviceId = redisUtils.get(deviceKey);
if (cacheDeviceId == null) { if (cacheDeviceId == null) {
throw new RenException("激活码错误"); throw new RenException("激活码错误");
} }
String deviceId = (String) cacheDeviceId; String deviceId = (String) cacheDeviceId;
String safeDeviceId = deviceId.replace(":", "_").toLowerCase(); String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId); String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId);
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(cacheDeviceKey); Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(cacheDeviceKey);
if (cacheMap == null) { if (cacheMap == null) {
throw new RenException("激活码错误"); throw new RenException("激活码错误");
} }
@@ -107,6 +97,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
deviceEntity.setMacAddress(macAddress); deviceEntity.setMacAddress(macAddress);
deviceEntity.setUserId(user.getId()); deviceEntity.setUserId(user.getId());
deviceEntity.setCreator(user.getId()); deviceEntity.setCreator(user.getId());
deviceEntity.setAutoUpdate(1);
deviceEntity.setCreateDate(currentTime); deviceEntity.setCreateDate(currentTime);
deviceEntity.setUpdater(user.getId()); deviceEntity.setUpdater(user.getId());
deviceEntity.setUpdateDate(currentTime); deviceEntity.setUpdateDate(currentTime);
@@ -114,8 +105,8 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
deviceDao.insert(deviceEntity); deviceDao.insert(deviceEntity);
// 清理redis缓存 // 清理redis缓存
redisTemplate.delete(cacheDeviceKey); redisUtils.delete(cacheDeviceKey);
redisTemplate.delete(deviceKey); redisUtils.delete(deviceKey);
return true; return true;
} }
@@ -124,19 +115,49 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
DeviceReportReqDTO deviceReport) { DeviceReportReqDTO deviceReport) {
DeviceReportRespDTO response = new DeviceReportRespDTO(); DeviceReportRespDTO response = new DeviceReportRespDTO();
response.setServer_time(buildServerTime()); response.setServer_time(buildServerTime());
// todo: 此处是固件信息,目前是针对固件上传上来的版本号再返回回去
// 在未来开发了固件更新功能,需要更换此处代码,
// 或写定时任务定期请求虾哥的OTA,获取最新的版本讯息保存到服务内
DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
firmware.setVersion(deviceReport.getApplication().getVersion());
firmware.setUrl("http://localhost:8002/xiaozhi/ota/download");
response.setFirmware(firmware);
DeviceEntity deviceById = getDeviceById(macAddress); DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
if (deviceById != null) { // 如果设备存在,则更新上次连接时间
if (deviceById == null || deviceById.getAutoUpdate() != 0) {
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
response.setFirmware(firmware);
} else {
response.setFirmware(null);
}
// 添加WebSocket配置
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
// 从系统参数获取WebSocket URL,如果未配置则使用默认值
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
websocket.setUrl(wsUrl);
} else {
String[] wsUrls = wsUrl.split("\\;");
if (wsUrls.length > 0) {
// 随机选择一个WebSocket URL
websocket.setUrl(wsUrls[RandomUtil.randomInt(0, wsUrls.length)]);
} else {
log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
websocket.setUrl("ws://xiaozhi.server.com:8000/xiaozhi/v1/");
}
}
response.setWebsocket(websocket);
if (deviceById != null) {
// 如果设备存在,则更新上次连接时间
deviceById.setLastConnectedAt(new Date()); deviceById.setLastConnectedAt(new Date());
if (deviceReport.getApplication() != null
&& StringUtils.isNotBlank(deviceReport.getApplication().getVersion())) {
deviceById.setAppVersion(deviceReport.getApplication().getVersion());
}
deviceDao.updateById(deviceById); deviceDao.updateById(deviceById);
} else { // 如果设备不存在,则生成激活码 } else {
// 如果设备不存在,则生成激活码
DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport); DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
response.setActivation(code); response.setActivation(code);
} }
@@ -229,7 +250,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
public String geCodeByDeviceId(String deviceId) { public String geCodeByDeviceId(String deviceId) {
String dataKey = getDeviceCacheKey(deviceId); String dataKey = getDeviceCacheKey(deviceId);
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(dataKey); Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(dataKey);
if (cacheMap != null && cacheMap.containsKey("activation_code")) { if (cacheMap != null && cacheMap.containsKey("activation_code")) {
String cachedCode = (String) cacheMap.get("activation_code"); String cachedCode = (String) cacheMap.get("activation_code");
return cachedCode; return cachedCode;
@@ -250,18 +271,23 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
if (StringUtils.isNotBlank(cachedCode)) { if (StringUtils.isNotBlank(cachedCode)) {
code.setCode(cachedCode); code.setCode(cachedCode);
String frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
code.setMessage(frontedUrl + "\n" + cachedCode); code.setMessage(frontedUrl + "\n" + cachedCode);
code.setChallenge(deviceId);
} else { } else {
String newCode = RandomUtil.randomNumbers(6); String newCode = RandomUtil.randomNumbers(6);
code.setCode(newCode); code.setCode(newCode);
String frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
code.setMessage(frontedUrl + "\n" + newCode); code.setMessage(frontedUrl + "\n" + newCode);
code.setChallenge(deviceId);
Map<String, Object> dataMap = new HashMap<>(); Map<String, Object> dataMap = new HashMap<>();
dataMap.put("id", deviceId); dataMap.put("id", deviceId);
dataMap.put("mac_address", deviceId); dataMap.put("mac_address", deviceId);
dataMap.put("board", (deviceReport.getChipModelName() != null) ? deviceReport.getChipModelName() dataMap.put("board", (deviceReport.getBoard() != null && deviceReport.getBoard().getType() != null)
: (deviceReport.getBoard() != null ? deviceReport.getBoard().getType() : "unknown")); ? deviceReport.getBoard().getType()
: (deviceReport.getChipModelName() != null ? deviceReport.getChipModelName() : "unknown"));
dataMap.put("app_version", (deviceReport.getApplication() != null) dataMap.put("app_version", (deviceReport.getApplication() != null)
? deviceReport.getApplication().getVersion() ? deviceReport.getApplication().getVersion()
: null); : null);
@@ -271,13 +297,77 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
// 写入主数据 key // 写入主数据 key
String dataKey = getDeviceCacheKey(deviceId); String dataKey = getDeviceCacheKey(deviceId);
redisTemplate.opsForHash().putAll(dataKey, dataMap); redisUtils.set(dataKey, dataMap);
redisTemplate.expire(dataKey, 24, TimeUnit.HOURS);
// 写入反查激活码 key // 写入反查激活码 key
String codeKey = "ota:activation:code:" + newCode; String codeKey = "ota:activation:code:" + newCode;
redisTemplate.opsForValue().set(codeKey, deviceId, 24, TimeUnit.HOURS); redisUtils.set(codeKey, deviceId);
} }
return code; return code;
} }
private DeviceReportRespDTO.Firmware buildFirmwareInfo(String type, String currentVersion) {
if (StringUtils.isBlank(type)) {
return null;
}
if (StringUtils.isBlank(currentVersion)) {
currentVersion = "0.0.0";
}
OtaEntity ota = otaService.getLatestOta(type);
DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
String downloadUrl = null;
if (ota != null) {
// 如果设备没有版本信息,或者OTA版本比设备版本新,则返回下载地址
if (compareVersions(ota.getVersion(), currentVersion) > 0) {
String otaUrl = sysParamsService.getValue(Constant.SERVER_OTA, true);
if (StringUtils.isBlank(otaUrl) || otaUrl.equals("null")) {
log.error("OTA地址未配置,请登录智控台,在参数管理找到【server.ota】配置");
// 尝试从请求中获取
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.getRequestAttributes())
.getRequest();
otaUrl = request.getRequestURL().toString();
}
// 将URL中的/ota/替换为/otaMag/download/
String uuid = UUID.randomUUID().toString();
redisUtils.set(RedisKeys.getOtaIdKey(uuid), ota.getId());
downloadUrl = otaUrl.replace("/ota/", "/otaMag/download/") + uuid;
}
}
firmware.setVersion(ota == null ? currentVersion : ota.getVersion());
firmware.setUrl(downloadUrl == null ? "" : downloadUrl);
return firmware;
}
/**
* 比较两个版本号
*
* @param version1 版本1
* @param version2 版本2
* @return 如果version1 > version2返回1version1 < version2返回-1,相等返回0
*/
private static int compareVersions(String version1, String version2) {
if (version1 == null || version2 == null) {
return 0;
}
String[] v1Parts = version1.split("\\.");
String[] v2Parts = version2.split("\\.");
int length = Math.max(v1Parts.length, v2Parts.length);
for (int i = 0; i < length; i++) {
int v1 = i < v1Parts.length ? Integer.parseInt(v1Parts[i]) : 0;
int v2 = i < v2Parts.length ? Integer.parseInt(v2Parts[i]) : 0;
if (v1 > v2) {
return 1;
} else if (v1 < v2) {
return -1;
}
}
return 0;
}
} }
@@ -0,0 +1,85 @@
package xiaozhi.modules.device.service.impl;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.micrometer.common.util.StringUtils;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.modules.device.dao.OtaDao;
import xiaozhi.modules.device.entity.OtaEntity;
import xiaozhi.modules.device.service.OtaService;
@Service
public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implements OtaService {
@Override
public PageData<OtaEntity> page(Map<String, Object> params) {
IPage<OtaEntity> page = baseDao.selectPage(
getPage(params, "update_date", true),
getWrapper(params));
return new PageData<>(page.getRecords(), page.getTotal());
}
private QueryWrapper<OtaEntity> getWrapper(Map<String, Object> params) {
String firmwareName = (String) params.get("firmwareName");
QueryWrapper<OtaEntity> wrapper = new QueryWrapper<>();
wrapper.like(StringUtils.isNotBlank(firmwareName), "firmware_name", firmwareName);
return wrapper;
}
@Override
public void update(OtaEntity entity) {
// 检查是否存在相同类型和版本的固件(排除当前记录)
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
.eq("type", entity.getType())
.eq("version", entity.getVersion())
.ne("id", entity.getId()); // 排除当前记录
if (baseDao.selectCount(queryWrapper) > 0) {
throw new RuntimeException("已存在相同类型和版本的固件,请修改后重试");
}
entity.setUpdateDate(new Date());
baseDao.updateById(entity);
}
@Override
public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
}
@Override
public boolean save(OtaEntity entity) {
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
.eq("type", entity.getType());
// 同类固件只保留最新的一条
List<OtaEntity> otaList = baseDao.selectList(queryWrapper);
if (otaList != null && otaList.size() > 0) {
OtaEntity otaBefore = otaList.getFirst();
entity.setId(otaBefore.getId());
baseDao.updateById(entity);
return true;
}
return baseDao.insert(entity) > 0;
}
@Override
public OtaEntity getLatestOta(String type) {
QueryWrapper<OtaEntity> wrapper = new QueryWrapper<>();
wrapper.eq("type", type)
.orderByDesc("update_date")
.last("LIMIT 1");
return baseDao.selectOne(wrapper);
}
}
@@ -3,6 +3,7 @@ package xiaozhi.modules.model.service.impl;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -19,6 +20,10 @@ import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.dao.AgentTemplateDao;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.model.dao.ModelConfigDao; import xiaozhi.modules.model.dao.ModelConfigDao;
import xiaozhi.modules.model.dto.ModelBasicInfoDTO; import xiaozhi.modules.model.dto.ModelBasicInfoDTO;
import xiaozhi.modules.model.dto.ModelConfigBodyDTO; import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
@@ -36,6 +41,9 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
private final ModelConfigDao modelConfigDao; private final ModelConfigDao modelConfigDao;
private final ModelProviderService modelProviderService; private final ModelProviderService modelProviderService;
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
private final AgentTemplateDao agentTemplateDao;
private final AgentTemplateService agentTemplateService;
private final AgentDao agentDao;
@Override @Override
public List<ModelBasicInfoDTO> getModelCodeList(String modelType, String modelName) { public List<ModelBasicInfoDTO> getModelCodeList(String modelType, String modelName) {
@@ -75,6 +83,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
// 再保存供应器提供的模型 // 再保存供应器提供的模型
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class); ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class);
modelConfigEntity.setModelType(modelType); modelConfigEntity.setModelType(modelType);
modelConfigEntity.setIsDefault(0);
modelConfigDao.insert(modelConfigEntity); modelConfigDao.insert(modelConfigEntity);
return ConvertUtils.sourceToTarget(modelConfigEntity, ModelConfigDTO.class); return ConvertUtils.sourceToTarget(modelConfigEntity, ModelConfigDTO.class);
} }
@@ -102,9 +111,64 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
@Override @Override
public void delete(String id) { public void delete(String id) {
// 查看是否是默认
ModelConfigEntity modelConfig = modelConfigDao.selectById(id);
if (modelConfig != null && modelConfig.getIsDefault() == 1) {
throw new RenException("该模型为默认模型,请先设置其他模型为默认模型");
}
// 验证是否有引用
checkAgentReference(id);
checkIntentConfigReference(id);
modelConfigDao.deleteById(id); modelConfigDao.deleteById(id);
} }
/**
* 检查智能体配置是否有引用
*
* @param modelId 模型ID
*/
private void checkAgentReference(String modelId) {
List<AgentEntity> agents = agentDao.selectList(
new QueryWrapper<AgentEntity>()
.eq("vad_model_id", modelId)
.or()
.eq("asr_model_id", modelId)
.or()
.eq("llm_model_id", modelId)
.or()
.eq("tts_model_id", modelId)
.or()
.eq("mem_model_id", modelId)
.or()
.eq("intent_model_id", modelId));
if (!agents.isEmpty()) {
String agentNames = agents.stream()
.map(AgentEntity::getAgentName)
.collect(Collectors.joining(""));
throw new RenException(String.format("该模型配置已被智能体[%s]引用,无法删除", agentNames));
}
}
/**
* 检查意图识别配置是否有引用
*
* @param modelId 模型ID
*/
private void checkIntentConfigReference(String modelId) {
ModelConfigEntity modelConfig = modelConfigDao.selectById(modelId);
if (modelConfig != null
&& "LLM".equals(modelConfig.getModelType() == null ? null : modelConfig.getModelType().toUpperCase())) {
List<ModelConfigEntity> intentConfigs = modelConfigDao.selectList(
new QueryWrapper<ModelConfigEntity>()
.eq("model_type", "Intent")
.like("config_json", "%" + modelId + "%"));
if (!intentConfigs.isEmpty()) {
throw new RenException("该LLM模型已被意图识别配置引用,无法删除");
}
}
}
@Override @Override
public String getModelNameById(String id) { public String getModelNameById(String id) {
if (StringUtils.isBlank(id)) { if (StringUtils.isBlank(id)) {
@@ -69,6 +69,7 @@ public class ShiroConfig {
*/ */
Map<String, String> filterMap = new LinkedHashMap<>(); Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/ota/**", "anon"); filterMap.put("/ota/**", "anon");
filterMap.put("/otaMag/download/**", "anon");
filterMap.put("/webjars/**", "anon"); filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon"); filterMap.put("/druid/**", "anon");
filterMap.put("/v3/api-docs/**", "anon"); filterMap.put("/v3/api-docs/**", "anon");
@@ -33,11 +33,15 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Override @Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) { public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
// 特殊用途的转换器
converters.add(new ByteArrayHttpMessageConverter()); converters.add(new ByteArrayHttpMessageConverter());
converters.add(new StringHttpMessageConverter());
converters.add(new ResourceHttpMessageConverter()); converters.add(new ResourceHttpMessageConverter());
converters.add(new AllEncompassingFormHttpMessageConverter());
// 通用转换器
converters.add(new StringHttpMessageConverter()); converters.add(new StringHttpMessageConverter());
converters.add(new AllEncompassingFormHttpMessageConverter());
// JSON 转换器
converters.add(jackson2HttpMessageConverter()); converters.add(jackson2HttpMessageConverter());
} }
@@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.TokenDTO; import xiaozhi.common.page.TokenDTO;
@@ -121,7 +122,7 @@ public class LoginController {
@Operation(summary = "公共配置") @Operation(summary = "公共配置")
public Result<Map<String, Object>> pubConfig() { public Result<Map<String, Object>> pubConfig() {
Map<String, Object> config = new HashMap<>(); Map<String, Object> config = new HashMap<>();
config.put("version", "0.3.6"); config.put("version", Constant.VERSION);
config.put("allowUserRegister", sysUserService.getAllowUserRegister()); config.put("allowUserRegister", sysUserService.getAllowUserRegister());
return new Result<Map<String, Object>>().ok(config); return new Result<Map<String, Object>>().ok(config);
} }
@@ -2,7 +2,10 @@ package xiaozhi.modules.sys.controller;
import java.util.Map; import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions; import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -11,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@@ -20,6 +24,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.annotation.LogOperation; import xiaozhi.common.annotation.LogOperation;
import xiaozhi.common.constant.Constant; import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.AssertUtils; import xiaozhi.common.validator.AssertUtils;
@@ -30,6 +35,7 @@ import xiaozhi.common.validator.group.UpdateGroup;
import xiaozhi.modules.config.service.ConfigService; import xiaozhi.modules.config.service.ConfigService;
import xiaozhi.modules.sys.dto.SysParamsDTO; import xiaozhi.modules.sys.dto.SysParamsDTO;
import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.utils.WebSocketValidator;
/** /**
* 参数管理 * 参数管理
@@ -44,6 +50,7 @@ import xiaozhi.modules.sys.service.SysParamsService;
public class SysParamsController { public class SysParamsController {
private final SysParamsService sysParamsService; private final SysParamsService sysParamsService;
private final ConfigService configService; private final ConfigService configService;
private final RestTemplate restTemplate;
@GetMapping("page") @GetMapping("page")
@Operation(summary = "分页") @Operation(summary = "分页")
@@ -91,11 +98,91 @@ public class SysParamsController {
// 效验数据 // 效验数据
ValidatorUtils.validateEntity(dto, UpdateGroup.class, DefaultGroup.class); ValidatorUtils.validateEntity(dto, UpdateGroup.class, DefaultGroup.class);
// 验证WebSocket地址列表
validateWebSocketUrls(dto.getParamCode(), dto.getParamValue());
// 验证OTA地址
validateOtaUrl(dto.getParamCode(), dto.getParamValue());
sysParamsService.update(dto); sysParamsService.update(dto);
configService.getConfig(false); configService.getConfig(false);
return new Result<Void>(); return new Result<Void>();
} }
/**
* 验证WebSocket地址列表
*
* @param urls WebSocket地址列表,以分号分隔
* @return 验证结果
*/
private void validateWebSocketUrls(String paramCode, String urls) {
if (!paramCode.equals(Constant.SERVER_WEBSOCKET)) {
return;
}
String[] wsUrls = urls.split("\\;");
if (wsUrls.length == 0) {
throw new RenException("WebSocket地址列表不能为空");
}
for (String url : wsUrls) {
if (StringUtils.isNotBlank(url)) {
// 检查是否包含localhost或127.0.0.1
if (url.contains("localhost") || url.contains("127.0.0.1")) {
throw new RenException("WebSocket地址不能使用localhost或127.0.0.1");
}
// 验证WebSocket地址格式
if (!WebSocketValidator.validateUrlFormat(url)) {
throw new RenException("WebSocket地址格式不正确: " + url);
}
// 测试WebSocket连接
if (!WebSocketValidator.testConnection(url)) {
throw new RenException("WebSocket连接测试失败: " + url);
}
}
}
}
/**
* 验证OTA地址
*/
private void validateOtaUrl(String paramCode, String url) {
if (!paramCode.equals(Constant.SERVER_OTA)) {
return;
}
if (StringUtils.isBlank(url) || url.equals("null")) {
throw new RenException("OTA地址不能为空");
}
// 检查是否包含localhost或127.0.0.1
if (url.contains("localhost") || url.contains("127.0.0.1")) {
throw new RenException("OTA地址不能使用localhost或127.0.0.1");
}
// 验证URL格式
if (!url.toLowerCase().startsWith("http")) {
throw new RenException("OTA地址必须以http或https开头");
}
if (!url.endsWith("/ota/")) {
throw new RenException("OTA地址必须以/ota/结尾");
}
try {
// 发送GET请求
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
if (response.getStatusCode() != HttpStatus.OK) {
throw new RenException("OTA接口访问失败,状态码:" + response.getStatusCode());
}
// 检查响应内容是否包含OTA相关信息
String body = response.getBody();
if (body == null || !body.contains("OTA")) {
throw new RenException("OTA接口返回内容格式不正确,可能不是一个真实的OTA接口");
}
} catch (Exception e) {
throw new RenException("OTA接口验证失败:" + e.getMessage());
}
}
@PostMapping("/delete") @PostMapping("/delete")
@Operation(summary = "删除") @Operation(summary = "删除")
@LogOperation("删除") @LogOperation("删除")
@@ -0,0 +1,46 @@
package xiaozhi.modules.sys.utils;
import java.util.concurrent.CompletableFuture;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
public class WebSocketTestHandler implements WebSocketHandler {
private final CompletableFuture<Boolean> future;
public WebSocketTestHandler(CompletableFuture<Boolean> future) {
this.future = future;
}
@Override
public void afterConnectionEstablished(WebSocketSession session) {
future.complete(true);
try {
session.close();
} catch (Exception e) {
// 忽略关闭异常
}
}
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) {
// 不需要处理消息
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
future.complete(false);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) {
// 连接关闭时不做任何处理
}
@Override
public boolean supportsPartialMessages() {
return false;
}
}
@@ -0,0 +1,58 @@
package xiaozhi.modules.sys.utils;
import java.net.URI;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.socket.client.WebSocketClient;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
public class WebSocketValidator {
private static final Logger logger = LoggerFactory.getLogger(WebSocketValidator.class);
// WebSocket URL正则表达式
private static final Pattern WS_URL_PATTERN = Pattern
.compile("^wss?://[\\w.-]+(?:\\.[\\w.-]+)*(?::\\d+)?(?:/[\\w.-]*)*$");
/**
* 验证WebSocket地址格式
*
* @param url WebSocket地址
* @return 是否有效
*/
public static boolean validateUrlFormat(String url) {
if (StringUtils.isBlank(url)) {
return false;
}
return WS_URL_PATTERN.matcher(url.trim()).matches();
}
/**
* 测试WebSocket连接
*
* @param url WebSocket地址
* @return 是否可连接
*/
public static boolean testConnection(String url) {
if (!validateUrlFormat(url)) {
return false;
}
try {
WebSocketClient client = new StandardWebSocketClient();
CompletableFuture<Boolean> future = new CompletableFuture<>();
client.doHandshake(new WebSocketTestHandler(future), null, URI.create(url));
// 等待最多5秒获取连接结果
return future.get(5, TimeUnit.SECONDS);
} catch (Exception e) {
logger.error("WebSocket连接测试失败: {}", url, e);
return false;
}
}
}
@@ -24,7 +24,6 @@ public class TimbreDataDTO {
private String name; private String name;
@Schema(description = "备注") @Schema(description = "备注")
@NotBlank(message = "{timbre.remark.require}")
private String remark; private String remark;
@Schema(description = "排序") @Schema(description = "排序")
@@ -40,6 +39,5 @@ public class TimbreDataDTO {
private String ttsVoice; private String ttsVoice;
@Schema(description = "音频播放地址") @Schema(description = "音频播放地址")
@NotBlank(message = "{timbre.voiceDemo.require}")
private String voiceDemo; private String voiceDemo;
} }
@@ -1,3 +0,0 @@
-- 对0.3.0版本之前的参数进行修改
update `sys_params` set param_value = '.mp3;.wav;.p3' where param_code = 'plugins.play_music.music_ext';
update `ai_model_config` set config_json = '{\"type\": \"intent_llm\", \"llm\": \"LLM_ChatGLMLLM\"}' where id = 'Intent_intent_llm';
@@ -0,0 +1,7 @@
-- 调整意图识别配置
delete from `ai_model_config` where id = 'Intent_function_call';
INSERT INTO `ai_model_config` VALUES ('Intent_function_call', 'Intent', 'function_call', '函数调用意图识别', 0, 1, '{\"type\": \"function_call\", \"functions\": \"change_role;get_weather;get_news;play_music\"}', NULL, NULL, 3, NULL, NULL, NULL, NULL);
-- 增加单台设备每天最多聊天句数
delete from `sys_params` where id = 105;
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (105, 'device_max_output_size', '0', 'number', 1, '单台设备每天最多输出字数,0表示不限制');
@@ -0,0 +1,88 @@
-- 删除无用模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_LLM_doubao';
delete from `ai_model_provider` where id = 'SYSTEM_LLM_chatglm';
delete from `ai_model_provider` where id = 'SYSTEM_TTS_302ai';
delete from `ai_model_provider` where id = 'SYSTEM_TTS_gizwits';
-- 添加模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_ASR_TencentASR';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_TencentASR', 'ASR', 'tencent', '腾讯语音识别', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 4, 1, NOW(), 1, NOW());
-- 添加腾讯语音合成模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_TTS_TencentTTS';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_TTS_TencentTTS', 'TTS', 'tencent', '腾讯语音合成', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"region","label":"区域","type":"string"},{"key":"voice","label":"音色ID","type":"string"}]', 5, 1, NOW(), 1, NOW());
-- 添加edge音色
delete from `ai_tts_voice` where id in ('TTS_EdgeTTS0001', 'TTS_EdgeTTS0002', 'TTS_EdgeTTS0003', 'TTS_EdgeTTS0004', 'TTS_EdgeTTS0005', 'TTS_EdgeTTS0006', 'TTS_EdgeTTS0007', 'TTS_EdgeTTS0008', 'TTS_EdgeTTS0009', 'TTS_EdgeTTS0010', 'TTS_EdgeTTS0011');
INSERT INTO `ai_tts_voice` VALUES
('TTS_EdgeTTS0001', 'TTS_EdgeTTS', 'EdgeTTS女声-晓晓', 'zh-CN-XiaoxiaoNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0002', 'TTS_EdgeTTS', 'EdgeTTS男声-云扬', 'zh-CN-YunyangNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0003', 'TTS_EdgeTTS', 'EdgeTTS女声-晓伊', 'zh-CN-XiaoyiNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0004', 'TTS_EdgeTTS', 'EdgeTTS男声-云健', 'zh-CN-YunjianNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0005', 'TTS_EdgeTTS', 'EdgeTTS男声-云希', 'zh-CN-YunxiNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0006', 'TTS_EdgeTTS', 'EdgeTTS男声-云夏', 'zh-CN-YunxiaNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0007', 'TTS_EdgeTTS', 'EdgeTTS女声-辽宁小贝', 'zh-CN-liaoning-XiaobeiNeural', '辽宁', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0008', 'TTS_EdgeTTS', 'EdgeTTS女声-陕西小妮', 'zh-CN-shaanxi-XiaoniNeural', '陕西', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0009', 'TTS_EdgeTTS', 'EdgeTTS女声-香港海佳', 'zh-HK-HiuGaaiNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0010', 'TTS_EdgeTTS', 'EdgeTTS女声-香港海曼', 'zh-HK-HiuMaanNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0011', 'TTS_EdgeTTS', 'EdgeTTS男声-香港万龙', 'zh-HK-WanLungNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL);
-- DoubaoTTS音色
delete from `ai_tts_voice` where id in ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS0002', 'TTS_DoubaoTTS0003', 'TTS_DoubaoTTS0004', 'TTS_DoubaoTTS0005');
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS', '通用女声', 'BV001_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV001.mp3', NULL, 3, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0002', 'TTS_DoubaoTTS', '通用男声', 'BV002_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV002.mp3', NULL, 2, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0003', 'TTS_DoubaoTTS', '阳光男生', 'BV056_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV056.mp3', NULL, 4, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0004', 'TTS_DoubaoTTS', '奶气萌娃', 'BV051_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV051.mp3', NULL, 5, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0005', 'TTS_DoubaoTTS', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '普通话', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 6, NULL, NULL, NULL, NULL);
-- 修正CosyVoiceSiliconflow音色
delete from `ai_tts_voice` where id in ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow0002');
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow', 'CosyVoice男声', 'FunAudioLLM/CosyVoice2-0.5B:alex', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0002', 'TTS_CosyVoiceSiliconflow', 'CosyVoice女声', 'FunAudioLLM/CosyVoice2-0.5B:bella', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
-- CozeCnTTS音色
delete from `ai_tts_voice` where id = 'TTS_CozeCnTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_CozeCnTTS0001', 'TTS_CozeCnTTS', 'CozeCn音色', '7426720361733046281', '中文', NULL, NULL, 7, NULL, NULL, NULL, NULL);
-- MinimaxTTS音色
delete from `ai_tts_voice` where id = 'TTS_MinimaxTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxTTS0001', 'TTS_MinimaxTTS', 'Minimax少女音', 'female-shaonv', '中文', NULL, NULL, 8, NULL, NULL, NULL, NULL);
-- AliyunTTS音色
delete from `ai_tts_voice` where id = 'TTS_AliyunTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunTTS0001', 'TTS_AliyunTTS', '阿里云小云', 'xiaoyun', '中文', NULL, NULL, 9, NULL, NULL, NULL, NULL);
-- TTS302AI音色
delete from `ai_tts_voice` where id = 'TTS_TTS302AI0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_TTS302AI0001', 'TTS_TTS302AI', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 10, NULL, NULL, NULL, NULL);
-- GizwitsTTS音色
delete from `ai_tts_voice` where id = 'TTS_GizwitsTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_GizwitsTTS0001', 'TTS_GizwitsTTS', '机智云湾湾', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 11, NULL, NULL, NULL, NULL);
-- ACGNTTS音色
delete from `ai_tts_voice` where id = 'TTS_ACGNTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_ACGNTTS0001', 'TTS_ACGNTTS', 'ACG音色', '1695', '中文', NULL, NULL, 12, NULL, NULL, NULL, NULL);
-- OpenAITTS音色
delete from `ai_tts_voice` where id = 'TTS_OpenAITTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_OpenAITTS0001', 'TTS_OpenAITTS', 'OpenAI男声', 'onyx', '中文', NULL, NULL, 13, NULL, NULL, NULL, NULL);
-- 添加腾讯语音合成音色
delete from `ai_tts_voice` where id = 'TTS_TencentTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_TencentTTS0001', 'TTS_TencentTTS', '智瑜', '101001', '中文', NULL, NULL, 1, NULL, NULL, NULL, NULL);
-- 其他音色
delete from `ai_tts_voice` where id = 'TTS_FishSpeech0000';
INSERT INTO `ai_tts_voice` VALUES ('TTS_FishSpeech0000', 'TTS_FishSpeech', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
delete from `ai_tts_voice` where id = 'TTS_GPT_SOVITS_V20000';
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V20000', 'TTS_GPT_SOVITS_V2', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
delete from `ai_tts_voice` where id in ('TTS_GPT_SOVITS_V30000', 'TTS_CustomTTS0000');
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V30000', 'TTS_GPT_SOVITS_V3', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CustomTTS0000', 'TTS_CustomTTS', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
@@ -0,0 +1,49 @@
-- 添加火山引擎边缘大模型网关(LLM + TTS)
INSERT INTO `ai_model_config` VALUES ('LLM_VolcesAiGatewayLLM', 'LLM', 'VolcesAiGatewayTTS', '火山引擎边缘大模型网关', 0, 1, '{\"type\": \"openai\", \"base_url\": \"https://ai-gateway.vei.volces.com/v1\", \"model_name\": \"doubao-pro-32k-functioncall\", \"api_key\": \"你的网关访问密钥\"}', NULL, NULL, 14, NULL, NULL, NULL, NULL);
INSERT INTO `ai_model_config` VALUES ('TTS_VolcesAiGatewayTTS', 'TTS', 'VolcesAiGatewayTTS', '火山引擎边缘大模型网关', 0, 1, '{\"type\": \"openai\", \"api_key\": \"你的网关访问密钥\", \"api_url\": \"https://ai-gateway.vei.volces.com/v1/audio/speech\", \"model\": \"doubao-tts\", \"voice\": \"zh_male_shaonianzixin_moon_bigtts\", \"speed\": 1, \"output_dir\": \"tmp/\"}', NULL, NULL, 15, NULL, NULL, NULL, NULL);
-- 添加火山引擎边缘大模型网关语音合成音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0001', 'TTS_VolcesAiGatewayTTS', '灿灿/Shiny', 'zh_female_cancan_mars_bigtts', '中文、美式英语', NULL, NULL, 1, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0002', 'TTS_VolcesAiGatewayTTS', '清新女声', 'zh_female_qingxinnvsheng_mars_bigtts', '中文', NULL, NULL, 2, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0003', 'TTS_VolcesAiGatewayTTS', '爽快思思/Skye', 'zh_female_shuangkuaisisi_moon_bigtts', '中文、美式英语', NULL, NULL, 3, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0004', 'TTS_VolcesAiGatewayTTS', '温暖阿虎/Alvin', 'zh_male_wennuanahu_moon_bigtts', '中文、美式英语', NULL, NULL, 4, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0005', 'TTS_VolcesAiGatewayTTS', '少年梓辛/Brayan', 'zh_male_shaonianzixin_moon_bigtts', '中文、美式英语', NULL, NULL, 5, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0006', 'TTS_VolcesAiGatewayTTS', '知性女声', 'zh_female_zhixingnvsheng_mars_bigtts', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0007', 'TTS_VolcesAiGatewayTTS', '清爽男大', 'zh_male_qingshuangnanda_mars_bigtts', '中文', NULL, NULL, 7, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0008', 'TTS_VolcesAiGatewayTTS', '邻家女孩', 'zh_female_linjianvhai_moon_bigtts', '中文', NULL, NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0009', 'TTS_VolcesAiGatewayTTS', '渊博小叔', 'zh_male_yuanboxiaoshu_moon_bigtts', '中文', NULL, NULL, 9, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0010', 'TTS_VolcesAiGatewayTTS', '阳光青年', 'zh_male_yangguangqingnian_moon_bigtts', '中文', NULL, NULL, 10, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0011', 'TTS_VolcesAiGatewayTTS', '甜美小源', 'zh_female_tianmeixiaoyuan_moon_bigtts', '中文', NULL, NULL, 11, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0012', 'TTS_VolcesAiGatewayTTS', '清澈梓梓', 'zh_female_qingchezizi_moon_bigtts', '中文', NULL, NULL, 12, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0013', 'TTS_VolcesAiGatewayTTS', '解说小明', 'zh_male_jieshuoxiaoming_moon_bigtts', '中文', NULL, NULL, 13, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0014', 'TTS_VolcesAiGatewayTTS', '开朗姐姐', 'zh_female_kailangjiejie_moon_bigtts', '中文', NULL, NULL, 14, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0015', 'TTS_VolcesAiGatewayTTS', '邻家男孩', 'zh_male_linjiananhai_moon_bigtts', '中文', NULL, NULL, 15, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0016', 'TTS_VolcesAiGatewayTTS', '甜美悦悦', 'zh_female_tianmeiyueyue_moon_bigtts', '中文', NULL, NULL, 16, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0017', 'TTS_VolcesAiGatewayTTS', '心灵鸡汤', 'zh_female_xinlingjitang_moon_bigtts', '中文', NULL, NULL, 17, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0018', 'TTS_VolcesAiGatewayTTS', '知性温婉', 'ICL_zh_female_zhixingwenwan_tob', '中文', NULL, NULL, 18, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0019', 'TTS_VolcesAiGatewayTTS', '暖心体贴', 'ICL_zh_male_nuanxintitie_tob', '中文', NULL, NULL, 19, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0020', 'TTS_VolcesAiGatewayTTS', '温柔文雅', 'ICL_zh_female_wenrouwenya_tob', '中文', NULL, NULL, 20, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0021', 'TTS_VolcesAiGatewayTTS', '开朗轻快', 'ICL_zh_male_kailangqingkuai_tob', '中文', NULL, NULL, 21, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0022', 'TTS_VolcesAiGatewayTTS', '活泼爽朗', 'ICL_zh_male_huoposhuanglang_tob', '中文', NULL, NULL, 22, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0023', 'TTS_VolcesAiGatewayTTS', '率真小伙', 'ICL_zh_male_shuaizhenxiaohuo_tob', '中文', NULL, NULL, 23, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0024', 'TTS_VolcesAiGatewayTTS', '温柔小哥', 'zh_male_wenrouxiaoge_mars_bigtts', '中文', NULL, NULL, 24, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0025', 'TTS_VolcesAiGatewayTTS', 'Smith', 'en_male_smith_mars_bigtts', '英式英语', NULL, NULL, 25, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0026', 'TTS_VolcesAiGatewayTTS', 'Anna', 'en_female_anna_mars_bigtts', '英式英语', NULL, NULL, 26, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0027', 'TTS_VolcesAiGatewayTTS', 'Adam', 'en_male_adam_mars_bigtts', '美式英语', NULL, NULL, 27, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0028', 'TTS_VolcesAiGatewayTTS', 'Sarah', 'en_female_sarah_mars_bigtts', '澳洲英语', NULL, NULL, 28, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0029', 'TTS_VolcesAiGatewayTTS', 'Dryw', 'en_male_dryw_mars_bigtts', '澳洲英语', NULL, NULL, 29, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0030', 'TTS_VolcesAiGatewayTTS', 'かずね(和音)', 'multi_male_jingqiangkanye_moon_bigtts', '日语、西语', NULL, NULL, 30, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0031', 'TTS_VolcesAiGatewayTTS', 'はるこ(晴子)', 'multi_female_shuangkuaisisi_moon_bigtts', '日语、西语', NULL, NULL, 31, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0032', 'TTS_VolcesAiGatewayTTS', 'ひろし(広志)', 'multi_male_wanqudashu_moon_bigtts', '日语、西语', NULL, NULL, 32, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0033', 'TTS_VolcesAiGatewayTTS', 'あけみ(朱美)', 'multi_female_gaolengyujie_moon_bigtts', '日语', NULL, NULL, 33, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0034', 'TTS_VolcesAiGatewayTTS', 'Amanda', 'en_female_amanda_mars_bigtts', '美式英语', NULL, NULL, 34, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0035', 'TTS_VolcesAiGatewayTTS', 'Jackson', 'en_male_jackson_mars_bigtts', '美式英语', NULL, NULL, 35, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0036', 'TTS_VolcesAiGatewayTTS', '京腔侃爷/Harmony', 'zh_male_jingqiangkanye_moon_bigtts', '中文-北京口音、英文', NULL, NULL, 36, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0037', 'TTS_VolcesAiGatewayTTS', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文-台湾口音', NULL, NULL, 37, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0038', 'TTS_VolcesAiGatewayTTS', '湾区大叔', 'zh_female_wanqudashu_moon_bigtts', '中文-广东口音', NULL, NULL, 38, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0039', 'TTS_VolcesAiGatewayTTS', '呆萌川妹', 'zh_female_daimengchuanmei_moon_bigtts', '中文-四川口音', NULL, NULL, 39, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0040', 'TTS_VolcesAiGatewayTTS', '广州德哥', 'zh_male_guozhoudege_moon_bigtts', '中文-广东口音', NULL, NULL, 40, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0041', 'TTS_VolcesAiGatewayTTS', '北京小爷', 'zh_male_beijingxiaoye_moon_bigtts', '中文-北京口音', NULL, NULL, 41, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0042', 'TTS_VolcesAiGatewayTTS', '浩宇小哥', 'zh_male_haoyuxiaoge_moon_bigtts', '中文-青岛口音', NULL, NULL, 42, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0043', 'TTS_VolcesAiGatewayTTS', '广西远舟', 'zh_male_guangxiyuanzhou_moon_bigtts', '中文-广西口音', NULL, NULL, 43, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0044', 'TTS_VolcesAiGatewayTTS', '妹坨洁儿', 'zh_female_meituojieer_moon_bigtts', '中文-长沙口音', NULL, NULL, 44, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0045', 'TTS_VolcesAiGatewayTTS', '豫州子轩', 'zh_male_yuzhouzixuan_moon_bigtts', '中文-河南口音', NULL, NULL, 45, NULL, NULL, NULL, NULL);
@@ -0,0 +1,30 @@
-- 增加server.ota,用于配置ota地址
delete from `sys_params` where id = 100;
delete from `sys_params` where id = 101;
delete from `sys_params` where id = 106;
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (106, 'server.websocket', 'null', 'string', 1, 'websocket地址,多个用;分隔');
delete from `sys_params` where id = 107;
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (107, 'server.ota', 'null', 'string', 1, 'ota地址');
-- 增加固件信息表
CREATE TABLE IF NOT EXISTS `ai_ota` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`firmware_name` varchar(100) DEFAULT NULL COMMENT '固件名称',
`type` varchar(50) DEFAULT NULL COMMENT '固件类型',
`version` varchar(50) DEFAULT NULL COMMENT '版本号',
`size` bigint DEFAULT NULL COMMENT '文件大小(字节)',
`remark` varchar(500) DEFAULT NULL COMMENT '备注/说明',
`firmware_path` varchar(255) DEFAULT NULL COMMENT '固件路径',
`sort` int unsigned DEFAULT '0' COMMENT '排序',
`updater` bigint DEFAULT NULL COMMENT '更新者',
`update_date` datetime DEFAULT NULL COMMENT '更新时间',
`creator` bigint DEFAULT NULL COMMENT '创建者',
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='固件信息表';
update ai_device set auto_update = 1;
@@ -1,44 +0,0 @@
-- 本文件用于初始化音色数据,无需手动执行,在项目启动时会自动执行
-- ----------------------------------------------------
-- 初始化音色数据
DELETE FROM `ai_tts_voice`;
-- EdgeTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_EdgeTTS0001', 'TTS_EdgeTTS', 'EdgeTTS女声', 'zh-CN-XiaoxiaoNeural', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/EdgeTTS.mp3', NULL, 1, NULL, NULL, NULL, NULL);
-- DoubaoTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS', '通用女声', 'BV001_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV001.mp3', NULL, 3, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0002', 'TTS_DoubaoTTS', '通用男声', 'BV002_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV002.mp3', NULL, 2, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0003', 'TTS_DoubaoTTS', '阳光男生', 'BV056_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV056.mp3', NULL, 4, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0004', 'TTS_DoubaoTTS', '奶气萌娃', 'BV051_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV051.mp3', NULL, 5, NULL, NULL, NULL, NULL);
-- CosyVoiceSiliconflow音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow', 'CosyVoice男声', 'alex', '中文', 'https://example.com/cosyvoice/alex.mp3', NULL, 6, NULL, NULL, NULL, NULL);
-- CozeCnTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_CozeCnTTS0001', 'TTS_CozeCnTTS', 'CozeCn音色', '7426720361733046281', '中文', 'https://example.com/cozecn/voice.mp3', NULL, 7, NULL, NULL, NULL, NULL);
-- MinimaxTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxTTS0001', 'TTS_MinimaxTTS', 'Minimax少女音', 'female-shaonv', '中文', 'https://example.com/minimax/female-shaonv.mp3', NULL, 8, NULL, NULL, NULL, NULL);
-- AliyunTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunTTS0001', 'TTS_AliyunTTS', '阿里云小云', 'xiaoyun', '中文', 'https://example.com/aliyun/xiaoyun.mp3', NULL, 9, NULL, NULL, NULL, NULL);
-- TTS302AI音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_TTS302AI0001', 'TTS_TTS302AI', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://example.com/302ai/wanwanxiaohe.mp3', NULL, 10, NULL, NULL, NULL, NULL);
-- GizwitsTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_GizwitsTTS0001', 'TTS_GizwitsTTS', '机智云湾湾', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://example.com/gizwits/wanwanxiaohe.mp3', NULL, 11, NULL, NULL, NULL, NULL);
-- ACGNTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_ACGNTTS0001', 'TTS_ACGNTTS', 'ACG音色', '1695', '中文', 'https://example.com/acgn/voice.mp3', NULL, 12, NULL, NULL, NULL, NULL);
-- OpenAITTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_OpenAITTS0001', 'TTS_OpenAITTS', 'OpenAI男声', 'onyx', '中文', 'https://example.com/openai/onyx.mp3', NULL, 13, NULL, NULL, NULL, NULL);
-- 其他音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_FishSpeech0000', 'TTS_FishSpeech', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V20000', 'TTS_GPT_SOVITS_V2', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V30000', 'TTS_GPT_SOVITS_V3', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CustomTTS0000', 'TTS_CustomTTS', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
@@ -16,13 +16,6 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202503141346.sql path: classpath:db/changelog/202503141346.sql
- changeSet:
id: 2025_tts_voive
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/2025_tts_voive.sql
- changeSet: - changeSet:
id: 202504082211 id: 202504082211
author: John author: John
@@ -58,3 +51,31 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202504151206.sql path: classpath:db/changelog/202504151206.sql
- changeSet:
id: 202504181536
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504181536.sql
- changeSet:
id: 202504221135
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504221135.sql
- changeSet:
id: 202504221555
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504221555.sql
- changeSet:
id: 202504251422
author: jiangkunyin
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504251422.sql
@@ -28,10 +28,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
timbre.languages.require=\u97F3\u8272\u7684\u8BED\u8A00\u4E0D\u53EF\u4EE5\u4E3A\u7A7A timbre.languages.require=\u97F3\u8272\u7684\u8BED\u8A00\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.name.require=\u97F3\u8272\u7684\u540D\u79F0\u4E0D\u53EF\u4EE5\u4E3A\u7A7A timbre.name.require=\u97F3\u8272\u7684\u540D\u79F0\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.remark.require=\u97F3\u8272\u7684\u5907\u6CE8\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4E3A\u7A7A timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
ota.device.not.found=\u8BBE\u5907\u672A\u627E\u5230 ota.device.not.found=\u8BBE\u5907\u672A\u627E\u5230
ota.device.need.bind={0} ota.device.need.bind={0}
@@ -27,10 +27,8 @@ sysuser.uuid.require=The unique identifier cannot be empty
timbre.languages.require=The language of the timbre cannot be empty timbre.languages.require=The language of the timbre cannot be empty
timbre.name.require=The name of the timbre cannot be empty timbre.name.require=The name of the timbre cannot be empty
timbre.remark.require=The remark of the timbre cannot be empty
timbre.ttsModelId.require=The TTS model ID of the timbre cannot be empty timbre.ttsModelId.require=The TTS model ID of the timbre cannot be empty
timbre.ttsVoice.require=The TTS voice of the timbre cannot be empty timbre.ttsVoice.require=The TTS voice of the timbre cannot be empty
timbre.voiceDemo.require=The voice demo of the timbre cannot be empty
ota.device.not.found=Device not found ota.device.not.found=Device not found
ota.device.need.bind={0} ota.device.need.bind={0}
@@ -27,10 +27,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
timbre.languages.require=\u97F3\u8272\u7684\u8A9E\u8A00\u4E0D\u53EF\u4EE5\u70BA\u7A7A timbre.languages.require=\u97F3\u8272\u7684\u8A9E\u8A00\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.name.require=\u97F3\u8272\u7684\u540D\u7A31\u4E0D\u53EF\u4EE5\u70BA\u7A7A timbre.name.require=\u97F3\u8272\u7684\u540D\u7A31\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.remark.require=\u97F3\u8272\u7684\u5099\u6CE8\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u9375\u4E0D\u53EF\u4EE5\u70BA\u7A7A timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u9375\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7DE8\u78BC\u4E0D\u53EF\u4EE5\u70BA\u7A7A timbre.ttsVoice.require=\u97F3\u8272\u7684\u7DE8\u78BC\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653E\u5730\u5740\u4E0D\u53EF\u4EE5\u70BA\u7A7A
ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230 ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230
ota.device.need.bind={0} ota.device.need.bind={0}
@@ -27,10 +27,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
timbre.languages.require=\u97F3\u8272\u7684\u8BED\u8A00\u4E0D\u53EF\u4EE5\u4E3A\u7A7A timbre.languages.require=\u97F3\u8272\u7684\u8BED\u8A00\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.name.require=\u97F3\u8272\u7684\u540D\u79F0\u4E0D\u53EF\u4EE5\u4E3A\u7A7A timbre.name.require=\u97F3\u8272\u7684\u540D\u79F0\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.remark.require=\u97F3\u8272\u7684\u5907\u6CE8\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4E3A\u7A7A timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230 ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230
ota.device.need.bind={0} ota.device.need.bind={0}
+1
View File
@@ -1,3 +1,4 @@
VUE_APP_API_BASE_URL=/xiaozhi VUE_APP_API_BASE_URL=/xiaozhi
VUE_APP_PUBLIC_PATH=/
# 是否开启CDN # 是否开启CDN
VUE_APP_USE_CDN=false VUE_APP_USE_CDN=false
+1
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"core-js": "^3.41.0", "core-js": "^3.41.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"element-ui": "^2.15.14", "element-ui": "^2.15.14",
"flyio": "^0.6.14", "flyio": "^0.6.14",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
File diff suppressed because one or more lines are too long
+2
View File
@@ -5,6 +5,7 @@ import device from './module/device.js'
import model from './module/model.js' import model from './module/model.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'
import ota from './module/ota.js'
/** /**
* 接口地址 * 接口地址
@@ -31,4 +32,5 @@ export default {
device, device,
model, model,
timbre, timbre,
ota
} }
@@ -51,4 +51,20 @@ export default {
}); });
}).send(); }).send();
}, },
enableOtaUpgrade(id, status, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/device/enableOta/${id}/${status}`)
.method('PUT')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('更新OTA状态失败:', err)
this.$message.error(err.msg || '更新OTA状态失败')
RequestService.reAjaxFun(() => {
this.enableOtaUpgrade(id, status, callback)
})
}).send()
},
} }
+123
View File
@@ -0,0 +1,123 @@
import { getServiceUrl } from '../api';
import RequestService from '../httpRequest';
export default {
// 分页查询OTA固件信息
getOtaList(params, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag`)
.method('GET')
.data(params)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('获取OTA固件列表失败:', err);
RequestService.reAjaxFun(() => {
this.getOtaList(params, callback);
});
}).send();
},
// 获取单个OTA固件信息
getOtaInfo(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/${id}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('获取OTA固件信息失败:', err);
RequestService.reAjaxFun(() => {
this.getOtaInfo(id, callback);
});
}).send();
},
// 保存OTA固件信息
saveOta(entity, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag`)
.method('POST')
.data(entity)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('保存OTA固件信息失败:', err);
RequestService.reAjaxFun(() => {
this.saveOta(entity, callback);
});
}).send();
},
// 更新OTA固件信息
updateOta(id, entity, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/${id}`)
.method('PUT')
.data(entity)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('更新OTA固件信息失败:', err);
RequestService.reAjaxFun(() => {
this.updateOta(id, entity, callback);
});
}).send();
},
// 删除OTA固件
deleteOta(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/${id}`)
.method('DELETE')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('删除OTA固件失败:', err);
RequestService.reAjaxFun(() => {
this.deleteOta(id, callback);
});
}).send();
},
// 上传固件文件
uploadFirmware(file, callback) {
const formData = new FormData();
formData.append('file', file);
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/upload`)
.method('POST')
.data(formData)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('上传固件文件失败:', err);
RequestService.reAjaxFun(() => {
this.uploadFirmware(file, callback);
});
}).send();
},
// 获取固件下载链接
getDownloadUrl(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/getDownloadUrl/${id}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('获取下载链接失败:', err);
RequestService.reAjaxFun(() => {
this.getDownloadUrl(id, callback);
});
}).send();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -77,7 +77,12 @@
</div> </div>
<div style="display: flex;justify-content: center;"> <div style="display: flex;justify-content: center;">
<el-button type="primary" @click="confirm" class="save-btn"> <el-button
type="primary"
@click="confirm"
class="save-btn"
:loading="saving"
:disabled="saving">
保存 保存
</el-button> </el-button>
</div> </div>
@@ -94,6 +99,7 @@ export default {
}, },
data() { data() {
return { return {
saving: false,
providers: [], providers: [],
dialogVisible: false, dialogVisible: false,
providersLoaded: false, providersLoaded: false,
@@ -175,6 +181,7 @@ export default {
}, },
handleClose() { handleClose() {
this.saving = false;
this.$emit('update:visible', false); this.$emit('update:visible', false);
}, },
initDynamicConfig() { initDynamicConfig() {
@@ -185,8 +192,11 @@ export default {
this.formData.configJson = newConfig; this.formData.configJson = newConfig;
}, },
confirm() { confirm() {
this.saving = true;
if (!this.formData.supplier) { if (!this.formData.supplier) {
this.$message.error('请选择供应器'); this.$message.error('请选择供应器');
this.saving = false;
return; return;
} }
@@ -206,11 +216,18 @@ export default {
} }
}; };
this.$emit('confirm', submitData); try {
this.$emit('update:visible', false); this.$emit('confirm', submitData);
this.resetForm(); this.$emit('update:visible', false);
this.resetForm();
} catch (e) {
console.error(e);
} finally {
this.saving = false;
}
}, },
resetForm() { resetForm() {
this.saving = false;
this.formData = { this.formData = {
modelName: '', modelName: '',
modelCode: '', modelCode: '',
@@ -11,7 +11,7 @@
<div style="height: 1px;background: #e8f0ff;" /> <div style="height: 1px;background: #e8f0ff;" />
<div style="margin: 22px 15px;"> <div style="margin: 22px 15px;">
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;"> <div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;">
<div style="color: red;display: inline-block;">*</div> 体名称 <div style="color: red;display: inline-block;">*</div> 体名称
</div> </div>
<div class="input-46" style="margin-top: 12px;"> <div class="input-46" style="margin-top: 12px;">
<el-input ref="inputRef" placeholder="请输入智能体名称.." v-model="wisdomBodyName" @keyup.enter.native="confirm" /> <el-input ref="inputRef" placeholder="请输入智能体名称.." v-model="wisdomBodyName" @keyup.enter.native="confirm" />
@@ -23,12 +23,6 @@
<div class="settings-btn" @click="handleConfigure"> <div class="settings-btn" @click="handleConfigure">
配置角色 配置角色
</div> </div>
<!-- <div class="settings-btn">-->
<!-- 声纹识别-->
<!-- </div>-->
<!-- <div class="settings-btn">-->
<!-- 历史对话-->
<!-- </div>-->
<div class="settings-btn" @click="handleDeviceManage"> <div class="settings-btn" @click="handleDeviceManage">
设备管理({{ device.deviceCount }}) 设备管理({{ device.deviceCount }})
</div> </div>
@@ -80,7 +74,7 @@ export default {
.settings-btn { .settings-btn {
font-weight: 500; font-weight: 500;
font-size: 10px; font-size: 12px;
color: #5778ff; color: #5778ff;
background: #e6ebff; background: #e6ebff;
width: auto; width: auto;
@@ -95,7 +89,7 @@ export default {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-top: 15px; margin-top: 15px;
font-size: 10px; font-size: 12px;
color: #979db1; color: #979db1;
font-weight: 400; font-weight: 400;
} }
@@ -0,0 +1,215 @@
<template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose" @open="handleOpen">
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="固件名称" prop="firmwareName">
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
</el-form-item>
<el-form-item label="固件类型" prop="type">
<el-select v-model="form.type" placeholder="请选择固件类型" style="width: 100%;" filterable :disabled="isTypeDisabled">
<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="version">
<el-input v-model="form.version" placeholder="请输入版本号(x.x.x格式)"></el-input>
</el-form-item>
<el-form-item label="固件文件" prop="firmwarePath">
<el-upload ref="upload" class="upload-demo" action="#" :http-request="handleUpload"
:before-upload="beforeUpload" :accept="'.bin,.apk'" :limit="1" :multiple="false" :auto-upload="true"
:on-remove="handleRemove">
<el-button size="small" type="primary">点击上传</el-button>
<div slot="tip" class="el-upload__tip">只能上传固件文件(.bin/.apk)且不超过100MB</div>
</el-upload>
<el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress"
:status="uploadStatus"></el-progress>
<div class="hint-text">
<span>温馨提示请上传xiaozhi.bin文件而不是merged-binary.bin文件</span>
</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input type="textarea" v-model="form.remark" placeholder="请输入备注信息"></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="handleCancel"> </el-button>
<el-button type="primary" @click="handleSubmit"> </el-button>
</div>
</el-dialog>
</template>
<script>
import Api from '@/apis/api';
import { FIRMWARE_TYPES } from '@/utils';
export default {
name: 'FirmwareDialog',
props: {
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
form: {
type: Object,
default: () => ({})
}
},
data() {
return {
firmwareTypes: FIRMWARE_TYPES,
uploadProgress: 0,
uploadStatus: '',
isUploading: false,
rules: {
firmwareName: [
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择固件类型', trigger: 'change' }
],
version: [
{ required: true, message: '请输入版本号', trigger: 'blur' },
{ pattern: /^\d+\.\d+\.\d+$/, message: '版本号格式不正确,请输入x.x.x格式', trigger: 'blur' }
],
firmwarePath: [
{ required: false, message: '请上传固件文件', trigger: 'change' }
]
}
}
},
computed: {
isTypeDisabled() {
// 如果有id,说明是编辑模式,禁用类型选择
return !!this.form.id
}
},
methods: {
handleClose() {
this.$refs.form.clearValidate();
this.$emit('cancel');
},
handleCancel() {
this.$refs.form.clearValidate();
this.$emit('cancel');
},
handleSubmit() {
this.$refs.form.validate(valid => {
if (valid) {
// 如果是新增模式且没有上传文件,则提示错误
if (!this.form.id && !this.form.firmwarePath) {
this.$message.error('请上传固件文件')
return
}
// 提交成功后将关闭对话框的逻辑交给父组件处理
this.$emit('submit', this.form)
}
})
},
beforeUpload(file) {
const isValidSize = file.size / 1024 / 1024 < 100
const isValidType = ['.bin', '.apk'].some(ext => file.name.toLowerCase().endsWith(ext))
if (!isValidType) {
this.$message.error('只能上传.bin/.apk格式的固件文件!')
return false
}
if (!isValidSize) {
this.$message.error('固件文件大小不能超过100MB!')
return false
}
return true
},
handleUpload(options) {
const { file } = options
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = true
// 使用setTimeout实现简单的0-50%过渡
const timer = setTimeout(() => {
if (this.uploadProgress < 50) { // 只有当进度小于50%时才设置
this.uploadProgress = 50
}
}, 1000)
Api.ota.uploadFirmware(file, (res) => {
clearTimeout(timer) // 清除定时器
res = res.data
if (res.code === 0) {
this.form.firmwarePath = res.data
this.form.size = file.size
this.uploadProgress = 100
this.uploadStatus = 'success'
this.$message.success('固件文件上传成功')
// 延迟2秒后隐藏进度条
setTimeout(() => {
this.isUploading = false
}, 2000)
} else {
this.uploadStatus = 'exception'
this.$message.error(res.msg || '文件上传失败')
this.isUploading = false
}
}, (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
// 只有当进度大于50%时才更新
if (progress > 50) {
this.uploadProgress = progress
}
// 如果上传完成但还没收到成功响应,保持进度条显示
if (progress === 100) {
this.uploadStatus = ''
}
}
})
},
handleRemove() {
this.form.firmwarePath = ''
this.form.size = 0
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = false
},
handleOpen() {
// 重置上传相关状态
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = false
// 重置表单中的文件相关字段
if (!this.form.id) { // 只在新增时重置
this.form.firmwarePath = ''
this.form.size = 0
// 重置上传组件
this.$nextTick(() => {
if (this.$refs.upload) {
this.$refs.upload.clearFiles()
}
})
}
}
}
}
</script>
<style lang="scss" scoped>
.upload-demo {
text-align: left;
}
.el-upload__tip {
line-height: 1.2;
padding-top: 5px;
color: #909399;
}
.hint-text {
display: flex;
align-items: center;
gap: 8px;
color: #979db1;
font-size: 11px;
}
</style>
+16 -6
View File
@@ -32,6 +32,12 @@
:style="{ filter: $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" /> :style="{ filter: $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" />
参数管理 参数管理
</div> </div>
<div v-if="isSuperAdmin" class="equipment-management"
:class="{ 'active-tab': $route.path === '/ota-management' }" @click="goOtaManagement">
<img loading="lazy" alt="" src="@/assets/header/firmware_update.png"
:style="{ filter: $route.path === '/ota-management' ? 'brightness(0) invert(1)' : 'None' }" />
OTA管理
</div>
</div> </div>
<!-- 右侧元素 --> <!-- 右侧元素 -->
@@ -105,6 +111,9 @@ export default {
goParamManagement() { goParamManagement() {
this.$router.push('/params-management') this.$router.push('/params-management')
}, },
goOtaManagement() {
this.$router.push('/ota-management')
},
// 获取用户信息 // 获取用户信息
fetchUserInfo() { fetchUserInfo() {
userApi.getUserInfo(({ data }) => { userApi.getUserInfo(({ data }) => {
@@ -170,7 +179,7 @@ export default {
.header { .header {
background: #f6fcfe66; background: #f6fcfe66;
border: 1px solid #fff; border: 1px solid #fff;
height: 53px !important; height: 63px !important;
min-width: 900px; min-width: 900px;
/* 设置最小宽度防止过度压缩 */ /* 设置最小宽度防止过度压缩 */
overflow: hidden; overflow: hidden;
@@ -197,7 +206,7 @@ export default {
} }
.brand-img { .brand-img {
height: 18px; height: 20px;
} }
.header-center { .header-center {
@@ -219,13 +228,13 @@ export default {
.equipment-management { .equipment-management {
padding: 0 9px; padding: 0 9px;
width: 82px; width: px;
height: 24px; height: 30px;
border-radius: 12px; border-radius: 15px;
background: #deeafe; background: #deeafe;
display: flex; display: flex;
justify-content: center; justify-content: center;
font-size: 10px; font-size: 14px;
font-weight: 500; font-weight: 500;
gap: 7px; gap: 7px;
color: #3d4566; color: #3d4566;
@@ -235,6 +244,7 @@ export default {
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
/* 防止导航按钮被压缩 */ /* 防止导航按钮被压缩 */
padding: 0px 15px;
} }
.equipment-management.active-tab { .equipment-management.active-tab {
@@ -76,7 +76,12 @@
</div> </div>
<div style="display: flex;justify-content: center;"> <div style="display: flex;justify-content: center;">
<el-button type="primary" @click="handleSave" class="save-btn"> <el-button
type="primary"
@click="handleSave"
class="save-btn"
:loading="saving"
:disabled="saving">
保存 保存
</el-button> </el-button>
</div> </div>
@@ -102,6 +107,7 @@ export default {
dialogVisible: this.visible, dialogVisible: this.visible,
providers: [], providers: [],
providersLoaded: false, providersLoaded: false,
saving: false,
allProvidersData: null, allProvidersData: null,
pendingProviderType: null, pendingProviderType: null,
pendingModelData: null, pendingModelData: null,
@@ -195,6 +201,8 @@ export default {
} }
}, },
handleSave() { handleSave() {
this.saving = true; // 开始保存加载
const provideCode = this.form.configJson.type; const provideCode = this.form.configJson.type;
const formData = { const formData = {
id: this.modelData.id, id: this.modelData.id,
@@ -209,8 +217,19 @@ export default {
...this.form.configJson, ...this.form.configJson,
} }
}; };
this.$emit("save", { provideCode, formData });
this.dialogVisible = false; this.$emit("save", {
provideCode,
formData,
done: () => {
this.saving = false; // 保存完成后回调
}
});
// 如果父组件不处理done回调,3秒后自动关闭加载状态
setTimeout(() => {
this.saving = false;
}, 3000);
}, },
loadProviders() { loadProviders() {
if (this.providersLoaded) return; if (this.providersLoaded) return;
@@ -40,7 +40,12 @@
</el-form> </el-form>
<div class="dialog-footer"> <div class="dialog-footer">
<el-button type="primary" @click="submit" class="save-btn"> <el-button
type="primary"
@click="submit"
class="save-btn"
:loading="saving"
:disabled="saving">
保存 保存
</el-button> </el-button>
<el-button @click="cancel" class="cancel-btn"> <el-button @click="cancel" class="cancel-btn">
@@ -76,6 +81,7 @@ export default {
data() { data() {
return { return {
dialogKey: Date.now(), dialogKey: Date.now(),
saving: false,
valueTypeOptions: [ valueTypeOptions: [
{ value: 'string', label: '字符串(string)' }, { value: 'string', label: '字符串(string)' },
{ value: 'number', label: '数字(number)' }, { value: 'number', label: '数字(number)' },
@@ -100,11 +106,22 @@ export default {
submit() { submit() {
this.$refs.form.validate((valid) => { this.$refs.form.validate((valid) => {
if (valid) { if (valid) {
this.$emit('submit', this.form); this.saving = true; // 开始加载
this.$emit('submit', {
form: this.form,
done: () => {
this.saving = false; // 加载完成
}
});
setTimeout(() => {
this.saving = false;
}, 3000);
} }
}); });
}, },
cancel() { cancel() {
this.saving = false; // 取消时重置状态
this.$emit('cancel'); this.$emit('cancel');
} }
}, },
+25 -20
View File
@@ -14,13 +14,16 @@
class="table-container" class="table-container"
ref="tableContainer" ref="tableContainer"
@scroll="handleScroll"> @scroll="handleScroll">
<el-table <el-table
v-loading="loading" v-loading="loading"
:data="filteredTtsModels" :data="filteredTtsModels"
style="width: 100%;" style="width: 100%;"
class="data-table" class="data-table"
header-row-class-name="table-header" header-row-class-name="table-header"
:fit="true"> :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>
@@ -187,28 +190,28 @@ export default {
}, },
methods: { methods: {
loadData() { loadData() {
this.loading = true;
const params = { const params = {
ttsModelId: this.ttsModelId, ttsModelId: this.ttsModelId,
page: this.currentPage, page: this.currentPage,
limit: this.pageSize, limit: this.pageSize,
name: this.searchQuery name: this.searchQuery
}; };
Api.timbre.getVoiceList(params, (data) => { Api.timbre.getVoiceList(params, (data) => {
if (data.code === 0) { if (data.code === 0) {
this.ttsModels = data.data.list this.ttsModels = data.data.list
.map(item => ({ .map(item => ({
id: item.id || '', id: item.id || '',
voiceCode: item.ttsVoice || '', voiceCode: item.ttsVoice || '',
voiceName: item.name || '未命名音色', voiceName: item.name || '未命名音色',
languageType: item.languages || '', languageType: item.languages || '',
remark: item.remark || '', remark: item.remark || '',
voiceDemo: item.voiceDemo || '', voiceDemo: item.voiceDemo || '',
selected: false, selected: false,
editing: false, editing: false,
sort: Number(item.sort) sort: Number(item.sort)
})) }))
.sort((a, b) => a.sort - b.sort); .sort((a, b) => a.sort - b.sort);
this.total = data.total; this.total = data.total;
} else { } else {
this.$message.error({ this.$message.error({
@@ -216,12 +219,14 @@ export default {
showClose: true showClose: true
}); });
} }
this.loading = false;
}, (err) => { }, (err) => {
console.error('加载失败:', err); console.error('加载失败:', err);
this.$message.error({ this.$message.error({
message: '加载音色数据失败', message: '加载音色数据失败',
showClose: true showClose: true
}); });
this.loading = false;
}); });
}, },
+12 -7
View File
@@ -62,13 +62,6 @@ const routes = [
return import('../views/ModelConfig.vue') return import('../views/ModelConfig.vue')
} }
}, },
{
path: '/test',
name: 'TestServer',
component: function () {
return import('../views/test.vue')
}
},
{ {
path: '/params-management', path: '/params-management',
name: 'ParamsManagement', name: 'ParamsManagement',
@@ -80,9 +73,21 @@ const routes = [
title: '参数管理' title: '参数管理'
} }
}, },
{
path: '/ota-management',
name: 'OtaManagement',
component: function () {
return import('../views/OtaManagement.vue')
},
meta: {
requiresAuth: true,
title: 'OTA管理'
}
},
] ]
const router = new VueRouter({ const router = new VueRouter({
base: process.env.VUE_APP_PUBLIC_PATH || '/',
routes routes
}) })
+21
View File
@@ -0,0 +1,21 @@
// 日期格式化函数
export function formatDate(date) {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hour = String(d.getHours()).padStart(2, '0');
const minute = String(d.getMinutes()).padStart(2, '0');
const second = String(d.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
// 文件大小格式化函数
export function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + units[i];
}
+60
View File
@@ -136,3 +136,63 @@ export function getUUID() {
}) })
} }
/**
* 固件类型选项
*/
export const FIRMWARE_TYPES = [
{ "key": "bread-compact-wifi", "name": "面包板新版接线(WiFi" },
{ "key": "bread-compact-wifi-lcd", "name": "面包板新版接线(WiFi+ LCD" },
{ "key": "bread-compact-ml307", "name": "面包板新版接线(ML307 AT" },
{ "key": "bread-compact-esp32", "name": "面包板(WiFi ESP32 DevKit" },
{ "key": "bread-compact-esp32-lcd", "name": "面包板(WiFi+ LCD ESP32 DevKit" },
{ "key": "df-k10", "name": "DFRobot 行空板 k10" },
{ "key": "esp32-cgc", "name": "ESP32 CGC" },
{ "key": "esp-box-3", "name": "ESP BOX 3" },
{ "key": "esp-box", "name": "ESP BOX" },
{ "key": "esp-box-lite", "name": "ESP BOX Lite" },
{ "key": "kevin-box-1", "name": "Kevin Box 1" },
{ "key": "kevin-box-2", "name": "Kevin Box 2" },
{ "key": "kevin-c3", "name": "Kevin C3" },
{ "key": "kevin-sp-v3-dev", "name": "Kevin SP V3开发板" },
{ "key": "kevin-sp-v4-dev", "name": "Kevin SP V4开发板" },
{ "key": "kevin-yuying-313lcd", "name": "鱼鹰科技3.13LCD开发板" },
{ "key": "lichuang-dev", "name": "立创·实战派ESP32-S3开发板" },
{ "key": "lichuang-c3-dev", "name": "立创·实战派ESP32-C3开发板" },
{ "key": "magiclick-2p4", "name": "神奇按钮 Magiclick_2.4" },
{ "key": "magiclick-2p5", "name": "神奇按钮 Magiclick_2.5" },
{ "key": "magiclick-c3", "name": "神奇按钮 Magiclick_C3" },
{ "key": "magiclick-c3-v2", "name": "神奇按钮 Magiclick_C3_v2" },
{ "key": "m5stack-core-s3", "name": "M5Stack CoreS3" },
{ "key": "atoms3-echo-base", "name": "AtomS3 + Echo Base" },
{ "key": "atoms3r-echo-base", "name": "AtomS3R + Echo Base" },
{ "key": "atoms3r-cam-m12-echo-base", "name": "AtomS3R CAM/M12 + Echo Base" },
{ "key": "atommatrix-echo-base", "name": "AtomMatrix + Echo Base" },
{ "key": "xmini-c3", "name": "虾哥 Mini C3" },
{ "key": "esp32s3-korvo2-v3", "name": "ESP32S3_KORVO2_V3开发板" },
{ "key": "esp-sparkbot", "name": "ESP-SparkBot开发板" },
{ "key": "esp-spot-s3", "name": "ESP-Spot-S3" },
{ "key": "esp32-s3-touch-amoled-1.8", "name": "Waveshare ESP32-S3-Touch-AMOLED-1.8" },
{ "key": "esp32-s3-touch-lcd-1.85c", "name": "Waveshare ESP32-S3-Touch-LCD-1.85C" },
{ "key": "esp32-s3-touch-lcd-1.85", "name": "Waveshare ESP32-S3-Touch-LCD-1.85" },
{ "key": "esp32-s3-touch-lcd-1.46", "name": "Waveshare ESP32-S3-Touch-LCD-1.46" },
{ "key": "esp32-s3-touch-lcd-3.5", "name": "Waveshare ESP32-S3-Touch-LCD-3.5" },
{ "key": "tudouzi", "name": "土豆子" },
{ "key": "lilygo-t-circle-s3", "name": "LILYGO T-Circle-S3" },
{ "key": "lilygo-t-cameraplus-s3", "name": "LILYGO T-CameraPlus-S3" },
{ "key": "movecall-moji-esp32s3", "name": "Movecall Moji 小智AI衍生版" },
{ "key": "movecall-cuican-esp32s3", "name": "Movecall CuiCan 璀璨·AI吊坠" },
{ "key": "atk-dnesp32s3", "name": "正点原子DNESP32S3开发板" },
{ "key": "atk-dnesp32s3-box", "name": "正点原子DNESP32S3-BOX" },
{ "key": "du-chatx", "name": "嘟嘟开发板CHATX(wifi)" },
{ "key": "taiji-pi-s3", "name": "太极小派esp32s3" },
{ "key": "xingzhi-cube-0.85tft-wifi", "name": "无名科技星智0.85(WIFI)" },
{ "key": "xingzhi-cube-0.85tft-ml307", "name": "无名科技星智0.85(ML307)" },
{ "key": "xingzhi-cube-0.96oled-wifi", "name": "无名科技星智0.96(WIFI)" },
{ "key": "xingzhi-cube-0.96oled-ml307", "name": "无名科技星智0.96(ML307)" },
{ "key": "xingzhi-cube-1.54tft-wifi", "name": "无名科技星智1.54(WIFI)" },
{ "key": "xingzhi-cube-1.54tft-ml307", "name": "无名科技星智1.54(ML307)" },
{ "key": "sensecap-watcher", "name": "SenseCAP Watcher" },
{ "key": "doit-s3-aibox", "name": "四博智联AI陪伴盒子" },
{ "key": "mixgo-nova", "name": "元控·青春" }
]
+92 -52
View File
@@ -5,8 +5,8 @@
<div class="operation-bar"> <div class="operation-bar">
<h2 class="page-title">设备管理</h2> <h2 class="page-title">设备管理</h2>
<div class="right-operations"> <div class="right-operations">
<el-input placeholder="请输入设备型号或Mac地址查询" v-model="searchKeyword" <el-input placeholder="请输入设备型号或Mac地址查询" v-model="searchKeyword" class="search-input"
class="search-input" @keyup.enter.native="handleSearch" clearable /> @keyup.enter.native="handleSearch" clearable />
<el-button class="btn-search" @click="handleSearch">搜索</el-button> <el-button class="btn-search" @click="handleSearch">搜索</el-button>
</div> </div>
</div> </div>
@@ -15,15 +15,20 @@
<div class="content-panel"> <div class="content-panel">
<div class="content-area"> <div class="content-area">
<el-card class="device-card" shadow="never"> <el-card class="device-card" shadow="never">
<el-table <el-table ref="deviceTable" :data="paginatedDeviceList" class="transparent-table"
ref="deviceTable" :header-cell-class-name="headerCellClassName" v-loading="loading" element-loading-text="拼命加载中"
:data="paginatedDeviceList" element-loading-spinner="el-icon-loading" element-loading-background="rgba(255, 255, 255, 0.7)">
@selection-change="handleSelectionChange" <el-table-column label="选择" align="center" width="120">
class="transparent-table" <template slot-scope="scope">
:header-cell-class-name="headerCellClassName"> <el-checkbox v-model="scope.row.selected"></el-checkbox>
<el-table-column type="selection" align="center" width="120"></el-table-column> </template>
<el-table-column label="设备型号" prop="model" align="center"></el-table-column> </el-table-column>
<el-table-column label="固件版本" prop="firmwareVersion" align="center" ></el-table-column> <el-table-column label="设备型号" prop="model" align="center">
<template slot-scope="scope">
{{ getFirmwareTypeName(scope.row.model) }}
</template>
</el-table-column>
<el-table-column label="固件版本" prop="firmwareVersion" align="center"></el-table-column>
<el-table-column label="Mac地址" prop="macAddress" align="center"></el-table-column> <el-table-column label="Mac地址" prop="macAddress" align="center"></el-table-column>
<el-table-column label="绑定时间" prop="bindTime" align="center"></el-table-column> <el-table-column label="绑定时间" prop="bindTime" align="center"></el-table-column>
<el-table-column label="最近对话" prop="lastConversation" align="center"></el-table-column> <el-table-column label="最近对话" prop="lastConversation" align="center"></el-table-column>
@@ -32,7 +37,8 @@
<el-input v-if="scope.row.isEdit" v-model="scope.row.remark" size="mini" <el-input v-if="scope.row.isEdit" v-model="scope.row.remark" size="mini"
@blur="stopEditRemark(scope.$index)"></el-input> @blur="stopEditRemark(scope.$index)"></el-input>
<span v-else> <span v-else>
<i v-if="!scope.row.remark" class="el-icon-edit" @click="startEditRemark(scope.$index, scope.row)"></i> <i v-if="!scope.row.remark" class="el-icon-edit"
@click="startEditRemark(scope.$index, scope.row)"></i>
<span v-else @click="startEditRemark(scope.$index, scope.row)"> <span v-else @click="startEditRemark(scope.$index, scope.row)">
{{ scope.row.remark }} {{ scope.row.remark }}
</span> </span>
@@ -41,8 +47,8 @@
</el-table-column> </el-table-column>
<el-table-column label="OTA升级" align="center"> <el-table-column label="OTA升级" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-switch v-model="scope.row.otaSwitch" size="mini" active-color="#13ce66" <el-switch v-model="scope.row.otaSwitch" size="mini" active-color="#13ce66" inactive-color="#ff4949"
inactive-color="#ff4949"></el-switch> @change="handleOtaSwitchChange(scope.row)"></el-switch>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" align="center"> <el-table-column label="操作" align="center">
@@ -56,32 +62,23 @@
<div class="table_bottom"> <div class="table_bottom">
<div class="ctrl_btn"> <div class="ctrl_btn">
<el-button size="mini" type="primary" class="select-all-btn" @click="toggleAllSelection"> <el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
{{ 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>
<el-button size="mini" type="danger" icon="el-icon-delete" <el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteSelected">解绑</el-button>
@click="deleteSelected">解绑</el-button>
</div> </div>
<div class="custom-pagination"> <div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select"> <el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option <el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
v-for="item in pageSizeOptions" </el-option>
:key="item"
:label="`${item}条/页`"
:value="item">
</el-option>
</el-select> </el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button> <button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button> <button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
<button <button v-for="page in visiblePages" :key="page" class="pagination-btn"
v-for="page in visiblePages" :class="{ active: page === currentPage }" @click="goToPage(page)">
:key="page"
class="pagination-btn"
:class="{ active: page === currentPage }"
@click="goToPage(page)">
{{ page }} {{ page }}
</button> </button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button> <button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
@@ -103,6 +100,7 @@
import Api from '@/apis/api'; import Api from '@/apis/api';
import AddDeviceDialog from "@/components/AddDeviceDialog.vue"; import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import { FIRMWARE_TYPES } from "@/utils";
export default { export default {
components: { HeaderBar, AddDeviceDialog }, components: { HeaderBar, AddDeviceDialog },
@@ -120,7 +118,6 @@ export default {
deviceList: [], deviceList: [],
loading: false, loading: false,
userApi: null, userApi: null,
}; };
}, },
computed: { computed: {
@@ -136,7 +133,10 @@ export default {
paginatedDeviceList() { paginatedDeviceList() {
const start = (this.currentPage - 1) * this.pageSize; const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize; const end = start + this.pageSize;
return this.filteredDeviceList.slice(start, end); return this.filteredDeviceList.slice(start, end).map(item => ({
...item,
selected: false
}));
}, },
pageCount() { pageCount() {
return Math.ceil(this.filteredDeviceList.length / this.pageSize); return Math.ceil(this.filteredDeviceList.length / this.pageSize);
@@ -173,15 +173,16 @@ export default {
this.currentPage = 1; this.currentPage = 1;
}, },
handleSelectionChange(val) { handleSelectAll() {
this.selectedDevices = val; this.isAllSelected = !this.isAllSelected;
this.isAllSelected = val.length === this.paginatedDeviceList.length; this.paginatedDeviceList.forEach(row => {
}, row.selected = this.isAllSelected;
toggleAllSelection() { });
this.$refs.deviceTable.toggleAllSelection(); this.selectedDevices = this.paginatedDeviceList.filter(device => device.selected);
}, },
deleteSelected() { deleteSelected() {
this.selectedDevices = this.paginatedDeviceList.filter(device => device.selected);
if (this.selectedDevices.length === 0) { if (this.selectedDevices.length === 0) {
this.$message.warning({ this.$message.warning({
message: '请至少选择一条记录', message: '请至少选择一条记录',
@@ -301,7 +302,7 @@ export default {
rawBindTime: new Date(device.createDate).getTime() rawBindTime: new Date(device.createDate).getTime()
}; };
}) })
.sort((a, b) => a.rawBindTime - b.rawBindTime); .sort((a, b) => a.rawBindTime - b.rawBindTime);
this.activeSearchKeyword = ""; this.activeSearchKeyword = "";
this.searchKeyword = ""; this.searchKeyword = "";
} else { } else {
@@ -309,12 +310,26 @@ export default {
} }
}); });
}, },
headerCellClassName({columnIndex}) { headerCellClassName({ columnIndex }) {
if (columnIndex === 0) { if (columnIndex === 0) {
return "custom-selection-header"; return "custom-selection-header";
} }
return ""; return "";
} },
getFirmwareTypeName(type) {
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
return firmwareType ? firmwareType.name : type;
},
handleOtaSwitchChange(row) {
Api.device.enableOtaUpgrade(row.device_id, row.otaSwitch ? 1 : 0, ({ data }) => {
if (data.code === 0) {
this.$message.success(row.otaSwitch ? '已设置成自动升级' : '已关闭自动升级')
} else {
row.otaSwitch = !row.otaSwitch
this.$message.error(data.msg || '操作失败')
}
})
},
} }
}; };
</script> </script>
@@ -383,12 +398,12 @@ export default {
transition: border-color 0.2s; transition: border-color 0.2s;
} }
::v-deep .page-size-select{ ::v-deep .page-size-select {
width: 100px; width: 100px;
margin-right: 8px; margin-right: 8px;
} }
::v-deep .page-size-select .el-input__inner{ ::v-deep .page-size-select .el-input__inner {
height: 32px; height: 32px;
line-height: 32px; line-height: 32px;
border-radius: 4px; border-radius: 4px;
@@ -397,7 +412,8 @@ export default {
color: #606266; color: #606266;
font-size: 14px; font-size: 14px;
} }
::v-deep .page-size-select .el-input__suffix{
::v-deep .page-size-select .el-input__suffix {
right: 6px; right: 6px;
width: 15px; width: 15px;
height: 20px; height: 20px;
@@ -408,13 +424,14 @@ export default {
border-radius: 4px; border-radius: 4px;
} }
::v-deep .page-size-select .el-input__suffix-inner{ ::v-deep .page-size-select .el-input__suffix-inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
::v-deep .page-size-select .el-icon-arrow-up:before{
::v-deep .page-size-select .el-icon-arrow-up:before {
content: ""; content: "";
display: inline-block; display: inline-block;
border-left: 6px solid transparent; border-left: 6px solid transparent;
@@ -460,6 +477,14 @@ export default {
overflow: hidden; overflow: hidden;
} }
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.table_bottom { .table_bottom {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -468,6 +493,7 @@ export default {
padding-bottom: 10px; padding-bottom: 10px;
} }
.ctrl_btn { .ctrl_btn {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -613,13 +639,7 @@ export default {
display: none !important; display: none !important;
} }
:deep(.custom-selection-header::after) {
content: "选择";
display: inline-block;
color: black;
font-weight: bold;
padding-bottom: 18px;
}
:deep(.el-table .el-button--text) { :deep(.el-table .el-button--text) {
color: #7079aa; color: #7079aa;
@@ -656,4 +676,24 @@ export default {
padding-bottom: 16px; padding-bottom: 16px;
} }
} }
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
border-color: #cccccc !important;
}
:deep(.el-checkbox__inner:hover) {
border-color: #cccccc !important;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #5f70f3 !important;
border-color: #5f70f3 !important;
}
::v-deep .el-table--border::after,
::v-deep .el-table--group::after,
::v-deep .el-table::before {
display: none !important;
}
</style> </style>
+76 -49
View File
@@ -44,12 +44,14 @@
<!-- 右侧内容 --> <!-- 右侧内容 -->
<div class="content-area"> <div class="content-area">
<el-card class="model-card" shadow="never"> <el-card class="model-card" shadow="never">
<el-table ref="modelTable" style="width: 100%" :header-cell-style="{ background: 'transparent' }" <el-table ref="modelTable" style="width: 100%" v-loading="loading" element-loading-text="拼命加载中"
:data="modelList" class="data-table" header-row-class-name="table-header" element-loading-spinner="el-icon-loading" element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-class-name="headerCellClassName" @selection-change="handleSelectionChange"> :header-cell-style="{ background: 'transparent' }" :data="modelList" class="data-table"
header-row-class-name="table-header" :header-cell-class-name="headerCellClassName"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center"></el-table-column> <el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="模型ID" prop="id" align="center"></el-table-column>
<el-table-column label="模型名称" prop="modelName" align="center"></el-table-column> <el-table-column label="模型名称" prop="modelName" align="center"></el-table-column>
<el-table-column label="模型编码" prop="modelCode" align="center"></el-table-column>
<el-table-column label="提供商" align="center"> <el-table-column label="提供商" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
{{ scope.row.configJson.type || '未知' }} {{ scope.row.configJson.type || '未知' }}
@@ -86,41 +88,37 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="table-footer"> <div class="table-footer">
<div class="batch-actions"> <div class="batch-actions">
<el-button size="mini" type="primary" @click="selectAll"> <el-button size="mini" type="primary" @click="selectAll">
{{ isAllSelected ? {{ isAllSelected ?
'取消全选' : '全选' }} '取消全选' : '全选' }}
</el-button> </el-button>
<el-button type="success" size="mini" @click="addModel" class="add-btn"> <el-button type="success" size="mini" @click="addModel" class="add-btn">
新增 新增
</el-button> </el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete"> <el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">
删除 删除
</el-button> </el-button>
</div> </div>
<div class="custom-pagination"> <div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select"> <el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option <el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
v-for="item in pageSizeOptions"
:key="item"
:label="`${item}条/页`"
:value="item">
</el-option> </el-option>
</el-select> </el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button> <button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button> <button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn" <button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)"> :class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }} {{ page }}
</button> </button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button> <button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
<span class="total-text">{{ total }}条记录</span> <span class="total-text">{{ total }}条记录</span>
</div>
</div> </div>
</div>
</el-card> </el-card>
</div> </div>
</div> </div>
@@ -130,6 +128,9 @@
<TtsModel :visible.sync="ttsDialogVisible" :ttsModelId="selectedTtsModelId" /> <TtsModel :visible.sync="ttsDialogVisible" :ttsModelId="selectedTtsModelId" />
<AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" /> <AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" />
</div> </div>
<el-footer>
<version-footer />
</el-footer>
</div> </div>
</template> </template>
@@ -139,9 +140,9 @@ import AddModelDialog from "@/components/AddModelDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import ModelEditDialog from "@/components/ModelEditDialog.vue"; import ModelEditDialog from "@/components/ModelEditDialog.vue";
import TtsModel from "@/components/TtsModel.vue"; import TtsModel from "@/components/TtsModel.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default { export default {
components: { HeaderBar, ModelEditDialog, TtsModel, AddModelDialog }, components: { HeaderBar, ModelEditDialog, TtsModel, AddModelDialog, VersionFooter },
data() { data() {
return { return {
addDialogVisible: false, addDialogVisible: false,
@@ -157,7 +158,8 @@ export default {
pageSize: 10, pageSize: 10,
total: 0, total: 0,
selectedModels: [], selectedModels: [],
isAllSelected: false isAllSelected: false,
loading: false
}; };
}, },
@@ -301,9 +303,10 @@ export default {
this.currentPage = page; this.currentPage = page;
this.$refs.modelTable.clearSelection(); this.$refs.modelTable.clearSelection();
}, },
handleModelSave({ provideCode, formData }) { handleModelSave({ provideCode, formData, done }) {
const modelType = this.activeTab; const modelType = this.activeTab;
const id = formData.id; const id = formData.id;
Api.model.updateModel( Api.model.updateModel(
{ modelType, provideCode, id, formData }, { modelType, provideCode, id, formData },
({ data }) => { ({ data }) => {
@@ -314,6 +317,7 @@ export default {
} else { } else {
this.$message.error(data.msg || '保存失败'); this.$message.error(data.msg || '保存失败');
} }
done && done(); // 调用done回调关闭加载状态
} }
); );
}, },
@@ -385,6 +389,7 @@ export default {
// 获取模型配置列表 // 获取模型配置列表
loadData() { loadData() {
this.loading = true; // 开始加载
const params = { const params = {
modelType: this.activeTab, modelType: this.activeTab,
modelName: this.search, modelName: this.search,
@@ -393,6 +398,7 @@ export default {
}; };
Api.model.getModelList(params, ({ data }) => { Api.model.getModelList(params, ({ data }) => {
this.loading = false; // 结束加载
if (data.code === 0) { if (data.code === 0) {
this.modelList = data.data.list; this.modelList = data.data.list;
this.total = data.data.total; this.total = data.data.total;
@@ -509,8 +515,9 @@ export default {
.nav-panel .el-menu-item { .nav-panel .el-menu-item {
height: 50px; height: 50px;
background: #e9f0ff;
line-height: 50px; line-height: 50px;
border-radius: 4px; border-radius: 4px 0 0 4px !important;
transition: all 0.3s; transition: all 0.3s;
display: flex !important; display: flex !important;
justify-content: flex-end; justify-content: flex-end;
@@ -521,8 +528,7 @@ export default {
} }
.nav-panel .el-menu-item.is-active { .nav-panel .el-menu-item.is-active {
background: #e9f0ff; background: #5778ff;
color: #0ba6f4 !important;
position: relative; position: relative;
padding-left: 40px !important; padding-left: 40px !important;
} }
@@ -535,7 +541,7 @@ export default {
transform: translateY(-50%); transform: translateY(-50%);
width: 13px; width: 13px;
height: 13px; height: 13px;
background: #409EFF; background: #fff;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 4px rgba(64, 158, 255, 0.5); box-shadow: 0 0 4px rgba(64, 158, 255, 0.5);
} }
@@ -592,12 +598,12 @@ export default {
transition: border-color 0.2s; transition: border-color 0.2s;
} }
::v-deep .page-size-select{ ::v-deep .page-size-select {
width: 100px; width: 100px;
margin-right: 8px; margin-right: 8px;
} }
::v-deep .page-size-select .el-input__inner{ ::v-deep .page-size-select .el-input__inner {
height: 32px; height: 32px;
line-height: 32px; line-height: 32px;
border-radius: 4px; border-radius: 4px;
@@ -606,7 +612,8 @@ export default {
color: #606266; color: #606266;
font-size: 14px; font-size: 14px;
} }
::v-deep .page-size-select .el-input__suffix{
::v-deep .page-size-select .el-input__suffix {
right: 6px; right: 6px;
width: 15px; width: 15px;
height: 20px; height: 20px;
@@ -617,13 +624,14 @@ export default {
border-radius: 4px; border-radius: 4px;
} }
::v-deep .page-size-select .el-input__suffix-inner{ ::v-deep .page-size-select .el-input__suffix-inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
::v-deep .page-size-select .el-icon-arrow-up:before{
::v-deep .page-size-select .el-icon-arrow-up:before {
content: ""; content: "";
display: inline-block; display: inline-block;
border-left: 6px solid transparent; border-left: 6px solid transparent;
@@ -745,7 +753,7 @@ export default {
} }
::v-deep .nav-panel .el-menu-item.is-active .menu-text { ::v-deep .nav-panel .el-menu-item.is-active .menu-text {
color: #409EFF !important; color: #fff !important;
} }
::v-deep .data-table { ::v-deep .data-table {
@@ -884,7 +892,7 @@ export default {
} }
} }
.model-card{ .model-card {
background: white; background: white;
flex: 1; flex: 1;
display: flex; display: flex;
@@ -894,7 +902,7 @@ export default {
overflow: hidden; overflow: hidden;
} }
.model-card ::v-deep .el-card__body{ .model-card ::v-deep .el-card__body {
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -912,4 +920,23 @@ export default {
overflow-y: auto; overflow-y: auto;
} }
::v-deep .el-loading-mask {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
::v-deep .el-loading-spinner .circular {
width: 28px;
height: 28px;
}
::v-deep .el-loading-spinner .path {
stroke: #6b8cff;
}
::v-deep .el-loading-text {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style> </style>
@@ -0,0 +1,756 @@
<template>
<div class="welcome">
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">固件管理</h2>
<div class="right-operations">
<el-input placeholder="请输入固件名称查询" v-model="searchName" class="search-input"
@keyup.enter.native="handleSearch" clearable />
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
</div>
</div>
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="params-card" shadow="never">
<el-table ref="paramsTable" :data="paramsList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-class-name="headerCellClassName">
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="固件名称" prop="firmwareName" align="center"></el-table-column>
<el-table-column label="固件类型" prop="type" align="center">
<template slot-scope="scope">
{{ getFirmwareTypeName(scope.row.type) }}
</template>
</el-table-column>
<el-table-column label="版本号" prop="version" align="center"></el-table-column>
<el-table-column label="文件大小" prop="size" align="center">
<template slot-scope="scope">
{{ formatFileSize(scope.row.size) }}
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" align="center"
show-overflow-tooltip></el-table-column>
<el-table-column label="创建时间" prop="createDate" align="center">
<template slot-scope="scope">
{{ formatDate(scope.row.createDate) }}
</template>
</el-table-column>
<el-table-column label="更新时间" prop="updateDate" align="center">
<template slot-scope="scope">
{{ formatDate(scope.row.updateDate) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template slot-scope="scope">
<el-button size="mini" type="text"
@click="downloadFirmware(scope.row)">下载</el-button>
<el-button size="mini" type="text" @click="editParam(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click="deleteParam(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table_bottom">
<div class="ctrl_btn">
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
<el-button size="mini" type="success" @click="showAddDialog"
style="background: #5bc98c;border: None;">新增</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete"
@click="deleteSelectedParams">删除</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
:value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
首页
</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
上一页
</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
下一页
</button>
<span class="total-text">{{ total }}条记录</span>
</div>
</div>
</el-card>
</div>
</div>
</div>
<!-- 新增/编辑固件对话框 -->
<firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm" @submit="handleSubmit"
@cancel="dialogVisible = false" />
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
<script>
import Api from "@/apis/api";
import FirmwareDialog from "@/components/FirmwareDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue";
import { FIRMWARE_TYPES } from "@/utils";
import { formatDate, formatFileSize } from "@/utils/format";
export default {
components: { HeaderBar, FirmwareDialog, VersionFooter },
data() {
return {
searchName: "",
loading: false,
paramsList: [],
firmwareList: [],
currentPage: 1,
pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
total: 0,
dialogVisible: false,
dialogTitle: "新增固件",
isAllSelected: false,
firmwareForm: {
id: null,
firmwareName: "",
type: "",
version: "",
size: 0,
remark: "",
firmwarePath: ""
},
};
},
created() {
this.fetchFirmwareList();
},
computed: {
pageCount() {
return Math.ceil(this.total / this.pageSize);
},
visiblePages() {
const pages = [];
const maxVisible = 3;
let start = Math.max(1, this.currentPage - 1);
let end = Math.min(this.pageCount, start + maxVisible - 1);
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
},
},
methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.fetchFirmwareList();
},
fetchFirmwareList() {
this.loading = true;
const params = {
pageNum: this.currentPage,
pageSize: this.pageSize,
firmwareName: this.searchName || "",
orderField: "create_date",
order: "desc"
};
Api.ota.getOtaList(params, (res) => {
this.loading = false;
res = res.data
if (res.code === 0) {
this.firmwareList = res.data.list.map(item => ({
...item,
selected: false
}));
this.paramsList = this.firmwareList;
this.total = res.data.total || 0;
} else {
this.firmwareList = [];
this.paramsList = [];
this.total = 0;
this.$message.error({
message: res?.data?.msg || '获取固件列表失败',
showClose: true
});
}
});
},
handleSearch() {
this.currentPage = 1;
this.fetchFirmwareList();
},
handleSelectAll() {
this.isAllSelected = !this.isAllSelected;
this.firmwareList.forEach(row => {
row.selected = this.isAllSelected;
});
},
showAddDialog() {
this.dialogTitle = "新增固件";
// 完全重置表单数据
this.firmwareForm = {
id: null,
firmwareName: "",
type: "",
version: "",
size: 0,
remark: "",
firmwarePath: ""
};
this.$nextTick(() => {
// 重置表单的校验状态
if (this.$refs.firmwareDialog && this.$refs.firmwareDialog.$refs.form) {
this.$refs.firmwareDialog.$refs.form.clearValidate();
}
});
this.dialogVisible = true;
},
editParam(row) {
this.dialogTitle = "编辑固件";
this.firmwareForm = { ...row };
this.dialogVisible = true;
},
handleSubmit(form) {
if (form.id) {
// 编辑
Api.ota.updateOta(form.id, form, (res) => {
res = res.data;
if (res.code === 0) {
this.$message.success({
message: "修改成功",
showClose: true
});
this.dialogVisible = false;
this.fetchFirmwareList();
} else {
this.$message.error({
message: res.msg || "修改失败",
showClose: true
});
}
});
} else {
// 新增
Api.ota.saveOta(form, (res) => {
res = res.data;
if (res.code === 0) {
this.$message.success({
message: "新增成功",
showClose: true
});
this.dialogVisible = false;
this.fetchFirmwareList();
} else {
this.$message.error({
message: res.msg || "新增失败",
showClose: true
});
}
});
}
},
deleteSelectedParams() {
const selectedRows = this.firmwareList.filter(row => row.selected);
if (selectedRows.length === 0) {
this.$message.warning({
message: "请先选择需要删除的固件",
showClose: true
});
return;
}
this.deleteParam(selectedRows);
},
deleteParam(row) {
// 处理单个参数或参数数组
const params = Array.isArray(row) ? row : [row];
if (Array.isArray(row) && row.length === 0) {
this.$message.warning({
message: "请先选择需要删除的参数",
showClose: true
});
return;
}
const paramCount = params.length;
this.$confirm(`确定要删除选中的${paramCount}个固件吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
distinguishCancelAndClose: true
}).then(() => {
const ids = params.map(param => param.id);
if (ids.some(id => !id)) {
this.$message.error({
message: '存在无效的参数ID',
showClose: true
});
return;
}
Api.ota.deleteOta(ids, (res) => {
res = res.data;
if (res.code === 0) {
this.$message.success({
message: `成功删除${paramCount}个固件`,
showClose: true
});
this.fetchFirmwareList();
} else {
this.$message.error({
message: res.msg || '删除失败,请重试',
showClose: true
});
}
});
}).catch(action => {
if (action === 'cancel') {
this.$message({
type: 'info',
message: '已取消删除操作',
duration: 1000
});
} else {
this.$message({
type: 'info',
message: '操作已关闭',
duration: 1000
});
}
});
},
headerCellClassName({ columnIndex }) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
},
goFirst() {
this.currentPage = 1;
this.fetchFirmwareList();
},
goPrev() {
if (this.currentPage > 1) {
this.currentPage--;
this.fetchFirmwareList();
}
},
goNext() {
if (this.currentPage < this.pageCount) {
this.currentPage++;
this.fetchFirmwareList();
}
},
goToPage(page) {
this.currentPage = page;
this.fetchFirmwareList();
},
downloadFirmware(firmware) {
if (!firmware || !firmware.id) {
this.$message.error('固件信息不完整');
return;
}
// 先获取下载链接
Api.ota.getDownloadUrl(firmware.id, (res) => {
if (res.data.code === 0) {
const uuid = res.data.data;
const baseUrl = process.env.VUE_APP_API_BASE_URL || '';
window.open(`${window.location.origin}${baseUrl}/otaMag/download/${uuid}`);
} else {
this.$message.error('获取下载链接失败');
}
});
},
formatDate,
formatFileSize,
getFirmwareTypeName(type) {
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
return firmwareType ? firmwareType.name : type;
},
},
};
</script>
<style lang="scss" scoped>
.welcome {
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
position: relative;
flex-direction: column;
background-size: cover;
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.right-operations {
display: flex;
gap: 10px;
margin-left: auto;
}
.search-input {
width: 240px;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
.content-panel {
flex: 1;
display: flex;
overflow: hidden;
height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
}
.content-area {
flex: 1;
height: 100%;
min-width: 600px;
overflow: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.params-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
}
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding-bottom: 10px;
}
.ctrl_btn {
display: flex;
gap: 8px;
padding-left: 26px;
.el-button {
min-width: 72px;
height: 32px;
padding: 7px 12px 7px 10px;
font-size: 12px;
border-radius: 4px;
line-height: 1;
font-weight: 500;
border: none;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
}
.el-button--primary {
background: #5f70f3;
color: white;
}
.el-button--danger {
background: #fd5b63;
color: white;
}
}
.custom-pagination {
display: flex;
align-items: center;
gap: 5px;
.el-select {
margin-right: 8px;
}
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-last-child(2),
.pagination-btn:nth-child(3) {
min-width: 60px;
height: 32px;
padding: 0 12px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #d7dce6;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(245, 247, 250, 0.3);
}
}
.pagination-btn.active {
background: #5f70f3 !important;
color: #ffffff !important;
border-color: #5f70f3 !important;
&:hover {
background: #6d7cf5 !important;
}
}
}
.total-text {
margin-left: 10px;
color: #606266;
font-size: 14px;
}
.page-size-select {
width: 100px;
margin-right: 10px;
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__suffix) {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
}
:deep(.transparent-table) {
background: white;
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
.el-table__body-wrapper {
flex: 1;
overflow-y: auto;
max-height: none !important;
}
.el-table__header-wrapper {
flex-shrink: 0;
}
.el-table__header th {
background: white !important;
color: black;
font-weight: 600;
height: 40px;
padding: 8px 0;
font-size: 14px;
border-bottom: 1px solid #e4e7ed;
}
.el-table__body tr {
background-color: white;
td {
border-top: 1px solid rgba(0, 0, 0, 0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
padding: 8px 0;
height: 40px;
color: #606266;
font-size: 14px;
}
}
.el-table__row:hover>td {
background-color: #f5f7fa !important;
}
&::before {
display: none;
}
}
:deep(.el-table .el-button--text) {
color: #7079aa !important;
}
:deep(.el-table .el-button--text:hover) {
color: #5a64b5 !important;
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
border-color: #cccccc !important;
}
:deep(.el-checkbox__inner:hover) {
border-color: #cccccc !important;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #5f70f3 !important;
border-color: #5f70f3 !important;
}
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
.el-table {
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
.el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px);
}
}
@media (min-width: 1144px) {
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
}
:deep(.transparent-table) {
.el-table__body tr {
td {
padding-top: 16px;
padding-bottom: 16px;
}
&+tr {
margin-top: 10px;
}
}
}
}
</style>
+78 -41
View File
@@ -15,7 +15,9 @@
<div class="content-panel"> <div class="content-panel">
<div class="content-area"> <div class="content-area">
<el-card class="params-card" shadow="never"> <el-card class="params-card" shadow="never">
<el-table ref="paramsTable" :data="paramsList" class="transparent-table" <el-table ref="paramsTable" :data="paramsList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-class-name="headerCellClassName"> :header-cell-class-name="headerCellClassName">
<el-table-column label="选择" align="center" width="120"> <el-table-column label="选择" align="center" width="120">
<template slot-scope="scope"> <template slot-scope="scope">
@@ -41,14 +43,11 @@
</el-button> </el-button>
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button> <el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" <el-button size="mini" type="danger" icon="el-icon-delete"
@click="deleteSelectedParams">删除</el-button> @click="deleteSelectedParams">删除</el-button>
</div> </div>
<div class="custom-pagination"> <div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select"> <el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option <el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
v-for="item in pageSizeOptions"
:key="item"
:label="`${item}条/页`"
:value="item"> :value="item">
</el-option> </el-option>
</el-select> </el-select>
@@ -76,7 +75,9 @@
<!-- 新增/编辑参数对话框 --> <!-- 新增/编辑参数对话框 -->
<param-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="paramForm" @submit="handleSubmit" <param-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="paramForm" @submit="handleSubmit"
@cancel="dialogVisible = false" /> @cancel="dialogVisible = false" />
<el-footer>
<version-footer />
</el-footer>
</div> </div>
</template> </template>
@@ -84,14 +85,15 @@
import Api from "@/apis/api"; import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import ParamDialog from "@/components/ParamDialog.vue"; import ParamDialog from "@/components/ParamDialog.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default { export default {
components: { HeaderBar, ParamDialog }, components: { HeaderBar, ParamDialog, VersionFooter },
data() { data() {
return { return {
searchCode: "", searchCode: "",
paramsList: [], paramsList: [],
currentPage: 1, currentPage: 1,
loading: false,
pageSize: 10, pageSize: 10,
pageSizeOptions: [10, 20, 50, 100], pageSizeOptions: [10, 20, 50, 100],
total: 0, total: 0,
@@ -138,6 +140,7 @@ export default {
this.fetchParams(); this.fetchParams();
}, },
fetchParams() { fetchParams() {
this.loading = true;
Api.admin.getParamsList( Api.admin.getParamsList(
{ {
page: this.currentPage, page: this.currentPage,
@@ -145,6 +148,7 @@ export default {
paramCode: this.searchCode, paramCode: this.searchCode,
}, },
({ data }) => { ({ data }) => {
this.loading = false;
if (data.code === 0) { if (data.code === 0) {
this.paramsList = data.data.list.map(item => ({ this.paramsList = data.data.list.map(item => ({
...item, ...item,
@@ -185,30 +189,33 @@ export default {
this.paramForm = { ...row }; this.paramForm = { ...row };
this.dialogVisible = true; this.dialogVisible = true;
}, },
handleSubmit(form) {
handleSubmit({ form, done }) {
if (form.id) { if (form.id) {
// 编辑 // 编辑
Api.admin.updateParam(form, ({ data }) => { Api.admin.updateParam(form, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.$message.success({ this.$message.success({
message:"修改成功", message: "修改成功",
showClose:true showClose: true
}); });
this.dialogVisible = false; this.dialogVisible = false;
this.fetchParams(); this.fetchParams();
} }
done && done();
}); });
} else { } else {
// 新增 // 新增
Api.admin.addParam(form, ({ data }) => { Api.admin.addParam(form, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.$message.success({ this.$message.success({
message:"新增成功", message: "新增成功",
showClose:true showClose: true
}); });
this.dialogVisible = false; this.dialogVisible = false;
this.fetchParams(); this.fetchParams();
} }
done && done();
}); });
} }
}, },
@@ -230,8 +237,8 @@ export default {
if (Array.isArray(row) && row.length === 0) { if (Array.isArray(row) && row.length === 0) {
this.$message.warning({ this.$message.warning({
message:"请先选择需要删除的参数", message: "请先选择需要删除的参数",
showClose:true showClose: true
}); });
return; return;
} }
@@ -246,8 +253,8 @@ export default {
const ids = params.map(param => param.id); const ids = params.map(param => param.id);
if (ids.some(id => isNaN(id))) { if (ids.some(id => isNaN(id))) {
this.$message.error({ this.$message.error({
message:'存在无效的参数ID', message: '存在无效的参数ID',
showClose: true showClose: true
}); });
return; return;
} }
@@ -328,16 +335,16 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; margin: 5px 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh); min-height: calc(100vh - 24vh);
height: auto; height: auto;
max-height: 80vh; max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.operation-bar { .operation-bar {
@@ -390,12 +397,20 @@ export default {
.params-card { .params-card {
background: white; background: white;
border: none; flex: 1;
box-shadow: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; border: none;
box-shadow: none;
overflow: hidden; overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
} }
.table_bottom { .table_bottom {
@@ -446,7 +461,7 @@ export default {
gap: 10px; gap: 10px;
.el-select { .el-select {
margin-right: 8px; margin-right: 8px;
} }
.pagination-btn:first-child, .pagination-btn:first-child,
@@ -514,15 +529,17 @@ export default {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.el-table__body-wrapper { .el-table__body-wrapper {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
max-height: none !important; max-height: none !important;
} }
.el-table__header-wrapper { .el-table__header-wrapper {
flex-shrink: 0; flex-shrink: 0;
} }
.el-table__header th { .el-table__header th {
background: white !important; background: white !important;
color: black; color: black;
@@ -649,11 +666,31 @@ export default {
} }
.el-table { .el-table {
--table-max-height: calc(100vh - 40vh); --table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height); max-height: var(--table-max-height);
.el-table__body-wrapper { .el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px); max-height: calc(var(--table-max-height) - 40px);
} }
}
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
} }
</style> </style>
+108 -77
View File
@@ -15,11 +15,13 @@
<div class="content-panel"> <div class="content-panel">
<div class="content-area"> <div class="content-area">
<el-card class="user-card" shadow="never"> <el-card class="user-card" shadow="never">
<el-table ref="userTable" :data="userList" class="transparent-table"> <el-table ref="userTable" :data="userList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)">
<el-table-column label="选择" align="center" width="120"> <el-table-column label="选择" align="center" width="120">
<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="用户Id" prop="userid" align="center"></el-table-column> <el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column> <el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
@@ -54,11 +56,7 @@
</div> </div>
<div class="custom-pagination"> <div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select"> <el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option <el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
v-for="item in pageSizeOptions"
:key="item"
:label="`${item}条/页`"
:value="item">
</el-option> </el-option>
</el-select> </el-select>
@@ -83,16 +81,19 @@
</div> </div>
<view-password-dialog :visible.sync="showViewPassword" :password="currentPassword" /> <view-password-dialog :visible.sync="showViewPassword" :password="currentPassword" />
<el-footer>
<version-footer />
</el-footer>
</div> </div>
</template> </template>
<script> <script>
import Api from "@/apis/api"; import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue";
import ViewPasswordDialog from "@/components/ViewPasswordDialog.vue"; import ViewPasswordDialog from "@/components/ViewPasswordDialog.vue";
export default { export default {
components: { HeaderBar, ViewPasswordDialog }, components: { HeaderBar, ViewPasswordDialog, VersionFooter },
data() { data() {
return { return {
showViewPassword: false, showViewPassword: false,
@@ -103,7 +104,8 @@ export default {
currentPage: 1, currentPage: 1,
pageSize: 10, pageSize: 10,
total: 0, total: 0,
isAllSelected: false isAllSelected: false,
loading: false,
}; };
}, },
created() { created() {
@@ -130,39 +132,41 @@ export default {
}, },
}, },
methods: { methods: {
handlePageSizeChange(val) { handlePageSizeChange(val) {
this.pageSize = val; this.pageSize = val;
this.currentPage = 1; this.currentPage = 1;
this.fetchUsers(); this.fetchUsers();
}, },
fetchUsers() { fetchUsers() {
Api.admin.getUserList( this.loading = true;
{ Api.admin.getUserList(
page: this.currentPage, {
limit: this.pageSize, page: this.currentPage,
mobile: this.searchPhone, limit: this.pageSize,
}, mobile: this.searchPhone,
({ data }) => { },
if (data.code === 0) { ({ data }) => {
this.userList = data.data.list.map(item => ({ this.loading = false; // 结束加载
...item, if (data.code === 0) {
selected: false this.userList = data.data.list.map(item => ({
})); ...item,
this.total = data.data.total; selected: false
} }));
} this.total = data.data.total;
); }
}
);
}, },
handleSearch() { handleSearch() {
this.currentPage = 1; this.currentPage = 1;
this.fetchUsers(); this.fetchUsers();
}, },
handleSelectAll() { handleSelectAll() {
this.isAllSelected = !this.isAllSelected; this.isAllSelected = !this.isAllSelected;
this.userList.forEach(row => { this.userList.forEach(row => {
row.selected = this.isAllSelected; row.selected = this.isAllSelected;
}); });
}, },
batchDelete() { batchDelete() {
const selectedUsers = this.userList.filter(user => user.selected); const selectedUsers = this.userList.filter(user => user.selected);
@@ -230,12 +234,12 @@ export default {
}); });
}, },
batchEnable() { batchEnable() {
const selectedUsers = this.userList.filter(user => user.selected); const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 1); this.handleChangeStatus(selectedUsers, 1);
}, },
batchDisable() { batchDisable() {
const selectedUsers = this.userList.filter(user => user.selected); const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 0); this.handleChangeStatus(selectedUsers, 0);
}, },
resetPassword(row) { resetPassword(row) {
this.$confirm("重置后将会生成新密码,是否继续?", "提示", { this.$confirm("重置后将会生成新密码,是否继续?", "提示", {
@@ -415,12 +419,20 @@ export default {
.user-card { .user-card {
background: white; background: white;
border: none; flex: 1;
box-shadow: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; border: none;
box-shadow: none;
overflow: hidden; overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
} }
.table_bottom { .table_bottom {
@@ -555,6 +567,7 @@ export default {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.el-table__body-wrapper { .el-table__body-wrapper {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -630,47 +643,47 @@ export default {
} }
.page-size-select { .page-size-select {
width: 100px; width: 100px;
margin-right: 10px; margin-right: 10px;
:deep(.el-input__inner) { :deep(.el-input__inner) {
height: 32px; height: 32px;
line-height: 32px; line-height: 32px;
border-radius: 4px; border-radius: 4px;
border: 1px solid #e4e7ed; border: 1px solid #e4e7ed;
background: #dee7ff; background: #dee7ff;
color: #606266; color: #606266;
font-size: 14px; font-size: 14px;
} }
:deep(.el-input__suffix) { :deep(.el-input__suffix) {
right: 6px; right: 6px;
width: 15px; width: 15px;
height: 20px; height: 20px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
top: 6px; top: 6px;
border-radius: 4px; border-radius: 4px;
} }
:deep(.el-input__suffix-inner) { :deep(.el-input__suffix-inner) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
:deep(.el-icon-arrow-up:before) { :deep(.el-icon-arrow-up:before) {
content: ""; content: "";
display: inline-block; display: inline-block;
border-left: 6px solid transparent; border-left: 6px solid transparent;
border-right: 6px solid transparent; border-right: 6px solid transparent;
border-top: 9px solid #606266; border-top: 9px solid #606266;
position: relative; position: relative;
transform: rotate(0deg); transform: rotate(0deg);
transition: transform 0.3s; transition: transform 0.3s;
} }
} }
.el-table { .el-table {
@@ -682,5 +695,23 @@ export default {
} }
} }
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style> </style>
+107 -10
View File
@@ -31,8 +31,30 @@
</div> </div>
</div> </div>
<div class="device-list-container"> <div class="device-list-container">
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" @configure="goToRoleConfig" <template v-if="isLoading">
@deviceManage="handleDeviceManage" @delete="handleDeleteAgent" /> <div
v-for="i in skeletonCount"
:key="'skeleton-'+i"
class="skeleton-item"
>
<div class="skeleton-image"></div>
<div class="skeleton-content">
<div class="skeleton-line"></div>
<div class="skeleton-line-short"></div>
</div>
</div>
</template>
<template v-else>
<DeviceItem
v-for="(item, index) in devices"
:key="index"
:device="item"
@configure="goToRoleConfig"
@deviceManage="handleDeviceManage"
@delete="handleDeleteAgent"
/>
</template>
</div> </div>
</div> </div>
<AddWisdomBodyDialog :visible.sync="addDeviceDialogVisible" @confirm="handleWisdomBodyAdded" /> <AddWisdomBodyDialog :visible.sync="addDeviceDialogVisible" @confirm="handleWisdomBodyAdded" />
@@ -60,7 +82,9 @@ export default {
devices: [], devices: [],
originalDevices: [], originalDevices: [],
isSearching: false, isSearching: false,
searchRegex: null searchRegex: null,
isLoading: true,
skeletonCount: localStorage.getItem('skeletonCount') || 8,
} }
}, },
@@ -109,12 +133,26 @@ export default {
}, },
// 获取智能体列表 // 获取智能体列表
fetchAgentList() { fetchAgentList() {
this.isLoading = true;
Api.agent.getAgentList(({ data }) => { Api.agent.getAgentList(({ data }) => {
this.originalDevices = data.data.map(item => ({ if (data?.data) {
...item, this.originalDevices = data.data.map(item => ({
agentId: item.id // 字段映射 ...item,
})); agentId: item.id
this.handleSearchReset(); // 重置搜索状态 }));
// 动态设置骨架屏数量(可选)
this.skeletonCount = Math.min(
Math.max(this.originalDevices.length, 3), // 最少3个
10 // 最多10个
);
this.handleSearchReset();
}
this.isLoading = false;
}, (error) => {
console.error('Failed to fetch agent list:', error);
this.isLoading = false;
}); });
}, },
// 删除智能体 // 删除智能体
@@ -199,7 +237,7 @@ export default {
.hi-hint { .hi-hint {
font-weight: 400; font-weight: 400;
font-size: 10px; font-size: 12px;
text-align: left; text-align: left;
color: #818cae; color: #818cae;
margin-left: 75px; margin-left: 75px;
@@ -220,7 +258,7 @@ export default {
border-radius: 17px; border-radius: 17px;
background: #5778ff; background: #5778ff;
color: #fff; color: #fff;
font-size: 10px; font-size: 14px;
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
line-height: 34px; line-height: 34px;
@@ -262,6 +300,65 @@ export default {
/* 居中显示 */ /* 居中显示 */
} }
/* 骨架屏动画 */
@keyframes shimmer {
100% { transform: translateX(100%); }
}
.skeleton-item {
background: #fff;
border-radius: 8px;
padding: 20px;
height: 120px;
position: relative;
overflow: hidden;
margin-bottom: 20px;
}
.skeleton-image {
width: 80px;
height: 80px;
background: #f0f2f5;
border-radius: 4px;
float: left;
position: relative;
overflow: hidden;
}
.skeleton-content {
margin-left: 100px;
}
.skeleton-line {
height: 16px;
background: #f0f2f5;
border-radius: 4px;
margin-bottom: 12px;
width: 70%;
position: relative;
overflow: hidden;
}
.skeleton-line-short {
height: 12px;
background: #f0f2f5;
border-radius: 4px;
width: 50%;
}
.skeleton-item::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255,255,255,0),
rgba(255,255,255,0.3),
rgba(255,255,255,0)
);
animation: shimmer 1.5s infinite;
}
</style> </style>
+36 -2
View File
@@ -15,6 +15,9 @@
<img loading="lazy" src="@/assets/home/setting-user.png" alt=""> <img loading="lazy" src="@/assets/home/setting-user.png" alt="">
</div> </div>
<span class="header-title">{{ form.agentName }}</span> <span class="header-title">{{ form.agentName }}</span>
<button class="custom-close-btn" @click="goToHome">
×
</button>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
@@ -165,6 +168,9 @@ export default {
} }
}, },
methods: { methods: {
goToHome() {
this.$router.push('/home');
},
saveConfig() { saveConfig() {
const configData = { const configData = {
agentCode: this.form.agentCode, agentCode: this.form.agentCode,
@@ -423,6 +429,7 @@ export default {
} }
.config-header { .config-header {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 13px; gap: 13px;
@@ -560,8 +567,35 @@ export default {
background: none; background: none;
position: absolute; position: absolute;
font-size: 12px; font-size: 12px;
bottom: -10px; bottom: -10%;
right: 21px; right: 3%;
}
.custom-close-btn {
position: absolute;
top: 25%;
right: 0;
transform: translateY(-50%);
width: 35px;
height: 35px;
border-radius: 50%;
border: 2px solid #cfcfcf;
background: none;
font-size: 30px;
font-weight: lighter;
color: #cfcfcf;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
padding: 0;
outline: none;
}
.custom-close-btn:hover {
color: #409EFF;
border-color: #409EFF;
} }
</style> </style>
-400
View File
@@ -1,400 +0,0 @@
<template>
<div class="container">
<h1 class="title">XiaoZhi ESP32 Server 测试助手</h1>
<div class="chat-container" ref="chatContainer">
<div v-for="(message, index) in messages" :key="index"
:class="['message', message.role === 'user' ? 'user' : 'assistant']">
<span>{{ message.content }}</span>
</div>
</div>
<div class="controls">
<button @click="toggleRecording" :disabled="wsStatus !== 'connected'" :class="{ recording: isRecording }">
{{ isRecording ? '停止录音' : '开始录音' }}
</button>
<p>WebSocket: {{ wsStatus }}</p>
</div>
</div>
</template>
<script>
import { OpusDecoder } from 'opus-decoder';
import Recorder from 'opus-recorder';
export default {
name: 'TestPage',
data() {
return {
messages: [],
wsStatus: 'disconnected',
isRecording: false,
ws: null,
recorder: null,
stream: null,
audioContext: null,
sourceNode: null,
audioDecoder: null,
audioBufferQueue: [], // 当前句子的音频缓冲区
playbackQueue: [], // 播放队列,存储待播放的句子
isPlaying: false,
}
},
methods: {
connectWebSocket() {
this.ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.wsStatus = 'connected';
console.log('WebSocket 连接成功');
this.ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
this.audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
};
this.ws.onmessage = async (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
console.log('收到文本消息:', msg);
if (msg.type === 'stt') {
this.messages.push({ role: 'user', content: msg.text });
this.scrollToBottom();
} else if (msg.type === 'llm') {
this.messages.push({ role: 'assistant', content: msg.text });
this.scrollToBottom();
} else if (msg.type === 'tts') {
if (msg.state === 'sentence_start') {
this.audioBufferQueue = [];
const lastMessage = this.messages[this.messages.length - 1];
if (!lastMessage || lastMessage.content !== msg.text) {
this.messages.push({ role: 'assistant', content: msg.text });
this.scrollToBottom();
}
} else if (msg.state === 'sentence_end') {
if (this.audioBufferQueue.length > 0) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
} else if (msg.state === 'stop') {
console.log('TTS 任务结束');
if (this.audioBufferQueue.length > 0) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
}
} else if (msg.type === 'hello') {
console.log('收到服务器初始化消息:', msg);
}
} else if (event.data instanceof ArrayBuffer) {
console.log('收到音频帧,大小:', event.data.byteLength, '字节');
const opusFrame = new Uint8Array(event.data);
const frameHead = Array.from(opusFrame.slice(0, 8))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
console.log('音频帧前8字节:', frameHead);
try {
const decoded = this.audioDecoder.decodeFrame(opusFrame);
console.log('解码结果:', decoded);
if (decoded && decoded.channelData && decoded.channelData[0]) {
const pcmData = decoded.channelData[0];
if (pcmData.length > 0) {
this.audioBufferQueue.push(pcmData);
console.log('解码音频帧,PCM 数据长度:', pcmData.length);
if (this.audioBufferQueue.length >= 5 && this.playbackQueue.length === 0 && !this.isPlaying) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
} else {
console.warn('解码成功,但 PCM 数据长度为 0');
}
} else {
console.warn('解码结果无效:', decoded);
}
} catch (e) {
console.error('Opus 解码错误:', e);
}
}
};
this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
this.wsStatus = 'error';
};
this.ws.onclose = () => {
this.wsStatus = 'disconnected';
console.log('WebSocket 断开');
setTimeout(this.connectWebSocket, 1000);
};
},
scrollToBottom() {
this.$nextTick(() => {
if (this.$refs.chatContainer) {
this.$refs.chatContainer.scrollTop = this.$refs.chatContainer.scrollHeight;
}
});
},
async playNextInQueue() {
if (this.isPlaying || this.playbackQueue.length === 0) {
return;
}
this.isPlaying = true;
if (!this.audioContext || this.audioContext.state === 'closed') {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
}
const pcmBuffers = this.playbackQueue.shift();
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
if (totalLength === 0) {
console.error('音频缓冲区总长度为 0,无法播放');
this.isPlaying = false;
this.playNextInQueue();
return;
}
const mergedPcm = new Float32Array(totalLength);
let offset = 0;
for (const pcm of pcmBuffers) {
mergedPcm.set(pcm, offset);
offset += pcm.length;
}
const audioBuffer = this.audioContext.createBuffer(1, totalLength, 16000);
audioBuffer.getChannelData(0).set(mergedPcm);
const source = this.audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.audioContext.destination);
source.onended = () => {
this.isPlaying = false;
console.log('音频播放结束');
this.playNextInQueue();
};
source.start();
console.log('开始播放音频,总长度:', totalLength);
},
stripOggContainer(data) {
let arrayBuffer;
if (data instanceof ArrayBuffer) {
arrayBuffer = data;
} else if (data.buffer instanceof ArrayBuffer) {
arrayBuffer = data.buffer;
} else {
console.error('输入数据类型不支持:', data);
return [data];
}
const dataView = new DataView(arrayBuffer);
const frames = [];
let offset = 0;
while (offset < arrayBuffer.byteLength) {
if (dataView.getUint32(offset) === 0x4F676753) { // 'OggS'
const segmentTableLength = dataView.getUint8(offset + 26);
const segmentTableOffset = offset + 27;
let segmentOffset = segmentTableOffset + segmentTableLength;
for (let i = 0; i < segmentTableLength; i++) {
const segmentLength = dataView.getUint8(segmentTableOffset + i);
const segmentData = arrayBuffer.slice(segmentOffset, segmentOffset + segmentLength);
segmentOffset += segmentLength;
const header = new TextDecoder().decode(segmentData.slice(0, 8));
if (header === 'OpusHead' || header === 'OpusTags') {
console.log(`跳过 ${header},大小: ${segmentLength} 字节`);
} else if (segmentLength >= 50 && segmentLength <= 300) {
frames.push(segmentData);
}
}
offset = segmentOffset;
} else {
const remainingLength = arrayBuffer.byteLength - offset;
if (remainingLength >= 50 && remainingLength <= 300) {
frames.push(arrayBuffer.slice(offset));
}
break;
}
}
if (frames.length === 0) {
console.warn('未找到有效裸 Opus 帧,返回原始数据');
return [arrayBuffer];
}
console.log('剥离后找到', frames.length, '个裸 Opus 帧');
return frames;
},
async initRecorder() {
console.log('开始初始化录音');
try {
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('获取麦克风权限成功,stream:', this.stream);
if (!this.audioContext || this.audioContext.state === 'closed') {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
}
this.sourceNode = this.audioContext.createMediaStreamSource(this.stream);
this.recorder = new Recorder({
encoderPath: '/encoderWorker.min.js',
sampleRate: 16000,
numberOfChannels: 1,
frameSize: 960,
encoderApplication: 2048,
encoderFrameSize: 60,
encoderBitRate: 24000,
monitorGain: 0,
sourceNode: this.sourceNode,
ogg: false,
streamPages: false,
maxFramesPerPage: 1,
});
this.recorder.ondataavailable = (data) => {
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
const frames = this.stripOggContainer(data);
frames.forEach((frame, index) => {
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
console.log(`${index} 大小: ${frame.byteLength} 字节,前8字节: ${frameHead}`);
if (frame.byteLength < 50 || frame.byteLength > 300) {
console.warn(`${index} 大小异常: ${frame.byteLength} 字节,预期 50-300 字节`);
return;
}
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(frame);
console.log('发送裸 Opus 帧:', frame.byteLength);
} else {
console.warn('WebSocket 未连接,跳过发送');
}
});
};
this.recorder.onstart = () => {
console.log('录音已启动');
};
this.recorder.onstop = () => {
console.log('录音停止');
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
this.sourceNode = null;
this.scrollToBottom();
};
console.log('Recorder 初始化成功');
} catch (err) {
console.error('初始化录音失败:', err);
alert('无法访问麦克风或录音初始化失败,请检查权限');
}
},
async toggleRecording() {
console.log('点击 toggleRecording,当前状态:', this.isRecording, 'WebSocket 状态:', this.wsStatus);
if (!this.recorder) {
await this.initRecorder();
if (!this.recorder) {
console.error('recorder 初始化失败');
return;
}
}
if (this.isRecording) {
console.log('停止录音');
this.recorder.stop();
this.isRecording = false;
} else {
try {
console.log('开始录音');
await this.initRecorder();
await this.recorder.start();
console.log('录音开始后,状态:', this.recorder.state);
this.isRecording = true;
} catch (err) {
console.error('录音启动失败:', err);
}
}
},
},
mounted() {
console.log('组件挂载,初始化 WebSocket');
this.connectWebSocket();
},
destroyed() {
if (this.ws) this.ws.close();
if (this.stream) this.stream.getTracks().forEach(track => track.stop());
if (this.recorder) this.recorder.stop();
if (this.audioContext) this.audioContext.close();
if (this.audioDecoder) this.audioDecoder.destroy();
}
};
</script>
<style scoped>
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: Arial, sans-serif;
}
.title {
text-align: center;
font-size: 24px;
margin-bottom: 20px;
}
.chat-container {
height: 60vh;
overflow-y: auto;
border: 1px solid #ccc;
padding: 10px;
background: #f9f9f9;
border-radius: 5px;
}
.message {
margin: 10px 0;
padding: 8px 12px;
border-radius: 5px;
max-width: 70%;
}
.user {
background: #007bff;
color: white;
margin-left: auto;
}
.assistant {
background: #e9ecef;
color: black;
}
.controls {
text-align: center;
margin-top: 20px;
}
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background: #007bff;
color: white;
border: none;
border-radius: 5px;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
button.recording {
background: #dc3545;
}
</style>
+2 -1
View File
@@ -39,7 +39,7 @@ const cdnResources = {
const useCDN = process.env.VUE_APP_USE_CDN === 'true'; const useCDN = process.env.VUE_APP_USE_CDN === 'true';
module.exports = defineConfig({ module.exports = defineConfig({
productionSourceMap: process.env.NODE_ENV === 'production' ? false : true, // 生产环境不生成 source map productionSourceMap: process.env.NODE_ENV !=='production', // 生产环境不生成 source map
devServer: { devServer: {
port: 8001, // 指定端口为 8001 port: 8001, // 指定端口为 8001
proxy: { proxy: {
@@ -52,6 +52,7 @@ module.exports = defineConfig({
overlay: false, // 不显示 webpack 错误覆盖层 overlay: false, // 不显示 webpack 错误覆盖层
}, },
}, },
publicPath: process.env.VUE_APP_PUBLIC_PATH || "/",
chainWebpack: config => { chainWebpack: config => {
// 修改 HTML 插件配置,动态插入 CDN 链接 // 修改 HTML 插件配置,动态插入 CDN 链接
+43
View File
@@ -3,9 +3,13 @@ import sys
import signal import signal
from config.settings import load_config, check_config_file from config.settings import load_config, check_config_file
from core.websocket_server import WebSocketServer from core.websocket_server import WebSocketServer
from core.ota_server import SimpleOtaServer
from core.utils.util import check_ffmpeg_installed from core.utils.util import check_ffmpeg_installed
from config.logger import setup_logging
from core.utils.util import get_local_ip
TAG = __name__ TAG = __name__
logger = setup_logging()
async def wait_for_exit(): async def wait_for_exit():
@@ -34,6 +38,41 @@ async def main():
# 启动 WebSocket 服务器 # 启动 WebSocket 服务器
ws_server = WebSocketServer(config) ws_server = WebSocketServer(config)
ws_task = asyncio.create_task(ws_server.start()) ws_task = asyncio.create_task(ws_server.start())
ota_task = None
read_config_from_api = config.get("read_config_from_api", False)
if not read_config_from_api:
# 启动 Simple OTA 服务器
ota_server = SimpleOtaServer(config)
ota_task = asyncio.create_task(ota_server.start())
logger.bind(tag=TAG).info(
"OTA接口是\t\thttp://{}:{}/xiaozhi/ota/",
get_local_ip(),
config["server"]["ota_port"],
)
# 获取WebSocket配置,使用安全的默认值
websocket_port = 8000
server_config = config.get("server", {})
if isinstance(server_config, dict):
websocket_port = int(server_config.get("port", 8000))
logger.bind(tag=TAG).info(
"Websocket地址是\tws://{}:{}/xiaozhi/v1/",
get_local_ip(),
websocket_port,
)
logger.bind(tag=TAG).info(
"=======上面的地址是websocket协议地址,请勿用浏览器访问======="
)
logger.bind(tag=TAG).info(
"如想测试websocket请用谷歌浏览器打开test目录下的test_page.html"
)
logger.bind(tag=TAG).info(
"=============================================================\n"
)
try: try:
await wait_for_exit() # 监听退出信号 await wait_for_exit() # 监听退出信号
@@ -41,8 +80,12 @@ async def main():
print("任务被取消,清理资源中...") print("任务被取消,清理资源中...")
finally: finally:
ws_task.cancel() ws_task.cancel()
if ota_task:
ota_task.cancel()
try: try:
await ws_task await ws_task
if ota_task:
await ota_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
print("服务器已关闭,程序退出。") print("服务器已关闭,程序退出。")
+34
View File
@@ -9,6 +9,15 @@ server:
# 服务器监听地址和端口(Server listening address and port) # 服务器监听地址和端口(Server listening address and port)
ip: 0.0.0.0 ip: 0.0.0.0
port: 8000 port: 8000
# OTA接口的端口号
ota_port: 8002
# 这个websocket配置是指ota接口向设备发送的websocket地址
# 如果按默认的写法,ota接口会自动生成websocket地址。这个地址你可以直接用浏览器访问ota接口确认一下
# 当你使用docker部署或使用公网部署(使用ssl、域名)时,不一定准确
# 所以如果你使用docker部署或使用公网部署时,请设置正确的websocket地址
websocket: ws://你的ip或者域名:端口号/xiaozhi/v1/
# OTA返回信息时区偏移量
timezone_offset: +8
# 认证配置 # 认证配置
auth: auth:
# 是否启用认证 # 是否启用认证
@@ -314,6 +323,17 @@ LLM:
bot_id: "你的bot_id" bot_id: "你的bot_id"
user_id: "你的user_id" user_id: "你的user_id"
personal_access_token: 你的coze个人令牌 personal_access_token: 你的coze个人令牌
VolcesAiGatewayLLM:
# 火山引擎 - 边缘大模型网关
# 定义LLM API类型
type: openai
# 先开通服务,打开以下网址,创建网关访问密钥,搜索并勾选 Doubao-pro-32k-functioncall ,开通
# 如果需要使用边缘大模型网关提供的语音合成,一并勾选 Doubao-语音合成 ,另见 TTS.VolcesAiGatewayTTS 配置
# https://console.volcengine.com/vei/aigateway/
# 开通后,进入这里获取密钥:https://console.volcengine.com/vei/aigateway/tokens-list
base_url: https://ai-gateway.vei.volces.com/v1
model_name: doubao-pro-32k-functioncall
api_key: 你的网关访问密钥
LMStudioLLM: LMStudioLLM:
# 定义LLM API类型 # 定义LLM API类型
type: openai type: openai
@@ -382,6 +402,20 @@ TTS:
output_dir: tmp/ output_dir: tmp/
access_token: 你的coze web key access_token: 你的coze web key
response_format: wav response_format: wav
VolcesAiGatewayTTS:
type: openai
# 火山引擎 - 边缘大模型网关
# 先开通服务,打开以下网址,创建网关访问密钥,搜索并勾选 Doubao-语音合成 ,开通
# 如果需要使用边缘大模型网关提供的 LLM,一并勾选 Doubao-pro-32k-functioncall ,另见 LLM.VolcesAiGatewayLLM 配置
# https://console.volcengine.com/vei/aigateway/
# 开通后,进入这里获取密钥:https://console.volcengine.com/vei/aigateway/tokens-list
api_key: 你的网关访问密钥
api_url: https://ai-gateway.vei.volces.com/v1/audio/speech
model: doubao-tts
# 音色列表见 https://www.volcengine.com/docs/6561/1257544
voice: zh_male_shaonianzixin_moon_bigtts
speed: 1
output_dir: tmp/
FishSpeech: FishSpeech:
# 定义TTS API类型 # 定义TTS API类型
#启动tts方法: #启动tts方法:
@@ -69,6 +69,11 @@ def get_config_from_api(config):
"url": config["manager-api"].get("url", ""), "url": config["manager-api"].get("url", ""),
"secret": config["manager-api"].get("secret", ""), "secret": config["manager-api"].get("secret", ""),
} }
if config.get("server"):
config_data["server"] = {
"ip": config["server"].get("ip", ""),
"port": config["server"].get("port", ""),
}
return config_data return config_data
+1 -1
View File
@@ -3,7 +3,7 @@ import sys
from loguru import logger from loguru import logger
from config.config_loader import load_config from config.config_loader import load_config
SERVER_VERSION = "0.3.6" SERVER_VERSION = "0.3.12"
def get_module_abbreviation(module_name, module_dict): def get_module_abbreviation(module_name, module_dict):
+8
View File
@@ -43,6 +43,14 @@ def check_config_file():
missing_keys = find_missing_keys(new_config, old_config) missing_keys = find_missing_keys(new_config, old_config)
read_config_from_api = old_config.get("read_config_from_api", False) read_config_from_api = old_config.get("read_config_from_api", False)
if read_config_from_api: if read_config_from_api:
old_config_origin = read_config(old_config_file)
if old_config_origin.get("selected_module") is not None:
missing_keys_str = "\n".join(f"- {key}" for key in missing_keys)
error_msg = "您的配置文件好像既包含智控台的配置又包含本地配置:\n"
error_msg += "\n建议您:\n"
error_msg += "1、将根目录的config_from_api.yaml文件复制到data下,重命名为.config.yaml\n"
error_msg += "2、按教程配置好接口地址和密钥\n"
raise ValueError(error_msg)
return return
if missing_keys: if missing_keys:
+3
View File
@@ -5,6 +5,9 @@
# 先启动manager-api和manager-web,注册一个账号,第一个注册的账号为管理员 # 先启动manager-api和manager-web,注册一个账号,第一个注册的账号为管理员
# 使用管理员,进入【参数管理】页面,找到【server.secret】,复制它到参数值,注意每次从零部署,server.secret都会变化 # 使用管理员,进入【参数管理】页面,找到【server.secret】,复制它到参数值,注意每次从零部署,server.secret都会变化
# 打开本data目录下的.config.yaml文件,修改manager-api.secret为刚才复制出来的server.secret # 打开本data目录下的.config.yaml文件,修改manager-api.secret为刚才复制出来的server.secret
server:
ip: 0.0.0.0
port: 8000
manager-api: manager-api:
# 你的manager-api的地址,最好使用局域网ip # 你的manager-api的地址,最好使用局域网ip
url: http://127.0.0.1:8002/xiaozhi url: http://127.0.0.1:8002/xiaozhi
+36 -19
View File
@@ -29,6 +29,7 @@ from core.auth import AuthMiddleware, AuthenticationError
from core.mcp.manager import MCPManager from core.mcp.manager import MCPManager
from config.config_loader import get_private_config_from_api from config.config_loader import get_private_config_from_api
from config.manage_api_client import DeviceNotFoundException, DeviceBindException from config.manage_api_client import DeviceNotFoundException, DeviceBindException
from core.utils.output_counter import add_device_output
TAG = __name__ TAG = __name__
@@ -57,6 +58,7 @@ class ConnectionHandler:
self.session_id = None self.session_id = None
self.prompt = None self.prompt = None
self.welcome_msg = None self.welcome_msg = None
self.max_output_size = 0
# 客户端状态相关 # 客户端状态相关
self.client_abort = False self.client_abort = False
@@ -214,19 +216,15 @@ class ConnectionHandler:
def _initialize_components(self, private_config): def _initialize_components(self, private_config):
"""初始化组件""" """初始化组件"""
self._initialize_models(private_config) if private_config is not None:
self._initialize_models(private_config)
else:
self.prompt = self.config["prompt"]
self.change_system_prompt(self.prompt)
"""加载记忆""" """加载记忆"""
self._initialize_memory() self._initialize_memory()
"""加载意图识别""" """加载意图识别"""
self._initialize_intent() self._initialize_intent()
"""加载位置信息"""
self.client_ip_info = get_ip_info(self.client_ip, self.logger)
if self.client_ip_info is not None and "city" in self.client_ip_info:
self.logger.bind(tag=TAG).info(f"Client ip info: {self.client_ip_info}")
self.prompt = self.prompt + f"\nuser location:{self.client_ip_info}"
self.dialogue.update_system_message(self.prompt)
def _initialize_private_config(self): def _initialize_private_config(self):
read_config_from_api = self.config.get("read_config_from_api", False) read_config_from_api = self.config.get("read_config_from_api", False)
@@ -238,8 +236,8 @@ class ConnectionHandler:
begin_time = time.time() begin_time = time.time()
private_config = get_private_config_from_api( private_config = get_private_config_from_api(
self.config, self.config,
self.headers.get("device-id", None), self.headers.get("device-id"),
self.headers.get("client-id", None), self.headers.get("client-id", self.headers.get("device-id")),
) )
private_config["delete_audio"] = bool(self.config.get("delete_audio", True)) private_config["delete_audio"] = bool(self.config.get("delete_audio", True))
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).info(
@@ -312,7 +310,6 @@ class ConnectionHandler:
self.config["selected_module"]["LLM"] = private_config["selected_module"][ self.config["selected_module"]["LLM"] = private_config["selected_module"][
"LLM" "LLM"
] ]
if private_config.get("Memory", None) is not None: if private_config.get("Memory", None) is not None:
init_memory = True init_memory = True
self.config["Memory"] = private_config["Memory"] self.config["Memory"] = private_config["Memory"]
@@ -325,6 +322,8 @@ class ConnectionHandler:
self.config["selected_module"]["Intent"] = private_config[ self.config["selected_module"]["Intent"] = private_config[
"selected_module" "selected_module"
]["Intent"] ]["Intent"]
if private_config.get("device_max_output_size", None) is not None:
self.max_output_size = int(private_config["device_max_output_size"])
try: try:
modules = initialize_modules( modules = initialize_modules(
self.logger, self.logger,
@@ -442,11 +441,16 @@ class ConnectionHandler:
current_text = full_text[processed_chars:] # 从未处理的位置开始 current_text = full_text[processed_chars:] # 从未处理的位置开始
# 查找最后一个有效标点 # 查找最后一个有效标点
punctuations = ("", "", "", "", "") punctuations = ("", ".", "", "?", "", "!", "", ";", "")
last_punct_pos = -1 last_punct_pos = -1
number_flag = True
for punct in punctuations: for punct in punctuations:
pos = current_text.rfind(punct) pos = current_text.rfind(punct)
if pos > last_punct_pos: prev_char = current_text[pos - 1] if pos - 1 >= 0 else ""
# 如果.前面是数字统一判断为小数
if prev_char.isdigit() and punct == '.':
number_flag = False
if pos > last_punct_pos and number_flag:
last_punct_pos = pos last_punct_pos = pos
# 找到分割点则处理 # 找到分割点则处理
@@ -568,11 +572,16 @@ class ConnectionHandler:
current_text = full_text[processed_chars:] # 从未处理的位置开始 current_text = full_text[processed_chars:] # 从未处理的位置开始
# 查找最后一个有效标点 # 查找最后一个有效标点
punctuations = ("", "", "", "", "") punctuations = ("", ".", "", "?", "", "!", "", ";", "")
last_punct_pos = -1 last_punct_pos = -1
number_flag = True
for punct in punctuations: for punct in punctuations:
pos = current_text.rfind(punct) pos = current_text.rfind(punct)
if pos > last_punct_pos: prev_char = current_text[pos - 1] if pos - 1 >= 0 else ""
# 如果.前面是数字统一判断为小数
if prev_char.isdigit() and punct == '.':
number_flag = False
if pos > last_punct_pos and number_flag:
last_punct_pos = pos last_punct_pos = pos
# 找到分割点则处理 # 找到分割点则处理
@@ -616,7 +625,7 @@ class ConnectionHandler:
) )
if not bHasError: if not bHasError:
response_message.clear() response_message.clear()
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).debug(
f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}" f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}"
) )
function_call_data = { function_call_data = {
@@ -842,6 +851,8 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"tts转换失败,{text}") self.logger.bind(tag=TAG).error(f"tts转换失败,{text}")
return None, text, text_index return None, text, text_index
self.logger.bind(tag=TAG).debug(f"TTS 文件生成完毕: {tts_file}") self.logger.bind(tag=TAG).debug(f"TTS 文件生成完毕: {tts_file}")
if self.max_output_size > 0:
add_device_output(self.headers.get("device-id"), len(text))
return tts_file, text, text_index return tts_file, text, text_index
def clearSpeakStatus(self): def clearSpeakStatus(self):
@@ -877,7 +888,7 @@ class ConnectionHandler:
self.executor = None self.executor = None
# 清空任务队列 # 清空任务队列
self._clear_queues() self.clear_queues()
if ws: if ws:
await ws.close() await ws.close()
@@ -885,8 +896,11 @@ class ConnectionHandler:
await self.websocket.close() await self.websocket.close()
self.logger.bind(tag=TAG).info("连接资源已释放") self.logger.bind(tag=TAG).info("连接资源已释放")
def _clear_queues(self): def clear_queues(self):
# 清空所有任务队列 # 清空所有任务队列
self.logger.bind(tag=TAG).debug(
f"开始清理: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
)
for q in [self.tts_queue, self.audio_play_queue]: for q in [self.tts_queue, self.audio_play_queue]:
if not q: if not q:
continue continue
@@ -898,6 +912,9 @@ class ConnectionHandler:
q.queue.clear() q.queue.clear()
# 添加毒丸信号到队列,确保线程退出 # 添加毒丸信号到队列,确保线程退出
# q.queue.put(None) # q.queue.put(None)
self.logger.bind(tag=TAG).debug(
f"清理结束: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
)
def reset_vad_states(self): def reset_vad_states(self):
self.client_audio_buffer = bytearray() self.client_audio_buffer = bytearray()
@@ -10,6 +10,7 @@ async def handleAbortMessage(conn):
logger.bind(tag=TAG).info("Abort message received") logger.bind(tag=TAG).info("Abort message received")
# 设置成打断状态,会自动打断llm、tts任务 # 设置成打断状态,会自动打断llm、tts任务
conn.client_abort = True conn.client_abort = True
conn.clear_queues()
# 打断客户端说话状态 # 打断客户端说话状态
await conn.websocket.send(json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id})) await conn.websocket.send(json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id}))
conn.clearSpeakStatus() conn.clearSpeakStatus()
@@ -79,7 +79,7 @@ class FunctionHandler:
func = funcItem.func func = funcItem.func
arguments = function_call_data["arguments"] arguments = function_call_data["arguments"]
arguments = json.loads(arguments) if arguments else {} arguments = json.loads(arguments) if arguments else {}
logger.bind(tag=TAG).info(f"调用函数: {function_name}, 参数: {arguments}") logger.bind(tag=TAG).debug(f"调用函数: {function_name}, 参数: {arguments}")
if ( if (
funcItem.type == ToolType.SYSTEM_CTL funcItem.type == ToolType.SYSTEM_CTL
or funcItem.type == ToolType.IOT_CTL or funcItem.type == ToolType.IOT_CTL
+54 -33
View File
@@ -57,7 +57,7 @@ def create_iot_function(device_name, method_name, method_info):
response_failure = "操作失败" response_failure = "操作失败"
# 打印响应参数 # 打印响应参数
logger.bind(tag=TAG).info( logger.bind(tag=TAG).debug(
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'" f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
) )
@@ -146,7 +146,7 @@ class IotDescriptor:
# 根据描述创建属性 # 根据描述创建属性
if properties is not None: if properties is not None:
for key, value in properties.items(): for key, value in properties.items():
property_item = globals()[key] = {} property_item = {}
property_item["name"] = key property_item["name"] = key
property_item["description"] = value["description"] property_item["description"] = value["description"]
if value["type"] == "number": if value["type"] == "number":
@@ -160,18 +160,17 @@ class IotDescriptor:
# 根据描述创建方法 # 根据描述创建方法
if methods is not None: if methods is not None:
for key, value in methods.items(): for key, value in methods.items():
method = globals()[key] = {} method = {}
method["description"] = value["description"] method["description"] = value["description"]
method["name"] = key method["name"] = key
for k, v in value["parameters"].items(): # 检查方法是否有参数
method[k] = {} if "parameters" in value:
method[k]["description"] = v["description"] method["parameters"] = {}
if v["type"] == "number": for k, v in value["parameters"].items():
method[k]["value"] = 0 method["parameters"][k] = {
elif v["type"] == "boolean": "description": v["description"],
method[k]["value"] = False "type": v["type"],
else: }
method[k]["value"] = ""
self.methods.append(method) self.methods.append(method)
@@ -221,13 +220,19 @@ def register_device_type(descriptor):
func_name = f"{device_name.lower()}_{method_name.lower()}" func_name = f"{device_name.lower()}_{method_name.lower()}"
# 创建参数字典,添加原有参数 # 创建参数字典,添加原有参数
parameters = { parameters = {}
param_name: { required_params = []
"type": param_info["type"],
"description": param_info["description"], # 如果方法有参数,则添加参数信息
if "parameters" in method_info:
parameters = {
param_name: {
"type": param_info["type"],
"description": param_info["description"],
}
for param_name, param_info in method_info["parameters"].items()
} }
for param_name, param_info in method_info["parameters"].items() required_params = list(method_info["parameters"].keys())
}
# 添加响应参数 # 添加响应参数
parameters.update( parameters.update(
@@ -244,7 +249,6 @@ def register_device_type(descriptor):
) )
# 构建必须参数列表(原有参数 + 响应参数) # 构建必须参数列表(原有参数 + 响应参数)
required_params = list(method_info["parameters"].keys())
required_params.extend(["response_success", "response_failure"]) required_params.extend(["response_success", "response_failure"])
func_desc = { func_desc = {
@@ -282,6 +286,25 @@ async def handleIotDescriptors(conn, descriptors):
functions_changed = False functions_changed = False
for descriptor in descriptors: for descriptor in descriptors:
# 如果descriptor没有properties和methods,则直接跳过
if "properties" not in descriptor and "methods" not in descriptor:
continue
# 处理缺失properties的情况
if "properties" not in descriptor:
descriptor["properties"] = {}
# 从methods中提取所有参数作为properties
if "methods" in descriptor:
for method_name, method_info in descriptor["methods"].items():
if "parameters" in method_info:
for param_name, param_info in method_info["parameters"].items():
# 将参数信息转换为属性信息
descriptor["properties"][param_name] = {
"description": param_info["description"],
"type": param_info["type"],
}
# 创建IOT设备描述符 # 创建IOT设备描述符
iot_descriptor = IotDescriptor( iot_descriptor = IotDescriptor(
descriptor["name"], descriptor["name"],
@@ -375,19 +398,17 @@ async def send_iot_conn(conn, name, method_name, parameters):
for method in value.methods: for method in value.methods:
# 找到了方法 # 找到了方法
if method["name"] == method_name: if method["name"] == method_name:
await conn.websocket.send( # 构建命令对象
json.dumps( command = {
{ "name": name,
"type": "iot", "method": method_name,
"commands": [ }
{
"name": name, # 只有当参数不为空时才添加parameters字段
"method": method_name, if parameters:
"parameters": parameters, command["parameters"] = parameters
} send_message = json.dumps({"type": "iot", "commands": [command]})
], await conn.websocket.send(send_message)
} logger.bind(tag=TAG).info(f"发送物联网指令: {send_message}")
)
)
return return
logger.bind(tag=TAG).error(f"未找到方法{method_name}") logger.bind(tag=TAG).error(f"未找到方法{method_name}")
@@ -1,9 +1,9 @@
from config.logger import setup_logging from config.logger import setup_logging
import time import time
import asyncio
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length
from core.handle.sendAudioHandle import send_stt_message from core.handle.sendAudioHandle import send_stt_message
from core.handle.intentHandler import handle_user_intent from core.handle.intentHandler import handle_user_intent
from core.utils.output_counter import check_device_output_limit
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -51,6 +51,15 @@ async def startToChat(conn, text):
if conn.need_bind: if conn.need_bind:
await check_bind_device(conn) await check_bind_device(conn)
return return
# 如果当日的输出字数大于限定的字数
if conn.max_output_size > 0:
if check_device_output_limit(
conn.headers.get("device-id"), conn.max_output_size
):
await max_out_size(conn)
return
# 首先进行意图分析 # 首先进行意图分析
intent_handled = await handle_user_intent(conn, text) intent_handled = await handle_user_intent(conn, text)
@@ -89,6 +98,18 @@ async def no_voice_close_connect(conn):
await startToChat(conn, prompt) await startToChat(conn, prompt)
async def max_out_size(conn):
text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!"
await send_stt_message(conn, text)
conn.tts_first_text_index = 0
conn.tts_last_text_index = 0
conn.llm_finish_task = True
file_path = "config/assets/max_output_size.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(file_path)
conn.audio_play_queue.put((opus_packets, text, 0))
conn.close_after_chat = True
async def check_bind_device(conn): async def check_bind_device(conn):
if conn.bind_code: if conn.bind_code:
# 确保bind_code是6位数字 # 确保bind_code是6位数字
@@ -4,14 +4,49 @@ import asyncio
import time import time
from core.utils.util import ( from core.utils.util import (
get_string_no_punctuation_or_emoji, get_string_no_punctuation_or_emoji,
analyze_emotion
) )
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
emoji_map = {
'neutral': '😶',
'happy': '🙂',
'laughing': '😆',
'funny': '😂',
'sad': '😔',
'angry': '😠',
'crying': '😭',
'loving': '😍',
'embarrassed': '😳',
'surprised': '😲',
'shocked': '😱',
'thinking': '🤔',
'winking': '😉',
'cool': '😎',
'relaxed': '😌',
'delicious': '🤤',
'kissy': '😘',
'confident': '😏',
'sleepy': '😴',
'silly': '😜',
'confused': '🙄'
}
async def sendAudioMessage(conn, audios, text, text_index=0): async def sendAudioMessage(conn, audios, text, text_index=0):
# 发送句子开始消息 # 发送句子开始消息
emotion = analyze_emotion(text)
emoji = emoji_map.get(emotion, '🙂') # 默认使用笑脸
await conn.websocket.send(json.dumps(
{
"type": "llm",
"text": emoji,
"emotion": emotion,
"session_id": conn.session_id,
}
)
)
if text_index == conn.tts_first_text_index: if text_index == conn.tts_first_text_index:
logger.bind(tag=TAG).info(f"发送第一段语音: {text}") logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
await send_tts_message(conn, "sentence_start", text) await send_tts_message(conn, "sentence_start", text)
@@ -86,14 +121,4 @@ async def send_stt_message(conn, text):
await conn.websocket.send( await conn.websocket.send(
json.dumps({"type": "stt", "text": stt_text, "session_id": conn.session_id}) json.dumps({"type": "stt", "text": stt_text, "session_id": conn.session_id})
) )
await conn.websocket.send(
json.dumps(
{
"type": "llm",
"text": "😊",
"emotion": "happy",
"session_id": conn.session_id,
}
)
)
await send_tts_message(conn, "start") await send_tts_message(conn, "start")
+135
View File
@@ -0,0 +1,135 @@
import json
import time
import asyncio
from aiohttp import web
from config.logger import setup_logging
from core.connection import ConnectionHandler
from core.utils.util import get_local_ip, initialize_modules
TAG = __name__
class SimpleOtaServer:
def __init__(self, config: dict):
self.config = config
self.logger = setup_logging()
def _get_websocket_url(self, local_ip: str, port: int) -> str:
"""获取websocket地址
Args:
local_ip: 本地IP地址
port: 端口号
Returns:
str: websocket地址
"""
server_config = self.config["server"]
websocket_config = server_config.get("websocket")
if websocket_config and "" not in websocket_config:
return websocket_config
else:
return f"ws://{local_ip}:{port}/xiaozhi/v1/"
async def start(self):
server_config = self.config["server"]
host = server_config.get("ip", "0.0.0.0")
port = int(server_config.get("ota_port"))
if port:
app = web.Application()
# 添加路由
app.add_routes(
[
web.get("/xiaozhi/ota/", self._handle_ota_get_request),
web.post("/xiaozhi/ota/", self._handle_ota_request),
web.options("/xiaozhi/ota/", self._handle_ota_request),
]
)
# 运行服务
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
# 保持服务运行
while True:
await asyncio.sleep(3600) # 每隔 1 小时检查一次
async def _handle_ota_request(self, request):
"""处理 /xiaozhi/ota/ 的 POST 请求"""
try:
data = await request.text()
self.logger.bind(tag=TAG).debug(f"OTA请求方法: {request.method}")
self.logger.bind(tag=TAG).debug(f"OTA请求头: {request.headers}")
self.logger.bind(tag=TAG).debug(f"OTA请求数据: {data}")
device_id = request.headers.get("device-id", "")
if device_id:
self.logger.bind(tag=TAG).info(f"OTA请求设备ID: {device_id}")
else:
raise Exception("OTA请求设备ID为空")
data_json = json.loads(data)
server_config = self.config["server"]
host = server_config.get("ip", "0.0.0.0")
port = int(server_config.get("port", 8000))
local_ip = get_local_ip()
# OTA基础信息
return_json = {
"server_time": {
"timestamp": int(round(time.time() * 1000)),
"timezone_offset": server_config.get("timezone_offset", 8) * 60,
},
"firmware": {
"version": data_json["application"].get("version", "1.0.0"),
"url": "",
},
"websocket": {
"url": self._get_websocket_url(local_ip, port),
},
}
response = web.Response(
text=json.dumps(return_json, separators=(",", ":")),
content_type="application/json",
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"OTA请求异常: {e}")
return_json = {"success": False, "message": "request error."}
response = web.Response(
text=json.dumps(return_json, separators=(",", ":")),
content_type="application/json",
)
finally:
# 添加header,允许跨域访问
response.headers["Access-Control-Allow-Headers"] = (
"client-id, content-type, device-id"
)
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Origin"] = "*"
return response
async def _handle_ota_get_request(self, request):
"""处理 /xiaozhi/ota/ 的 GET 请求"""
try:
server_config = self.config["server"]
local_ip = get_local_ip()
port = int(server_config.get("port", 8000))
websocket_url = self._get_websocket_url(local_ip, port)
message = f"OTA接口运行正常,向设备发送的websocket地址是:{websocket_url}"
response = web.Response(text=message, content_type="text/plain")
except Exception as e:
self.logger.bind(tag=TAG).error(f"OTA GET请求异常: {e}")
response = web.Response(text="OTA接口异常", content_type="text/plain")
finally:
# 添加header,允许跨域访问
response.headers["Access-Control-Allow-Headers"] = (
"client-id, content-type, device-id"
)
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Origin"] = "*"
return response
@@ -45,14 +45,14 @@ def parse_response(res):
payload 类似与http 请求体 payload 类似与http 请求体
""" """
protocol_version = res[0] >> 4 protocol_version = res[0] >> 4
header_size = res[0] & 0x0f header_size = res[0] & 0x0F
message_type = res[1] >> 4 message_type = res[1] >> 4
message_type_specific_flags = res[1] & 0x0f message_type_specific_flags = res[1] & 0x0F
serialization_method = res[2] >> 4 serialization_method = res[2] >> 4
message_compression = res[2] & 0x0f message_compression = res[2] & 0x0F
reserved = res[3] reserved = res[3]
header_extensions = res[4:header_size * 4] header_extensions = res[4 : header_size * 4]
payload = res[header_size * 4:] payload = res[header_size * 4 :]
result = {} result = {}
payload_msg = None payload_msg = None
payload_size = 0 payload_size = 0
@@ -61,13 +61,13 @@ def parse_response(res):
payload_msg = payload[4:] payload_msg = payload[4:]
elif message_type == SERVER_ACK: elif message_type == SERVER_ACK:
seq = int.from_bytes(payload[:4], "big", signed=True) seq = int.from_bytes(payload[:4], "big", signed=True)
result['seq'] = seq result["seq"] = seq
if len(payload) >= 8: if len(payload) >= 8:
payload_size = int.from_bytes(payload[4:8], "big", signed=False) payload_size = int.from_bytes(payload[4:8], "big", signed=False)
payload_msg = payload[8:] payload_msg = payload[8:]
elif message_type == SERVER_ERROR_RESPONSE: elif message_type == SERVER_ERROR_RESPONSE:
code = int.from_bytes(payload[:4], "big", signed=False) code = int.from_bytes(payload[:4], "big", signed=False)
result['code'] = code result["code"] = code
payload_size = int.from_bytes(payload[4:8], "big", signed=False) payload_size = int.from_bytes(payload[4:8], "big", signed=False)
payload_msg = payload[8:] payload_msg = payload[8:]
if payload_msg is None: if payload_msg is None:
@@ -78,8 +78,8 @@ def parse_response(res):
payload_msg = json.loads(str(payload_msg, "utf-8")) payload_msg = json.loads(str(payload_msg, "utf-8"))
elif serialization_method != NO_SERIALIZATION: elif serialization_method != NO_SERIALIZATION:
payload_msg = str(payload_msg, "utf-8") payload_msg = str(payload_msg, "utf-8")
result['payload_msg'] = payload_msg result["payload_msg"] = payload_msg
result['payload_size'] = payload_size result["payload_size"] = payload_size
return result return result
@@ -122,7 +122,9 @@ class ASRProvider(ASRProviderBase):
return file_path return file_path
@staticmethod @staticmethod
def _generate_header(message_type=CLIENT_FULL_REQUEST, message_type_specific_flags=NO_SEQUENCE) -> bytearray: def _generate_header(
message_type=CLIENT_FULL_REQUEST, message_type_specific_flags=NO_SEQUENCE
) -> bytearray:
"""Generate protocol header.""" """Generate protocol header."""
header = bytearray() header = bytearray()
header_size = 1 header_size = 1
@@ -143,13 +145,9 @@ class ASRProvider(ASRProviderBase):
"user": { "user": {
"uid": str(uuid.uuid4()), "uid": str(uuid.uuid4()),
}, },
"request": { "request": {"reqid": reqid, "show_utterances": False, "sequence": 1},
"reqid": reqid,
"show_utterances": False,
"sequence": 1
},
"audio": { "audio": {
"format": "wav", "format": "raw",
"rate": 16000, "rate": 16000,
"language": "zh-CN", "language": "zh-CN",
"bits": 16, "bits": 16,
@@ -158,18 +156,23 @@ class ASRProvider(ASRProviderBase):
}, },
} }
async def _send_request(self, audio_data: List[bytes], segment_size: int) -> Optional[str]: async def _send_request(
self, audio_data: List[bytes], segment_size: int
) -> Optional[str]:
"""Send request to Volcano ASR service.""" """Send request to Volcano ASR service."""
try: try:
auth_header = {'Authorization': 'Bearer; {}'.format(self.access_token)} auth_header = {"Authorization": "Bearer; {}".format(self.access_token)}
async with websockets.connect(self.ws_url, additional_headers=auth_header) as websocket: async with websockets.connect(
self.ws_url, additional_headers=auth_header
) as websocket:
# Prepare request data # Prepare request data
request_params = self._construct_request(str(uuid.uuid4())) request_params = self._construct_request(str(uuid.uuid4()))
print(request_params)
payload_bytes = str.encode(json.dumps(request_params)) payload_bytes = str.encode(json.dumps(request_params))
payload_bytes = gzip.compress(payload_bytes) payload_bytes = gzip.compress(payload_bytes)
full_client_request = self._generate_header() full_client_request = self._generate_header()
full_client_request.extend((len(payload_bytes)).to_bytes(4, 'big')) # payload size(4 bytes) full_client_request.extend(
(len(payload_bytes)).to_bytes(4, "big")
) # payload size(4 bytes)
full_client_request.extend(payload_bytes) # payload full_client_request.extend(payload_bytes) # payload
# Send header and metadata # Send header and metadata
@@ -177,22 +180,29 @@ class ASRProvider(ASRProviderBase):
await websocket.send(full_client_request) await websocket.send(full_client_request)
res = await websocket.recv() res = await websocket.recv()
result = parse_response(res) result = parse_response(res)
if 'payload_msg' in result and result['payload_msg']['code'] != self.success_code: if (
"payload_msg" in result
and result["payload_msg"]["code"] != self.success_code
):
logger.bind(tag=TAG).error(f"ASR error: {result}") logger.bind(tag=TAG).error(f"ASR error: {result}")
return None return None
for seq, (chunk, last) in enumerate(self.slice_data(audio_data, segment_size), 1): for seq, (chunk, last) in enumerate(
self.slice_data(audio_data, segment_size), 1
):
if last: if last:
audio_only_request = self._generate_header( audio_only_request = self._generate_header(
message_type=CLIENT_AUDIO_ONLY_REQUEST, message_type=CLIENT_AUDIO_ONLY_REQUEST,
message_type_specific_flags=NEG_SEQUENCE message_type_specific_flags=NEG_SEQUENCE,
) )
else: else:
audio_only_request = self._generate_header( audio_only_request = self._generate_header(
message_type=CLIENT_AUDIO_ONLY_REQUEST message_type=CLIENT_AUDIO_ONLY_REQUEST
) )
payload_bytes = gzip.compress(chunk) payload_bytes = gzip.compress(chunk)
audio_only_request.extend((len(payload_bytes)).to_bytes(4, 'big')) # payload size(4 bytes) audio_only_request.extend(
(len(payload_bytes)).to_bytes(4, "big")
) # payload size(4 bytes)
audio_only_request.extend(payload_bytes) # payload audio_only_request.extend(payload_bytes) # payload
# Send audio data # Send audio data
await websocket.send(audio_only_request) await websocket.send(audio_only_request)
@@ -201,9 +211,12 @@ class ASRProvider(ASRProviderBase):
response = await websocket.recv() response = await websocket.recv()
result = parse_response(response) result = parse_response(response)
if 'payload_msg' in result and result['payload_msg']['code'] == self.success_code: if (
if len(result['payload_msg']['result']) > 0: "payload_msg" in result
return result['payload_msg']['result'][0]["text"] and result["payload_msg"]["code"] == self.success_code
):
if len(result["payload_msg"]["result"]) > 0:
return result["payload_msg"]["result"][0]["text"]
return None return None
else: else:
logger.bind(tag=TAG).error(f"ASR error: {result}") logger.bind(tag=TAG).error(f"ASR error: {result}")
@@ -231,7 +244,7 @@ class ASRProvider(ASRProviderBase):
@staticmethod @staticmethod
def read_wav_info(data: io.BytesIO = None) -> (int, int, int, int, int): def read_wav_info(data: io.BytesIO = None) -> (int, int, int, int, int):
with io.BytesIO(data) as _f: with io.BytesIO(data) as _f:
wave_fp = wave.open(_f, 'rb') wave_fp = wave.open(_f, "rb")
nchannels, sampwidth, framerate, nframes = wave_fp.getparams()[:4] nchannels, sampwidth, framerate, nframes = wave_fp.getparams()[:4]
wave_bytes = wave_fp.readframes(nframes) wave_bytes = wave_fp.readframes(nframes)
return nchannels, sampwidth, framerate, nframes, len(wave_bytes) return nchannels, sampwidth, framerate, nframes, len(wave_bytes)
@@ -247,37 +260,32 @@ class ASRProvider(ASRProviderBase):
data_len = len(data) data_len = len(data)
offset = 0 offset = 0
while offset + chunk_size < data_len: while offset + chunk_size < data_len:
yield data[offset: offset + chunk_size], False yield data[offset : offset + chunk_size], False
offset += chunk_size offset += chunk_size
else: else:
yield data[offset: data_len], True yield data[offset:data_len], True
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]: async def speech_to_text(
self, opus_data: List[bytes], session_id: str
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本""" """将语音数据转换为文本"""
try: try:
# 合并所有opus数据包 # 合并所有opus数据包
pcm_data = self.decode_opus(opus_data, session_id) pcm_data = self.decode_opus(opus_data, session_id)
combined_pcm_data = b''.join(pcm_data) combined_pcm_data = b"".join(pcm_data)
wav_buffer = io.BytesIO() # 直接使用PCM数据
# 计算分段大小 (单声道, 16bit, 16kHz采样率)
with wave.open(wav_buffer, "wb") as wav_file: size_per_sec = 1 * 2 * 16000 # nchannels * sampwidth * framerate
wav_file.setnchannels(1) # 设置声道数
wav_file.setsampwidth(2) # 设置采样宽度
wav_file.setframerate(16000) # 设置采样率
wav_file.writeframes(combined_pcm_data) # 写入 PCM 数据
# 获取封装后的 WAV 数据
wav_data = wav_buffer.getvalue()
nchannels, sampwidth, framerate, nframes, wav_len = self.read_wav_info(wav_data)
size_per_sec = nchannels * sampwidth * framerate
segment_size = int(size_per_sec * self.seg_duration / 1000) segment_size = int(size_per_sec * self.seg_duration / 1000)
# 语音识别 # 语音识别
start_time = time.time() start_time = time.time()
text = await self._send_request(wav_data, segment_size) text = await self._send_request(combined_pcm_data, segment_size)
if text: if text:
logger.bind(tag=TAG).debug(f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}") logger.bind(tag=TAG).debug(
f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}"
)
return text, None return text, None
return "", None return "", None
@@ -2,10 +2,12 @@ from config.logger import setup_logging
from http import HTTPStatus from http import HTTPStatus
from dashscope import Application from dashscope import Application
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
from core.utils.util import check_model_key
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
class LLMProvider(LLMProviderBase): class LLMProvider(LLMProviderBase):
def __init__(self, config): def __init__(self, config):
self.api_key = config["api_key"] self.api_key = config["api_key"]
@@ -13,27 +15,32 @@ class LLMProvider(LLMProviderBase):
self.base_url = config.get("base_url") self.base_url = config.get("base_url")
self.is_No_prompt = config.get("is_no_prompt") self.is_No_prompt = config.get("is_no_prompt")
self.memory_id = config.get("ali_memory_id") self.memory_id = config.get("ali_memory_id")
check_model_key("AliBLLLM", self.api_key)
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
try: try:
# 处理dialogue # 处理dialogue
if self.is_No_prompt: if self.is_No_prompt:
dialogue.pop(0) dialogue.pop(0)
logger.bind(tag=TAG).debug(f"【阿里百练API服务】处理后的dialogue: {dialogue}") logger.bind(tag=TAG).debug(
f"【阿里百练API服务】处理后的dialogue: {dialogue}"
)
# 构造调用参数 # 构造调用参数
call_params = { call_params = {
"api_key": self.api_key, "api_key": self.api_key,
"app_id": self.app_id, "app_id": self.app_id,
"session_id": session_id, "session_id": session_id,
"messages": dialogue "messages": dialogue,
} }
if self.memory_id != False: if self.memory_id != False:
# 百练memory需要prompt参数 # 百练memory需要prompt参数
prompt = dialogue[-1].get("content") prompt = dialogue[-1].get("content")
call_params["memory_id"] = self.memory_id call_params["memory_id"] = self.memory_id
call_params["prompt"] = prompt call_params["prompt"] = prompt
logger.bind(tag=TAG).debug(f"【阿里百练API服务】处理后的prompt: {prompt}") logger.bind(tag=TAG).debug(
f"【阿里百练API服务】处理后的prompt: {prompt}"
)
responses = Application.call(**call_params) responses = Application.call(**call_params)
if responses.status_code != HTTPStatus.OK: if responses.status_code != HTTPStatus.OK:
@@ -44,9 +51,15 @@ class LLMProvider(LLMProviderBase):
) )
yield "【阿里百练API服务响应异常】" yield "【阿里百练API服务响应异常】"
else: else:
logger.bind(tag=TAG).debug(f"【阿里百练API服务】构造参数: {call_params}") logger.bind(tag=TAG).debug(
f"【阿里百练API服务】构造参数: {call_params}"
)
yield responses.output.text yield responses.output.text
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}") logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
yield "【LLM服务响应异常】" yield "【LLM服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"阿里百练暂未实现完整的工具调用(function call")
return self.response(session_id, dialogue)
@@ -1,14 +1,17 @@
from config.logger import setup_logging from config.logger import setup_logging
import requests
import json import json
import re
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
import os
# official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py) # official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py)
from cozepy import COZE_CN_BASE_URL from cozepy import COZE_CN_BASE_URL
from cozepy import Coze, TokenAuth, Message, ChatStatus, MessageContentType, ChatEventType # noqa from cozepy import (
Coze,
TokenAuth,
Message,
ChatEventType,
) # noqa
from core.providers.llm.system_prompt import get_system_prompt_for_function from core.providers.llm.system_prompt import get_system_prompt_for_function
from core.utils.util import check_model_key
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -20,6 +23,7 @@ class LLMProvider(LLMProviderBase):
self.bot_id = str(config.get("bot_id")) self.bot_id = str(config.get("bot_id"))
self.user_id = str(config.get("user_id")) self.user_id = str(config.get("user_id"))
self.session_conversation_map = {} # 存储session_id和conversation_id的映射 self.session_conversation_map = {} # 存储session_id和conversation_id的映射
check_model_key("CozeLLM", self.personal_access_token)
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
coze_api_token = self.personal_access_token coze_api_token = self.personal_access_token
@@ -59,7 +63,7 @@ class LLMProvider(LLMProviderBase):
# 如果最后一个是 role="tool",附加到user上 # 如果最后一个是 role="tool",附加到user上
if len(dialogue) > 1 and dialogue[-1]["role"] == "tool": if len(dialogue) > 1 and dialogue[-1]["role"] == "tool":
assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n" assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n"
while len(dialogue) > 1 : while len(dialogue) > 1:
if dialogue[-1]["role"] == "user": if dialogue[-1]["role"] == "user":
dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"] dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"]
break break
@@ -3,6 +3,7 @@ from config.logger import setup_logging
import requests import requests
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
from core.providers.llm.system_prompt import get_system_prompt_for_function from core.providers.llm.system_prompt import get_system_prompt_for_function
from core.utils.util import check_model_key
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -14,6 +15,7 @@ class LLMProvider(LLMProviderBase):
self.mode = config.get("mode", "chat-messages") self.mode = config.get("mode", "chat-messages")
self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip("/") self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip("/")
self.session_conversation_map = {} # 存储session_id和conversation_id的映射 self.session_conversation_map = {} # 存储session_id和conversation_id的映射
check_model_key("DifyLLM", self.api_key)
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
try: try:
@@ -60,7 +62,9 @@ class LLMProvider(LLMProviderBase):
conversation_id # 更新映射 conversation_id # 更新映射
) )
# 过滤 message_replace 事件,此事件会全量推一次 # 过滤 message_replace 事件,此事件会全量推一次
if event.get("event") != "message_replace" and event.get("answer"): if event.get("event") != "message_replace" and event.get(
"answer"
):
yield event["answer"] yield event["answer"]
elif self.mode == "workflows/run": elif self.mode == "workflows/run":
for line in r.iter_lines(): for line in r.iter_lines():
@@ -76,7 +80,9 @@ class LLMProvider(LLMProviderBase):
if line.startswith(b"data: "): if line.startswith(b"data: "):
event = json.loads(line[6:]) event = json.loads(line[6:])
# 过滤 message_replace 事件,此事件会全量推一次 # 过滤 message_replace 事件,此事件会全量推一次
if event.get("event") != "message_replace" and event.get("answer"): if event.get("event") != "message_replace" and event.get(
"answer"
):
yield event["answer"] yield event["answer"]
except Exception as e: except Exception as e:
@@ -94,7 +100,7 @@ class LLMProvider(LLMProviderBase):
# 如果最后一个是 role="tool",附加到user上 # 如果最后一个是 role="tool",附加到user上
if len(dialogue) > 1 and dialogue[-1]["role"] == "tool": if len(dialogue) > 1 and dialogue[-1]["role"] == "tool":
assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n" assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n"
while len(dialogue) > 1 : while len(dialogue) > 1:
if dialogue[-1]["role"] == "user": if dialogue[-1]["role"] == "user":
dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"] dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"]
break break
@@ -2,6 +2,7 @@ import json
from config.logger import setup_logging from config.logger import setup_logging
import requests import requests
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
from core.utils.util import check_model_key
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -13,6 +14,7 @@ class LLMProvider(LLMProviderBase):
self.base_url = config.get("base_url") self.base_url = config.get("base_url")
self.detail = config.get("detail", False) self.detail = config.get("detail", False)
self.variables = config.get("variables", {}) self.variables = config.get("variables", {})
check_model_key("FastGPTLLM", self.api_key)
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
try: try:
@@ -21,37 +23,36 @@ class LLMProvider(LLMProviderBase):
# 发起流式请求 # 发起流式请求
with requests.post( with requests.post(
f"{self.base_url}/chat/completions", f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"}, headers={"Authorization": f"Bearer {self.api_key}"},
json={ json={
"stream": True, "stream": True,
"chatId": session_id, "chatId": session_id,
"detail": self.detail, "detail": self.detail,
"variables": self.variables, "variables": self.variables,
"messages": [ "messages": [{"role": "user", "content": last_msg["content"]}],
{ },
"role": "user", stream=True,
"content": last_msg["content"]
}
]
},
stream=True
) as r: ) as r:
for line in r.iter_lines(): for line in r.iter_lines():
if line: if line:
try: try:
if line.startswith(b'data: '): if line.startswith(b"data: "):
if line[6:].decode('utf-8') == '[DONE]': if line[6:].decode("utf-8") == "[DONE]":
break break
data = json.loads(line[6:]) data = json.loads(line[6:])
if 'choices' in data and len(data['choices']) > 0: if "choices" in data and len(data["choices"]) > 0:
delta = data['choices'][0].get('delta', {}) delta = data["choices"][0].get("delta", {})
if delta and 'content' in delta and delta['content'] is not None: if (
content = delta['content'] delta
if '<think>' in content: and "content" in delta
and delta["content"] is not None
):
content = delta["content"]
if "<think>" in content:
continue continue
if '</think>' in content: if "</think>" in content:
continue continue
yield content yield content
@@ -63,3 +64,7 @@ class LLMProvider(LLMProviderBase):
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error in response generation: {e}") logger.bind(tag=TAG).error(f"Error in response generation: {e}")
yield "【服务响应异常】" yield "【服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"fastgpt暂未实现完整的工具调用(function call")
return self.response(session_id, dialogue)
@@ -134,3 +134,7 @@ class LLMProvider(LLMProviderBase):
yield f"JSON解码错误:{e}" yield f"JSON解码错误:{e}"
except Exception as e: except Exception as e:
yield f"发生错误:{e}" yield f"发生错误:{e}"
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"gemini暂未实现完整的工具调用(function call")
return self.response(session_id, dialogue)
@@ -1,4 +1,5 @@
import openai import openai
from openai.types import CompletionUsage
from config.logger import setup_logging from config.logger import setup_logging
from core.utils.util import check_model_key from core.utils.util import check_model_key
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
@@ -70,7 +71,17 @@ class LLMProvider(LLMProviderBase):
) )
for chunk in stream: for chunk in stream:
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls # 检查是否存在有效的choice且content不为空
if getattr(chunk, "choices", None):
yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls
# 存在 CompletionUsage 消息时,生成 Token 消耗 log
elif isinstance(getattr(chunk, 'usage', None), CompletionUsage):
usage_info = getattr(chunk, 'usage', None)
logger.bind(tag=TAG).info(
f"Token 消耗:输入 {getattr(usage_info, 'prompt_tokens', '未知')}"
f"输出 {getattr(usage_info, 'completion_tokens', '未知')}"
f"共计 {getattr(usage_info, 'total_tokens', '未知')}"
)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error in function call streaming: {e}") logger.bind(tag=TAG).error(f"Error in function call streaming: {e}")
+21 -9
View File
@@ -26,13 +26,23 @@ class TTSProviderBase(ABC):
max_repeat_time = 5 max_repeat_time = 5
text = MarkdownCleaner.clean_markdown(text) text = MarkdownCleaner.clean_markdown(text)
while not os.path.exists(tmp_file) and max_repeat_time > 0: while not os.path.exists(tmp_file) and max_repeat_time > 0:
asyncio.run(self.text_to_speak(text, tmp_file)) try:
if not os.path.exists(tmp_file): asyncio.run(self.text_to_speak(text, tmp_file))
max_repeat_time = max_repeat_time - 1 except Exception as e:
logger.bind(tag=TAG).error(f"语音生成失败: {text}:{tmp_file}再试{max_repeat_time}") logger.bind(tag=TAG).warning(f"语音生成失败{5 - max_repeat_time + 1}次: {text}错误: {e}")
# 未执行成功,删除文件
if os.path.exists(tmp_file):
os.remove(tmp_file)
max_repeat_time -= 1
if max_repeat_time > 0: if max_repeat_time > 0:
logger.bind(tag=TAG).info(f"语音生成成功: {text}:{tmp_file},重试{5 - max_repeat_time}") logger.bind(tag=TAG).info(
f"语音生成成功: {text}:{tmp_file},重试{5 - max_repeat_time}"
)
else:
logger.bind(tag=TAG).error(
f"语音生成失败: {text},请检查网络或服务是否正常"
)
return tmp_file return tmp_file
except Exception as e: except Exception as e:
@@ -48,9 +58,11 @@ class TTSProviderBase(ABC):
# 获取文件后缀名 # 获取文件后缀名
file_type = os.path.splitext(audio_file_path)[1] file_type = os.path.splitext(audio_file_path)[1]
if file_type: if file_type:
file_type = file_type.lstrip('.') file_type = file_type.lstrip(".")
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞 # 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
audio = AudioSegment.from_file(audio_file_path, format=file_type, parameters=["-nostdin"]) audio = AudioSegment.from_file(
audio_file_path, format=file_type, parameters=["-nostdin"]
)
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配) # 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2) audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
@@ -72,11 +84,11 @@ class TTSProviderBase(ABC):
# 按帧处理所有音频数据(包括最后一帧可能补零) # 按帧处理所有音频数据(包括最后一帧可能补零)
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
# 获取当前帧的二进制数据 # 获取当前帧的二进制数据
chunk = raw_data[i:i + frame_size * 2] chunk = raw_data[i : i + frame_size * 2]
# 如果最后一帧不足,补零 # 如果最后一帧不足,补零
if len(chunk) < frame_size * 2: if len(chunk) < frame_size * 2:
chunk += b'\x00' * (frame_size * 2 - len(chunk)) chunk += b"\x00" * (frame_size * 2 - len(chunk))
# 转换为numpy数组处理 # 转换为numpy数组处理
np_frame = np.frombuffer(chunk, dtype=np.int16) np_frame = np.frombuffer(chunk, dtype=np.int16)
@@ -15,7 +15,10 @@ logger = setup_logging()
class TTSProvider(TTSProviderBase): 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.appid = config.get("appid") if config.get("appid"):
self.appid = int(config.get("appid"))
else:
self.appid = ""
self.access_token = config.get("access_token") self.access_token = config.get("access_token")
self.cluster = config.get("cluster") self.cluster = config.get("cluster")
@@ -5,6 +5,10 @@ import requests
import shutil import shutil
from datetime import datetime from datetime import datetime
from core.providers.tts.base import TTSProviderBase from core.providers.tts.base import TTSProviderBase
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class TTSProvider(TTSProviderBase): class TTSProvider(TTSProviderBase):
@@ -54,6 +58,7 @@ class TTSProvider(TTSProviderBase):
resp = requests.request("POST", url, data=payload) resp = requests.request("POST", url, data=payload)
if resp.status_code != 200: if resp.status_code != 200:
logger.bind(tag=TAG).error(f"TTS请求失败: {resp.text}")
return None return None
resp_json = resp.json() resp_json = resp.json()
try: try:
@@ -0,0 +1,50 @@
import datetime
from typing import Dict, Tuple
# 全局字典,用于存储每个设备的每日输出字数
_device_daily_output: Dict[Tuple[str, datetime.date], int] = {}
# 记录最后一次检查的日期
_last_check_date: datetime.date = None
def reset_device_output():
"""
重置所有设备的每日输出字数
每天0点调用此函数
"""
_device_daily_output.clear()
def get_device_output(device_id: str) -> int:
"""
获取设备当日的输出字数
"""
current_date = datetime.datetime.now().date()
return _device_daily_output.get((device_id, current_date), 0)
def add_device_output(device_id: str, char_count: int):
"""
增加设备的输出字数
"""
current_date = datetime.datetime.now().date()
global _last_check_date
# 如果是第一次调用或者日期发生变化,清空计数器
if _last_check_date is None or _last_check_date != current_date:
_device_daily_output.clear()
_last_check_date = current_date
current_count = _device_daily_output.get((device_id, current_date), 0)
_device_daily_output[(device_id, current_date)] = current_count + char_count
def check_device_output_limit(device_id: str, max_output_size: int) -> bool:
"""
检查设备是否超过输出限制
:return: True 如果超过限制False 如果未超过
"""
if not device_id:
return False
current_output = get_device_output(device_id)
return current_output >= max_output_size
+162 -7
View File
@@ -7,7 +7,29 @@ from typing import Dict, Any
from core.utils import tts, llm, intent, memory, vad, asr from core.utils import tts, llm, intent, memory, vad, asr
TAG = __name__ TAG = __name__
emoji_map = {
'neutral': '😶',
'happy': '🙂',
'laughing': '😆',
'funny': '😂',
'sad': '😔',
'angry': '😠',
'crying': '😭',
'loving': '😍',
'embarrassed': '😳',
'surprised': '😲',
'shocked': '😱',
'thinking': '🤔',
'winking': '😉',
'cool': '😎',
'relaxed': '😌',
'delicious': '🤤',
'kissy': '😘',
'confident': '😏',
'sleepy': '😴',
'silly': '😜',
'confused': '🙄'
}
def get_local_ip(): def get_local_ip():
try: try:
@@ -90,13 +112,14 @@ def is_punctuation_or_emoji(char):
punctuation_set = { punctuation_set = {
"", "",
",", # 中文逗号 + 英文逗号 ",", # 中文逗号 + 英文逗号
"",
".", # 中文句号 + 英文句号
"",
"!", # 中文感叹号 + 英文感叹号
"-", "-",
"", # 英文连字符 + 中文全角横线 "", # 英文连字符 + 中文全角横线
"", # 中文顿号 "", # 中文顿号
"",
"",
"\"", # 中文双引号 + 英文引号
"",
":", # 中文冒号 + 英文冒号
} }
if char.isspace() or char in punctuation_set: if char.isspace() or char in punctuation_set:
return True return True
@@ -159,7 +182,6 @@ def check_model_key(modelType, modelKey):
raise ValueError( raise ValueError(
"你还没配置" + modelType + "的密钥,请检查一下所使用的LLM是否配置了密钥" "你还没配置" + modelType + "的密钥,请检查一下所使用的LLM是否配置了密钥"
) )
return False
return True return True
@@ -211,7 +233,7 @@ def check_ffmpeg_installed():
def extract_json_from_string(input_string): def extract_json_from_string(input_string):
"""提取字符串中的 JSON 部分""" """提取字符串中的 JSON 部分"""
pattern = r"(\{.*\})" pattern = r"(\{.*\})"
match = re.search(pattern, input_string, re.DOTALL) #添加 re.DOTALL match = re.search(pattern, input_string, re.DOTALL) # 添加 re.DOTALL
if match: if match:
return match.group(1) # 返回提取的 JSON 字符串 return match.group(1) # 返回提取的 JSON 字符串
return None return None
@@ -330,3 +352,136 @@ def initialize_modules(
logger.bind(tag=TAG).info(f"初始化组件: prompt成功 {modules['prompt'][:50]}...") logger.bind(tag=TAG).info(f"初始化组件: prompt成功 {modules['prompt'][:50]}...")
return modules return modules
def analyze_emotion(text):
"""
分析文本情感并返回对应的emoji名称支持中英文
"""
if not text or not isinstance(text, str):
return 'neutral'
original_text = text
text = text.lower().strip()
# 检查是否包含现有emoji
for emotion, emoji in emoji_map.items():
if emoji in original_text:
return emotion
# 标点符号分析
has_exclamation = '!' in original_text or '' in original_text
has_question = '?' in original_text or '' in original_text
has_ellipsis = '...' in original_text or '' in original_text
# 定义情感关键词映射(中英文扩展版)
emotion_keywords = {
'happy': ['开心', '高兴', '快乐', '愉快', '幸福', '满意', '', '', '不错', '完美', '棒极了', '太好了',
'好呀', '好的', 'happy', 'joy', 'great', 'good', 'nice', 'awesome', 'fantastic', 'wonderful'],
'laughing': ['哈哈', '哈哈哈', '呵呵', '嘿嘿', '嘻嘻', '笑死', '太好笑了', '笑死我了', 'lol', 'lmao', 'haha',
'hahaha', 'hehe', 'rofl', 'funny', 'laugh'],
'funny': ['搞笑', '滑稽', '', '幽默', '笑点', '段子', '笑话', '太逗了', 'hilarious', 'joke', 'comedy'],
'sad': ['伤心', '难过', '悲哀', '悲伤', '忧郁', '郁闷', '沮丧', '失望', '想哭', '难受', '不开心', '', '呜呜',
'sad', 'upset', 'unhappy', 'depressed', 'sorrow', 'gloomy'],
'angry': ['生气', '愤怒', '气死', '讨厌', '烦人', '可恶', '烦死了', '恼火', '暴躁', '火大', '愤怒', '气炸了',
'angry', 'mad', 'annoyed', 'furious', 'pissed', 'hate'],
'crying': ['哭泣', '泪流', '大哭', '伤心欲绝', '泪目', '流泪', '哭死', '哭晕', '想哭', '泪崩',
'cry', 'crying', 'tears', 'sob', 'weep'],
'loving': ['爱你', '喜欢', '', '亲爱的', '宝贝', '么么哒', '抱抱', '想你', '思念', '最爱', '亲亲', '喜欢你',
'love', 'like', 'adore', 'darling', 'sweetie', 'honey', 'miss you', 'heart'],
'embarrassed': ['尴尬', '不好意思', '害羞', '脸红', '难为情', '社死', '丢脸', '出丑',
'embarrassed', 'awkward', 'shy', 'blush'],
'surprised': ['惊讶', '吃惊', '天啊', '哇塞', '', '居然', '竟然', '没想到', '出乎意料',
'surprise', 'wow', 'omg', 'oh my god', 'amazing', 'unbelievable'],
'shocked': ['震惊', '吓到', '惊呆了', '不敢相信', '震撼', '吓死', '恐怖', '害怕', '吓人',
'shocked', 'shocking', 'scared', 'frightened', 'terrified', 'horror'],
'thinking': ['思考', '考虑', '想一下', '琢磨', '沉思', '冥想', '', '思考中', '在想',
'think', 'thinking', 'consider', 'ponder', 'meditate'],
'winking': ['调皮', '眨眼', '你懂的', '坏笑', '邪恶', '奸笑', '使眼色',
'wink', 'teasing', 'naughty', 'mischievous'],
'cool': ['', '', '厉害', '棒极了', '真棒', '牛逼', '', '优秀', '杰出', '出色', '完美',
'cool', 'awesome', 'amazing', 'great', 'impressive', 'perfect'],
'relaxed': ['放松', '舒服', '惬意', '悠闲', '轻松', '舒适', '安逸', '自在',
'relax', 'relaxed', 'comfortable', 'cozy', 'chill', 'peaceful'],
'delicious': ['好吃', '美味', '', '', '可口', '香甜', '大餐', '大快朵颐', '流口水', '垂涎',
'delicious', 'yummy', 'tasty', 'yum', 'appetizing', 'mouthwatering'],
'kissy': ['亲亲', '么么', '', 'mua', 'muah', '亲一下', '飞吻',
'kiss', 'xoxo', 'hug', 'muah', 'smooch'],
'confident': ['自信', '肯定', '确定', '毫无疑问', '当然', '必须的', '毫无疑问', '确信', '坚信',
'confident', 'sure', 'certain', 'definitely', 'positive'],
'sleepy': ['', '睡觉', '晚安', '想睡', '好累', '疲惫', '疲倦', '困了', '想休息', '睡意',
'sleep', 'sleepy', 'tired', 'exhausted', 'bedtime', 'good night'],
'silly': ['', '', '', '', '', '', '憨憨', '傻乎乎', '呆萌',
'silly', 'stupid', 'dumb', 'foolish', 'goofy', 'ridiculous'],
'confused': ['疑惑', '不明白', '不懂', '困惑', '疑问', '为什么', '怎么回事', '啥意思', '不清楚',
'confused', 'puzzled', 'doubt', 'question', 'what', 'why', 'how']
}
# 特殊句型判断(中英文)
# 赞美他人
if any(phrase in text for phrase in
['你真', '你好', '您真', '你真棒', '你好厉害', '你太强了', '你真好', '你真聪明',
'you are', 'you\'re', 'you look', 'you seem', 'so smart', 'so kind']):
return 'loving'
# 自我赞美
if any(phrase in text for phrase in ['我真', '我最', '我太棒了', '我厉害', '我聪明', '我优秀',
'i am', 'i\'m', 'i feel', 'so good', 'so happy']):
return 'cool'
# 晚安/睡觉相关
if any(phrase in text for phrase in ['睡觉', '晚安', '睡了', '好梦', '休息了', '去睡了',
'sleep', 'good night', 'bedtime', 'go to bed']):
return 'sleepy'
# 疑问句
if has_question and not has_exclamation:
return 'thinking'
# 强烈情感(感叹号)
if has_exclamation and not has_question:
# 检查是否是积极内容
positive_words = emotion_keywords['happy'] + emotion_keywords['laughing'] + emotion_keywords['cool']
if any(word in text for word in positive_words):
return 'laughing'
# 检查是否是消极内容
negative_words = emotion_keywords['angry'] + emotion_keywords['sad'] + emotion_keywords['crying']
if any(word in text for word in negative_words):
return 'angry'
return 'surprised'
# 省略号(表示犹豫或思考)
if has_ellipsis:
return 'thinking'
# 关键词匹配(带权重)
emotion_scores = {emotion: 0 for emotion in emoji_map.keys()}
# 给匹配到的关键词加分
for emotion, keywords in emotion_keywords.items():
for keyword in keywords:
if keyword in text:
emotion_scores[emotion] += 1
# 给长文本中的重复关键词额外加分
if len(text) > 20: # 长文本
for emotion, keywords in emotion_keywords.items():
for keyword in keywords:
emotion_scores[emotion] += text.count(keyword) * 0.5
# 根据分数选择最可能的情感
max_score = max(emotion_scores.values())
if max_score == 0:
return 'happy' # 默认
# 可能有多个情感同分,根据上下文选择最合适的
top_emotions = [e for e, s in emotion_scores.items() if s == max_score]
# 如果多个情感同分,使用以下优先级
priority_order = [
'laughing', 'crying', 'angry', 'surprised', 'shocked', # 强烈情感优先
'loving', 'happy', 'funny', 'cool', # 积极情感
'sad', 'embarrassed', 'confused', # 消极情感
'thinking', 'winking', 'relaxed', # 中性情感
'delicious', 'kissy', 'confident', 'sleepy', 'silly' # 特殊场景
]
for emotion in priority_order:
if emotion in top_emotions:
return emotion
return top_emotions[0] # 如果都不在优先级列表里,返回第一个
+1 -13
View File
@@ -24,21 +24,9 @@ class WebSocketServer:
async def start(self): async def start(self):
server_config = self.config["server"] server_config = self.config["server"]
host = server_config["ip"] host = server_config.get("ip", "0.0.0.0")
port = int(server_config.get("port", 8000)) port = int(server_config.get("port", 8000))
self.logger.bind(tag=TAG).info(
"Server is running at ws://{}:{}/xiaozhi/v1/", get_local_ip(), port
)
self.logger.bind(tag=TAG).info(
"=======上面的地址是websocket协议地址,请勿用浏览器访问======="
)
self.logger.bind(tag=TAG).info(
"如想测试websocket请用谷歌浏览器打开test目录下的test_page.html"
)
self.logger.bind(tag=TAG).info(
"=============================================================\n"
)
async with websockets.serve(self._handle_connection, host, port): async with websockets.serve(self._handle_connection, host, port):
await asyncio.Future() await asyncio.Future()
+2
View File
@@ -13,6 +13,8 @@ services:
ports: ports:
# ws服务端 # ws服务端
- "8000:8000" - "8000:8000"
# ota服务端
- "8002:8002"
volumes: volumes:
# 配置文件目录 # 配置文件目录
- ./data:/opt/xiaozhi-esp32-server/data - ./data:/opt/xiaozhi-esp32-server/data

Some files were not shown because too many files have changed in this diff Show More