Compare commits

...
115 Commits
Author SHA1 Message Date
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
hrzandGitHub bd2e2e77d5 update:更新版本号 (#874) 2025-04-18 00:51:09 +08:00
6282ef14e8 fix:修复第一句话是默认配置的bug (#873)
* update: 增加对httpClient的统一管理

* update: 增加retry机制

* update: 增加retry机制

* fix:websocket连接后不说话的备用关闭方法

* fix:修复第一句话是默认配置的bug

---------

Co-authored-by: haotian <haotian@codemao.cn>
Co-authored-by: GoodyHao <865700600@qq.com>
2025-04-18 00:37:40 +08:00
558f23688f fix:websocket连接后不说话的备用关闭方法 (#872)
* update: 增加对httpClient的统一管理

* update: 增加retry机制

* update: 增加retry机制

* fix:websocket连接后不说话的备用关闭方法

---------

Co-authored-by: haotian <haotian@codemao.cn>
Co-authored-by: GoodyHao <865700600@qq.com>
2025-04-17 23:57:04 +08:00
CGDandGitHub 541f2de599 Merge pull request #856 from xinnan-tech/web-page-check
解决切换页面样式变化的问题
2025-04-17 13:26:26 +08:00
hrzandGitHub 68116254cd dify、coze对话模式支持functioncall
Function call v2
2025-04-17 11:54:04 +08:00
hrzandGitHub 77ff4599ea Merge branch 'main' into function-call-v2 2025-04-17 11:53:25 +08:00
玄凤科技 652f5a3247 Merge branch 'function-call-v2' of https://github.com/xinnan-tech/xiaozhi-esp32-server into function-call-v2 2025-04-17 10:00:23 +08:00
玄凤科技 9b6e57b143 修改提示词,增强tool调用约束 2025-04-17 10:00:13 +08:00
Ran_Chen bdc19256bf 解决切换页面样式变化的问题 2025-04-17 09:34:42 +08:00
hrzandGitHub de5eaf4467 更新版本号0.3.5 (#852)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况

* update:修复智控台下发配置布尔类型转换出错bug

* update:修复智控台下发配置字符类型转换出错bug

* udpate:增加iot消息properties和methods可能为空的情况

* update:发布0.3.5版本
2025-04-16 23:47:37 +08:00
hrzandGitHub ca884833d1 udpate:增加iot消息properties和methods可能为空的情况 (#851)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况

* update:修复智控台下发配置布尔类型转换出错bug

* update:修复智控台下发配置字符类型转换出错bug

* udpate:增加iot消息properties和methods可能为空的情况
2025-04-16 23:34:23 +08:00
hrz 9fc164a69d udpate:增加iot消息properties和methods可能为空的情况 2025-04-16 23:30:46 +08:00
hrzandGitHub 0da2da83a5 update:修复智控台下发配置布尔类型转换出错bug (#850)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况

* update:修复智控台下发配置布尔类型转换出错bug

* update:修复智控台下发配置字符类型转换出错bug
2025-04-16 22:55:13 +08:00
hrzandGitHub bfdfa44edd Merge pull request #844 from xinnan-tech/web-page-modify
“角色配置”行内样式调整
2025-04-16 18:43:07 +08:00
hrzandGitHub ed1cfe5eb6 update:兼容旧设备,无Client-Id的情况 (#847)
* update:测试页面增加OTA地址

* update:兼容旧设备,无Client-Id的情况
2025-04-16 18:03:30 +08:00
Ran_Chen b696f3f5db “角色配置”行内样式调整 2025-04-16 17:18:11 +08:00
hrzandGitHub 7a0cf5ef9a update:测试页面增加OTA地址 (#842) 2025-04-16 16:44:44 +08:00
CGDandGitHub d1b7cb6a36 Merge pull request #837 from xinnan-tech/web-modify
“角色配置”页面间距调整
2025-04-16 14:47:58 +08:00
Ran_Chen b21d699472 “角色配置”页面间距调整 2025-04-16 14:25:46 +08:00
CGDandGitHub 029fadd778 Merge pull request #836 from xinnan-tech/web-modify
Web modify
2025-04-16 14:04:19 +08:00
Sakura-RanChenandGitHub 215d50235b Merge pull request #835 from xinnan-tech/web-vue-agent-modify
修改了部分components的样式
2025-04-16 14:03:24 +08:00
CGD 000e676397 修改了部分components的样式 2025-04-16 12:55:07 +08:00
玄凤科技 208c045d3d 触发function call后,清空回复队列 2025-04-16 11:48:40 +08:00
Ran_Chen ee20f59a8e 增加“回车”事件 2025-04-16 11:42:05 +08:00
Ran_Chen 925cf3dabb 新增智能体按钮绑定问题 2025-04-16 11:34:30 +08:00
CGDandGitHub 0049d0e926 Merge pull request #831 from xinnan-tech/web-pages-modify
Web pages modify
2025-04-16 11:23:42 +08:00
Ran_Chen 56959808a9 解决新增设备按钮绑定问题,优化“角色配置”页面 2025-04-16 11:17:02 +08:00
Ran_Chen 8667a9680e 增加时间戳转换,修改各页面的布局 2025-04-16 10:15:58 +08:00
玄凤科技 8c7d129089 同步coze llm 2025-04-16 08:58:55 +08:00
玄凤科技 a3a9b98a1d 增加系统提示词,支持dify使用function call 2025-04-16 08:56:23 +08:00
欣南科技andGitHub f2f66579cb 合并网友提交优化
合并网友提交优化
2025-04-15 22:48:03 +08:00
hrz 1354cf2a87 合并网友提交优化 2025-04-15 22:46:54 +08:00
hrzandGitHub 15f5592429 Merge pull request #814 from funshine/bug_fix_funshine
Bug fix test_page.html, 以及TTS_CosyVoiceSiliconflow
2025-04-15 22:02:19 +08:00
hrzandGitHub dc69094f00 Merge pull request #817 from GOODDAYDAY/main
update: 增加prompt个性化配置 & 增加vad,asr个性初始化 & 优化日志若干
2025-04-15 22:01:51 +08:00
hrzandGitHub 8ad3783a36 Merge pull request #812 from bohell/main
Update Deployment_all.md
2025-04-15 21:52:09 +08:00
hrzandGitHub 15d5dd8e92 Merge pull request #823 from xinnan-tech/web-vue-select-modify
修改了各个页面的“选择”表头样式
2025-04-15 21:50:46 +08:00
CGD b07edb977b 修改了各个页面的“选择”表头样式 2025-04-15 20:33:09 +08:00
hrzandGitHub 1509fc2039 update:播报音频文件
* update:websocket播报6位验证码

* update:播报音频文件
2025-04-15 18:55:23 +08:00
欣南科技andGitHub 2877bb8faa Web device modify (#818)
* “设备管理”分页响应调整

* 样式调整
2025-04-15 18:30:41 +08:00
hrzandGitHub a83410cb49 update:websocket播报6位验证码 (#821) 2025-04-15 18:30:12 +08:00
Ran_Chen 4ff9f2235b 样式调整 2025-04-15 18:02:04 +08:00
Ran_Chen 1bc611f097 “设备管理”分页响应调整 2025-04-15 17:59:29 +08:00
haotian 4e0a3941ff update: 增加prompt个性化配置 & 增加vad,asr个性初始化 & 优化日志若干 2025-04-15 17:21:44 +08:00
CGDandGitHub 4e885dac3e Merge pull request #815 from xinnan-tech/web-page-modify
Web page modify
2025-04-15 16:27:51 +08:00
Ran_Chen eafd131a22 “模型配置”响应适配,修改下拉图标样式,调整bottom高度差 2025-04-15 16:25:56 +08:00
Guijie HongandGitHub 289ce3716b Merge branch 'xinnan-tech:main' into bug_fix_funshine 2025-04-15 16:11:18 +08:00
Sakura-RanChenandGitHub 2242070bdc Merge pull request #813 from xinnan-tech/fix/manager-web-cdn
Fix/manager web cdn
2025-04-15 16:06:02 +08:00
funshine ee83cea11c 修正CosyVoiceSiliconflow音色 2025-04-15 15:51:36 +08:00
ZhilongandGitHub 6e571a43d8 Update Deployment_all.md
增加管理api的端口号
2025-04-15 15:44:21 +08:00
funshine 466a0245a9 add default value for siliconflow tts speed 2025-04-15 15:28:02 +08:00
funshine d9ce5e4ce7 fix audio test page websocket disconn 2025-04-15 15:22:47 +08:00
Sakura-RanChenandGitHub 6b0dd291c2 Merge pull request #811 from xinnan-tech/web-vue-tts
修复了“音色管理”样式
2025-04-15 14:09:50 +08:00
hrzandGitHub 4ff77d0d7e update:更新烧录时ota地址 (#810)
* update:修复默认模型没有修改到模版配置的bug

* update:智控台可设置允许用户自由注册

* update:更新烧录时ota地址
2025-04-15 13:18:23 +08:00
hrzandGitHub e00da5d8d2 update:智控台可设置允许用户自由注册 (#809)
* update:修复默认模型没有修改到模版配置的bug

* update:智控台可设置允许用户自由注册
2025-04-15 13:03:29 +08:00
CGD 1a525a59ab 修复了“音色管理”样式 2025-04-15 12:20:33 +08:00
Ran_Chen d5581c4236 “用户管理”页面,响应式调整 2025-04-15 11:27:35 +08:00
Sakura-RanChenandGitHub 4bf950574c Merge pull request #803 from xinnan-tech/web-vue-modify
修改了分页器样式
2025-04-15 10:33:34 +08:00
CGDandGitHub 0b9e434c4b Merge pull request #802 from xinnan-tech/web-pages-modify
参数页面响应调整
2025-04-15 10:33:26 +08:00
CGD c4b1466643 修改了分页器样式 2025-04-15 10:30:52 +08:00
Ran_Chen 488842029b 参数页面响应调整 2025-04-15 10:28:37 +08:00
Junsen HuangandGitHub 1b0ce59371 Merge pull request #800 from huangjunsen0406/fix/manager-web-cdn
Fix/manager web cdn
2025-04-15 10:00:36 +08:00
huangjunsen0406 97bea89204 Merge branch 'main' into fix/manager-web-cdn 2025-04-15 09:56:22 +08:00
huangjunsen0406 0892dfc601 Merge remote-tracking branch 'upstream/main' 2025-04-15 09:54:05 +08:00
huangjunsen0406 20d66ff401 fix(service_worker): 根据标志位在app.vue开启缓存检测工具 2025-04-15 09:53:06 +08:00
Sakura-RanChenandGitHub e2fda532d1 Merge pull request #796 from xinnan-tech/web-vue-modify-device
Web vue modify device
2025-04-15 09:06:01 +08:00
Junsen HuangandGitHub 68035af7b3 Merge pull request #794 from huangjunsen0406/fix/manager-web-cdn
Fix/manager web cdn
2025-04-15 09:01:35 +08:00
CGDandGitHub ee25555f0e Merge branch 'main' into web-vue-modify-device 2025-04-15 08:31:28 +08:00
CGD e528510f5c 修改了参数管理的页面高度 2025-04-15 08:24:06 +08:00
huangjunsen0406 afa0527b4c Merge remote-tracking branch 'upstream/main' 2025-04-15 04:07:27 +08:00
huangjunsen0406 dd7d23498f build(cdn): 📦 更换cdn为unpkg、新增service_worker缓存资源文件
closed #772
2025-04-15 04:03:28 +08:00
hrzandGitHub eaaea2b24c update:智控台添加版本号 (#793) 2025-04-15 01:46:36 +08:00
58d0aefae1 fix:修复manager下发配置的bug (#792)
* update: 优化系统prompt

* Update Deployment_all.md

更新部分的安装配置文档,后续需要修改 docker-compose.yaml

* Update docker-compose_all.yml

- 直接提供全量的部署配置,包括 mysql 与 Redis 实现真正意义上的一键配置。
- 将 MySQL 的数据内容本地挂载存储,需要备份迁移的时候,直接打包复制文件夹即可。MySQL 容器删除的时候也不会出现数据丢失的问题。

* Update Deployment_all.md

- 使用 docker compose 部署的时候,不应该每次更新的时候都把原来的 docker-compose 文件删除,再重新配置。
- 如果没有自己部署数据库需求的话,目前可以直接开箱即用,同步更新了 server 中的 Redis 与 MySQL 的镜像服务配置。

* Update Deployment_all.md

- MySQL 与 Redis 的安装是可选的

* 现在新版本的 docker 直接集成了 compose 命令,不需要手动安装与执行 docker-compose 命令了,提高兼容性

* fix:manager下发配置字符转数字bug

* update: 优化三方模块打印日志报错问题

* update: 修复Dify对接未过滤全量数据问题

* update:通过代理转发接口api

* fix:number熟知去掉小数点后的00

* update:优化docker启动

* update:优化参数读取

---------

Co-authored-by: haotian <haotian@codemao.cn>
Co-authored-by: expoli <31023767+expoli@users.noreply.github.com>
Co-authored-by: GoodyHao <865700600@qq.com>
2025-04-15 00:46:37 +08:00
hrz 11c9915a70 update:优化参数读取 2025-04-15 00:44:34 +08:00
hrz 6cf6b0d645 update:优化docker启动 2025-04-15 00:22:53 +08:00
hrzandGitHub 1980a39b73 Merge pull request #763 from GOODDAYDAY/main
update: 优化系统prompt & 优化三方模块打印日志报错问题 & 修复Dify未过滤全量数据问题
2025-04-14 23:09:43 +08:00
hrzandGitHub 8d3fbeeb22 Merge pull request #782 from expoli/main
[docker] 更新了 docker-compose
2025-04-14 22:50:50 +08:00
hrzandGitHub 2678644c2c Merge branch 'hot-fix' into main 2025-04-14 22:50:42 +08:00
hrz 25da9fe119 fix:number熟知去掉小数点后的00 2025-04-14 22:09:18 +08:00
hrz 03d89d22ec update:通过代理转发接口api 2025-04-14 21:53:52 +08:00
haotian 100cd25ffc Merge remote-tracking branch 'origin/main' 2025-04-14 21:44:06 +08:00
haotian 6284a74a6e update: 修复Dify对接未过滤全量数据问题 2025-04-14 21:43:55 +08:00
GoodyHaoandGitHub 978cd8ba8e Merge branch 'xinnan-tech:main' into main 2025-04-14 18:51:15 +08:00
haotian c76f28824c update: 优化三方模块打印日志报错问题 2025-04-14 18:49:24 +08:00
hrz 703ecfae40 fix:manager下发配置字符转数字bug 2025-04-14 18:30:58 +08:00
CGD b15c59aa48 fix:修复了分页器颜色不一致的问题 2025-04-14 17:23:07 +08:00
CGD 98a4ce701c fix:修复"设备管理"页面解绑设备时出现的异常,添加批量解绑功能,完善搜索功能 2025-04-14 16:10:46 +08:00
CGD 2fc0de8f4e fix:部分页面删去底部的©2025 xiaozhi-esp32-server 2025-04-14 14:38:04 +08:00
CGD 66fd551d68 完成“参数管理”的设置每页大小 2025-04-14 14:20:33 +08:00
expoliandGitHub 6e4e9a002b 现在新版本的 docker 直接集成了 compose 命令,不需要手动安装与执行 docker-compose 命令了,提高兼容性 2025-04-14 14:18:07 +08:00
expoliandGitHub 91278b0b3d Update Deployment_all.md
- MySQL 与 Redis 的安装是可选的
2025-04-14 14:16:25 +08:00
expoliandGitHub ff9b3ed1a2 Update Deployment_all.md
- 使用 docker compose 部署的时候,不应该每次更新的时候都把原来的 docker-compose 文件删除,再重新配置。
- 如果没有自己部署数据库需求的话,目前可以直接开箱即用,同步更新了 server 中的 Redis 与 MySQL 的镜像服务配置。
2025-04-14 14:15:37 +08:00
expoliandGitHub 0c855d4ba8 Update docker-compose_all.yml
- 直接提供全量的部署配置,包括 mysql 与 Redis 实现真正意义上的一键配置。
- 将 MySQL 的数据内容本地挂载存储,需要备份迁移的时候,直接打包复制文件夹即可。MySQL 容器删除的时候也不会出现数据丢失的问题。
2025-04-14 14:06:30 +08:00
expoliandGitHub 9176e9d255 Update Deployment_all.md
更新部分的安装配置文档,后续需要修改 docker-compose.yaml
2025-04-14 14:00:31 +08:00
hrzandGitHub 2bab917b3e update:优化文档 (#775)
* update:添加设计公司致谢

* update:添加设计公司致谢

* update:优化文档

* update:优化文档

* update:优化文档

* update:优化文档
2025-04-14 11:31:52 +08:00
CGD b80086a349 fix:移除“添加智能体”子元素触发弹框,将点击事件仅绑定在"添加智能体"文字部分 2025-04-14 10:48:43 +08:00
hrzandGitHub 8a4fca9bd5 update:优化文档 (#774)
* update:添加设计公司致谢

* update:添加设计公司致谢

* update:优化文档

* update:优化文档
2025-04-14 10:35:14 +08:00
hrzandGitHub 13288a6b3a update:优化文档
* update:添加设计公司致谢

* update:添加设计公司致谢

* update:优化文档
2025-04-14 10:26:34 +08:00
CGD 57ed552f43 完成“设备管理”样式的修改 2025-04-14 09:34:51 +08:00
CGD 5a4d65986d 优化了界面样式 2025-04-14 07:52:00 +08:00
CGD 427b3bb774 修改“设备管理”背景颜色,添加搜索框,完成设备管理的前端搜索功能 2025-04-13 22:32:34 +08:00
hrzandGitHub 4912f385c6 fix:智能体音色未随manager生效bug
* fix:连接manager后无法使用functioncallbug

* fix:意图识别使用llm无法播放音乐bug

* fix:manager第一图识别使用独立llm无法初始化llm的bug

* fix:智能体音色未随manager生效bug

* update:添加edgeTTS音色
2025-04-13 19:01:19 +08:00
haotian dcb6d0feab update: 优化系统prompt 2025-04-13 18:46:04 +08:00
hrzandGitHub de8c762d79 fix:manager意图识别bug (#762)
* fix:连接manager后无法使用functioncallbug

* fix:意图识别使用llm无法播放音乐bug

* fix:manager第一图识别使用独立llm无法初始化llm的bug
2025-04-13 18:10:17 +08:00
hrzandGitHub 056659304a Merge pull request #752 from xinnan-tech/web-api-model-fix
fix:修复了"模型配置"问题
2025-04-13 10:27:09 +08:00
hrzandGitHub 525bf18a03 Merge pull request #748 from xinnan-tech/web--vue-device-modify
Web  vue device modify
2025-04-13 10:26:40 +08:00
CGD e3928ffbcc fix:修复了"模型配置"问题 2025-04-12 22:53:41 +08:00
CGD 7c73f060ed update:添加了按钮样式 2025-04-12 09:28:19 +08:00
CGD f0aa7df3b0 update:完成“分页器”样式的替换 2025-04-12 08:27:03 +08:00
126 changed files with 7714 additions and 1502 deletions
+6 -1
View File
@@ -155,7 +155,12 @@ main/manager-web/node_modules
main/xiaozhi-server/models/SenseVoiceSmall/model.pt main/xiaozhi-server/models/SenseVoiceSmall/model.pt
main/xiaozhi-server/models/sherpa-onnx* main/xiaozhi-server/models/sherpa-onnx*
my_wakeup_words.mp3 my_wakeup_words.mp3
!main/xiaozhi-server/config/assets/bind_code.wav
!main/xiaozhi-server/config/assets/bind_not_found.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
+45 -69
View File
@@ -145,42 +145,24 @@ server:
--- ---
## 部署方式 🚀 ## 部署文档
[![Banners](docs/images/banner2.png)](./docs/Deployment.md) 本项目提供两种部署方式,请根据您的具体需求选择:
### 部署文档 #### 🚀 部署方式选择
本项目支持`最简化`安装和`全模块`安装。请根据具体需求选择安装方式。 | 部署方式 | 特点 | 适用场景 | Docker部署文档 | 源码部署文档 |
|---------|------|---------|---------|---------|
| **最简化安装** | 智能对话、IOT功能,数据存储在配置文件 | 低配置环境,无需数据库 | [Docker只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) | [本地源码只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)|
| **全模块安装** | 智能对话、IOT、OTA、智控台,数据存储在数据库 | 完整功能体验 |[Docker运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | [本地源码运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) |
`最简化`适合在较低资源配置中运行,无需数据库。[请参考-最简化安装架构图](./docs/images/deploy1.png) > 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据
`全模块`适合想体验完整功能,过程复杂。[请参考-全模块安装架构图](./docs/images/deploy2.png)
#### 1、 最简化安装
`最简化`安装包含:智能对话、IOT等功能,数据存储在`配置文件`。我们提供了两种安装教程,如果你决定使用最简化安装,可选择`Docker只运行Server``本地源码只运行Server`任意一种方式。
##### 1.1. Docker只运行Server
你可以使用编译好的docker镜像安装,[点击Docker只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)。优点:减少环境配置的烦恼;缺点:版本可能有点旧,镜像文件较大。
##### 2.2. 本地源码只运行Server
你可以以本地源码的方式运行,[点击本地源码只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)。优点:可以随时更新最新代码;缺点:安装Conda环境较为复杂。
#### 2、 全模块安装
`全模块`安装包含:智能对话、IOT、OTA、智控台,数据存储在`数据库`中。我们提供了两种安装教程,如果你决定使用全模块安装,可选择`Docker运行全模块``本地源码运行全模块`任意一种方式。
##### 2.1. Docker运行全模块
你可以使用编译好的docker镜像安装,[点击Docker运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97)。优点:减少环境配置的烦恼;缺点:版本可能有点旧,镜像文件较大。
##### 2.2. 本地源码运行全模块
你可以以本地源码的方式运行,[点击本地源码运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97)。优点:可以随时更新最新代码;缺点:安装Java、Node.js、Conda环境较为复杂。
```
智控台地址: https://2662r3426b.vicp.fun
OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/
Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
```
--- ---
## 常见问题 ❓ ## 常见问题 ❓
@@ -188,54 +170,41 @@ server:
如遇到问题或产品建议反馈[点这里](docs/FAQ.md)。 如遇到问题或产品建议反馈[点这里](docs/FAQ.md)。
--- ---
## 产品生态 👬
小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的优秀项目
- [小智安卓客户端](https://github.com/TOM88812/xiaozhi-android-client)
一个基于xiaozhi-server的Android、IOS语音对话应用,支持实时语音交互和文字对话。现在是flutter版本,打通IOS、Android端。
- [小智电脑客户端](https://github.com/Huang-junsen/py-xiaozhi)
该项目提供了一个基于 Python 实现的小白 AI 客户端,使得在不具备实体硬件条件的情况下,依然能够体过代码体验小智 AI 的功能。主要功能包括 AI 语音交互、视觉多模态识别、IoT 设备集成、联网音乐播放、语音唤醒、自动对话模式、图形化界面、命令行模式、跨平台支持、音量控制、会话管理、加密音频传输、自动验证码处理等。
- [小智Java服务端](https://github.com/joey-zhou/xiaozhi-esp32-server-java)
小智开源后端服务 Java 版本是一个基于 Java 的开源项目,它包括前后端的服务,旨在为用户提供一个完整的后端服务解决方案。
---
## 功能清单 ✨ ## 功能清单 ✨
### 已实现 ✅ ### 已实现 ✅
- **通信协议** | 功能模块 | 描述 |
基于 `xiaozhi-esp32` 协议,通过 WebSocket 实现数据交互。 |---------|------|
- **对话交互** | 通信协议 | 基于 `xiaozhi-esp32` 协议,通过 WebSocket 实现数据交互 |
支持唤醒对话、手动对话及实时打断。长时间无对话时自动休眠 | 对话交互 | 支持唤醒对话、手动对话及实时打断。长时间无对话时自动休眠 |
- **意图识别** | 意图识别 | 支持使用LLM意图识别、function call函数调用,减少硬编码意图判断 |
支持使用LLM意图识别、function call函数调用,减少硬编码意图判断 | 多语言识别 | 支持国语、粤语、英语、日语、韩语(默认使用 FunASR) |
- **多语言识别** | LLM 模块 | 支持灵活切换 LLM 模块,默认使用 ChatGLMLLM,也可选用阿里百炼、DeepSeek、Ollama 等接口 |
支持国语、粤语、英语、日语、韩语(默认使用 FunASR)。 | TTS 模块 | 支持 EdgeTTS(默认)、火山引擎豆包 TTS 等多种 TTS 接口,满足语音合成需求 |
- **LLM 模块** | 记忆功能 | 支持超长记忆、本地总结记忆、无记忆三种模式,满足不同场景需求 |
支持灵活切换 LLM 模块,默认使用 ChatGLMLLM,也可选用阿里百炼、DeepSeek、Ollama 等接口。 | IOT功能 | 支持管理注册设备IOT功能,支持基于对话上下文语境下的智能物联网控制 |
- **TTS 模块** | 智控台 | 提供Web管理界面,支持智能体管理、用户管理、系统配置等功能,方便管理员和用户进行管理 |
支持 EdgeTTS(默认)、火山引擎豆包 TTS 等多种 TTS 接口,满足语音合成需求。
- **记忆功能**
支持超长记忆、本地总结记忆、无记忆三种模式,满足不同场景需求。
- **IOT功能**
支持管理注册设备IOT功能,支持基于对话上下文语境下的智能物联网控制。
- **智控台**
提供Web管理界面,支持智能体管理、用户管理、系统配置等功能,方便管理员和用户进行管理。
### 正在开发 🚧 ### 正在开发 🚧
- 多种心情模式 想了解具体开发计划进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3)
想了解具体开发进度,[请点击这里](https://github.com/users/xinnan-tech/projects/3)
如果你是一名软件开发者,这里有一份[《致开发者的公开信》](docs/contributor_open_letter.md),欢迎加入! 如果你是一名软件开发者,这里有一份[《致开发者的公开信》](docs/contributor_open_letter.md),欢迎加入!
--- ---
## 产品生态 👬
小智是一个生态,当你使用这个产品时,也可以看看其他在这个生态圈的优秀项目
| 项目名称 | 项目地址 | 项目描述 |
|:---------------------|:--------|:--------|
| 小智安卓客户端 | [xiaozhi-android-client](https://github.com/TOM88812/xiaozhi-android-client) | 一个基于xiaozhi-server的Android、IOS语音对话应用,支持实时语音交互和文字对话。<br/>现在是flutter版本,打通IOS、Android端。 |
| 小智电脑客户端 | [py-xiaozhi](https://github.com/Huang-junsen/py-xiaozhi) | 该项目提供了一个基于 Python 实现的小白 AI 客户端,使得在不具备实体硬件条件的情况下,<br/>依然能够体过代码体验小智 AI 的功能。 |
| 小智Java服务端 | [xiaozhi-esp32-server-java](https://github.com/joey-zhou/xiaozhi-esp32-server-java) | 小智开源后端服务 Java 版本是一个基于 Java 的开源项目。<br/>它包括前后端的服务,旨在为用户提供一个完整的后端服务解决方案。 |
---
## 本项目支持的平台/组件列表 📋 ## 本项目支持的平台/组件列表 📋
### LLM 语言模型 ### LLM 语言模型
@@ -298,10 +267,17 @@ server:
## 鸣谢 🙏 ## 鸣谢 🙏
- 本项目受 [百聆语音对话机器人](https://github.com/wwbin2017/bailing) 启发,并在其基础上实现。 | Logo | 项目/公司 | 说明 |
- 感谢 [十方融海](https://www.tenclass.com/) 对小智通讯协议提供的详尽文档支持。 |:---:|:---:|:---|
| <img src="./docs/images/logo_bailing.png" width="160"> | [百聆语音对话机器人](https://github.com/wwbin2017/bailing) | 本项目受[百聆语音对话机器人](https://github.com/wwbin2017/bailing)启发,并在其基础上实现 |
| <img src="./docs/images/logo_tenclass.png" width="160"> | [十方融海](https://www.tenclass.com/) | 感谢[十方融海](https://www.tenclass.com/)为小智生态制定了标准的通讯协议、多设备兼容性方案及高并发场景实践示范;为本项目提供了全链路技术文档支持 |
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [玄凤科技](https://github.com/Eric0308) | 感谢[玄凤科技](https://github.com/Eric0308)贡献函数调用框架、MCP通信协议及插件化调用机制的实现代码,通过标准化的指令调度体系与动态扩展能力,显著提升了前端设备(IoT)的交互效率和功能延展性 |
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [汇远设计](http://ui.kwd988.net/) | 感谢[汇远设计](http://ui.kwd988.net/)为本项目提供专业视觉解决方案,用其服务超千家企业的设计实战经验,赋能本项目产品用户体验 |
| <img src="./docs/images/logo_qinren.png" width="160"> | [西安勤人信息科技](https://www.029app.com/) | 感谢[西安勤人信息科技](https://www.029app.com/)深化本项目视觉体系,确保整体设计风格在多场景应用中的一致性和扩展性 |
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date"> <a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date&theme=dark" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
+4 -1
View File
@@ -137,7 +137,10 @@ docker logs -f xiaozhi-esp32-server
``` ```
docker stop xiaozhi-esp32-server docker stop xiaozhi-esp32-server
docker rm xiaozhi-esp32-server docker rm xiaozhi-esp32-server
docker stop xiaozhi-esp32-server-web
docker rm xiaozhi-esp32-server-web
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
``` ```
5.3、重新按docker方式部署 5.3、重新按docker方式部署
@@ -221,7 +224,7 @@ python app.py
## 配置项目 ## 配置项目
如果你的`xiaozhi-server`目录没有`data`,你需要创建`data`目录。 如果你的`xiaozhi-server`目录没有`data`,你需要创建`data`目录。
如果你的`data`下面没有`.config.yaml`文件,你可以把源码目录下的`config.yaml`文件复制一份,重命名为`.config.yaml` 如果你的`data`下面没有`.config.yaml`文件,你可以把`xiaozhi-server`目录下的`config.yaml`文件复制`data`,并重命名为`.config.yaml`
修改`xiaozhi-server``data`目录下的`.config.yaml`文件,配置本项目必须的一个配置。 修改`xiaozhi-server``data`目录下的`.config.yaml`文件,配置本项目必须的一个配置。
+36 -68
View File
@@ -44,6 +44,8 @@ xiaozhi-server
在页面的右侧找到名称为`RAW`按钮,在`RAW`按钮的旁边,找到下载的图标,点击下载按钮,下载`docker-compose_all.yml`文件。 把文件下载到你的 在页面的右侧找到名称为`RAW`按钮,在`RAW`按钮的旁边,找到下载的图标,点击下载按钮,下载`docker-compose_all.yml`文件。 把文件下载到你的
`xiaozhi-server`中。 `xiaozhi-server`中。
或者直接执行 `wget https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/docker-compose_all.yml` 下载。
下载完后,回到本教程继续往下。 下载完后,回到本教程继续往下。
##### 1.3.2 下载 config_from_api.yaml ##### 1.3.2 下载 config_from_api.yaml
@@ -53,6 +55,8 @@ xiaozhi-server
在页面的右侧找到名称为`RAW`按钮,在`RAW`按钮的旁边,找到下载的图标,点击下载按钮,下载`config_from_api.yaml`文件。 把文件下载到你的 在页面的右侧找到名称为`RAW`按钮,在`RAW`按钮的旁边,找到下载的图标,点击下载按钮,下载`config_from_api.yaml`文件。 把文件下载到你的
`xiaozhi-server`下面的`data`文件夹中,然后把`config_from_api.yaml`文件重命名为`.config.yaml` `xiaozhi-server`下面的`data`文件夹中,然后把`config_from_api.yaml`文件重命名为`.config.yaml`
或者直接执行 `wget https://raw.githubusercontent.com/xinnan-tech/xiaozhi-esp32-server/refs/heads/main/main/xiaozhi-server/config_from_api.yaml` 下载保存。
下载完配置文件后,我们确认一下整个`xiaozhi-server`里面的文件如下所示: 下载完配置文件后,我们确认一下整个`xiaozhi-server`里面的文件如下所示:
``` ```
@@ -67,57 +71,37 @@ xiaozhi-server
如果你的文件目录结构也是上面的,就继续往下。如果不是,你就再仔细看看是不是漏操作了什么。 如果你的文件目录结构也是上面的,就继续往下。如果不是,你就再仔细看看是不是漏操作了什么。
## 2. 安装Mysql和Redis ## 2. 备份数据
### 2.1 安装Mysql数据库 如果你之前已经成功运行智控台,如果上面保存有你的密钥信息,请先从智控台上拷贝重要数据下来。因为升级过程中,有可能会覆盖原来的数据。
如果本机已经安装了MySQL,可以直接在数据库中创建名为`xiaozhi_esp32_server`的数据库。
```sql
CREATE DATABASE xiaozhi_esp32_server CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
如果还没有MySQL,你可以通过docker安装mysql
```
docker run --name xiaozhi-esp32-server-db -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -e MYSQL_DATABASE=xiaozhi_esp32_server -e MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci" -d mysql:latest
```
### 2.2 安装Redis
如果还没有Redis,你可以通过docker安装redis
```
docker run --name xiaozhi-esp32-server-redis -d -p 6379:6379 redis
```
## 3. 配置docker-compose_all.yml文件文件
`xiaozhi-server`目录,打开docker-compose_all.yml
1、修改`SPRING_DATASOURCE_DRUID_URL`里,把`192.168.1.25`修改成部署mysql的电脑局域网ip,如果是你本地,你要查看一下你本机的局域网ip是什么
2、修改`SPRING_DATA_REDIS_HOST`里,把`192.168.1.25`修改成部署redis的电脑局域网ip,如果是你本地,你要查看一下你本机的局域网ip是什么
3、确认一下mysql的用户名`SPRING_DATASOURCE_DRUID_USERNAME`和密码`SPRING_DATASOURCE_DRUID_PASSWORD`
## 4. 运行程序
确认mysql和redis是否运行正常,输入
```
docker ps
```
如果你能看到`xiaozhi-esp32-server-redis``xiaozhi-esp32-server-db`信息,就可以继续往下,如果看不到,说明你的`mysql``redis`没有安装或没有启动。需要继续回到上面的教程,看看哪一步漏了。
```
CONTAINER ID IMAGE COMMAND CREATED TATUS PORTS NAMES
xxx redis "xx" xxx xxx 0.0.0.0:6379->6379/tcp xiaozhi-esp32-server-redis
xxx mysql:latest "xx" xxx xxx 0.0.0.0:3306->3306/tcp, 33060/tcp xiaozhi-esp32-server-db
```
## 3. 清除历史版本镜像和容器
接下来打开命令行工具,使用`终端``命令行`工具 进入到你的`xiaozhi-server`,执行以下命令 接下来打开命令行工具,使用`终端``命令行`工具 进入到你的`xiaozhi-server`,执行以下命令
``` ```
docker-compose -f docker-compose_all.yml up -d docker compose -f docker-compose_all.yml down
docker stop xiaozhi-esp32-server
docker rm xiaozhi-esp32-server
docker stop xiaozhi-esp32-server-web
docker rm xiaozhi-esp32-server-web
docker stop xiaozhi-esp32-server-db
docker rm xiaozhi-esp32-server-db
docker stop xiaozhi-esp32-server-redis
docker rm xiaozhi-esp32-server-redis
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
```
## 4. 运行程序
执行以下命令启动新版本容器
```
docker compose -f docker-compose_all.yml up -d
``` ```
执行完后,再执行以下命令,查看日志信息。 执行完后,再执行以下命令,查看日志信息。
@@ -150,17 +134,16 @@ http://localhost:8002/xiaozhi/doc.html
``` ```
manager-api: manager-api:
url: http://127.0.0.1:8002/xiaozhi url: http://127.0.0.1:8002/xiaozhi
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、把`url`里的`127.0.0.1`改成你电脑局域网内的ip
类似这样的效果 类似这样的效果
``` ```
manager-api: manager-api:
url: http://192.168.1.25:8002/xiaozhi url: http://xiaozhi-esp32-server-web:8002/xiaozhi
secret: 12345678-xxxx-xxxx-xxxx-123456789000 secret: 12345678-xxxx-xxxx-xxxx-123456789000
``` ```
@@ -202,24 +185,6 @@ ws://你电脑局域网的ip:8000/xiaozhi/v1/
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。 接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。
## 6. 版本升级操作
如果后期想升级版本,先备份你的模型密钥。
进入`xiaozhi-server`目录
5.1、执行以下命令
```
docker-compose down
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
docker rmi ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
```
5.2、删掉`docker-compose_all.yaml`文件
5.3、重新按1.1开始部署
# 方式二:本地源码运行全模块 # 方式二:本地源码运行全模块
## 1.安装MySQL数据库 ## 1.安装MySQL数据库
@@ -388,6 +353,9 @@ pip install -r requirements.txt
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。 `server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
如果你的`xiaozhi-server`目录没有`data`,你需要创建`data`目录。
如果你的`data`下面没有`.config.yaml`文件,你可以把`xiaozhi-server`目录下的`config_from_api.yaml`文件复制到`data`,并重命名为`.config.yaml`
复制`参数值`后,打开`xiaozhi-server`下的`data`目录的`.config.yaml`文件。此刻你的配置文件内容应该是这样的: 复制`参数值`后,打开`xiaozhi-server`下的`data`目录的`.config.yaml`文件。此刻你的配置文件内容应该是这样的:
``` ```
+2 -2
View File
@@ -42,7 +42,7 @@ config WEBSOCKET_URL
如果你是全模块部署本项目,就修改OTA接口,如果你只是部署了8000端口的xiaozhi-server,可以继续沿用虾哥团队的OTA接口。如果你不修改OTA接口,请直接忽略本第4步,直接看第5步 如果你是全模块部署本项目,就修改OTA接口,如果你只是部署了8000端口的xiaozhi-server,可以继续沿用虾哥团队的OTA接口。如果你不修改OTA接口,请直接忽略本第4步,直接看第5步
找到`OTA_VERSION_URL``default`的内容,把`https://api.tenclass.net/xiaozhi/ota/` 找到`OTA_VERSION_URL``default`的内容,把`https://api.tenclass.net/xiaozhi/ota/`
改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8000/xiaozhi/ota/`,就把内容改成这个。 改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8002/xiaozhi/ota/`,就把内容改成这个。
修改前: 修改前:
``` ```
@@ -56,7 +56,7 @@ config OTA_VERSION_URL
``` ```
config OTA_VERSION_URL config OTA_VERSION_URL
string "OTA Version URL" string "OTA Version URL"
default "http://192.168.1.25:8000/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 updates.
``` ```
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -84,6 +84,16 @@ public interface Constant {
*/ */
String SERVER_SECRET = "server.secret"; String SERVER_SECRET = "server.secret";
/**
* 是否允许用户注册
*/
String SERVER_ALLOW_USER_REGISTER = "server.allow_user_register";
/**
* 下发六位验证码时显示的控制面板地址
*/
String SERVER_FRONTED_URL = "server.fronted_url";
/** /**
* 路径分割符 * 路径分割符
*/ */
@@ -153,4 +163,9 @@ public interface Constant {
return value; return value;
} }
} }
/**
* 版本号
*/
public static final String VERSION = "0.3.8";
} }
@@ -52,4 +52,7 @@ public interface ErrorCode {
int PARAM_BOOLEAN_INVALID = 10038; int PARAM_BOOLEAN_INVALID = 10038;
int PARAM_ARRAY_INVALID = 10039; int PARAM_ARRAY_INVALID = 10039;
int PARAM_JSON_INVALID = 10040; int PARAM_JSON_INVALID = 10040;
int OTA_DEVICE_NOT_FOUND = 10041;
int OTA_DEVICE_NEED_BIND = 10042;
} }
@@ -75,4 +75,11 @@ 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 "system:version";
}
} }
@@ -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);
}
} }
@@ -44,7 +44,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
@Override @Override
public PageData<AgentEntity> adminAgentList(Map<String, Object> params) { public PageData<AgentEntity> adminAgentList(Map<String, Object> params) {
IPage<AgentEntity> page = agentDao.selectPage( IPage<AgentEntity> page = agentDao.selectPage(
getPage(params, "sort", true), getPage(params, "agent_name", true),
new QueryWrapper<>()); new QueryWrapper<>());
return new PageData<>(page.getRecords(), page.getTotal()); return new PageData<>(page.getRecords(), page.getTotal());
} }
@@ -3,6 +3,7 @@ package xiaozhi.modules.agent.service.impl;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import xiaozhi.modules.agent.dao.AgentTemplateDao; import xiaozhi.modules.agent.dao.AgentTemplateDao;
@@ -39,29 +40,30 @@ public class AgentTemplateServiceImpl extends ServiceImpl<AgentTemplateDao, Agen
@Override @Override
public void updateDefaultTemplateModelId(String modelType, String modelId) { public void updateDefaultTemplateModelId(String modelType, String modelId) {
modelType = modelType.toUpperCase(); modelType = modelType.toUpperCase();
AgentTemplateEntity defaultTemplate = getDefaultTemplate();
if (defaultTemplate != null) { UpdateWrapper<AgentTemplateEntity> wrapper = new UpdateWrapper<>();
switch (modelType) { switch (modelType) {
case "ASR": case "ASR":
defaultTemplate.setAsrModelId(modelId); wrapper.set("asr_model_id", modelId);
break; break;
case "VAD": case "VAD":
defaultTemplate.setVadModelId(modelId); wrapper.set("vad_model_id", modelId);
break; break;
case "LLM": case "LLM":
defaultTemplate.setLlmModelId(modelId); wrapper.set("llm_model_id", modelId);
break; break;
case "TTS": case "TTS":
defaultTemplate.setTtsModelId(modelId); wrapper.set("tts_model_id", modelId);
break; wrapper.set("tts_voice_id", null);
case "Memory": break;
defaultTemplate.setMemModelId(modelId); case "MEMORY":
break; wrapper.set("mem_model_id", modelId);
case "Intent": break;
defaultTemplate.setIntentModelId(modelId); case "INTENT":
break; wrapper.set("intent_model_id", modelId);
} break;
this.updateById(defaultTemplate);
} }
wrapper.ge("sort", 0);
update(wrapper);
} }
} }
@@ -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);
} }
@@ -9,6 +9,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
@@ -83,8 +84,14 @@ public class ConfigServiceImpl implements ConfigService {
// 根据MAC地址查找设备 // 根据MAC地址查找设备
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress); DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device == null) { if (device == null) {
throw new RenException("设备未找到"); // 如果设备,去redis里看看有没有需要连接的设备
String cachedCode = deviceService.geCodeByDeviceId(macAddress);
if (StringUtils.isNotBlank(cachedCode)) {
throw new RenException(ErrorCode.OTA_DEVICE_NEED_BIND, cachedCode);
}
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) {
@@ -98,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())) {
@@ -171,7 +180,13 @@ public class ConfigServiceImpl implements ConfigService {
switch (param.getValueType().toLowerCase()) { switch (param.getValueType().toLowerCase()) {
case "number": case "number":
try { try {
current.put(lastKey, Double.parseDouble(value)); double doubleValue = Double.parseDouble(value);
// 如果数值是整数形式,则转换为Integer
if (doubleValue == (int) doubleValue) {
current.put(lastKey, (int) doubleValue);
} else {
current.put(lastKey, doubleValue);
}
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
current.put(lastKey, value); current.put(lastKey, value);
} }
@@ -231,8 +246,9 @@ public class ConfigServiceImpl implements ConfigService {
boolean isCache) { boolean isCache) {
Map<String, String> selectedModule = new HashMap<>(); Map<String, String> selectedModule = new HashMap<>();
String[] modelTypes = { "VAD", "ASR", "LLM", "TTS", "Memory", "Intent" }; String[] modelTypes = { "VAD", "ASR", "TTS", "Memory", "Intent", "LLM" };
String[] modelIds = { vadModelId, asrModelId, llmModelId, ttsModelId, memModelId, intentModelId }; String[] modelIds = { vadModelId, asrModelId, ttsModelId, memModelId, intentModelId, llmModelId };
String intentLLMModelId = null;
for (int i = 0; i < modelIds.length; i++) { for (int i = 0; i < modelIds.length; i++) {
if (modelIds[i] == null) { if (modelIds[i] == null) {
@@ -246,10 +262,33 @@ public class ConfigServiceImpl implements ConfigService {
if ("TTS".equals(modelTypes[i]) && voice != null) { if ("TTS".equals(modelTypes[i]) && voice != null) {
((Map<String, Object>) model.getConfigJson()).put("private_voice", voice); ((Map<String, Object>) model.getConfigJson()).put("private_voice", voice);
} }
// 如果是Intent类型,且type=intent_llm,则给他添加附加模型
if ("Intent".equals(modelTypes[i])) {
Map<String, Object> map = (Map<String, Object>) model.getConfigJson();
if ("intent_llm".equals(map.get("type"))) {
intentLLMModelId = (String) map.get("llm");
if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) {
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不为空,则添加附加模型
if ("LLM".equals(modelTypes[i]) && intentLLMModelId != null) {
ModelConfigEntity intentLLM = modelConfigService.getModelById(intentLLMModelId, isCache);
typeConfig.put(intentLLM.getId(), intentLLM.getConfigJson());
}
} }
result.put(modelTypes[i], typeConfig); result.put(modelTypes[i], typeConfig);
selectedModule.put(modelTypes[i], model.getId()); selectedModule.put(modelTypes[i], model.getId());
} }
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}}", "小智");
@@ -37,23 +37,23 @@ public class OTAController {
@PostMapping @PostMapping
public ResponseEntity<String> checkOTAVersion( public ResponseEntity<String> checkOTAVersion(
@RequestBody DeviceReportReqDTO deviceReportReqDTO, @RequestBody DeviceReportReqDTO deviceReportReqDTO,
@Parameter(name = "Device-Id", description = "设备唯一标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Device-Id") String deviceId, @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) {
@Parameter(name = "Client-Id", description = "客户端标识", required = true, in = ParameterIn.HEADER) @RequestHeader("Client-Id") String clientId) { if (StringUtils.isBlank(deviceId)) {
if (StringUtils.isAnyBlank(deviceId, clientId)) {
return createResponse(DeviceReportRespDTO.createError("Device ID is required")); return createResponse(DeviceReportRespDTO.createError("Device ID is required"));
} }
if (StringUtils.isBlank(clientId)) {
clientId = deviceId;
}
String macAddress = deviceReportReqDTO.getMacAddress(); String macAddress = deviceReportReqDTO.getMacAddress();
boolean macAddressValid = NetworkUtil.isMacAddressValid(macAddress); boolean macAddressValid = NetworkUtil.isMacAddressValid(macAddress);
// 设备Id和Mac地址应是一致的, 并且必须需要application字段 // 设备Id和Mac地址应是一致的, 并且必须需要application字段
if (!deviceId.equals(macAddress) || !macAddressValid || deviceReportReqDTO.getApplication() == null) { if (!deviceId.equals(macAddress) || !macAddressValid || deviceReportReqDTO.getApplication() == null) {
return createResponse(DeviceReportRespDTO.createError("Invalid OTA request")); return createResponse(DeviceReportRespDTO.createError("Invalid OTA request"));
} }
return createResponse(deviceService.checkDeviceActive(macAddress, deviceId, clientId, deviceReportReqDTO)); return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO));
} }
@Operation(summary = "获取 OTA 提示信息")
@GetMapping @GetMapping
public ResponseEntity<String> getOTAPrompt() { public ResponseEntity<String> getOTAPrompt() {
return createResponse(DeviceReportRespDTO.createError("请提交正确的ota参数")); return createResponse(DeviceReportRespDTO.createError("请提交正确的ota参数"));
@@ -19,7 +19,7 @@ public interface DeviceService {
/** /**
* 检查设备是否激活 * 检查设备是否激活
*/ */
DeviceReportRespDTO checkDeviceActive(String macAddress, String deviceId, String clientId, DeviceReportRespDTO checkDeviceActive(String macAddress, String clientId,
DeviceReportReqDTO deviceReport); DeviceReportReqDTO deviceReport);
/** /**
@@ -74,4 +74,12 @@ public interface DeviceService {
* @return 设备信息 * @return 设备信息
*/ */
DeviceEntity getDeviceByMacAddress(String macAddress); DeviceEntity getDeviceByMacAddress(String macAddress);
/**
* 根据设备ID获取激活码
*
* @param deviceId 设备ID
* @return 激活码
*/
String geCodeByDeviceId(String deviceId);
} }
@@ -9,7 +9,6 @@ import java.util.TimeZone;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -34,6 +33,7 @@ import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService; import xiaozhi.modules.device.service.DeviceService;
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.SysUserUtilService; import xiaozhi.modules.sys.service.SysUserUtilService;
@Service @Service
@@ -49,11 +49,11 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
// 添加构造函数来初始化 deviceMapper // 添加构造函数来初始化 deviceMapper
public DeviceServiceImpl(DeviceDao deviceDao, SysUserUtilService sysUserUtilService, public DeviceServiceImpl(DeviceDao deviceDao, SysUserUtilService sysUserUtilService,
@Value("${app.fronted-url:http://localhost:8001}") String frontedUrl, SysParamsService sysParamsService,
RedisTemplate<String, Object> redisTemplate) { RedisTemplate<String, Object> redisTemplate) {
this.deviceDao = deviceDao; this.deviceDao = deviceDao;
this.sysUserUtilService = sysUserUtilService; this.sysUserUtilService = sysUserUtilService;
this.frontedUrl = frontedUrl; this.frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
} }
@@ -120,7 +120,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
} }
@Override @Override
public DeviceReportRespDTO checkDeviceActive(String macAddress, String deviceId, String clientId, public DeviceReportRespDTO checkDeviceActive(String macAddress, String clientId,
DeviceReportReqDTO deviceReport) { DeviceReportReqDTO deviceReport) {
DeviceReportRespDTO response = new DeviceReportRespDTO(); DeviceReportRespDTO response = new DeviceReportRespDTO();
response.setServer_time(buildServerTime()); response.setServer_time(buildServerTime());
@@ -132,46 +132,12 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
firmware.setUrl("http://localhost:8002/xiaozhi/ota/download"); firmware.setUrl("http://localhost:8002/xiaozhi/ota/download");
response.setFirmware(firmware); response.setFirmware(firmware);
DeviceEntity deviceById = getDeviceById(deviceId); DeviceEntity deviceById = getDeviceById(macAddress);
if (deviceById != null) { // 如果设备存在,则更新上次连接时间 if (deviceById != null) { // 如果设备存在,则更新上次连接时间
deviceById.setLastConnectedAt(new Date()); deviceById.setLastConnectedAt(new Date());
deviceDao.updateById(deviceById); deviceDao.updateById(deviceById);
} else { // 如果设备不存在,则生成激活码 } else { // 如果设备不存在,则生成激活码
String safeDeviceId = deviceId.replace(":", "_").toLowerCase(); DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
String dataKey = String.format("ota:activation:data:%s", safeDeviceId);
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(dataKey);
DeviceReportRespDTO.Activation code = new DeviceReportRespDTO.Activation();
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
String cachedCode = (String) cacheMap.get("activation_code");
code.setCode(cachedCode);
code.setMessage(frontedUrl + "\n" + cachedCode);
} else {
String newCode = RandomUtil.randomNumbers(6);
code.setCode(newCode);
code.setMessage(frontedUrl + "\n" + newCode);
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("id", deviceId);
dataMap.put("mac_address", macAddress);
dataMap.put("board", (deviceReport.getChipModelName() != null) ? deviceReport.getChipModelName()
: (deviceReport.getBoard() != null ? deviceReport.getBoard().getType() : "unknown"));
dataMap.put("app_version", (deviceReport.getApplication() != null)
? deviceReport.getApplication().getVersion()
: null);
dataMap.put("deviceId", deviceId);
dataMap.put("activation_code", newCode);
// 写入主数据 key
redisTemplate.opsForHash().putAll(dataKey, dataMap);
redisTemplate.expire(dataKey, 24, TimeUnit.HOURS);
// 写入反查激活码 key
String codeKey = "ota:activation:code:" + newCode;
redisTemplate.opsForValue().set(codeKey, deviceId, 24, TimeUnit.HOURS);
}
response.setActivation(code); response.setActivation(code);
} }
@@ -221,7 +187,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
params.put(Constant.PAGE, dto.getPage()); params.put(Constant.PAGE, dto.getPage());
params.put(Constant.LIMIT, dto.getLimit()); params.put(Constant.LIMIT, dto.getLimit());
IPage<DeviceEntity> page = baseDao.selectPage( IPage<DeviceEntity> page = baseDao.selectPage(
getPage(params, "sort", true), getPage(params, "mac_address", true),
// 定义查询条件 // 定义查询条件
new QueryWrapper<DeviceEntity>() new QueryWrapper<DeviceEntity>()
// 必须设备关键词查找 // 必须设备关键词查找
@@ -258,4 +224,61 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
serverTime.setTimezone_offset(tz.getOffset(System.currentTimeMillis()) / (60 * 1000)); serverTime.setTimezone_offset(tz.getOffset(System.currentTimeMillis()) / (60 * 1000));
return serverTime; return serverTime;
} }
@Override
public String geCodeByDeviceId(String deviceId) {
String dataKey = getDeviceCacheKey(deviceId);
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(dataKey);
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
String cachedCode = (String) cacheMap.get("activation_code");
return cachedCode;
}
return null;
}
private String getDeviceCacheKey(String deviceId) {
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String dataKey = String.format("ota:activation:data:%s", safeDeviceId);
return dataKey;
}
public DeviceReportRespDTO.Activation buildActivation(String deviceId, DeviceReportReqDTO deviceReport) {
DeviceReportRespDTO.Activation code = new DeviceReportRespDTO.Activation();
String cachedCode = geCodeByDeviceId(deviceId);
if (StringUtils.isNotBlank(cachedCode)) {
code.setCode(cachedCode);
code.setMessage(frontedUrl + "\n" + cachedCode);
} else {
String newCode = RandomUtil.randomNumbers(6);
code.setCode(newCode);
code.setMessage(frontedUrl + "\n" + newCode);
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("id", deviceId);
dataMap.put("mac_address", deviceId);
dataMap.put("board", (deviceReport.getBoard() != null && deviceReport.getBoard().getType() != null)
? deviceReport.getBoard().getType()
: (deviceReport.getChipModelName() != null ? deviceReport.getChipModelName() : "unknown"));
dataMap.put("app_version", (deviceReport.getApplication() != null)
? deviceReport.getApplication().getVersion()
: null);
dataMap.put("deviceId", deviceId);
dataMap.put("activation_code", newCode);
// 写入主数据 key
String dataKey = getDeviceCacheKey(deviceId);
redisTemplate.opsForHash().putAll(dataKey, dataMap);
redisTemplate.expire(dataKey, 24, TimeUnit.HOURS);
// 写入反查激活码 key
String codeKey = "ota:activation:code:" + newCode;
redisTemplate.opsForValue().set(codeKey, deviceId, 24, TimeUnit.HOURS);
}
return code;
}
} }
@@ -135,6 +135,7 @@ public class ModelController {
} }
// 将其他模型设置为非默认 // 将其他模型设置为非默认
modelConfigService.setDefaultModel(entity.getModelType(), 0); modelConfigService.setDefaultModel(entity.getModelType(), 0);
entity.setIsEnabled(1);
entity.setIsDefault(1); entity.setIsDefault(1);
modelConfigService.updateById(entity); modelConfigService.updateById(entity);
@@ -42,6 +42,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
List<ModelConfigEntity> entities = modelConfigDao.selectList( List<ModelConfigEntity> entities = modelConfigDao.selectList(
new QueryWrapper<ModelConfigEntity>() new QueryWrapper<ModelConfigEntity>()
.eq("model_type", modelType) .eq("model_type", modelType)
.eq("is_enabled", 1)
.like(StringUtils.isNotBlank(modelName), "model_name", "%" + modelName + "%") .like(StringUtils.isNotBlank(modelName), "model_name", "%" + modelName + "%")
.select("id", "model_name")); .select("id", "model_name"));
return ConvertUtils.sourceToTarget(entities, ModelBasicInfoDTO.class); return ConvertUtils.sourceToTarget(entities, ModelBasicInfoDTO.class);
@@ -76,6 +76,7 @@ public class ShiroConfig {
filterMap.put("/favicon.ico", "anon"); filterMap.put("/favicon.ico", "anon");
filterMap.put("/user/captcha", "anon"); filterMap.put("/user/captcha", "anon");
filterMap.put("/user/login", "anon"); filterMap.put("/user/login", "anon");
filterMap.put("/user/pub-config", "anon");
filterMap.put("/user/register", "anon"); filterMap.put("/user/register", "anon");
filterMap.put("/config/server-base", "anon"); filterMap.put("/config/server-base", "anon");
filterMap.put("/config/agent-models", "anon"); filterMap.put("/config/agent-models", "anon");
@@ -1,6 +1,8 @@
package xiaozhi.modules.security.controller; package xiaozhi.modules.security.controller;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -13,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;
@@ -75,6 +78,9 @@ public class LoginController {
@PostMapping("/register") @PostMapping("/register")
@Operation(summary = "注册") @Operation(summary = "注册")
public Result<Void> register(@RequestBody LoginDTO login) { public Result<Void> register(@RequestBody LoginDTO login) {
if (!sysUserService.getAllowUserRegister()) {
throw new RenException("当前不允许普通用户注册");
}
// 验证是否正确输入验证码 // 验证是否正确输入验证码
boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha()); boolean validate = captchaService.validate(login.getCaptchaId(), login.getCaptcha());
if (!validate) { if (!validate) {
@@ -111,4 +117,13 @@ public class LoginController {
sysUserTokenService.changePassword(userId, passwordDTO); sysUserTokenService.changePassword(userId, passwordDTO);
return new Result<>(); return new Result<>();
} }
@GetMapping("/pub-config")
@Operation(summary = "公共配置")
public Result<Map<String, Object>> pubConfig() {
Map<String, Object> config = new HashMap<>();
config.put("version", Constant.VERSION);
config.put("allowUserRegister", sysUserService.getAllowUserRegister());
return new Result<Map<String, Object>>().ok(config);
}
} }
@@ -27,6 +27,7 @@ import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.common.validator.group.AddGroup; import xiaozhi.common.validator.group.AddGroup;
import xiaozhi.common.validator.group.DefaultGroup; import xiaozhi.common.validator.group.DefaultGroup;
import xiaozhi.common.validator.group.UpdateGroup; import xiaozhi.common.validator.group.UpdateGroup;
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;
@@ -42,6 +43,7 @@ import xiaozhi.modules.sys.service.SysParamsService;
@AllArgsConstructor @AllArgsConstructor
public class SysParamsController { public class SysParamsController {
private final SysParamsService sysParamsService; private final SysParamsService sysParamsService;
private final ConfigService configService;
@GetMapping("page") @GetMapping("page")
@Operation(summary = "分页") @Operation(summary = "分页")
@@ -77,7 +79,7 @@ public class SysParamsController {
ValidatorUtils.validateEntity(dto, AddGroup.class, DefaultGroup.class); ValidatorUtils.validateEntity(dto, AddGroup.class, DefaultGroup.class);
sysParamsService.save(dto); sysParamsService.save(dto);
configService.getConfig(false);
return new Result<Void>(); return new Result<Void>();
} }
@@ -90,7 +92,7 @@ public class SysParamsController {
ValidatorUtils.validateEntity(dto, UpdateGroup.class, DefaultGroup.class); ValidatorUtils.validateEntity(dto, UpdateGroup.class, DefaultGroup.class);
sysParamsService.update(dto); sysParamsService.update(dto);
configService.getConfig(false);
return new Result<Void>(); return new Result<Void>();
} }
@@ -103,7 +105,7 @@ public class SysParamsController {
AssertUtils.isArrayEmpty(ids, "id"); AssertUtils.isArrayEmpty(ids, "id");
sysParamsService.delete(ids); sysParamsService.delete(ids);
configService.getConfig(false);
return new Result<Void>(); return new Result<Void>();
} }
} }
@@ -65,4 +65,11 @@ public interface SysUserService extends BaseService<SysUserEntity> {
* @param userIds 用户ID数组 * @param userIds 用户ID数组
*/ */
void changeStatus(Integer status, String[] userIds); void changeStatus(Integer status, String[] userIds);
/**
* 获取是否允许用户注册
*
* @return 是否允许用户注册
*/
boolean getAllowUserRegister();
} }
@@ -37,7 +37,7 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
@Override @Override
public PageData<SysParamsDTO> page(Map<String, Object> params) { public PageData<SysParamsDTO> page(Map<String, Object> params) {
IPage<SysParamsEntity> page = baseDao.selectPage( IPage<SysParamsEntity> page = baseDao.selectPage(
getPage(params, Constant.CREATE_DATE, false), getPage(params, null, false),
getWrapper(params)); getWrapper(params));
return getPageData(page, SysParamsDTO.class); return getPageData(page, SysParamsDTO.class);
@@ -30,6 +30,7 @@ import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO; import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.entity.SysUserEntity; import xiaozhi.modules.sys.entity.SysUserEntity;
import xiaozhi.modules.sys.enums.SuperAdminEnum; import xiaozhi.modules.sys.enums.SuperAdminEnum;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.service.SysUserService; import xiaozhi.modules.sys.service.SysUserService;
import xiaozhi.modules.sys.vo.AdminPageUserVO; import xiaozhi.modules.sys.vo.AdminPageUserVO;
@@ -45,6 +46,8 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
private final AgentService agentService; private final AgentService agentService;
private final SysParamsService sysParamsService;
@Override @Override
public SysUserDTO getByUsername(String username) { public SysUserDTO getByUsername(String username) {
QueryWrapper<SysUserEntity> queryWrapper = new QueryWrapper<>(); QueryWrapper<SysUserEntity> queryWrapper = new QueryWrapper<>();
@@ -205,4 +208,17 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
updateById(entity); updateById(entity);
} }
} }
@Override
public boolean getAllowUserRegister() {
String allowUserRegister = sysParamsService.getValue(Constant.SERVER_ALLOW_USER_REGISTER, true);
if (allowUserRegister.equals("true")) {
return true;
}
Long userCount = baseDao.selectCount(new QueryWrapper<SysUserEntity>());
if (userCount == 0) {
return true;
}
return false;
}
} }
@@ -47,7 +47,7 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
params.put(Constant.PAGE, dto.getPage()); params.put(Constant.PAGE, dto.getPage());
params.put(Constant.LIMIT, dto.getLimit()); params.put(Constant.LIMIT, dto.getLimit());
IPage<TimbreEntity> page = baseDao.selectPage( IPage<TimbreEntity> page = baseDao.selectPage(
getPage(params, "sort", true), getPage(params, null, true),
// 定义查询条件 // 定义查询条件
new QueryWrapper<TimbreEntity>() new QueryWrapper<TimbreEntity>()
// 必须按照ttsID查找 // 必须按照ttsID查找
@@ -63,7 +63,4 @@ mybatis-plus:
configuration-properties: configuration-properties:
prefix: prefix:
blobType: BLOB blobType: BLOB
boolValue: TRUE boolValue: TRUE
app:
fronted-url: http://localhost:8001
@@ -0,0 +1,3 @@
-- 对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,28 @@
-- 对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';
-- 添加edge音色
delete from `ai_tts_voice` where tts_model_id = 'TTS_EdgeTTS';
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);
-- 增加是否允许用户注册参数
delete from `sys_params` where id in (103,104);
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (103, 'server.allow_user_register', 'false', 'boolean', 1, '是否运行管理员以外的人注册');
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (104, 'server.fronted_url', 'http://xiaozhi.server.com', 'string', 1, '下发六位验证码时显示的控制面板地址');
-- 修正CosyVoiceSiliconflow音色
delete from `ai_tts_voice` where tts_model_id = 'TTS_CosyVoiceSiliconflow';
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow', 'CosyVoice男声', 'FunAudioLLM/CosyVoice2-0.5B:alex', '中文', 'https://example.com/cosyvoice/alex.mp3', NULL, 6, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0002', 'TTS_CosyVoiceSiliconflow', 'CosyVoice女声', 'FunAudioLLM/CosyVoice2-0.5B:bella', '中文', 'https://example.com/cosyvoice/bella.mp3', NULL, 6, NULL, NULL, NULL, NULL);
@@ -0,0 +1,2 @@
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);
@@ -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表示不限制');
@@ -50,4 +50,18 @@ databaseChangeLog:
changes: changes:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202504112058.sql path: classpath:db/changelog/202504112058.sql
- changeSet:
id: 202504151206
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504151206.sql
- changeSet:
id: 202504181536
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504181536.sql
@@ -41,3 +41,6 @@
10038=\u53C2\u6570\u503C\u5FC5\u987B\u662Ftrue\u6216false 10038=\u53C2\u6570\u503C\u5FC5\u987B\u662Ftrue\u6216false
10039=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u6570\u7EC4\u683C\u5F0F 10039=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u6570\u7EC4\u683C\u5F0F
10040=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u683C\u5F0F 10040=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10041=\u8BBE\u5907\u672A\u627E\u5230
10042={0}
@@ -40,4 +40,7 @@
10037=Parameter value must be a valid number 10037=Parameter value must be a valid number
10038=Parameter value must be true or false 10038=Parameter value must be true or false
10039=Parameter value must be a valid JSON array format 10039=Parameter value must be a valid JSON array format
10040=Parameter value must be a valid JSON format 10040=Parameter value must be a valid JSON format
10041=Device not found
10042={0}
@@ -40,4 +40,7 @@
10037=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684\u6570\u5B57 10037=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684\u6570\u5B57
10038=\u53C2\u6570\u503C\u5FC5\u987B\u662Ftrue\u6216false 10038=\u53C2\u6570\u503C\u5FC5\u987B\u662Ftrue\u6216false
10039=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u6570\u7EC4\u683C\u5F0F 10039=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u6570\u7EC4\u683C\u5F0F
10040=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u683C\u5F0F 10040=\u53C2\u6570\u503C\u5FC5\u987B\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10041=\u8BBE\u5907\u672A\u627E\u5230
10042={0}
@@ -40,4 +40,7 @@
10037=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684\u6578\u5B57 10037=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684\u6578\u5B57
10038=\u53C3\u6578\u503C\u5FC5\u9808\u662Ftrue\u6216false 10038=\u53C3\u6578\u503C\u5FC5\u9808\u662Ftrue\u6216false
10039=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684JSON\u6578\u7D44\u683C\u5F0F 10039=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684JSON\u6578\u7D44\u683C\u5F0F
10040=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684JSON\u683C\u5F0F 10040=\u53C3\u6578\u503C\u5FC5\u9808\u662F\u6709\u6548\u7684JSON\u683C\u5F0F
10041=\u8A2D\u5099\u672A\u627E\u5230
10042={0}
@@ -33,3 +33,5 @@ timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4
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 timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
ota.device.not.found=\u8BBE\u5907\u672A\u627E\u5230
ota.device.need.bind={0}
@@ -32,3 +32,5 @@ 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 timbre.voiceDemo.require=The voice demo of the timbre cannot be empty
ota.device.not.found=Device not found
ota.device.need.bind={0}
@@ -32,3 +32,5 @@ timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u9375\u4E0D\u53EF\u4EE5\u7
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 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.need.bind={0}
@@ -31,3 +31,6 @@ timbre.remark.require=\u97F3\u8272\u7684\u5907\u6CE8\u4E0D\u53EF\u4EE5\u4E3A\u7A
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 timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230
ota.device.need.bind={0}
+1 -4
View File
@@ -1,4 +1 @@
# 暂时使用群主的接口 VUE_APP_API_BASE_URL=/xiaozhi
# VUE_APP_API_BASE_URL=https://2662r3426b.vicp.fun/xiaozhi
# 如果本地开发,请使用以下接口
VUE_APP_API_BASE_URL=http://localhost:8002/xiaozhi
+3 -1
View File
@@ -1 +1,3 @@
VUE_APP_API_BASE_URL=/xiaozhi VUE_APP_API_BASE_URL=/xiaozhi
# 是否开启CDN
VUE_APP_USE_CDN=false
+3249 -11
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -31,7 +31,8 @@
"sass": "^1.32.7", "sass": "^1.32.7",
"sass-loader": "^12.0.0", "sass-loader": "^12.0.0",
"vue-template-compiler": "^2.6.14", "vue-template-compiler": "^2.6.14",
"webpack-bundle-analyzer": "^4.10.2" "webpack-bundle-analyzer": "^4.10.2",
"workbox-webpack-plugin": "^7.3.0"
}, },
"browserslist": [ "browserslist": [
"> 1%", "> 1%",
@@ -43,4 +44,4 @@
"*.scss", "*.scss",
"*.vue" "*.vue"
] ]
} }
+2 -2
View File
@@ -9,7 +9,7 @@
<title> <title>
<%= process.env.VUE_APP_TITLE %> <%= process.env.VUE_APP_TITLE %>
</title> </title>
<% if (htmlWebpackPlugin.options.cdn) { %> <% if (htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.css) { %>
<% for (var i in htmlWebpackPlugin.options.cdn.css) { %> <% for (var i in htmlWebpackPlugin.options.cdn.css) { %>
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.cdn.css[i] %>"> <link rel="stylesheet" href="<%= htmlWebpackPlugin.options.cdn.css[i] %>">
<% } %> <% } %>
@@ -23,7 +23,7 @@
</noscript> </noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->
<% if (htmlWebpackPlugin.options.cdn) { %> <% if (htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.js) { %>
<% for (var i in htmlWebpackPlugin.options.cdn.js) { %> <% for (var i in htmlWebpackPlugin.options.cdn.js) { %>
<script src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script> <script src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
<% } %> <% } %>
+70
View File
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线模式 - 小智控制台</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f5f7fa;
color: #333;
}
.container {
text-align: center;
padding: 2rem;
background: white;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
max-width: 80%;
}
h1 {
margin-bottom: 1rem;
color: #409EFF;
}
p {
margin-bottom: 1.5rem;
line-height: 1.6;
}
.btn {
background-color: #409EFF;
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #337ecc;
}
.icon {
font-size: 4rem;
margin-bottom: 1rem;
color: #409EFF;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📶</div>
<h1>您当前处于离线模式</h1>
<p>看起来您的网络连接有问题,无法连接到小智控制台服务器。</p>
<p>部分已缓存的内容和静态资源可能仍然可用,但功能可能受到限制。</p>
<button class="btn" onclick="window.location.reload()">重新尝试连接</button>
</div>
<script>
// 检测网络状态变化
window.addEventListener('online', () => {
window.location.reload();
});
</script>
</body>
</html>
+115
View File
@@ -1,6 +1,7 @@
<template> <template>
<div id="app"> <div id="app">
<router-view /> <router-view />
<cache-viewer v-if="isCDNEnabled" :visible.sync="showCacheViewer" />
</div> </div>
</template> </template>
@@ -45,4 +46,118 @@ nav {
} }
</style> </style>
<script> <script>
import CacheViewer from '@/components/CacheViewer.vue';
import { logCacheStatus } from '@/utils/cacheViewer';
export default {
components: {
CacheViewer
},
data() {
return {
showCacheViewer: false,
isCDNEnabled: process.env.VUE_APP_USE_CDN === 'true'
};
},
mounted() {
// 只有在启用CDN时才添加相关事件和功能
if (this.isCDNEnabled) {
// 添加全局快捷键Alt+C用于显示缓存查看器
document.addEventListener('keydown', this.handleKeyDown);
// 在全局对象上添加缓存检查方法,便于调试
window.checkCDNCacheStatus = () => {
this.showCacheViewer = true;
};
// 在控制台输出提示信息
console.info(
'%c[小智服务] CDN缓存检查工具已加载',
'color: #409EFF; font-weight: bold;'
);
console.info(
'按下 Alt+C 组合键或在控制台运行 checkCDNCacheStatus() 可以查看CDN缓存状态'
);
// 检查Service Worker状态
this.checkServiceWorkerStatus();
} else {
console.info(
'%c[小智服务] CDN模式已禁用,使用本地打包资源',
'color: #67C23A; font-weight: bold;'
);
}
},
beforeDestroy() {
// 只有在启用CDN时才需要移除事件监听
if (this.isCDNEnabled) {
document.removeEventListener('keydown', this.handleKeyDown);
}
},
methods: {
handleKeyDown(e) {
// Alt+C 快捷键
if (e.altKey && e.key === 'c') {
this.showCacheViewer = true;
}
},
async checkServiceWorkerStatus() {
// 检查Service Worker是否已注册
if ('serviceWorker' in navigator) {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
if (registrations.length > 0) {
console.info(
'%c[小智服务] Service Worker已注册',
'color: #67C23A; font-weight: bold;'
);
// 输出缓存状态到控制台
setTimeout(async () => {
const hasCaches = await logCacheStatus();
if (!hasCaches) {
console.info(
'%c[小智服务] 还未检测到缓存,请刷新页面或等待缓存建立',
'color: #E6A23C; font-weight: bold;'
);
// 开发环境下提供额外提示
if (process.env.NODE_ENV === 'development') {
console.info(
'%c[小智服务] 在开发环境中,Service Worker可能无法正常初始化缓存',
'color: #E6A23C; font-weight: bold;'
);
console.info('请尝试以下方法检查Service Worker是否生效:');
console.info('1. 在开发者工具的Application/Application标签页中查看Service Worker状态');
console.info('2. 在开发者工具的Application/Cache/Cache Storage中查看缓存内容');
console.info('3. 使用生产构建(npm run build)并通过HTTP服务器访问以测试完整功能');
}
}
}, 2000);
} else {
console.info(
'%c[小智服务] Service Worker未注册,CDN资源可能无法缓存',
'color: #F56C6C; font-weight: bold;'
);
if (process.env.NODE_ENV === 'development') {
console.info(
'%c[小智服务] 在开发环境中,这是正常现象',
'color: #E6A23C; font-weight: bold;'
);
console.info('Service Worker通常只在生产环境中生效');
console.info('要测试Service Worker功能:');
console.info('1. 运行npm run build构建生产版本');
console.info('2. 通过HTTP服务器访问构建后的页面');
}
}
} catch (error) {
console.error('检查Service Worker状态失败:', error);
}
} else {
console.warn('当前浏览器不支持Service WorkerCDN资源缓存功能不可用');
}
}
}
};
</script> </script>
+18 -15
View File
@@ -1,7 +1,6 @@
import { getServiceUrl } from '../api'; import { getServiceUrl } from '../api';
import RequestService from '../httpRequest'; import RequestService from '../httpRequest';
export default { export default {
// 获取模型配置列表 // 获取模型配置列表
getModelList(params, callback) { getModelList(params, callback) {
@@ -162,21 +161,25 @@ export default {
// 更新模型配置 // 更新模型配置
updateModel(params, callback) { updateModel(params, callback) {
const { modelType, provideCode, id, formData } = params; const { modelType, provideCode, id, formData } = params;
const payload = {
...formData,
configJson: formData.configJson
};
RequestService.sendRequest() RequestService.sendRequest()
.url(`${getServiceUrl()}/models/${modelType}/${provideCode}/${id}`) .url(`${getServiceUrl()}/models/${modelType}/${provideCode}/${id}`)
.method('PUT') .method('PUT')
.data(formData) .data(payload)
.success((res) => { .success((res) => {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
callback(res); callback(res);
}) })
.fail((err) => { .fail((err) => {
console.error('更新模型失败:', err); console.error('更新模型失败:', err);
this.$message.error(err.msg || '更新模型失败'); this.$message.error(err.msg || '更新模型失败');
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.updateModel(params, callback); this.updateModel(params, callback);
}); });
}).send(); }).send();
}, },
// 设置默认模型 // 设置默认模型
setDefaultModel(id, callback) { setDefaultModel(id, callback) {
+17 -1
View File
@@ -106,7 +106,7 @@ export default {
}, },
// 修改用户状态 // 修改用户状态
changeUserStatus(status, userIds, successCallback) { changeUserStatus(status, userIds, successCallback) {
console.log(555,userIds) console.log(555, userIds)
RequestService.sendRequest() RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/users/changeStatus/${status}`) .url(`${getServiceUrl()}/admin/users/changeStatus/${status}`)
.method('put') .method('put')
@@ -122,4 +122,20 @@ export default {
}) })
}).send() }).send()
}, },
// 获取公共配置
getPubConfig(callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/user/pub-config`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('获取公共配置失败:', err);
RequestService.reAjaxFun(() => {
this.getPubConfig(callback);
});
}).send();
},
} }
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :visible.sync="visible" width="400px" center> <el-dialog :visible="visible" @close="handleClose" width="400px" center>
<div <div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
@@ -79,7 +79,10 @@ export default {
cancel() { cancel() {
this.$emit('update:visible', false) this.$emit('update:visible', false)
this.deviceCode = "" this.deviceCode = ""
} },
handleClose() {
this.$emit('update:visible', false);
},
} }
} }
</script> </script>
@@ -1,12 +1,11 @@
<template> <template>
<el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="975px" center custom-class="custom-dialog" :show-close="false" <el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="975px" center
class="center-dialog"> custom-class="custom-dialog" :show-close="false" class="center-dialog">
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;"> <div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;"> <div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
添加模型 添加模型
</div> </div>
<button class="custom-close-btn" @click="handleClose"> <button class="custom-close-btn" @click="handleClose">
× ×
</button> </button>
@@ -19,7 +18,7 @@
<span style="margin-right: 8px;">是否启用</span> <span style="margin-right: 8px;">是否启用</span>
<el-switch v-model="formData.isEnabled" class="custom-switch"></el-switch> <el-switch v-model="formData.isEnabled" class="custom-switch"></el-switch>
</div> </div>
<div style="display: flex; align-items: center;"> <div style="display: none; align-items: center;">
<span style="margin-right: 8px;">设为默认</span> <span style="margin-right: 8px;">设为默认</span>
<el-switch v-model="formData.isDefault" class="custom-switch"></el-switch> <el-switch v-model="formData.isDefault" class="custom-switch"></el-switch>
</div> </div>
@@ -45,7 +44,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="排序号" prop="sortOrder" style="flex: 1;"> <el-form-item label="排序号" prop="sortOrder" style="flex: 1;">
<el-input v-model="formData.sort" placeholder="请输入排序号" class="custom-input-bg"></el-input> <el-input v-model="formData.sort" type="number" placeholder="请输入排序号" class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</div> </div>
@@ -66,18 +65,10 @@
<el-form :model="formData.configJson" label-width="auto" label-position="left" class="custom-form"> <el-form :model="formData.configJson" label-width="auto" label-position="left" class="custom-form">
<template v-for="(row, rowIndex) in chunkedCallInfoFields"> <template v-for="(row, rowIndex) in chunkedCallInfoFields">
<div :key="rowIndex" style="display: flex; gap: 20px; margin-bottom: 0;"> <div :key="rowIndex" style="display: flex; gap: 20px; margin-bottom: 0;">
<el-form-item <el-form-item v-for="field in row" :key="field.prop" :label="field.label" :prop="field.prop"
v-for="field in row"
:key="field.prop"
:label="field.label"
:prop="field.prop"
style="flex: 1;"> style="flex: 1;">
<el-input <el-input v-model="formData.configJson[field.prop]" :placeholder="field.placeholder"
v-model="formData.configJson[field.prop]" :type="field.type || 'text'" class="custom-input-bg" :show-password="field.type === 'password'">
:placeholder="field.placeholder"
:type="field.type || 'text'"
class="custom-input-bg"
:show-password="field.type === 'password'">
</el-input> </el-input>
</el-form-item> </el-form-item>
</div> </div>
@@ -124,7 +115,7 @@ export default {
watch: { watch: {
visible(val) { visible(val) {
this.dialogVisible = val; this.dialogVisible = val;
if(val) { if (val) {
this.initConfigJson(); this.initConfigJson();
} else { } else {
this.resetForm(); this.resetForm();
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :visible.sync="visible" width="400px" center @open="handleOpen"> <el-dialog :visible="visible" @close="handleClose" width="400px" center @open="handleOpen">
<div <div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
@@ -66,7 +66,10 @@ export default {
cancel() { cancel() {
this.$emit('update:visible', false) this.$emit('update:visible', false)
this.wisdomBodyName = "" this.wisdomBodyName = ""
} },
handleClose() {
this.$emit('update:visible', false);
},
} }
} }
</script> </script>
@@ -0,0 +1,207 @@
<template>
<el-dialog
title="CDN资源缓存状态"
:visible.sync="visible"
width="70%"
:before-close="handleClose"
>
<div v-if="isLoading" class="loading-container">
<p>正在加载缓存信息...</p>
</div>
<div v-else>
<div v-if="!cacheAvailable" class="no-cache-message">
<i class="el-icon-warning-outline"></i>
<p>您的浏览器不支持Cache API或Service Worker未安装</p>
<el-button type="primary" @click="refreshPage">刷新页面</el-button>
</div>
<div v-else>
<el-alert
v-if="cacheData.totalCached === 0"
title="未发现缓存的CDN资源"
type="warning"
:closable="false"
show-icon
>
<p>Service Worker可能尚未完成初始化或缓存尚未建立请刷新页面或等待一会后再试</p>
</el-alert>
<div v-else>
<el-alert
title="CDN资源缓存状态"
type="success"
:closable="false"
show-icon
>
共发现 {{ cacheData.totalCached }} 个缓存资源
</el-alert>
<h3>JavaScript 资源 ({{ cacheData.js.length }})</h3>
<el-table :data="cacheData.js" stripe style="width: 100%">
<el-table-column prop="url" label="URL" width="auto" show-overflow-tooltip />
<el-table-column prop="cached" label="状态" width="100">
<template slot-scope="scope">
<el-tag type="success" v-if="scope.row.cached">已缓存</el-tag>
<el-tag type="danger" v-else>未缓存</el-tag>
</template>
</el-table-column>
</el-table>
<h3>CSS 资源 ({{ cacheData.css.length }})</h3>
<el-table :data="cacheData.css" stripe style="width: 100%">
<el-table-column prop="url" label="URL" width="auto" show-overflow-tooltip />
<el-table-column prop="cached" label="状态" width="100">
<template slot-scope="scope">
<el-tag type="success" v-if="scope.row.cached">已缓存</el-tag>
<el-tag type="danger" v-else>未缓存</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
<el-button type="primary" @click="refreshCache">刷新缓存状态</el-button>
<el-button type="danger" @click="clearCache">清除缓存</el-button>
</span>
</el-dialog>
</template>
<script>
import {
getCacheNames,
checkCdnCacheStatus,
clearAllCaches,
logCacheStatus
} from '../utils/cacheViewer';
export default {
name: 'CacheViewer',
props: {
visible: {
type: Boolean,
default: false
}
},
data() {
return {
isLoading: true,
cacheAvailable: false,
cacheData: {
css: [],
js: [],
totalCached: 0,
totalNotCached: 0
}
};
},
watch: {
visible(newVal) {
if (newVal) {
this.loadCacheData();
}
}
},
methods: {
async loadCacheData() {
this.isLoading = true;
try {
// 先检查是否支持缓存API
if (!('caches' in window)) {
this.cacheAvailable = false;
this.isLoading = false;
return;
}
// 检查是否有Service Worker缓存
const cacheNames = await getCacheNames();
this.cacheAvailable = cacheNames.length > 0;
if (this.cacheAvailable) {
// 获取CDN缓存状态
this.cacheData = await checkCdnCacheStatus();
// 在控制台输出完整缓存状态
await logCacheStatus();
}
} catch (error) {
console.error('加载缓存数据失败:', error);
this.$message.error('加载缓存数据失败');
} finally {
this.isLoading = false;
}
},
async refreshCache() {
this.loadCacheData();
this.$message.success('正在刷新缓存状态');
},
async clearCache() {
this.$confirm('确定要清除所有缓存吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const success = await clearAllCaches();
if (success) {
this.$message.success('缓存已清除');
await this.loadCacheData();
} else {
this.$message.error('清除缓存失败');
}
} catch (error) {
console.error('清除缓存失败:', error);
this.$message.error('清除缓存失败');
}
}).catch(() => {
this.$message.info('已取消清除');
});
},
refreshPage() {
window.location.reload();
},
handleClose() {
this.$emit('update:visible', false);
}
}
};
</script>
<style scoped>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.loading-spinner {
margin-bottom: 10px;
}
.no-cache-message {
text-align: center;
padding: 20px;
}
.no-cache-message i {
font-size: 48px;
color: #E6A23C;
margin-bottom: 10px;
}
h3 {
margin-top: 20px;
margin-bottom: 10px;
font-weight: 500;
}
</style>
@@ -83,7 +83,8 @@ export default {
font-size: 10px; font-size: 10px;
color: #5778ff; color: #5778ff;
background: #e6ebff; background: #e6ebff;
width: 57px; width: auto;
padding: 0 12px;
height: 21px; height: 21px;
line-height: 21px; line-height: 21px;
cursor: pointer; cursor: pointer;
@@ -17,7 +17,7 @@
<span style="margin-right: 8px;">是否启用</span> <span style="margin-right: 8px;">是否启用</span>
<el-switch v-model="form.isEnabled" :active-value="1" :inactive-value="0" class="custom-switch"></el-switch> <el-switch v-model="form.isEnabled" :active-value="1" :inactive-value="0" class="custom-switch"></el-switch>
</div> </div>
<div style="display: flex; align-items: center;"> <div style="display: none; align-items: center;">
<span style="margin-right: 8px;">设为默认</span> <span style="margin-right: 8px;">设为默认</span>
<el-switch v-model="form.isDefault" :active-value="1" :inactive-value="0" class="custom-switch"></el-switch> <el-switch v-model="form.isDefault" :active-value="1" :inactive-value="0" class="custom-switch"></el-switch>
</div> </div>
@@ -64,18 +64,10 @@
<el-form :model="form.configJson" ref="callInfoForm" label-width="auto" class="custom-form"> <el-form :model="form.configJson" ref="callInfoForm" label-width="auto" class="custom-form">
<template v-for="(row, rowIndex) in chunkedCallInfoFields"> <template v-for="(row, rowIndex) in chunkedCallInfoFields">
<div :key="rowIndex" style="display: flex; gap: 20px; margin-bottom: 0;"> <div :key="rowIndex" style="display: flex; gap: 20px; margin-bottom: 0;">
<el-form-item <el-form-item v-for="field in row" :key="field.prop" :label="field.label" :prop="field.prop"
v-for="field in row"
:key="field.prop"
:label="field.label"
:prop="field.prop"
style="flex: 1;"> style="flex: 1;">
<el-input <el-input v-model="form.configJson[field.prop]" :placeholder="field.placeholder" :type="field.type"
v-model="form.configJson[field.prop]" class="custom-input-bg" :show-password="field.type === 'password'">
:placeholder="field.placeholder"
:type="field.type"
class="custom-input-bg"
:show-password="field.type === 'password'">
</el-input> </el-input>
</el-form-item> </el-form-item>
</div> </div>
@@ -94,14 +86,6 @@
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
const DEFAULT_CONFIG_JSON = {
type: "",
base_url: "",
model_name: "",
api_key: "",
empty: false
};
export default { export default {
name: "ModelEditDialog", name: "ModelEditDialog",
props: { props: {
@@ -132,7 +116,7 @@ export default {
docLink: "", docLink: "",
remark: "", remark: "",
sort: 0, sort: 0,
configJson: JSON.parse(JSON.stringify(DEFAULT_CONFIG_JSON)) configJson: {}
} }
}; };
}, },
@@ -173,54 +157,57 @@ export default {
}, },
methods: { methods: {
resetForm() { resetForm() {
this.form = { this.form = {
id: "", id: "",
modelType: "", modelType: "",
modelCode: "", modelCode: "",
modelName: "", modelName: "",
isDefault: false, isDefault: false,
isEnabled: false, isEnabled: false,
docLink: "", docLink: "",
remark: "", remark: "",
sort: 0, sort: 0,
configJson: JSON.parse(JSON.stringify(DEFAULT_CONFIG_JSON)) configJson: {}
}; };
this.dynamicCallInfoFields.forEach(field => {
this.$set(this.form.configJson, field.prop, '');
});
}, },
resetProviders() { resetProviders() {
this.providers = []; this.providers = [];
this.providersLoaded = false; this.providersLoaded = false;
}, },
loadModelData() { loadModelData() {
if (this.modelData.id) { if (this.modelData.id) {
Api.model.getModelConfig(this.modelData.id, ({ data }) => { Api.model.getModelConfig(this.modelData.id, ({ data }) => {
if (data.code === 0 && data.data) { if (data.code === 0 && data.data) {
const model = data.data; const model = data.data;
this.pendingProviderType = model.configJson.type; this.pendingProviderType = model.configJson.type;
this.pendingModelData = model; this.pendingModelData = model;
if (this.providersLoaded) { if (this.providersLoaded) {
this.loadProviderFields(model.configJson.type); this.loadProviderFields(model.configJson.type);
} else { } else {
this.loadProviders(); this.loadProviders();
} }
} }
}); });
} }
}, },
handleSave() { handleSave() {
const provideCode = this.form.configJson.type; const provideCode = this.form.configJson.type;
const formData = { const formData = {
id: this.modelData.id, id: this.modelData.id,
modelCode: this.form.modelCode, modelCode: this.form.modelCode,
modelName: this.form.modelName, modelName: this.form.modelName,
isDefault: this.form.isDefault ? 1 : 0, isDefault: this.form.isDefault ? 1 : 0,
isEnabled: this.form.isEnabled ? 1 : 0, isEnabled: this.form.isEnabled ? 1 : 0,
docLink: this.form.docLink, docLink: this.form.docLink,
remark: this.form.remark, remark: this.form.remark,
sort: this.form.sort || 0, sort: this.form.sort || 0,
configJson: { configJson: {
...this.form.configJson, ...this.form.configJson,
} }
}; };
this.$emit("save", { provideCode, formData }); this.$emit("save", { provideCode, formData });
this.dialogVisible = false; this.dialogVisible = false;
@@ -266,6 +253,8 @@ export default {
this.dynamicCallInfoFields.forEach(field => { this.dynamicCallInfoFields.forEach(field => {
if (!configJson.hasOwnProperty(field.prop)) { if (!configJson.hasOwnProperty(field.prop)) {
configJson[field.prop] = ''; configJson[field.prop] = '';
} else if (typeof configJson[field.prop] !== 'string') {
configJson[field.prop] = String(configJson[field.prop]);
} }
}); });
@@ -280,7 +269,6 @@ export default {
remark: model.remark, remark: model.remark,
sort: Number(model.sort) || 0, sort: Number(model.sort) || 0,
configJson: { configJson: {
...DEFAULT_CONFIG_JSON,
...configJson ...configJson
} }
}; };
+54 -14
View File
@@ -551,28 +551,26 @@ export default {
/* 备注文本 */ /* 备注文本 */
::v-deep .remark-input .el-textarea__inner { ::v-deep .remark-input .el-textarea__inner {
background-color: #f5f5f5;
border-radius: 4px; border-radius: 4px;
border: 1px solid #e6e6e6; border: 1px solid #e6e6e6;
padding: 8px 12px; padding: 8px 12px;
resize: none; resize: none;
max-height: 40px !important; max-height: 40px !important;
line-height: 1.5; line-height: 1.5;
} background-color: transparent !important;
::v-deep .remark-input .el-textarea__inner::placeholder {
color: black !important;
opacity: 0.7;
}
::v-deep .remark-input .el-textarea__inner {
background-color: #f4f6fa;
} }
::v-deep .remark-input .el-textarea__inner:focus { ::v-deep .remark-input .el-textarea__inner:focus {
background-color: #edeffb; border-color: #409EFF !important;
outline: none;
} }
::v-deep .remark-input .el-textarea__inner::placeholder {
color: #c0c4cc !important;
opacity: 1;
}
/* 滚动容器 */ /* 滚动容器 */
.scroll-wrapper { .scroll-wrapper {
display: flex; display: flex;
@@ -582,7 +580,7 @@ export default {
.table-container { .table-container {
flex: 1; flex: 1;
overflow-y: scroll; overflow: auto;
scrollbar-width: none; scrollbar-width: none;
padding-right: 15px; padding-right: 15px;
width: calc(100% - 16px); width: calc(100% - 16px);
@@ -641,8 +639,8 @@ export default {
/* 音频播放器容器样式 */ /* 音频播放器容器样式 */
.custom-audio-container { .custom-audio-container {
width: 280px; width: 90%;
margin: auto; margin: 0 auto;
} }
/* 新增按钮组样式 */ /* 新增按钮组样式 */
@@ -650,6 +648,12 @@ export default {
bottom: 20px; bottom: 20px;
padding-top: 10px; padding-top: 10px;
} }
.action-buttons .el-button {
padding: 8px 15px;
font-size: 11px;
}
.edit-btn, .edit-btn,
.delete-btn, .delete-btn,
.save-btn { .save-btn {
@@ -668,4 +672,40 @@ export default {
.save-btn { .save-btn {
color: #5cca8e !important; color: #5cca8e !important;
} }
/* 表格单元格自适应 */
::v-deep .el-table__body-wrapper {
overflow-x: hidden !important;
}
::v-deep .el-table td {
white-space: pre-wrap !important;
word-break: break-all !important;
}
/* 按钮组定位调整 */
.action-buttons {
position: static;
padding: 15px 0;
background: white;
box-shadow: 0 -2px 12px rgba(0,0,0,0.05);
}
/* 输入框自适应 */
::v-deep .el-input__inner,
::v-deep .el-textarea__inner {
width: 100% !important;
min-width: 120px;
}
/* 音频输入框特殊处理 */
.audio-input ::v-deep .el-input__inner {
min-width: 200px;
}
/* 操作按钮弹性布局 */
::v-deep .el-table__row .el-button {
flex-shrink: 0;
margin: 2px !important;
}
</style> </style>
@@ -0,0 +1,23 @@
<template>
<div class="copyright">
©2025 xiaozhi-esp32-server v{{ version }}
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'VersionFooter',
computed: {
...mapState({
version: state => state.pubConfig.version
})
},
mounted() {
this.$store.dispatch('fetchPubConfig');
}
}
</script>
<style scoped></style>
+5
View File
@@ -6,11 +6,16 @@ import App from './App.vue';
import router from './router'; import router from './router';
import store from './store'; import store from './store';
import './styles/global.scss'; import './styles/global.scss';
import { register as registerServiceWorker } from './registerServiceWorker';
Vue.use(ElementUI); Vue.use(ElementUI);
Vue.config.productionTip = false Vue.config.productionTip = false
// 注册Service Worker
registerServiceWorker();
// 创建Vue实例
new Vue({ new Vue({
router, router,
store, store,
@@ -0,0 +1,113 @@
/* eslint-disable no-console */
export const register = () => {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
const swUrl = `${process.env.BASE_URL}service-worker.js`;
console.info(`[小智服务] 正在尝试注册Service WorkerURL: ${swUrl}`);
// 先检查Service Worker是否已注册
navigator.serviceWorker.getRegistrations().then(registrations => {
if (registrations.length > 0) {
console.info('[小智服务] 发现已有Service Worker注册,正在检查更新');
}
// 继续注册Service Worker
navigator.serviceWorker
.register(swUrl)
.then(registration => {
console.info('[小智服务] Service Worker注册成功');
// 更新处理
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// 内容已缓存更新,通知用户刷新
console.log('[小智服务] 新内容可用,请刷新页面');
// 可以在这里展示更新提示
const updateNotification = document.createElement('div');
updateNotification.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: #409EFF;
color: white;
padding: 12px 20px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
z-index: 9999;
`;
updateNotification.innerHTML = `
<div style="display: flex; align-items: center;">
<span style="margin-right: 10px;">发现新版本,点击刷新应用</span>
<button style="background: white; color: #409EFF; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer;">刷新</button>
</div>
`;
document.body.appendChild(updateNotification);
updateNotification.querySelector('button').addEventListener('click', () => {
window.location.reload();
});
} else {
// 一切正常,Service Worker已成功安装
console.log('[小智服务] 内容已缓存供离线使用');
// 可以在这里初始化缓存
setTimeout(() => {
// 预热CDN缓存
const cdnUrls = [
'https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css',
'https://unpkg.com/vue@2.6.14/dist/vue.min.js',
'https://unpkg.com/vue-router@3.6.5/dist/vue-router.min.js',
'https://unpkg.com/vuex@3.6.2/dist/vuex.min.js',
'https://unpkg.com/element-ui@2.15.14/lib/index.js',
'https://unpkg.com/axios@0.27.2/dist/axios.min.js',
'https://unpkg.com/opus-decoder@0.7.7/dist/opus-decoder.min.js'
];
// 预热缓存
cdnUrls.forEach(url => {
fetch(url, { mode: 'no-cors' }).catch(err => {
console.log(`预热缓存 ${url} 失败`, err);
});
});
}, 2000);
}
}
};
};
})
.catch(error => {
console.error('Service Worker 注册失败:', error);
if (error.name === 'TypeError' && error.message.includes('Failed to register a ServiceWorker')) {
console.warn('[小智服务] 注册Service Worker时出现网络错误,CDN资源可能无法缓存');
if (process.env.NODE_ENV === 'production') {
console.info(
'可能原因:1. 服务器未配置正确的MIME类型 2. 服务器SSL证书问题 3. 服务器未返回service-worker.js文件'
);
}
}
});
});
});
}
};
export const unregister = () => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
})
.catch(error => {
console.error(error.message);
});
}
};
+174
View File
@@ -0,0 +1,174 @@
/* global self, workbox */
// 自定义Service Worker安装和激活的处理逻辑
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// CDN资源列表
const CDN_CSS = [
'https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
];
const CDN_JS = [
'https://unpkg.com/vue@2.6.14/dist/vue.min.js',
'https://unpkg.com/vue-router@3.6.5/dist/vue-router.min.js',
'https://unpkg.com/vuex@3.6.2/dist/vuex.min.js',
'https://unpkg.com/element-ui@2.15.14/lib/index.js',
'https://unpkg.com/axios@0.27.2/dist/axios.min.js',
'https://unpkg.com/opus-decoder@0.7.7/dist/opus-decoder.min.js'
];
// 当Service Worker被注入manifest后会自动执行
const manifest = self.__WB_MANIFEST || [];
// 检查是否启用CDN模式
const isCDNEnabled = manifest.some(entry =>
entry.url === 'cdn-mode' && entry.revision === 'enabled'
);
console.log(`Service Worker 已初始化, CDN模式: ${isCDNEnabled ? '启用' : '禁用'}`);
// 注入workbox相关代码
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
workbox.setConfig({ debug: false });
// 开启workbox
workbox.core.skipWaiting();
workbox.core.clientsClaim();
// 预缓存离线页面
const OFFLINE_URL = '/offline.html';
workbox.precaching.precacheAndRoute([
{ url: OFFLINE_URL, revision: null }
]);
// 添加安装完成事件处理器,在控制台显示安装消息
self.addEventListener('install', event => {
if (isCDNEnabled) {
console.log('Service Worker 已安装,开始缓存CDN资源');
} else {
console.log('Service Worker 已安装,CDN模式禁用,仅缓存本地资源');
}
// 确保离线页面被缓存
event.waitUntil(
caches.open('offline-cache').then((cache) => {
return cache.add(OFFLINE_URL);
})
);
});
// 添加激活事件处理器
self.addEventListener('activate', event => {
console.log('Service Worker 已激活,现在控制着页面');
// 清理旧版本缓存
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(cacheName => {
// 清理除当前版本外的缓存
return cacheName.startsWith('workbox-') && !workbox.core.cacheNames.runtime.includes(cacheName);
}).map(cacheName => {
return caches.delete(cacheName);
})
);
})
);
});
// 添加fetch事件拦截器,用于查看CDN资源是否命中缓存
self.addEventListener('fetch', event => {
// 只有启用CDN模式时才进行CDN资源缓存监控
if (isCDNEnabled) {
const url = new URL(event.request.url);
// 针对CDN资源,输出是否命中缓存的信息
if ([...CDN_CSS, ...CDN_JS].includes(url.href)) {
// 不干扰正常的fetch流程,只添加日志
console.log(`请求CDN资源: ${url.href}`);
}
}
});
// 仅在CDN模式下缓存CDN资源
if (isCDNEnabled) {
// 缓存CDN的CSS资源
workbox.routing.registerRoute(
({ url }) => CDN_CSS.includes(url.href),
new workbox.strategies.CacheFirst({
cacheName: 'cdn-stylesheets',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 365 * 24 * 60 * 60, // 增加到1年缓存
maxEntries: 10, // 最多缓存10个CSS文件
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200], // 缓存成功响应
}),
],
})
);
// 缓存CDN的JS资源
workbox.routing.registerRoute(
({ url }) => CDN_JS.includes(url.href),
new workbox.strategies.CacheFirst({
cacheName: 'cdn-scripts',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 365 * 24 * 60 * 60, // 增加到1年缓存
maxEntries: 20, // 最多缓存20个JS文件
}),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200], // 缓存成功响应
}),
],
})
);
}
// 无论是否启用CDN模式,都缓存本地静态资源
workbox.routing.registerRoute(
/\.(?:js|css|png|jpg|jpeg|svg|gif|ico|woff|woff2|eot|ttf|otf)$/,
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'static-resources',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天缓存
maxEntries: 50, // 最多缓存50个文件
}),
],
})
);
// 缓存HTML页面
workbox.routing.registerRoute(
/\.html$/,
new workbox.strategies.NetworkFirst({
cacheName: 'html-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 1 * 24 * 60 * 60, // 1天缓存
maxEntries: 10, // 最多缓存10个HTML文件
}),
],
})
);
// 离线页面 - 使用更可靠的处理方式
workbox.routing.setCatchHandler(async ({ event }) => {
// 根据请求类型返回适当的默认页面
switch (event.request.destination) {
case 'document':
// 如果是网页请求,返回离线页面
return caches.match(OFFLINE_URL);
default:
// 所有其他请求返回错误
return Response.error();
}
});
+23 -1
View File
@@ -1,6 +1,7 @@
import { goToPage } from "@/utils"; import { goToPage } from "@/utils";
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import Api from '../apis/api';
import Constant from '../utils/constant'; import Constant from '../utils/constant';
Vue.use(Vuex) Vue.use(Vuex)
@@ -9,7 +10,11 @@ export default new Vuex.Store({
state: { state: {
token: '', token: '',
userInfo: {}, // 添加用户信息存储 userInfo: {}, // 添加用户信息存储
isSuperAdmin: false // 添加superAdmin状态 isSuperAdmin: false, // 添加superAdmin状态
pubConfig: { // 添加公共配置存储
version: '',
allowUserRegister: false
}
}, },
getters: { getters: {
getToken(state) { getToken(state) {
@@ -26,6 +31,9 @@ export default new Vuex.Store({
return state.isSuperAdmin return state.isSuperAdmin
} }
return localStorage.getItem('isSuperAdmin') === 'true' return localStorage.getItem('isSuperAdmin') === 'true'
},
getPubConfig(state) {
return state.pubConfig
} }
}, },
mutations: { mutations: {
@@ -39,6 +47,9 @@ export default new Vuex.Store({
state.isSuperAdmin = isSuperAdmin state.isSuperAdmin = isSuperAdmin
localStorage.setItem('isSuperAdmin', isSuperAdmin) localStorage.setItem('isSuperAdmin', isSuperAdmin)
}, },
setPubConfig(state, config) {
state.pubConfig = config
},
clearAuth(state) { clearAuth(state) {
state.token = '' state.token = ''
state.userInfo = {} state.userInfo = {}
@@ -55,6 +66,17 @@ export default new Vuex.Store({
goToPage(Constant.PAGE.LOGIN, true); goToPage(Constant.PAGE.LOGIN, true);
window.location.reload(); // 彻底重置状态 window.location.reload(); // 彻底重置状态
}) })
},
// 添加获取公共配置的 action
fetchPubConfig({ commit }) {
return new Promise((resolve) => {
Api.user.getPubConfig(({ data }) => {
if (data.code === 0) {
commit('setPubConfig', data.data);
}
resolve();
});
});
} }
}, },
modules: { modules: {
+142
View File
@@ -0,0 +1,142 @@
/**
* 缓存查看工具 - 用于检查CDN资源是否已被Service Worker缓存
*/
/**
* 获取所有Service Worker缓存的名称
* @returns {Promise<string[]>} 缓存名称列表
*/
export const getCacheNames = async () => {
if (!('caches' in window)) {
return [];
}
try {
return await caches.keys();
} catch (error) {
console.error('获取缓存名称失败:', error);
return [];
}
};
/**
* 获取指定缓存中的所有URL
* @param {string} cacheName 缓存名称
* @returns {Promise<string[]>} 缓存的URL列表
*/
export const getCacheUrls = async (cacheName) => {
if (!('caches' in window)) {
return [];
}
try {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
return requests.map(request => request.url);
} catch (error) {
console.error(`获取缓存 ${cacheName} 的URL失败:`, error);
return [];
}
};
/**
* 检查特定URL是否已被缓存
* @param {string} url 要检查的URL
* @returns {Promise<boolean>} 是否已缓存
*/
export const isUrlCached = async (url) => {
if (!('caches' in window)) {
return false;
}
try {
const cacheNames = await getCacheNames();
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const match = await cache.match(url);
if (match) {
return true;
}
}
return false;
} catch (error) {
console.error(`检查URL ${url} 是否缓存失败:`, error);
return false;
}
};
/**
* 获取当前页面所有CDN资源的缓存状态
* @returns {Promise<Object>} 缓存状态对象
*/
export const checkCdnCacheStatus = async () => {
// 从CDN缓存中查找资源
const cdnCaches = ['cdn-stylesheets', 'cdn-scripts'];
const results = {
css: [],
js: [],
totalCached: 0,
totalNotCached: 0
};
for (const cacheName of cdnCaches) {
try {
const urls = await getCacheUrls(cacheName);
// 区分CSS和JS资源
for (const url of urls) {
if (url.endsWith('.css')) {
results.css.push({ url, cached: true });
} else if (url.endsWith('.js')) {
results.js.push({ url, cached: true });
}
results.totalCached++;
}
} catch (error) {
console.error(`获取 ${cacheName} 缓存信息失败:`, error);
}
}
return results;
};
/**
* 清除所有Service Worker缓存
* @returns {Promise<boolean>} 是否成功清除
*/
export const clearAllCaches = async () => {
if (!('caches' in window)) {
return false;
}
try {
const cacheNames = await getCacheNames();
for (const cacheName of cacheNames) {
await caches.delete(cacheName);
}
return true;
} catch (error) {
console.error('清除所有缓存失败:', error);
return false;
}
};
/**
* 将缓存状态输出到控制台
*/
export const logCacheStatus = async () => {
console.group('Service Worker 缓存状态');
const cacheNames = await getCacheNames();
console.log('已发现的缓存:', cacheNames);
for (const cacheName of cacheNames) {
const urls = await getCacheUrls(cacheName);
console.group(`缓存: ${cacheName} (${urls.length} 项)`);
urls.forEach(url => console.log(url));
console.groupEnd();
}
console.groupEnd();
return cacheNames.length > 0;
};
+538 -99
View File
@@ -1,54 +1,101 @@
<template> <template>
<div class="welcome"> <div class="welcome">
<HeaderBar /> <HeaderBar />
<el-main style="padding: 20px; display: flex; flex-direction: column;">
<div class="table-container"> <div class="operation-bar">
<h3 class="device-list-title">设备列表</h3> <h2 class="page-title">设备管理</h2>
<el-button type="primary" class="add-device-btn" @click="handleAddDevice"> <div class="right-operations">
+ 添加设备 <el-input placeholder="请输入设备型号或Mac地址查询" v-model="searchKeyword"
</el-button> class="search-input" @keyup.enter.native="handleSearch" clearable />
<el-table :data="paginatedDeviceList" style="width: 100%; margin-top: 20px" border stripe> <el-button class="btn-search" @click="handleSearch">搜索</el-button>
<el-table-column label="设备型号" prop="model" flex></el-table-column>
<el-table-column label="固件版本" prop="firmwareVersion" width="120"></el-table-column>
<el-table-column label="Mac地址" prop="macAddress"></el-table-column>
<el-table-column label="绑定时间" prop="bindTime" width="200"></el-table-column>
<el-table-column label="最近对话" prop="lastConversation" width="140"></el-table-column>
<el-table-column label="备注" width="180">
<template slot-scope="scope">
<el-input v-if="scope.row.isEdit" v-model="scope.row.remark" size="mini"
@blur="stopEditRemark(scope.$index)"></el-input>
<span v-else>
<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)">
{{ scope.row.remark }}
</span>
</span>
</template>
</el-table-column>
<el-table-column label="OTA升级" width="120">
<template slot-scope="scope">
<el-switch v-model="scope.row.otaSwitch" size="mini" active-color="#13ce66"
inactive-color="#ff4949"></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="handleUnbind(scope.row.device_id)" style="color: #ff4949">
解绑
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination class="pagination" @size-change="handleSizeChange" @current-change="handleCurrentChange"
:current-page="currentPage" :page-sizes="[5, 10, 20, 50]" :page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper" :total="deviceList.length"></el-pagination>
</div> </div>
<div class="copyright"> </div>
©2025 xiaozhi-esp32-server
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="device-card" shadow="never">
<el-table
ref="deviceTable"
:data="paginatedDeviceList"
@selection-change="handleSelectionChange"
class="transparent-table"
:header-cell-class-name="headerCellClassName">
<el-table-column type="selection" align="center" width="120"></el-table-column>
<el-table-column label="设备型号" prop="model" align="center"></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="绑定时间" prop="bindTime" align="center"></el-table-column>
<el-table-column label="最近对话" prop="lastConversation" align="center"></el-table-column>
<el-table-column label="备注" align="center">
<template slot-scope="scope">
<el-input v-if="scope.row.isEdit" v-model="scope.row.remark" size="mini"
@blur="stopEditRemark(scope.$index)"></el-input>
<span v-else>
<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)">
{{ scope.row.remark }}
</span>
</span>
</template>
</el-table-column>
<el-table-column label="OTA升级" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.otaSwitch" size="mini" active-color="#13ce66"
inactive-color="#ff4949"></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="handleUnbind(scope.row.device_id)">
解绑
</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="toggleAllSelection">
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
<el-button type="success" size="mini" class="add-device-btn" @click="handleAddDevice">
新增
</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete"
@click="deleteSelected">解绑</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">{{ deviceList.length }}条记录</span>
</div>
</div>
</el-card>
</div>
</div> </div>
<AddDeviceDialog :visible.sync="addDeviceDialogVisible" :agent-id="currentAgentId" </div>
@refresh="fetchBindDevices(currentAgentId)" />
</el-main> <AddDeviceDialog :visible.sync="addDeviceDialogVisible" :agent-id="currentAgentId"
@refresh="fetchBindDevices(currentAgentId)" />
</div> </div>
</template> </template>
@@ -62,20 +109,53 @@ export default {
data() { data() {
return { return {
addDeviceDialogVisible: false, addDeviceDialogVisible: false,
selectedDevices: [],
isAllSelected: false,
searchKeyword: "",
activeSearchKeyword: "",
currentAgentId: this.$route.query.agentId || '', currentAgentId: this.$route.query.agentId || '',
currentPage: 1, currentPage: 1,
pageSize: 5, pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
deviceList: [], deviceList: [],
loading: false, loading: false,
userApi: null, userApi: null,
}; };
}, },
computed: { computed: {
filteredDeviceList() {
const keyword = this.activeSearchKeyword.toLowerCase();
if (!keyword) return this.deviceList;
return this.deviceList.filter(device =>
(device.model && device.model.toLowerCase().includes(keyword)) ||
(device.macAddress && device.macAddress.toLowerCase().includes(keyword))
);
},
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.deviceList.slice(start, end); return this.filteredDeviceList.slice(start, end);
} },
pageCount() {
return Math.ceil(this.filteredDeviceList.length / 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;
},
}, },
mounted() { mounted() {
const agentId = this.$route.query.agentId; const agentId = this.$route.query.agentId;
@@ -84,6 +164,73 @@ export default {
} }
}, },
methods: { methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
},
handleSearch() {
this.activeSearchKeyword = this.searchKeyword;
this.currentPage = 1;
},
handleSelectionChange(val) {
this.selectedDevices = val;
this.isAllSelected = val.length === this.paginatedDeviceList.length;
},
toggleAllSelection() {
this.$refs.deviceTable.toggleAllSelection();
},
deleteSelected() {
if (this.selectedDevices.length === 0) {
this.$message.warning({
message: '请至少选择一条记录',
showClose: true
});
return;
}
this.$confirm(`确认要解绑选中的 ${this.selectedDevices.length} 台设备吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const deviceIds = this.selectedDevices.map(device => device.device_id);
this.batchUnbindDevices(deviceIds);
});
},
batchUnbindDevices(deviceIds) {
const promises = deviceIds.map(id => {
return new Promise((resolve, reject) => {
Api.device.unbindDevice(id, ({ data }) => {
if (data.code === 0) {
resolve();
} else {
reject(data.msg || '解绑失败');
}
});
});
});
Promise.all(promises)
.then(() => {
this.$message.success({
message: `成功解绑 ${deviceIds.length} 台设备`,
showClose: true
});
this.fetchBindDevices(this.currentAgentId);
this.selectedDevices = [];
this.isAllSelected = false;
})
.catch(error => {
this.$message.error({
message: error || '批量解绑过程中出现错误',
showClose: true
});
});
},
handleAddDevice() { handleAddDevice() {
this.addDeviceDialogVisible = true; this.addDeviceDialogVisible = true;
}, },
@@ -115,46 +262,61 @@ export default {
}); });
}); });
}, },
handleSizeChange(val) { goFirst() {
this.pageSize = val; this.currentPage = 1;
}, },
handleCurrentChange(val) { goPrev() {
this.currentPage = val; if (this.currentPage > 1) this.currentPage--;
}, },
goNext() {
if (this.currentPage < this.pageCount) this.currentPage++;
},
goToPage(page) {
this.currentPage = page;
},
fetchBindDevices(agentId) { fetchBindDevices(agentId) {
this.loading = true; this.loading = true;
Api.device.getAgentBindDevices(agentId, ({ data }) => { Api.device.getAgentBindDevices(agentId, ({ data }) => {
this.loading = false; this.loading = false;
if (data.code === 0) { if (data.code === 0) {
// 格式化日期并按照绑定时间降序排列
this.deviceList = data.data.map(device => { this.deviceList = data.data.map(device => {
// 格式化绑定时间
const bindDate = new Date(device.createDate); const bindDate = new Date(device.createDate);
const formattedBindTime = `${bindDate.getFullYear()}-${(bindDate.getMonth() + 1).toString().padStart(2, '0')}-${bindDate.getDate().toString().padStart(2, '0')} ${bindDate.getHours().toString().padStart(2, '0')}:${bindDate.getMinutes().toString().padStart(2, '0')}:${bindDate.getSeconds().toString().padStart(2, '0')}`; const formattedBindTime = `${bindDate.getFullYear()}-${(bindDate.getMonth() + 1).toString().padStart(2, '0')}-${bindDate.getDate().toString().padStart(2, '0')} ${bindDate.getHours().toString().padStart(2, '0')}:${bindDate.getMinutes().toString().padStart(2, '0')}:${bindDate.getSeconds().toString().padStart(2, '0')}`;
let formattedLastConversation = '';
if (device.lastConnectedAt) {
const lastConvoDate = new Date(device.lastConnectedAt);
formattedLastConversation = `${lastConvoDate.getFullYear()}-${(lastConvoDate.getMonth() + 1).toString().padStart(2, '0')}-${lastConvoDate.getDate().toString().padStart(2, '0')} ${lastConvoDate.getHours().toString().padStart(2, '0')}:${lastConvoDate.getMinutes().toString().padStart(2, '0')}:${lastConvoDate.getSeconds().toString().padStart(2, '0')}`;
}
return { return {
device_id: device.id, device_id: device.id,
model: device.board, model: device.board,
firmwareVersion: device.appVersion, firmwareVersion: device.appVersion,
macAddress: device.macAddress, macAddress: device.macAddress,
bindTime: formattedBindTime, // 使用格式化后的时间 bindTime: formattedBindTime,
lastConversation: device.lastConnectedAt, lastConversation: formattedLastConversation,
remark: device.alias, remark: device.alias,
isEdit: false, isEdit: false,
otaSwitch: device.autoUpdate === 1, otaSwitch: device.autoUpdate === 1,
// 添加原始时间用于排序
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.searchKeyword = "";
} else { } else {
this.$message.error(data.msg || '获取设备列表失败'); this.$message.error(data.msg || '获取设备列表失败');
} }
}); });
}, },
headerCellClassName({columnIndex}) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
}
} }
}; };
</script> </script>
<style scoped> <style scoped>
@@ -163,58 +325,335 @@ export default {
min-height: 506px; min-height: 506px;
height: 100vh; height: 100vh;
display: flex; display: flex;
position: relative;
flex-direction: column; flex-direction: column;
background: linear-gradient(145deg, #e6eeff, #eff0ff); background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd);
background-size: cover; background-size: cover;
background-position: center;
-webkit-background-size: cover; -webkit-background-size: cover;
-o-background-size: cover; -o-background-size: cover;
} }
.table-container { .main-wrapper {
background: #f9fafc; margin: 5px 22px;
padding: 20px; border-radius: 15px;
border-radius: 20px; min-height: calc(100vh - 24vh);
height: auto;
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);
margin-top: 15px; position: relative;
} background: rgba(237, 242, 255, 0.5);
.add-device-btn {
float: right;
background: #409eff;
border: none;
border-radius: 10px;
width: 105px;
height: 32px;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center;
font-size: 14px;
gap: 8px;
margin-bottom: 15px;
&:hover {
background: #3a8ee6;
}
} }
.device-list-title { .operation-bar {
float: left; display: flex;
font-size: 18px; justify-content: space-between;
font-weight: 700; align-items: center;
margin: 5px; padding: 16px 24px;
}
.page-title {
font-size: 24px;
margin: 0;
color: #2c3e50; color: #2c3e50;
} }
.el-icon-edit { .right-operations {
color: #409eff; display: flex;
cursor: pointer; gap: 10px;
font-size: 14px; margin-left: auto;
vertical-align: middle;
} }
.pagination { .search-input {
margin-top: 20px; width: 280px;
text-align: right; border-radius: 4px;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
::v-deep .search-input .el-input__inner {
border-radius: 4px;
border: 1px solid #DCDFE6;
background-color: white;
transition: border-color 0.2s;
}
::v-deep .page-size-select{
width: 100px;
margin-right: 8px;
}
::v-deep .page-size-select .el-input__inner{
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
::v-deep .page-size-select .el-input__suffix{
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
::v-deep .page-size-select .el-input__suffix-inner{
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
::v-deep .page-size-select .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;
}
::v-deep .search-input .el-input__inner:focus {
border-color: #6b8cff;
outline: none;
}
.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;
}
.device-card {
background: white;
border: none;
box-shadow: none;
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;
}
.ctrl_btn .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);
}
.ctrl_btn .el-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.ctrl_btn .el-button--primary {
background: #5f70f3;
color: white;
}
.ctrl_btn .el-button--success {
background: #5bc98c;
color: white;
}
.ctrl_btn .el-button--danger {
background: #fd5b63;
color: white;
}
.custom-pagination {
display: flex;
align-items: center;
gap: 10px;
}
.custom-pagination .el-select {
margin-right: 8px;
}
.custom-pagination .pagination-btn:first-child,
.custom-pagination .pagination-btn:nth-child(2),
.custom-pagination .pagination-btn:nth-last-child(2),
.custom-pagination .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;
}
.custom-pagination .pagination-btn:first-child:hover,
.custom-pagination .pagination-btn:nth-child(2):hover,
.custom-pagination .pagination-btn:nth-last-child(2):hover,
.custom-pagination .pagination-btn:nth-child(3):hover {
background: #d7dce6;
}
.custom-pagination .pagination-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.custom-pagination .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;
}
.custom-pagination .pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)):hover {
background: rgba(245, 247, 250, 0.3);
}
.custom-pagination .pagination-btn.active {
background: #5f70f3 !important;
color: #ffffff !important;
border-color: #5f70f3 !important;
}
.custom-pagination .pagination-btn.active:hover {
background: #6d7cf5 !important;
}
.custom-pagination .total-text {
color: #909399;
font-size: 14px;
margin-left: 10px;
}
:deep(.transparent-table) {
background: white;
border: none;
}
:deep(.transparent-table .el-table__header th) {
background: white !important;
color: black;
border-right: none !important;
}
:deep(.transparent-table .el-table__body tr td) {
border-top: 1px solid rgba(0, 0, 0, 0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
border-right: none !important;
}
:deep(.transparent-table .el-table__header tr th:first-child .cell),
:deep(.transparent-table .el-table__body tr td:first-child .cell) {
padding-left: 10px;
}
:deep(.el-icon-edit) {
color: #7079aa;
cursor: pointer;
}
:deep(.el-icon-edit:hover) {
color: #5a64b5;
}
:deep(.custom-selection-header .el-checkbox) {
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) {
color: #7079aa;
}
:deep(.el-table .el-button--text:hover) {
color: #5a64b5;
}
:deep(.transparent-table) {
flex: 1;
display: flex;
flex-direction: column;
max-height: calc(100vh - 40vh);
}
:deep(.el-table__body-wrapper) {
flex: 1;
overflow-y: auto;
max-height: none !important;
}
:deep(.el-table__header-wrapper) {
flex-shrink: 0;
}
@media (min-width: 1144px) {
.table_bottom {
margin-top: 40px;
}
:deep(.transparent-table) .el-table__body tr td {
padding-top: 16px;
padding-bottom: 16px;
}
} }
</style> </style>
+159 -123
View File
@@ -3,21 +3,15 @@
<HeaderBar /> <HeaderBar />
<div class="operation-bar"> <div class="operation-bar">
<h2 class="page-title">{{ modelTypeText }}</h2> <h2 class="page-title">{{ modelTypeText }}</h2>
<div class="action-group"> <div class="action-group">
<div class="search-group"> <div class="search-group">
<el-input <el-input placeholder="请输入模型名称查询" v-model="search" class="search-input" clearable
placeholder="请输入模型名称查询" @keyup.enter.native="handleSearch" style="width: 240px" />
v-model="search" <el-button class="btn-search" @click="handleSearch">
class="search-input" 搜索
clearable </el-button>
@keyup.enter.native="handleSearch" </div>
style="width: 240px"
/>
<el-button class="btn-search" @click="handleSearch">
搜索
</el-button>
</div>
</div> </div>
</div> </div>
@@ -49,53 +43,53 @@
<!-- 右侧内容 --> <!-- 右侧内容 -->
<div class="content-area"> <div class="content-area">
<el-table ref="modelTable" style="width: 100%" :header-cell-style="{ background: 'transparent' }" <el-card class="model-card" shadow="never">
:data="modelList" class="data-table" header-row-class-name="table-header" <el-table ref="modelTable" style="width: 100%" :header-cell-style="{ background: 'transparent' }"
:header-cell-class-name="headerCellClassName" @selection-change="handleSelectionChange"> :data="modelList" class="data-table" header-row-class-name="table-header"
<el-table-column type="selection" width="55" align="center"></el-table-column> :header-cell-class-name="headerCellClassName" @selection-change="handleSelectionChange">
<el-table-column label="模型名称" prop="modelName" align="center"></el-table-column> <el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="模型编码" prop="modelCode" align="center"></el-table-column> <el-table-column label="模型名称" prop="modelName" align="center"></el-table-column>
<el-table-column label="提供商" align="center"> <el-table-column label="模型编码" prop="modelCode" align="center"></el-table-column>
<template slot-scope="scope"> <el-table-column label="提供商" align="center">
{{ scope.row.configJson.type || '未知' }} <template slot-scope="scope">
</template> {{ scope.row.configJson.type || '未知' }}
</el-table-column> </template>
<el-table-column label="是否启用" align="center"> </el-table-column>
<template slot-scope="scope"> <el-table-column label="是否启用" align="center">
<el-switch v-model="scope.row.isEnabled" class="custom-switch" :active-value="1" :inactive-value="0" <template slot-scope="scope">
@change="handleStatusChange(scope.row)" /> <el-switch v-model="scope.row.isEnabled" class="custom-switch" :active-value="1" :inactive-value="0"
</template> @change="handleStatusChange(scope.row)" />
</el-table-column> </template>
<el-table-column label="是否默认" align="center"> </el-table-column>
<template slot-scope="scope"> <el-table-column label="是否默认" align="center">
<el-switch v-model="scope.row.isDefault" class="custom-switch" :active-value="1" :inactive-value="0" <template slot-scope="scope">
@change="handleDefaultChange(scope.row)" /> <el-switch v-model="scope.row.isDefault" class="custom-switch" :active-value="1" :inactive-value="0"
</template> @change="handleDefaultChange(scope.row)" />
</el-table-column> </template>
<el-table-column v-if="activeTab === 'tts'" label="音色管理" align="center"> </el-table-column>
<template slot-scope="scope"> <el-table-column v-if="activeTab === 'tts'" label="音色管理" align="center">
<el-button type="text" size="mini" @click="openTtsDialog(scope.row)" class="voice-management-btn"> <template slot-scope="scope">
音色管理 <el-button type="text" size="mini" @click="openTtsDialog(scope.row)" class="voice-management-btn">
</el-button> 音色管理
</template> </el-button>
</el-table-column> </template>
<el-table-column label="操作" align="center" width="150px"> </el-table-column>
<template slot-scope="scope"> <el-table-column label="操作" align="center" width="150px">
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn"> <template slot-scope="scope">
修改 <el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn">
</el-button> 修改
<el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn"> </el-button>
删除 <el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn">
</el-button> 删除
</template> </el-button>
</el-table-column> </template>
</el-table> </el-table-column>
</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">
新增 新增
@@ -105,6 +99,16 @@
</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-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="goFirst">首页</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button> <button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
@@ -117,6 +121,7 @@
<span class="total-text">{{ total }}条记录</span> <span class="total-text">{{ total }}条记录</span>
</div> </div>
</div> </div>
</el-card>
</div> </div>
</div> </div>
@@ -125,10 +130,6 @@
<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>
<div class="copyright">
©2025 xiaozhi-esp32-server
</div>
</div> </div>
</template> </template>
@@ -151,8 +152,9 @@ export default {
ttsDialogVisible: false, ttsDialogVisible: false,
selectedTtsModelId: '', selectedTtsModelId: '',
modelList: [], modelList: [],
pageSizeOptions: [10, 20, 50, 100],
currentPage: 1, currentPage: 1,
pageSize: 5, pageSize: 10,
total: 0, total: 0,
selectedModels: [], selectedModels: [],
isAllSelected: false isAllSelected: false
@@ -164,7 +166,6 @@ export default {
}, },
computed: { computed: {
modelTypeText() { modelTypeText() {
const map = { const map = {
vad: '语言活动检测模型(VAD)', vad: '语言活动检测模型(VAD)',
@@ -176,8 +177,6 @@ export default {
} }
return map[this.activeTab] || '模型配置' return map[this.activeTab] || '模型配置'
}, },
pageCount() { pageCount() {
return Math.ceil(this.total / this.pageSize); return Math.ceil(this.total / this.pageSize);
}, },
@@ -199,6 +198,11 @@ export default {
}, },
methods: { methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.loadData();
},
openTtsDialog(row) { openTtsDialog(row) {
this.selectedTtsModelId = row.id; this.selectedTtsModelId = row.id;
this.ttsDialogVisible = true; this.ttsDialogVisible = true;
@@ -211,7 +215,8 @@ export default {
}, },
handleMenuSelect(index) { handleMenuSelect(index) {
this.activeTab = index; this.activeTab = index;
this.currentPage = 1; this.currentPage = 1; // 重置到第一页
this.pageSize = 10; // 可选:重置每页条数
this.loadData(); this.loadData();
}, },
handleSearch() { handleSearch() {
@@ -456,7 +461,9 @@ export default {
.main-wrapper { .main-wrapper {
margin: 5px 22px; margin: 5px 22px;
border-radius: 15px; border-radius: 15px;
min-height: 600px; min-height: calc(100vh - 24vh);
height: auto;
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);
@@ -474,12 +481,6 @@ export default {
margin: 0; margin: 0;
} }
.right-operations {
display: flex;
gap: 10px;
margin-left: auto;
}
.content-panel { .content-panel {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -552,17 +553,10 @@ export default {
padding: 24px; padding: 24px;
height: 100%; height: 100%;
min-width: 600px; min-width: 600px;
overflow-x: auto; overflow: hidden;
background-color: white; background-color: white;
}
.title-bar {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center;
margin-bottom: 24px;
flex-wrap: nowrap;
} }
.action-group { .action-group {
@@ -586,6 +580,11 @@ export default {
color: white; color: white;
} }
.btn-search:hover {
opacity: 0.9;
transform: translateY(-1px);
}
::v-deep .search-input .el-input__inner { ::v-deep .search-input .el-input__inner {
border-radius: 4px; border-radius: 4px;
border: 1px solid #DCDFE6; border: 1px solid #DCDFE6;
@@ -593,22 +592,53 @@ export default {
transition: border-color 0.2s; transition: border-color 0.2s;
} }
::v-deep .page-size-select{
width: 100px;
margin-right: 8px;
}
::v-deep .page-size-select .el-input__inner{
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
::v-deep .page-size-select .el-input__suffix{
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
::v-deep .page-size-select .el-input__suffix-inner{
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
::v-deep .page-size-select .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;
}
::v-deep .search-input .el-input__inner:focus { ::v-deep .search-input .el-input__inner:focus {
border-color: #6b8cff; border-color: #6b8cff;
outline: none; outline: none;
} }
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
.btn-search:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.data-table { .data-table {
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
@@ -626,12 +656,14 @@ export default {
} }
.table-footer { .table-footer {
margin-top: 24px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 16px 0; padding: 16px 0;
width: 100%; width: 100%;
flex-shrink: 0;
min-height: 60px;
background: white;
} }
.batch-actions { .batch-actions {
@@ -639,26 +671,6 @@ export default {
gap: 8px; gap: 8px;
} }
.copyright {
text-align: center;
color: #979db1;
font-size: 12px;
font-weight: 400;
margin-top: auto;
padding: 30px 0 20px;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
}
.title-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.batch-actions .el-button { .batch-actions .el-button {
min-width: 72px; min-width: 72px;
height: 32px; height: 32px;
@@ -710,11 +722,6 @@ export default {
background-color: transparent !important; background-color: transparent !important;
} }
.pagination-container {
display: flex;
justify-content: flex-end;
}
::v-deep .el-table .custom-selection-header .cell .el-checkbox__inner { ::v-deep .el-table .custom-selection-header .cell .el-checkbox__inner {
display: none !important; display: none !important;
} }
@@ -815,11 +822,11 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-top: 15px;
/* 导航按钮样式 (首页、上一页、下一页) */ /* 导航按钮样式 (首页、上一页、下一页) */
.pagination-btn:first-child, .pagination-btn:first-child,
.pagination-btn:nth-child(2), .pagination-btn:nth-child(2),
.pagination-btn:nth-child(3),
.pagination-btn:nth-last-child(2) { .pagination-btn:nth-last-child(2) {
min-width: 60px; min-width: 60px;
height: 32px; height: 32px;
@@ -843,7 +850,7 @@ export default {
} }
/* 数字按钮样式 */ /* 数字按钮样式 */
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-last-child(2)) { .pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-child(3)):not(:nth-last-child(2)) {
min-width: 28px; min-width: 28px;
height: 32px; height: 32px;
padding: 0; padding: 0;
@@ -876,4 +883,33 @@ export default {
margin-left: 10px; margin-left: 10px;
} }
} }
.model-card{
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
}
.model-card ::v-deep .el-card__body{
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.data-table {
--table-max-height: calc(100vh - 45vh);
max-height: var(--table-max-height);
}
.data-table ::v-deep .el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 80px);
overflow-y: auto;
}
</style> </style>
+161 -55
View File
@@ -17,7 +17,11 @@
<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"
:header-cell-class-name="headerCellClassName"> :header-cell-class-name="headerCellClassName">
<el-table-column label="选择" type="selection" align="center" width="120"></el-table-column> <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="paramCode" align="center"></el-table-column> <el-table-column label="参数编码" prop="paramCode" align="center"></el-table-column>
<el-table-column label="参数值" prop="paramValue" align="center" <el-table-column label="参数值" prop="paramValue" align="center"
show-overflow-tooltip></el-table-column> show-overflow-tooltip></el-table-column>
@@ -37,9 +41,17 @@
</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="deleteParam($refs.paramsTable.selection)">删除</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-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 class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
首页 首页
</button> </button>
@@ -65,7 +77,6 @@
<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" />
<div class="copyright">©2025 xiaozhi-esp32-server</div>
</div> </div>
</template> </template>
@@ -81,7 +92,8 @@ export default {
searchCode: "", searchCode: "",
paramsList: [], paramsList: [],
currentPage: 1, currentPage: 1,
pageSize: 5, pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
total: 0, total: 0,
dialogVisible: false, dialogVisible: false,
dialogTitle: "新增参数", dialogTitle: "新增参数",
@@ -96,7 +108,9 @@ export default {
}, },
created() { created() {
this.fetchParams(); this.fetchParams();
}, },
computed: { computed: {
pageCount() { pageCount() {
return Math.ceil(this.total / this.pageSize); return Math.ceil(this.total / this.pageSize);
@@ -118,6 +132,11 @@ export default {
}, },
}, },
methods: { methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.fetchParams();
},
fetchParams() { fetchParams() {
Api.admin.getParamsList( Api.admin.getParamsList(
{ {
@@ -127,10 +146,16 @@ export default {
}, },
({ data }) => { ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.paramsList = data.data.list; this.paramsList = data.data.list.map(item => ({
...item,
selected: false
}));
this.total = data.data.total; this.total = data.data.total;
} else { } else {
this.$message.error(data.msg || '获取参数列表失败'); this.$message.error({
message: data.msg || '获取参数列表失败',
showClose: true
});
} }
} }
); );
@@ -140,12 +165,10 @@ export default {
this.fetchParams(); this.fetchParams();
}, },
handleSelectAll() { handleSelectAll() {
if (this.isAllSelected) {
this.$refs.paramsTable.clearSelection();
} else {
this.$refs.paramsTable.toggleAllSelection();
}
this.isAllSelected = !this.isAllSelected; this.isAllSelected = !this.isAllSelected;
this.paramsList.forEach(row => {
row.selected = this.isAllSelected;
});
}, },
showAddDialog() { showAddDialog() {
this.dialogTitle = "新增参数"; this.dialogTitle = "新增参数";
@@ -167,7 +190,10 @@ export default {
// 编辑 // 编辑
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:"修改成功",
showClose:true
});
this.dialogVisible = false; this.dialogVisible = false;
this.fetchParams(); this.fetchParams();
} }
@@ -176,23 +202,40 @@ export default {
// 新增 // 新增
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:"新增成功",
showClose:true
});
this.dialogVisible = false; this.dialogVisible = false;
this.fetchParams(); this.fetchParams();
} }
}); });
} }
}, },
deleteSelectedParams() {
const selectedRows = this.paramsList.filter(row => row.selected);
if (selectedRows.length === 0) {
this.$message.warning({
message: "请先选择需要删除的参数",
showClose: true
});
return;
}
this.deleteParam(selectedRows);
},
deleteParam(row) { deleteParam(row) {
// 处理单个参数或参数数组 // 处理单个参数或参数数组
const params = Array.isArray(row) ? row : [row]; const params = Array.isArray(row) ? row : [row];
if (Array.isArray(row) && row.length === 0) { if (Array.isArray(row) && row.length === 0) {
this.$message.warning("请先选择需要删除的参数"); this.$message.warning({
message:"请先选择需要删除的参数",
showClose:true
});
return; return;
} }
const paramCount = params.length; const paramCount = params.length;
this.$confirm(`确定要删除选中的${paramCount}个参数吗?`, '警告', { this.$confirm(`确定要删除选中的${paramCount}个参数吗?`, '警告', {
confirmButtonText: '确定', confirmButtonText: '确定',
@@ -202,7 +245,10 @@ export default {
}).then(() => { }).then(() => {
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('存在无效的参数ID'); this.$message.error({
message:'存在无效的参数ID',
showClose: true
});
return; return;
} }
@@ -212,7 +258,7 @@ export default {
message: `成功删除${paramCount}个参数`, message: `成功删除${paramCount}个参数`,
showClose: true showClose: true
}); });
this.fetchParams(); // 刷新参数列表 this.fetchParams();
} else { } else {
this.$message.error({ this.$message.error({
message: data.msg || '删除失败,请重试', message: data.msg || '删除失败,请重试',
@@ -278,15 +324,20 @@ export default {
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center; background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover; -webkit-background-size: cover;
-o-background-size: cover; -o-background-size: cover;
overflow: hidden;
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; margin: 5px 22px;
border-radius: 15px; border-radius: 15px;
min-height: 600px; min-height: calc(100vh - 24vh);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); height: auto;
position: relative; max-height: 80vh;
background: rgba(237, 242, 255, 0.5); 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 { .operation-bar {
@@ -331,14 +382,20 @@ export default {
flex: 1; flex: 1;
height: 100%; height: 100%;
min-width: 600px; min-width: 600px;
overflow-x: auto; overflow: auto;
background-color: white; background-color: white;
display: flex;
flex-direction: column;
} }
.params-card { .params-card {
background: white; background: white;
border: none; border: none;
box-shadow: none; box-shadow: none;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
} }
.table_bottom { .table_bottom {
@@ -346,6 +403,7 @@ export default {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 10px; margin-top: 10px;
padding-bottom: 10px;
} }
.ctrl_btn { .ctrl_btn {
@@ -382,29 +440,19 @@ export default {
} }
} }
.copyright {
text-align: center;
color: #979db1;
font-size: 12px;
font-weight: 400;
margin-top: auto;
padding: 30px 0 20px;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
}
.custom-pagination { .custom-pagination {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
margin-top: 15px;
.el-select {
margin-right: 8px;
}
.pagination-btn:first-child, .pagination-btn:first-child,
.pagination-btn:nth-child(2), .pagination-btn:nth-child(2),
.pagination-btn:nth-last-child(2) { .pagination-btn:nth-last-child(2),
.pagination-btn:nth-child(3) {
min-width: 60px; min-width: 60px;
height: 32px; height: 32px;
padding: 0 12px; padding: 0 12px;
@@ -426,7 +474,7 @@ export default {
} }
} }
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-last-child(2)) { .pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
min-width: 28px; min-width: 28px;
height: 32px; height: 32px;
padding: 0; padding: 0;
@@ -462,7 +510,19 @@ export default {
:deep(.transparent-table) { :deep(.transparent-table) {
background: white; 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 { .el-table__header th {
background: white !important; background: white !important;
color: black; color: black;
@@ -482,19 +542,6 @@ export default {
} }
} }
:deep(.custom-selection-header) {
.el-checkbox {
display: none !important;
}
&::after {
content: "选择";
display: inline-block;
color: black;
font-weight: bold;
padding-bottom: 18px;
}
}
:deep(.el-checkbox__inner) { :deep(.el-checkbox__inner) {
background-color: #eeeeee !important; background-color: #eeeeee !important;
@@ -550,4 +597,63 @@ export default {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.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(.el-table) {
.el-table__body-wrapper {
transition: height 0.3s ease;
}
}
.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);
}
}
</style> </style>
+135 -74
View File
@@ -15,9 +15,12 @@
<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">
:header-cell-class-name="headerCellClassName"> <el-table-column label="选择" align="center" width="120">
<el-table-column label="选择" type="selection" align="center" width="120"></el-table-column> <template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</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>
<el-table-column label="设备数量" prop="deviceCount" align="center"></el-table-column> <el-table-column label="设备数量" prop="deviceCount" align="center"></el-table-column>
@@ -42,7 +45,7 @@
<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="handleSelectAll"> <el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
{{ isAllSelected ? '取消全选' : '全选' }} {{ isAllSelected ? '取消全选' : '全选' }}
</el-button> </el-button>
<el-button size="mini" type="success" icon="el-icon-circle-check" @click="batchEnable">启用</el-button> <el-button size="mini" type="success" icon="el-icon-circle-check" @click="batchEnable">启用</el-button>
<el-button size="mini" type="warning" @click="batchDisable"><i <el-button size="mini" type="warning" @click="batchDisable"><i
@@ -50,6 +53,15 @@
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">删除</el-button> <el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">删除</el-button>
</div> </div>
<div class="custom-pagination"> <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 class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
首页 首页
</button> </button>
@@ -70,7 +82,6 @@
</div> </div>
</div> </div>
<div class="copyright">©2025 xiaozhi-esp32-server</div>
<view-password-dialog :visible.sync="showViewPassword" :password="currentPassword" /> <view-password-dialog :visible.sync="showViewPassword" :password="currentPassword" />
</div> </div>
</template> </template>
@@ -88,8 +99,9 @@ export default {
currentPassword: "", currentPassword: "",
searchPhone: "", searchPhone: "",
userList: [], userList: [],
pageSizeOptions: [10, 20, 50, 100],
currentPage: 1, currentPage: 1,
pageSize: 5, pageSize: 10,
total: 0, total: 0,
isAllSelected: false isAllSelected: false
}; };
@@ -118,35 +130,42 @@ export default {
}, },
}, },
methods: { methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.fetchUsers();
},
fetchUsers() { fetchUsers() {
Api.admin.getUserList( Api.admin.getUserList(
{ {
page: this.currentPage, page: this.currentPage,
limit: this.pageSize, limit: this.pageSize,
mobile: this.searchPhone, mobile: this.searchPhone,
}, },
({ data }) => { ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.userList = data.data.list this.userList = data.data.list.map(item => ({
this.total = data.data.total; ...item,
} selected: false
} }));
); this.total = data.data.total;
}
}
);
}, },
handleSearch() { handleSearch() {
this.currentPage = 1; this.currentPage = 1;
this.fetchUsers(); this.fetchUsers();
}, },
handleSelectAll() { handleSelectAll() {
if (this.isAllSelected) { this.isAllSelected = !this.isAllSelected;
this.$refs.userTable.clearSelection(); this.userList.forEach(row => {
} else { row.selected = this.isAllSelected;
this.$refs.userTable.toggleAllSelection(); });
}
this.isAllSelected = !this.isAllSelected;
}, },
batchDelete() { batchDelete() {
const selectedUsers = this.$refs.userTable.selection; const selectedUsers = this.userList.filter(user => user.selected);
if (selectedUsers.length === 0) { if (selectedUsers.length === 0) {
this.$message.warning("请先选择需要删除的用户"); this.$message.warning("请先选择需要删除的用户");
return; return;
@@ -211,20 +230,12 @@ export default {
}); });
}, },
batchEnable() { batchEnable() {
const selectedUsers = this.$refs.userTable.selection; const selectedUsers = this.userList.filter(user => user.selected);
if (selectedUsers.length === 0) { this.handleChangeStatus(selectedUsers, 1);
this.$message.warning("请先选择需要启用的用户");
return;
}
this.handleChangeStatus(selectedUsers, 1);
}, },
batchDisable() { batchDisable() {
const selectedUsers = this.$refs.userTable.selection; const selectedUsers = this.userList.filter(user => user.selected);
if (selectedUsers.length === 0) { this.handleChangeStatus(selectedUsers, 0);
this.$message.warning("请先选择需要禁用的用户");
return;
}
this.handleChangeStatus(selectedUsers, 0);
}, },
resetPassword(row) { resetPassword(row) {
this.$confirm("重置后将会生成新密码,是否继续?", "提示", { this.$confirm("重置后将会生成新密码,是否继续?", "提示", {
@@ -267,12 +278,6 @@ export default {
}) })
.catch(() => { }); .catch(() => { });
}, },
headerCellClassName({ columnIndex }) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
},
goFirst() { goFirst() {
this.currentPage = 1; this.currentPage = 1;
this.fetchUsers(); this.fetchUsers();
@@ -344,15 +349,20 @@ export default {
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center; background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover; -webkit-background-size: cover;
-o-background-size: cover; -o-background-size: cover;
overflow: hidden;
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; margin: 5px 22px;
border-radius: 15px; border-radius: 15px;
min-height: 600px; min-height: calc(100vh - 24vh);
height: auto;
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;
flex-direction: column;
} }
.operation-bar { .operation-bar {
@@ -399,12 +409,18 @@ export default {
min-width: 600px; min-width: 600px;
overflow-x: auto; overflow-x: auto;
background-color: white; background-color: white;
display: flex;
flex-direction: column;
} }
.user-card { .user-card {
background: white; background: white;
border: none; border: none;
box-shadow: none; box-shadow: none;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
} }
.table_bottom { .table_bottom {
@@ -465,28 +481,18 @@ export default {
color: black; color: black;
} }
.copyright {
text-align: center;
color: #979db1;
font-size: 12px;
font-weight: 400;
margin-top: auto;
padding: 30px 0 20px;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
}
.custom-pagination { .custom-pagination {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-top: 15px;
.el-select {
margin-right: 8px;
}
.pagination-btn:first-child, .pagination-btn:first-child,
.pagination-btn:nth-child(2), .pagination-btn:nth-child(2),
.pagination-btn:nth-child(3),
.pagination-btn:nth-last-child(2) { .pagination-btn:nth-last-child(2) {
min-width: 60px; min-width: 60px;
height: 32px; height: 32px;
@@ -509,7 +515,7 @@ export default {
} }
} }
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-last-child(2)) { .pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-child(3)):not(:nth-last-child(2)) {
min-width: 28px; min-width: 28px;
height: 32px; height: 32px;
padding: 0; padding: 0;
@@ -545,6 +551,19 @@ export default {
:deep(.transparent-table) { :deep(.transparent-table) {
background: white; 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 { .el-table__header th {
background: white !important; background: white !important;
@@ -573,19 +592,6 @@ export default {
color: #5a64b5 !important; color: #5a64b5 !important;
} }
:deep(.custom-selection-header) {
.el-checkbox {
display: none !important;
}
&::after {
content: "选择";
display: inline-block;
color: black;
font-weight: bold;
padding-bottom: 18px;
}
}
:deep(.el-checkbox__inner) { :deep(.el-checkbox__inner) {
background-color: #eeeeee !important; background-color: #eeeeee !important;
@@ -622,4 +628,59 @@ export default {
} }
} }
} }
.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;
}
}
.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);
}
}
</style> </style>
+11 -7
View File
@@ -19,13 +19,13 @@
<div class="hi-hint"> <div class="hi-hint">
Hello, Let's have a wonderful day! Hello, Let's have a wonderful day!
</div> </div>
<div class="add-device-btn" @click="showAddDialog"> <div class="add-device-btn">
<div class="left-add"> <div class="left-add" @click="showAddDialog">
添加智能体 添加智能体
</div> </div>
<div style="width: 23px;height: 13px;background: #5778ff;margin-left: -10px;" /> <div style="width: 23px;height: 13px;background: #5778ff;margin-left: -10px;" />
<div class="right-add"> <div class="right-add">
<i class="el-icon-right" style="font-size: 20px;color: #fff;" /> <i class="el-icon-right" @click="showAddDialog" style="font-size: 20px;color: #fff;" />
</div> </div>
</div> </div>
</div> </div>
@@ -35,11 +35,11 @@
@deviceManage="handleDeviceManage" @delete="handleDeleteAgent" /> @deviceManage="handleDeviceManage" @delete="handleDeleteAgent" />
</div> </div>
</div> </div>
<div class="copyright">
©2025 xiaozhi-esp32-server
</div>
<AddWisdomBodyDialog :visible.sync="addDeviceDialogVisible" @confirm="handleWisdomBodyAdded" /> <AddWisdomBodyDialog :visible.sync="addDeviceDialogVisible" @confirm="handleWisdomBodyAdded" />
</el-main> </el-main>
<el-footer>
<version-footer />
</el-footer>
</div> </div>
</template> </template>
@@ -49,10 +49,11 @@ import Api from '@/apis/api';
import AddWisdomBodyDialog from '@/components/AddWisdomBodyDialog.vue'; import AddWisdomBodyDialog from '@/components/AddWisdomBodyDialog.vue';
import DeviceItem from '@/components/DeviceItem.vue'; import DeviceItem from '@/components/DeviceItem.vue';
import HeaderBar from '@/components/HeaderBar.vue'; import HeaderBar from '@/components/HeaderBar.vue';
import VersionFooter from '@/components/VersionFooter.vue';
export default { export default {
name: 'HomePage', name: 'HomePage',
components: { DeviceItem, AddWisdomBodyDialog, HeaderBar }, components: { DeviceItem, AddWisdomBodyDialog, HeaderBar, VersionFooter },
data() { data() {
return { return {
addDeviceDialogVisible: false, addDeviceDialogVisible: false,
@@ -260,4 +261,7 @@ export default {
text-align: center; text-align: center;
/* 居中显示 */ /* 居中显示 */
} }
</style> </style>
+14 -6
View File
@@ -38,7 +38,7 @@
</div> </div>
<div <div
style="font-weight: 400;font-size: 14px;text-align: left;color: #5778ff;display: flex;justify-content: space-between;margin-top: 20px;"> style="font-weight: 400;font-size: 14px;text-align: left;color: #5778ff;display: flex;justify-content: space-between;margin-top: 20px;">
<div style="cursor: pointer;" @click="goToRegister">新用户注册</div> <div v-if="allowUserRegister" style="cursor: pointer;" @click="goToRegister">新用户注册</div>
</div> </div>
</div> </div>
<div class="login-btn" @click="login">登录</div> <div class="login-btn" @click="login">登录</div>
@@ -51,9 +51,7 @@
</div> </div>
</el-main> </el-main>
<el-footer> <el-footer>
<div class="copyright"> <version-footer />
©2025 xiaozhi-esp32-server
</div>
</el-footer> </el-footer>
</el-container> </el-container>
</div> </div>
@@ -61,11 +59,20 @@
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
import VersionFooter from '@/components/VersionFooter.vue';
import { getUUID, goToPage, showDanger, showSuccess } from '@/utils'; import { getUUID, goToPage, showDanger, showSuccess } from '@/utils';
import { mapState } from 'vuex';
export default { export default {
name: 'login', name: 'login',
components: {
VersionFooter
},
computed: {
...mapState({
allowUserRegister: state => state.pubConfig.allowUserRegister
})
},
data() { data() {
return { return {
activeName: "username", activeName: "username",
@@ -81,6 +88,7 @@ export default {
}, },
mounted() { mounted() {
this.fetchCaptcha(); this.fetchCaptcha();
this.$store.dispatch('fetchPubConfig');
}, },
methods: { methods: {
fetchCaptcha() { fetchCaptcha() {
@@ -144,7 +152,7 @@ export default {
goToRegister() { goToRegister() {
goToPage('/register') goToPage('/register')
} },
} }
} }
</script> </script>
+20 -4
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="welcome"> <div class="welcome" @keyup.enter="register">
<el-container style="height: 100%;"> <el-container style="height: 100%;">
<!-- 保持相同的头部 --> <!-- 保持相同的头部 -->
<el-header> <el-header>
@@ -72,9 +72,7 @@
<!-- 保持相同的页脚 --> <!-- 保持相同的页脚 -->
<el-footer> <el-footer>
<div class="copyright"> <version-footer />
©2025 xiaozhi-esp32-server
</div>
</el-footer> </el-footer>
</el-container> </el-container>
</div> </div>
@@ -82,10 +80,20 @@
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
import VersionFooter from '@/components/VersionFooter.vue';
import { getUUID, goToPage, showDanger, showSuccess } from '@/utils'; import { getUUID, goToPage, showDanger, showSuccess } from '@/utils';
import { mapState } from 'vuex';
export default { export default {
name: 'register', name: 'register',
components: {
VersionFooter
},
computed: {
...mapState({
allowUserRegister: state => state.pubConfig.allowUserRegister
})
},
data() { data() {
return { return {
form: { form: {
@@ -99,6 +107,14 @@ export default {
} }
}, },
mounted() { mounted() {
this.$store.dispatch('fetchPubConfig').then(() => {
if (!this.allowUserRegister) {
showDanger('当前不允许普通用户注册');
setTimeout(() => {
goToPage('/login');
}, 1500);
}
});
this.fetchCaptcha(); this.fetchCaptcha();
}, },
methods: { methods: {
+285 -165
View File
@@ -1,90 +1,126 @@
<template> <template>
<div class="welcome"> <div class="welcome">
<HeaderBar /> <HeaderBar/>
<el-main style="padding: 16px;display: flex;flex-direction: column;">
<div style="border-radius: 16px;background: #fafcfe; border: 1px solid #e8f0ff;"> <div class="operation-bar">
<div <h2 class="page-title">角色配置</h2>
style="padding: 15px 24px;font-weight: 700;font-size: 19px;text-align: left;color: #3d4566;display: flex;gap: 13px;align-items: center;"> </div>
<div
style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;"> <div class="main-wrapper">
<img loading="lazy" src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;" /> <div class="content-panel">
</div> <div class="content-area">
{{ form.agentName }} <el-card class="config-card" shadow="never">
</div> <div class="config-header">
<div style="height: 1px;background: #e8f0ff;" /> <div class="header-icon">
<el-form ref="form" :model="form" label-width="72px"> <img loading="lazy" src="@/assets/home/setting-user.png" alt="">
<div style="padding: 16px 24px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px;">
<div>
<el-form-item label="助手昵称:">
<div class="input-46" style="width: 100%;">
<el-input v-model="form.agentName" />
</div>
</el-form-item>
<el-form-item label="角色模版:">
<div style="display: flex;gap: 8px;flex-wrap: wrap;">
<div v-for="(template, index) in templates" :key="`template-${index}`" class="template-item"
:class="{ 'template-loading': loadingTemplate }" @click="selectTemplate(template)">
{{ template.agentName }}
</div>
</div>
</el-form-item>
<el-form-item label="角色介绍:">
<div class="textarea-box">
<el-input type="textarea" rows="5" resize="none" placeholder="请输入内容" v-model="form.systemPrompt"
maxlength="2000" show-word-limit />
</div>
</el-form-item>
<el-form-item label="语言编码:">
<div class="input-46" style="width: 100%;">
<el-input v-model="form.langCode" placeholder="请输入语言编码,如:zh_CN" maxlength="10" show-word-limit />
</div>
</el-form-item>
<el-form-item label="交互语种:">
<div class="input-46" style="width: 100%;">
<el-input v-model="form.language" placeholder="请输入交互语种,如:中文" maxlength="10" show-word-limit />
</div>
</el-form-item>
</div>
<div>
<el-form-item v-for="(model, index) in models" :key="`model-${index}`" :label="model.label"
class="model-item">
<el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="select-field">
<el-option v-for="(item, optionIndex) in modelOptions[model.type]"
:key="`option-${index}-${optionIndex}`" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="角色音色:">
<div style="display: flex;gap: 8px;align-items: center;">
<div class="input-46" style="width: 100%;">
<el-select v-model="form.ttsVoiceId" placeholder="请选择" style="width: 100%;">
<el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label"
:value="item.value" />
</el-select>
</div>
</div>
</el-form-item>
</div> </div>
<span class="header-title">{{ form.agentName }}</span>
</div> </div>
</div> <div class="divider"></div>
</el-form>
<div style="display: flex;padding: 16px;gap: 8px;align-items: center;"> <el-form ref="form" :model="form" label-width="72px">
<div class="save-btn" @click="saveConfig"> <div class="form-content">
保存配置 <div class="form-grid">
</div> <div class="form-column">
<div class="reset-btn" @click="resetConfig"> <el-form-item label="助手昵称:">
重制 <el-input v-model="form.agentName" class="form-input"/>
</div> </el-form-item>
<div class="clear-text"> <el-form-item label="角色模版:">
<img loading="lazy" src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;" /> <div class="template-container">
保存配置后需要重启设备新的配置才会生效 <div
</div> v-for="(template, index) in templates"
:key="`template-${index}`"
class="template-item"
:class="{ 'template-loading': loadingTemplate }"
@click="selectTemplate(template)"
>
{{ template.agentName }}
</div>
</div>
</el-form-item>
<el-form-item label="角色介绍:">
<el-input
type="textarea"
rows="5"
resize="none"
placeholder="请输入内容"
v-model="form.systemPrompt"
maxlength="2000"
show-word-limit
class="form-textarea"
/>
</el-form-item>
<el-form-item label="语言编码:">
<el-input
v-model="form.langCode"
placeholder="请输入语言编码,如:zh_CN"
maxlength="10"
show-word-limit
class="form-input"
/>
</el-form-item>
<el-form-item label="交互语种:">
<el-input
v-model="form.language"
placeholder="请输入交互语种,如:中文"
maxlength="10"
show-word-limit
class="form-input"
/>
</el-form-item>
<div class="action-bar">
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
<el-button class="reset-btn" @click="resetConfig">重置</el-button>
<div class="hint-text">
<img loading="lazy" src="@/assets/home/red-info.png" alt="">
<span>保存配置后需要重启设备新的配置才会生效</span>
</div>
</div>
</div>
<div class="form-column">
<el-form-item
v-for="(model, index) in models"
:key="`model-${index}`"
:label="model.label"
class="model-item"
>
<el-select
v-model="form.model[model.key]"
filterable
placeholder="请选择"
class="form-select"
>
<el-option
v-for="(item, optionIndex) in modelOptions[model.type]"
:key="`option-${index}-${optionIndex}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="角色音色:">
<el-select
v-model="form.ttsVoiceId"
placeholder="请选择"
class="form-select"
>
<el-option
v-for="(item, index) in voiceOptions"
:key="`voice-${index}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</div>
</div>
</div>
</el-form>
</el-card>
</div> </div>
</div> </div>
<div class="copyright"> </div>
©2025 xiaozhi-esp32-server
</div>
</el-main>
</div> </div>
</template> </template>
@@ -92,10 +128,9 @@
import Api from '@/apis/api'; import Api from '@/apis/api';
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
export default { export default {
name: 'RoleConfigPage', name: 'RoleConfigPage',
components: { HeaderBar }, components: {HeaderBar},
data() { data() {
return { return {
form: { form: {
@@ -116,12 +151,12 @@ export default {
} }
}, },
models: [ models: [
{ label: '语音活动检测(VAD)', key: 'vadModelId', type: 'VAD' }, {label: '语音活动检测(VAD)', key: 'vadModelId', type: 'VAD'},
{ label: '语音识别(ASR)', key: 'asrModelId', type: 'ASR' }, {label: '语音识别(ASR)', key: 'asrModelId', type: 'ASR'},
{ label: '大语言模型(LLM)', key: 'llmModelId', type: 'LLM' }, {label: '大语言模型(LLM)', key: 'llmModelId', type: 'LLM'},
{ label: '意图识别(Intent)', key: 'intentModelId', type: 'Intent' }, {label: '意图识别(Intent)', key: 'intentModelId', type: 'Intent'},
{ label: '记忆(Memory)', key: 'memModelId', type: 'Memory' }, {label: '记忆(Memory)', key: 'memModelId', type: 'Memory'},
{ label: '语音合成(TTS)', key: 'ttsModelId', type: 'TTS' }, {label: '语音合成(TTS)', key: 'ttsModelId', type: 'TTS'},
], ],
modelOptions: {}, modelOptions: {},
templates: [], templates: [],
@@ -146,7 +181,7 @@ export default {
language: this.form.language, language: this.form.language,
sort: this.form.sort sort: this.form.sort
}; };
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => { Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({data}) => {
if (data.code === 0) { if (data.code === 0) {
this.$message.success({ this.$message.success({
message: '配置保存成功', message: '配置保存成功',
@@ -166,7 +201,6 @@ export default {
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'
}).then(() => { }).then(() => {
// 重置表单
this.form = { this.form = {
agentCode: "", agentCode: "",
agentName: "", agentName: "",
@@ -189,10 +223,10 @@ export default {
showClose: true showClose: true
}) })
}).catch(() => { }).catch(() => {
}) });
}, },
fetchTemplates() { fetchTemplates() {
Api.agent.getAgentTemplate(({ data }) => { Api.agent.getAgentTemplate(({data}) => {
if (data.code === 0) { if (data.code === 0) {
this.templates = data.data; this.templates = data.data;
} else { } else {
@@ -237,7 +271,7 @@ export default {
}; };
}, },
fetchAgentConfig(agentId) { fetchAgentConfig(agentId) {
Api.agent.getDeviceConfig(agentId, ({ data }) => { Api.agent.getDeviceConfig(agentId, ({data}) => {
if (data.code === 0) { if (data.code === 0) {
this.form = { this.form = {
...this.form, ...this.form,
@@ -257,9 +291,8 @@ export default {
}); });
}, },
fetchModelOptions() { fetchModelOptions() {
// 为每个模型类型获取选项
this.models.forEach(model => { this.models.forEach(model => {
Api.model.getModelNames(model.type, '', ({ data }) => { Api.model.getModelNames(model.type, '', ({data}) => {
if (data.code === 0) { if (data.code === 0) {
this.$set(this.modelOptions, model.type, data.data.map(item => ({ this.$set(this.modelOptions, model.type, data.data.map(item => ({
value: item.id, value: item.id,
@@ -276,7 +309,7 @@ export default {
this.voiceOptions = []; this.voiceOptions = [];
return; return;
} }
Api.model.getModelVoices(modelId, '', ({ data }) => { Api.model.getModelVoices(modelId, '', ({data}) => {
if (data.code === 0 && data.data) { if (data.code === 0 && data.data) {
this.voiceOptions = data.data.map(voice => ({ this.voiceOptions = data.data.map(voice => ({
value: voice.id, value: voice.id,
@@ -291,7 +324,6 @@ export default {
watch: { watch: {
'form.model.ttsModelId': { 'form.model.ttsModelId': {
handler(newVal, oldVal) { handler(newVal, oldVal) {
console.log('TTS模型变化:', newVal);
if (oldVal && newVal !== oldVal) { if (oldVal && newVal !== oldVal) {
this.form.ttsVoiceId = ''; this.form.ttsVoiceId = '';
this.fetchVoiceOptions(newVal); this.fetchVoiceOptions(newVal);
@@ -327,60 +359,131 @@ export default {
min-height: 506px; min-height: 506px;
height: 100vh; height: 100vh;
display: flex; display: flex;
position: relative;
flex-direction: column; flex-direction: column;
background: linear-gradient(145deg, #e6eeff, #eff0ff); background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd);
background-size: cover; background-size: cover;
/* 确保背景图像覆盖整个元素 */
background-position: center;
/* 从顶部中心对齐 */
-webkit-background-size: cover; -webkit-background-size: cover;
/* 兼容老版本WebKit浏览器 */
-o-background-size: cover; -o-background-size: cover;
/* 兼容老版本Opera浏览器 */ overflow: hidden;
} }
.el-form-item ::v-deep .el-form-item__label { .operation-bar {
font-size: 10px !important; display: flex;
color: #3d4566 !important; justify-content: space-between;
font-weight: 400; align-items: center;
line-height: 22px; padding: 16px 24px;
padding-bottom: 2px;
} }
.select-field { .page-title {
width: 100%; font-size: 24px;
max-width: 720px; margin: 0;
border: 1px solid #e4e6ef; color: #2c3e50;
background: #f6f8fb;
border-radius: 8px;
height: 36px !important;
} }
.audio-box { .main-wrapper {
margin: 5px 22px;
border-radius: 15px;
height: calc(100vh - 24vh);
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;
}
.content-panel {
flex: 1; flex: 1;
height: 37px; display: flex;
border-radius: 20px; overflow: hidden;
border: 1px solid #e4e6ef; height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
} }
.clear-btn { .content-area {
width: 48px; flex: 1;
height: 19px; height: 100%;
background: #fd8383; min-width: 600px;
border-radius: 10px; overflow: auto;
line-height: 19px; background-color: white;
font-size: 11px; display: flex;
color: #fff; flex-direction: column;
cursor: pointer;
} }
.clear-text { .config-card {
color: #979db1; background: white;
font-size: 11px; border: none;
box-shadow: none;
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
}
.config-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 13px;
padding: 0 0 5px 0;
font-weight: 700;
font-size: 19px;
color: #3d4566;
}
.header-icon {
width: 37px;
height: 37px;
background: #5778ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.header-icon img {
width: 19px;
height: 19px;
}
.divider {
height: 1px;
background: #e8f0ff;
}
.form-content {
padding: 20px 0;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-column {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-input {
width: 100%;
}
.form-select {
width: 100%;
}
.form-textarea {
width: 100%;
}
.template-container {
display: flex;
gap: 8px; gap: 8px;
margin-left: 16px; flex-wrap: wrap;
} }
.template-item { .template-item {
@@ -401,47 +504,64 @@ export default {
background-color: #d0d8ff; background-color: #d0d8ff;
} }
.prompt-bottom { .action-bar {
margin-bottom: 4px;
display: flex; display: flex;
justify-content: space-between; flex-wrap: wrap;
padding: 0 16px; gap: 8px;
margin-top: 20px;
align-items: center; align-items: center;
} }
.input-46 { .action-bar {
border: 1px solid #e4e6ef; .el-button.save-btn {
background: #f6f8fb; background: #5778ff;
border-radius: 8px; color: white;
height: 36px !important; border: none;
border-radius: 18px;
padding: 10px 20px;
width: 100px;
height: 35px;
font-size: 14px;
}
.el-button.reset-btn {
background: #e6ebff;
color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 10px 20px;
}
} }
.save-btn, .hint-text {
.reset-btn { display: flex;
width: 112px; align-items: center;
height: 37px; gap: 8px;
border-radius: 18px; color: #979db1;
line-height: 37px; font-size: 11px;
box-sizing: border-box; margin-left: 16px;
cursor: pointer;
font-size: 11px
} }
.save-btn { .hint-text img {
border-radius: 18px; width: 19px;
background: #5778ff; height: 19px;
color: #fff;
} }
.reset-btn { ::v-deep .el-form-item__label {
border: 1px solid #adbdff; font-size: 10px !important;
background: #e6ebff; color: #3d4566 !important;
color: #5778ff; font-weight: 400;
line-height: 22px;
padding-bottom: 2px;
} }
.textarea-box { ::v-deep .el-textarea .el-input__count {
border: 1px solid #e4e6ef; color: #909399;
border-radius: 8px; background: none;
background: #f6f8fb; position: absolute;
font-size: 12px;
bottom: -10px;
right: 21px;
} }
</style>
</style>
+75 -31
View File
@@ -6,15 +6,47 @@ const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin') const CompressionPlugin = require('compression-webpack-plugin')
// BundleAnalyzerPlugin 用于分析打包后的文件 // BundleAnalyzerPlugin 用于分析打包后的文件
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
// WorkboxPlugin 用于生成Service Worker
const { InjectManifest } = require('workbox-webpack-plugin');
// 引入 path 模块 // 引入 path 模块
const path = require('path');
const path = require('path')
function resolve(dir) {
return path.join(__dirname, dir)
}
// 确保加载 .env 文件 // 确保加载 .env 文件
dotenv.config(); dotenv.config();
// 定义CDN资源列表,确保Service Worker也能访问
const cdnResources = {
css: [
'https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
],
js: [
'https://unpkg.com/vue@2.6.14/dist/vue.min.js',
'https://unpkg.com/vue-router@3.6.5/dist/vue-router.min.js',
'https://unpkg.com/vuex@3.6.2/dist/vuex.min.js',
'https://unpkg.com/element-ui@2.15.14/lib/index.js',
'https://unpkg.com/axios@0.27.2/dist/axios.min.js',
'https://unpkg.com/opus-decoder@0.7.7/dist/opus-decoder.min.js'
]
};
// 判断是否使用CDN
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' ? false : true, // 生产环境不生成 source map
devServer: { devServer: {
port: 8001, // 指定端口为 8001 port: 8001, // 指定端口为 8001
proxy: { proxy: {
'/xiaozhi': {
target: 'http://127.0.0.1:8002',
changeOrigin: true
}
}, },
client: { client: {
overlay: false, // 不显示 webpack 错误覆盖层 overlay: false, // 不显示 webpack 错误覆盖层
@@ -25,21 +57,9 @@ module.exports = defineConfig({
// 修改 HTML 插件配置,动态插入 CDN 链接 // 修改 HTML 插件配置,动态插入 CDN 链接
config.plugin('html') config.plugin('html')
.tap(args => { .tap(args => {
if (process.env.NODE_ENV === 'production') { // 根据配置决定是否使用CDN
args[0].cdn = { if (process.env.NODE_ENV === 'production' && useCDN) {
css: [ args[0].cdn = cdnResources;
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/theme-chalk/index.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
],
js: [
'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js',
'https://cdn.jsdelivr.net/npm/vue-router@3.6.5/dist/vue-router.min.js',
'https://cdn.jsdelivr.net/npm/vuex@3.6.2/dist/vuex.min.js',
'https://cdn.jsdelivr.net/npm/element-ui@2.15.14/lib/index.js',
'https://cdn.jsdelivr.net/npm/axios@0.27.2/dist/axios.min.js',
'https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/dist/opus-decoder.min.js'
]
};
} }
return args; return args;
}); });
@@ -97,14 +117,38 @@ module.exports = defineConfig({
minRatio: 0.8 minRatio: 0.8
}) })
); );
config.externals = {
'vue': 'Vue', // 根据是否使用CDN来决定是否添加Service Worker
'vue-router': 'VueRouter', config.plugins.push(
'vuex': 'Vuex', new InjectManifest({
'element-ui': 'ELEMENT', swSrc: path.resolve(__dirname, 'src/service-worker.js'),
'axios': 'axios', swDest: 'service-worker.js',
'opus-decoder': 'OpusDecoder' exclude: [/\.map$/, /asset-manifest\.json$/],
}; maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
// 自定义Service Worker注入点
injectionPoint: 'self.__WB_MANIFEST',
// 添加额外信息传递给Service Worker
additionalManifestEntries: useCDN ?
[{ url: 'cdn-mode', revision: 'enabled' }] :
[{ url: 'cdn-mode', revision: 'disabled' }]
})
);
// 如果使用CDN,则配置externals排除依赖包
if (useCDN) {
config.externals = {
'vue': 'Vue',
'vue-router': 'VueRouter',
'vuex': 'Vuex',
'element-ui': 'ELEMENT',
'axios': 'axios',
'opus-decoder': 'OpusDecoder'
};
} else {
// 确保不使用CDN时不设置externals,让webpack打包所有依赖
config.externals = {};
}
if (process.env.ANALYZE === 'true') { // 通过环境变量控制 if (process.env.ANALYZE === 'true') { // 通过环境变量控制
config.plugins.push( config.plugins.push(
new BundleAnalyzerPlugin({ new BundleAnalyzerPlugin({
@@ -124,13 +168,13 @@ module.exports = defineConfig({
config: [__filename] // 每次配置文件修改时缓存失效 config: [__filename] // 每次配置文件修改时缓存失效
} }
}; };
config.resolve.alias = {
'@': path.resolve(__dirname, 'src'), // 让 '@' 代表 'src' 目录
'@assets': path.resolve(__dirname, 'src/assets'), // 设置 '@assets' 为 'src/assets' 目录
'@components': path.resolve(__dirname, 'src/components'), // 设置 '@components' 为 'src/components' 目录
'@views': path.resolve(__dirname, 'src/views'), // 设置 '@views' 为 'src/views' 目录
}
} }
}, },
// 将CDN资源信息暴露给service-worker.js
pwa: {
workboxOptions: {
skipWaiting: true,
clientsClaim: true
}
}
}); });
+2 -2
View File
@@ -147,7 +147,7 @@ selected_module:
Memory: nomem Memory: nomem
# 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。 # 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
# 不想开通意图识别,就设置成:nointent # 不想开通意图识别,就设置成:nointent
# 意图识别可使用intent_llm,如果你的LLM是DifyLLM或CozeLLM,建议使用这个。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作 # 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作
# 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令 # 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令
# 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-pro-32k-functioncall-241028 # 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-pro-32k-functioncall-241028
Intent: function_call Intent: function_call
@@ -163,7 +163,7 @@ Intent:
type: intent_llm type: intent_llm
# 配备意图识别独立的思考模型 # 配备意图识别独立的思考模型
# 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型 # 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型
# 如果你的selected_module.LLM选择了DifyLLM或CozeLLM,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM # 如果你的不想使用selected_module.LLM意图识别,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
llm: ChatGLMLLM llm: ChatGLMLLM
function_call: function_call:
# 不需要动type # 不需要动type
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+12 -78
View File
@@ -1,8 +1,8 @@
import os import os
import argparse import argparse
import requests
import yaml import yaml
import time from config.manage_api_client import init_service, get_server_config, get_agent_models
# 添加全局配置缓存 # 添加全局配置缓存
_config_cache = None _config_cache = None
@@ -54,93 +54,27 @@ def get_config_file():
return config_file return config_file
def _make_api_request(api_url, secret, endpoint, json_data=None):
"""执行API请求的通用函数
Args:
api_url: API的基础URL
secret: API密钥
endpoint: API端点
json_data: 请求的JSON数据
Returns:
dict: API返回的数据
Raises:
Exception: 当请求失败时抛出异常
"""
if not api_url or not secret:
raise Exception("manager-api的url或secret配置错误")
if "" in secret:
raise Exception("请先配置manager-api的secret")
max_retries = 10
retry_delay = 2 # 秒
for attempt in range(max_retries):
try:
response = requests.post(f"{api_url}{endpoint}", json=json_data)
if response.status_code == 200:
result = response.json()
if result.get("code") != 0:
raise Exception(f"API返回错误: {result.get('msg', '未知错误')}")
return result.get("data")
error_msg = f"manager-api请求失败,状态码: {response.status_code}"
try:
error_data = response.json()
if "msg" in error_data:
error_msg = f"{error_msg}, 错误信息: {error_data['msg']}"
except:
error_msg = f"{error_msg}, 响应内容: {response.text}"
if attempt < max_retries - 1:
print(f"请求manager-api失败,正在重试 ({attempt + 1}/{max_retries})...")
time.sleep(retry_delay)
else:
raise Exception(error_msg)
except requests.exceptions.RequestException as e:
if attempt < max_retries - 1:
print(f"请求manager-api异常,正在重试 ({attempt + 1}/{max_retries})...")
time.sleep(retry_delay)
else:
raise Exception(f"manager-api请求异常: {str(e)}")
def get_config_from_api(config): def get_config_from_api(config):
"""从Java API获取配置""" """从Java API获取配置"""
api_url = config["manager-api"].get("url", "") # 初始化API客户端
secret = config["manager-api"].get("secret", "") init_service(config)
# 获取服务器配置
config_data = get_server_config()
if config_data is None:
raise Exception("Failed to fetch server config from API")
config_data = _make_api_request(
api_url, secret, "/config/server-base", {"secret": secret}
)
config_data["read_config_from_api"] = True config_data["read_config_from_api"] = True
config_data["manager-api"] = { config_data["manager-api"] = {
"url": api_url, "url": config["manager-api"].get("url", ""),
"secret": secret, "secret": config["manager-api"].get("secret", ""),
} }
return config_data return config_data
def get_private_config_from_api(config, device_id, client_id): def get_private_config_from_api(config, device_id, client_id):
"""从Java API获取私有配置""" """从Java API获取私有配置"""
api_url = config["manager-api"].get("url", "") return get_agent_models(device_id, client_id, config["selected_module"])
secret = config["manager-api"].get("secret", "")
return _make_api_request(
api_url,
secret,
"/config/agent-models",
{
"secret": secret,
"macAddress": device_id,
"clientId": client_id,
"selectedModule": config["selected_module"],
},
)
def ensure_directories(config): def ensure_directories(config):
+14 -3
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.1" SERVER_VERSION = "0.3.8"
def get_module_abbreviation(module_name, module_dict): def get_module_abbreviation(module_name, module_dict):
@@ -25,6 +25,12 @@ def build_module_string(selected_module):
) )
def formatter(record):
"""为没有 tag 的日志添加默认值"""
record["extra"].setdefault("tag", record["name"])
return record["message"]
def setup_logging(): def setup_logging():
"""从配置文件中读取日志配置,并设置日志输出格式和级别""" """从配置文件中读取日志配置,并设置日志输出格式和级别"""
config = load_config() config = load_config()
@@ -56,9 +62,14 @@ def setup_logging():
logger.remove() logger.remove()
# 输出到控制台 # 输出到控制台
logger.add(sys.stdout, format=log_format, level=log_level) logger.add(sys.stdout, format=log_format, level=log_level, filter=formatter)
# 输出到文件 # 输出到文件
logger.add(os.path.join(log_dir, log_file), format=log_format_file, level=log_level) logger.add(
os.path.join(log_dir, log_file),
format=log_format_file,
level=log_level,
filter=formatter,
)
return logger return logger
@@ -0,0 +1,154 @@
import os
import time
from typing import Optional, Dict
import httpx
TAG = __name__
class DeviceNotFoundException(Exception):
pass
class DeviceBindException(Exception):
def __init__(self, bind_code):
self.bind_code = bind_code
super().__init__(f"设备绑定异常,绑定码: {bind_code}")
class ManageApiClient:
_instance = None
_client = None
_secret = None
def __new__(cls, config):
"""单例模式确保全局唯一实例,并支持传入配置参数"""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._init_client(config)
return cls._instance
@classmethod
def _init_client(cls, config):
"""初始化持久化连接池"""
cls.config = config.get("manager-api")
if not cls.config:
raise Exception("manager-api配置错误")
if not cls.config.get("url") or not cls.config.get("secret"):
raise Exception("manager-api的url或secret配置错误")
if "" in cls.config.get("secret"):
raise Exception("请先配置manager-api的secret")
cls._secret = cls.config.get("secret")
cls.max_retries = cls.config.get("max_retries", 6) # 最大重试次数
cls.retry_delay = cls.config.get("retry_delay", 10) # 初始重试延迟(秒)
# NOTE(goody): 2025/4/16 http相关资源统一管理,后续可以增加线程池或者超时
# 后续也可以统一配置apiToken之类的走通用的Auth
cls._client = httpx.Client(
base_url=cls.config.get("url"),
headers={
"User-Agent": f"PythonClient/2.0 (PID:{os.getpid()})",
"Accept": "application/json",
},
timeout=cls.config.get("timeout", 30), # 默认超时时间30秒
)
@classmethod
def _request(cls, method: str, endpoint: str, **kwargs) -> Dict:
"""发送单次HTTP请求并处理响应"""
endpoint = endpoint.lstrip("/")
response = cls._client.request(method, endpoint, **kwargs)
response.raise_for_status()
result = response.json()
# 处理API返回的业务错误
if result.get("code") == 10041:
raise DeviceNotFoundException(result.get("msg"))
elif result.get("code") == 10042:
raise DeviceBindException(result.get("msg"))
elif result.get("code") != 0:
raise Exception(f"API返回错误: {result.get('msg', '未知错误')}")
# 返回成功数据
return result.get("data") if result.get("code") == 0 else None
@classmethod
def _should_retry(cls, exception: Exception) -> bool:
"""判断异常是否应该重试"""
# 网络连接相关错误
if isinstance(
exception, (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError)
):
return True
# HTTP状态码错误
if isinstance(exception, httpx.HTTPStatusError):
status_code = exception.response.status_code
return status_code in [408, 429, 500, 502, 503, 504]
return False
@classmethod
def _execute_request(cls, method: str, endpoint: str, **kwargs) -> Dict:
"""带重试机制的请求执行器"""
retry_count = 0
while retry_count <= cls.max_retries:
try:
# 执行请求
return cls._request(method, endpoint, **kwargs)
except Exception as e:
# 判断是否应该重试
if retry_count < cls.max_retries and cls._should_retry(e):
retry_count += 1
print(
f"{method} {endpoint} 请求失败,将在 {cls.retry_delay:.1f} 秒后进行第 {retry_count} 次重试"
)
time.sleep(cls.retry_delay)
continue
else:
# 不重试,直接抛出异常
raise
@classmethod
def safe_close(cls):
"""安全关闭连接池"""
if cls._client:
cls._client.close()
cls._instance = None
def get_server_config() -> Optional[Dict]:
"""获取服务器基础配置"""
return ManageApiClient._instance._execute_request(
"POST", "/config/server-base", json={"secret": ManageApiClient._secret}
)
def get_agent_models(
mac_address: str, client_id: str, selected_module: Dict
) -> Optional[Dict]:
"""获取代理模型配置"""
return ManageApiClient._instance._execute_request(
"POST",
"/config/agent-models",
json={
"secret": ManageApiClient._secret,
"macAddress": mac_address,
"clientId": client_id,
"selectedModule": selected_module,
},
)
def init_service(config):
ManageApiClient(config)
def manage_api_http_safe_close():
ManageApiClient.safe_close()
+153 -61
View File
@@ -28,6 +28,8 @@ from plugins_func.register import Action, ActionResponse
from core.auth import AuthMiddleware, AuthenticationError 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 core.utils.output_counter import add_device_output
TAG = __name__ TAG = __name__
@@ -46,6 +48,9 @@ class ConnectionHandler:
self.logger = setup_logging() self.logger = setup_logging()
self.auth = AuthMiddleware(config) self.auth = AuthMiddleware(config)
self.need_bind = False
self.bind_code = None
self.websocket = None self.websocket = None
self.headers = None self.headers = None
self.client_ip = None self.client_ip = None
@@ -53,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
@@ -104,13 +110,37 @@ class ConnectionHandler:
self.close_after_chat = False # 是否在聊天结束后关闭连接 self.close_after_chat = False # 是否在聊天结束后关闭连接
self.use_function_call_mode = False self.use_function_call_mode = False
if self.config["selected_module"]["Intent"] == "function_call":
self.use_function_call_mode = True self.timeout_task = None
self.timeout_seconds = (
int(self.config.get("close_connection_no_voice_time", 120)) + 60
) # 在原来第一道关闭的基础上加60秒,进行二道关闭
async def handle_connection(self, ws): async def handle_connection(self, ws):
try: try:
# 获取并验证headers # 获取并验证headers
self.headers = dict(ws.request.headers) self.headers = dict(ws.request.headers)
if self.headers.get("device-id", None) is None:
# 尝试从 URL 的查询参数中获取 device-id
from urllib.parse import parse_qs, urlparse
# 从 WebSocket 请求中获取路径
request_path = ws.request.path
if not request_path:
self.logger.bind(tag=TAG).error("无法获取请求路径")
return
parsed_url = urlparse(request_path)
query_params = parse_qs(parsed_url.query)
if "device-id" in query_params:
self.headers["device-id"] = query_params["device-id"][0]
self.headers["client-id"] = query_params["client-id"][0]
else:
self.logger.bind(tag=TAG).error(
"无法从请求头和URL查询参数中获取device-id"
)
return
# 获取客户端ip地址 # 获取客户端ip地址
self.client_ip = ws.remote_address[0] self.client_ip = ws.remote_address[0]
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).info(
@@ -124,12 +154,17 @@ class ConnectionHandler:
self.websocket = ws self.websocket = ws
self.session_id = str(uuid.uuid4()) self.session_id = str(uuid.uuid4())
# 启动超时检查任务
self.timeout_task = asyncio.create_task(self._check_timeout())
self.welcome_msg = self.config["xiaozhi"] self.welcome_msg = self.config["xiaozhi"]
self.welcome_msg["session_id"] = self.session_id self.welcome_msg["session_id"] = self.session_id
await self.websocket.send(json.dumps(self.welcome_msg)) await self.websocket.send(json.dumps(self.welcome_msg))
# 获取差异化配置
private_config = self._initialize_private_config()
# 异步初始化 # 异步初始化
self.executor.submit(self._initialize_components) self.executor.submit(self._initialize_components, private_config)
# tts 消化线程 # tts 消化线程
self.tts_priority_thread = threading.Thread( self.tts_priority_thread = threading.Thread(
target=self._tts_priority_thread, daemon=True target=self._tts_priority_thread, daemon=True
@@ -169,19 +204,23 @@ class ConnectionHandler:
async def _route_message(self, message): async def _route_message(self, message):
"""消息路由""" """消息路由"""
# 重置超时计时器
if self.timeout_task:
self.timeout_task.cancel()
self.timeout_task = asyncio.create_task(self._check_timeout())
if isinstance(message, str): if isinstance(message, str):
await handleTextMessage(self, message) await handleTextMessage(self, message)
elif isinstance(message, bytes): elif isinstance(message, bytes):
await handleAudioMessage(self, message) await handleAudioMessage(self, message)
def _initialize_components(self): def _initialize_components(self, private_config):
"""初始化组件""" """初始化组件"""
self._initialize_models() if private_config is not None:
self._initialize_models(private_config)
"""加载提示词""" else:
self.prompt = self.config["prompt"] self.prompt = self.config["prompt"]
self.dialogue.put(Message(role="system", content=self.prompt)) self.change_system_prompt(self.prompt)
"""加载记忆""" """加载记忆"""
self._initialize_memory() self._initialize_memory()
"""加载意图识别""" """加载意图识别"""
@@ -194,26 +233,66 @@ class ConnectionHandler:
self.dialogue.update_system_message(self.prompt) self.dialogue.update_system_message(self.prompt)
def _initialize_models(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)
"""如果是从配置文件获取,则进行二次实例化""" """如果是从配置文件获取,则进行二次实例化"""
if not read_config_from_api: if not read_config_from_api:
return return
"""从接口获取差异化的配置进行二次实例化,非全量重新实例化""" """从接口获取差异化的配置进行二次实例化,非全量重新实例化"""
try: try:
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"] = self.config["delete_audio"] private_config["delete_audio"] = bool(self.config.get("delete_audio", True))
self.logger.bind(tag=TAG).info(f"获取差异化配置成功: {private_config}") self.logger.bind(tag=TAG).info(
f"{time.time() - begin_time} 秒,获取差异化配置成功: {private_config}"
)
except DeviceNotFoundException as e:
self.need_bind = True
private_config = {}
except DeviceBindException as e:
self.need_bind = True
self.bind_code = e.bind_code
private_config = {}
except Exception as e: except Exception as e:
self.need_bind = True
self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}") self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}")
private_config = {} private_config = {}
init_vad, init_asr, init_llm, init_tts, init_memory, init_intent = ( init_tts = False
False, if private_config.get("TTS", None) is not None:
init_tts = True
self.config["TTS"] = private_config["TTS"]
self.config["selected_module"]["TTS"] = private_config["selected_module"][
"TTS"
]
try:
modules = initialize_modules(
self.logger,
private_config,
False,
False,
False,
init_tts,
False,
False,
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
modules = {}
if modules.get("tts", None) is not None:
self.tts = modules["tts"]
if modules.get("prompt", None) is not None:
self.change_system_prompt(modules["prompt"])
private_config["prompt"] = None
return private_config
def _initialize_models(self, private_config):
init_vad, init_asr, init_llm, init_memory, init_intent = (
False, False,
False, False,
False, False,
@@ -222,42 +301,36 @@ class ConnectionHandler:
) )
if private_config.get("VAD", None) is not None: if private_config.get("VAD", None) is not None:
init_vad = True init_vad = True
self.config["vad"] = private_config["VAD"] self.config["VAD"] = private_config["VAD"]
self.config["selected_module"]["VAD"] = private_config["selected_module"][ self.config["selected_module"]["VAD"] = private_config["selected_module"][
"VAD" "VAD"
] ]
if private_config.get("ASR", None) is not None: if private_config.get("ASR", None) is not None:
init_asr = True init_asr = True
self.config["asr"] = private_config["ASR"] self.config["ASR"] = private_config["ASR"]
self.config["selected_module"]["ASR"] = private_config["selected_module"][ self.config["selected_module"]["ASR"] = private_config["selected_module"][
"ASR" "ASR"
] ]
if private_config.get("LLM", None) is not None: if private_config.get("LLM", None) is not None:
init_llm = True init_llm = True
self.config["llm"] = private_config["LLM"] self.config["LLM"] = private_config["LLM"]
self.config["selected_module"]["LLM"] = private_config["selected_module"][ self.config["selected_module"]["LLM"] = private_config["selected_module"][
"LLM" "LLM"
] ]
if private_config.get("TTS", None) is not None:
init_tts = True
self.config["tts"] = private_config["TTS"]
self.config["selected_module"]["TTS"] = private_config["selected_module"][
"TTS"
]
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"]
self.config["selected_module"]["Memory"] = private_config[ self.config["selected_module"]["Memory"] = private_config[
"selected_module" "selected_module"
]["Memory"] ]["Memory"]
if private_config.get("Intent", None) is not None: if private_config.get("Intent", None) is not None:
init_intent = True init_intent = True
self.config["intent"] = private_config["Intent"] self.config["Intent"] = private_config["Intent"]
self.config["selected_module"]["Intent"] = private_config[ self.config["selected_module"]["Intent"] = private_config[
"selected_module" "selected_module"
]["Intent"] ]["Intent"]
if private_config.get("prompt", None) is not None: if private_config.get("device_max_output_size", None) is not None:
self.config["prompt"] = private_config["prompt"] self.max_output_size = int(private_config["device_max_output_size"])
try: try:
modules = initialize_modules( modules = initialize_modules(
self.logger, self.logger,
@@ -265,15 +338,17 @@ class ConnectionHandler:
init_vad, init_vad,
init_asr, init_asr,
init_llm, init_llm,
init_tts, False,
init_memory, init_memory,
init_intent, init_intent,
) )
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}") self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
modules = {} modules = {}
if modules.get("tts", None) is not None: if modules.get("vad", None) is not None:
self.tts = modules["tts"] self.vad = modules["vad"]
if modules.get("asr", None) is not None:
self.asr = modules["asr"]
if modules.get("llm", None) is not None: if modules.get("llm", None) is not None:
self.llm = modules["llm"] self.llm = modules["llm"]
if modules.get("intent", None) is not None: if modules.get("intent", None) is not None:
@@ -287,17 +362,26 @@ class ConnectionHandler:
self.memory.init_memory(device_id, self.llm) self.memory.init_memory(device_id, self.llm)
def _initialize_intent(self): def _initialize_intent(self):
if (
self.config["Intent"][self.config["selected_module"]["Intent"]]["type"]
== "function_call"
):
self.use_function_call_mode = True
"""初始化意图识别模块""" """初始化意图识别模块"""
# 获取意图识别配置 # 获取意图识别配置
intent_config = self.config["Intent"] intent_config = self.config["Intent"]
intent_type = self.config["selected_module"]["Intent"] intent_type = self.config["Intent"][self.config["selected_module"]["Intent"]][
"type"
]
# 如果使用 nointent,直接返回 # 如果使用 nointent,直接返回
if intent_type == "nointent": if intent_type == "nointent":
return return
# 使用 intent_llm 模式 # 使用 intent_llm 模式
elif intent_type == "intent_llm": elif intent_type == "intent_llm":
intent_llm_name = intent_config["intent_llm"]["llm"] intent_llm_name = intent_config[self.config["selected_module"]["Intent"]][
"llm"
]
if intent_llm_name and intent_llm_name in self.config["LLM"]: if intent_llm_name and intent_llm_name in self.config["LLM"]:
# 如果配置了专用LLM,则创建独立的LLM实例 # 如果配置了专用LLM,则创建独立的LLM实例
@@ -328,10 +412,8 @@ class ConnectionHandler:
def change_system_prompt(self, prompt): def change_system_prompt(self, prompt):
self.prompt = prompt self.prompt = prompt
# 找到原来的role==system,替换原来的系统提示 # 更新系统prompt至上下文
for m in self.dialogue.dialogue: self.dialogue.update_system_message(self.prompt)
if m.role == "system":
m.content = prompt
def chat(self, query): def chat(self, query):
@@ -340,7 +422,6 @@ class ConnectionHandler:
response_message = [] response_message = []
processed_chars = 0 # 跟踪已处理的字符位置 processed_chars = 0 # 跟踪已处理的字符位置
try: try:
start_time = time.time()
# 使用带记忆的对话 # 使用带记忆的对话
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
self.memory.query_memory(query), self.loop self.memory.query_memory(query), self.loop
@@ -362,9 +443,6 @@ class ConnectionHandler:
if self.client_abort: if self.client_abort:
break break
end_time = time.time()
# self.logger.bind(tag=TAG).debug(f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}")
# 合并当前全部文本并处理未分割部分 # 合并当前全部文本并处理未分割部分
full_text = "".join(response_message) full_text = "".join(response_message)
current_text = full_text[processed_chars:] # 从未处理的位置开始 current_text = full_text[processed_chars:] # 从未处理的位置开始
@@ -457,16 +535,19 @@ class ConnectionHandler:
function_id = None function_id = None
function_arguments = "" function_arguments = ""
content_arguments = "" content_arguments = ""
for response in llm_responses: for response in llm_responses:
content, tools_call = response content, tools_call = response
if "content" in response: if "content" in response:
content = response["content"] content = response["content"]
tools_call = None tools_call = None
if content is not None and len(content) > 0: if content is not None and len(content) > 0:
if len(response_message) <= 0 and ( content_arguments += content
content == "```" or "<tool_call>" in content
): if not tool_call_flag and content_arguments.startswith("<tool_call>"):
tool_call_flag = True # print("content_arguments", content_arguments)
tool_call_flag = True
if tools_call is not None: if tools_call is not None:
tool_call_flag = True tool_call_flag = True
@@ -478,9 +559,7 @@ class ConnectionHandler:
function_arguments += tools_call[0].function.arguments function_arguments += tools_call[0].function.arguments
if content is not None and len(content) > 0: if content is not None and len(content) > 0:
if tool_call_flag: if not tool_call_flag:
content_arguments += content
else:
response_message.append(content) response_message.append(content)
if self.client_abort: if self.client_abort:
@@ -541,10 +620,9 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error( self.logger.bind(tag=TAG).error(
f"function call error: {content_arguments}" f"function call error: {content_arguments}"
) )
else:
function_arguments = json.loads(function_arguments)
if not bHasError: if not bHasError:
self.logger.bind(tag=TAG).info( response_message.clear()
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 = {
@@ -639,7 +717,6 @@ class ConnectionHandler:
self.tts_queue.put(future) self.tts_queue.put(future)
self.dialogue.put(Message(role="assistant", content=text)) self.dialogue.put(Message(role="assistant", content=text))
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复 elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
text = result.result text = result.result
if text is not None and len(text) > 0: if text is not None and len(text) > 0:
function_id = function_call_data["id"] function_id = function_call_data["id"]
@@ -666,18 +743,14 @@ class ConnectionHandler:
Message(role="tool", tool_call_id=function_id, content=text) Message(role="tool", tool_call_id=function_id, content=text)
) )
self.chat_with_function_calling(text, tool_call=True) self.chat_with_function_calling(text, tool_call=True)
elif result.action == Action.NOTFOUND: elif result.action == Action.NOTFOUND or result.action == Action.ERROR:
text = result.result text = result.result
self.recode_first_last_text(text, text_index) self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index) future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future) self.tts_queue.put(future)
self.dialogue.put(Message(role="assistant", content=text)) self.dialogue.put(Message(role="assistant", content=text))
else: else:
text = result.result pass
self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future)
self.dialogue.put(Message(role="assistant", content=text))
def _tts_priority_thread(self): def _tts_priority_thread(self):
while not self.stop_event.is_set(): while not self.stop_event.is_set():
@@ -695,7 +768,7 @@ class ConnectionHandler:
opus_datas, text_index, tts_file = [], 0, None opus_datas, text_index, tts_file = [], 0, None
try: try:
self.logger.bind(tag=TAG).debug("正在处理TTS任务...") self.logger.bind(tag=TAG).debug("正在处理TTS任务...")
tts_timeout = self.config.get("tts_timeout", 10) tts_timeout = int(self.config.get("tts_timeout", 10))
tts_file, text, text_index = future.result(timeout=tts_timeout) tts_file, text, text_index = future.result(timeout=tts_timeout)
if text is None or len(text) <= 0: if text is None or len(text) <= 0:
self.logger.bind(tag=TAG).error( self.logger.bind(tag=TAG).error(
@@ -775,6 +848,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):
@@ -791,6 +866,11 @@ class ConnectionHandler:
async def close(self, ws=None): async def close(self, ws=None):
"""资源清理方法""" """资源清理方法"""
# 取消超时任务
if self.timeout_task:
self.timeout_task.cancel()
self.timeout_task = None
# 清理MCP资源 # 清理MCP资源
if hasattr(self, "mcp_manager") and self.mcp_manager: if hasattr(self, "mcp_manager") and self.mcp_manager:
await self.mcp_manager.cleanup_all() await self.mcp_manager.cleanup_all()
@@ -844,3 +924,15 @@ class ConnectionHandler:
self.close_after_chat = True self.close_after_chat = True
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"Chat and close error: {str(e)}") self.logger.bind(tag=TAG).error(f"Chat and close error: {str(e)}")
async def _check_timeout(self):
"""检查连接超时"""
try:
while not self.stop_event.is_set():
await asyncio.sleep(self.timeout_seconds)
if not self.stop_event.is_set():
self.logger.bind(tag=TAG).info("连接超时,准备关闭")
await self.close(self.websocket)
break
except Exception as e:
self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}")
@@ -57,7 +57,9 @@ class FunctionHandler:
def register_config_functions(self): def register_config_functions(self):
"""注册配置中的函数,可以不同客户端使用不同的配置""" """注册配置中的函数,可以不同客户端使用不同的配置"""
for func in self.config["Intent"]["function_call"].get("functions", []): for func in self.config["Intent"][self.config["selected_module"]["Intent"]].get(
"functions", []
):
self.function_registry.register_function(func) self.function_registry.register_function(func)
"""home assistant需要初始化提示词""" """home assistant需要初始化提示词"""
@@ -77,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
@@ -81,6 +81,8 @@ async def wakeupWordsResponse(conn):
"""唤醒词响应""" """唤醒词响应"""
wakeup_word = random.choice(WAKEUP_CONFIG["words"]) wakeup_word = random.choice(WAKEUP_CONFIG["words"])
result = conn.llm.response_no_stream(conn.config["prompt"], wakeup_word) result = conn.llm.response_no_stream(conn.config["prompt"], wakeup_word)
if result is None or result == "":
return
tts_file = await asyncio.to_thread(conn.tts.to_tts, result) tts_file = await asyncio.to_thread(conn.tts.to_tts, result)
if tts_file is not None and os.path.exists(tts_file): if tts_file is not None and os.path.exists(tts_file):
@@ -76,6 +76,11 @@ async def process_intent_result(conn, intent_result, original_text):
if function_name == "continue_chat": if function_name == "continue_chat":
return False return False
if function_name == "play_music":
funcItem = conn.func_handler.get_function(function_name)
if not funcItem:
conn.func_handler.function_registry.register_function("play_music")
function_args = None function_args = None
if "arguments" in intent_data["function_call"]: if "arguments" in intent_data["function_call"]:
function_args = intent_data["function_call"]["arguments"] function_args = intent_data["function_call"]["arguments"]
+71 -50
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}'"
) )
@@ -144,33 +144,34 @@ class IotDescriptor:
self.methods = [] self.methods = []
# 根据描述创建属性 # 根据描述创建属性
for key, value in properties.items(): if properties is not None:
property_item = globals()[key] = {} for key, value in properties.items():
property_item["name"] = key property_item = {}
property_item["description"] = value["description"] property_item["name"] = key
if value["type"] == "number": property_item["description"] = value["description"]
property_item["value"] = 0 if value["type"] == "number":
elif value["type"] == "boolean": property_item["value"] = 0
property_item["value"] = False elif value["type"] == "boolean":
else: property_item["value"] = False
property_item["value"] = "" else:
self.properties.append(property_item) property_item["value"] = ""
self.properties.append(property_item)
# 根据描述创建方法 # 根据描述创建方法
for key, value in methods.items(): if methods is not None:
method = globals()[key] = {} for key, value in methods.items():
method["description"] = value["description"] method = {}
method["name"] = key method["description"] = value["description"]
for k, v in value["parameters"].items(): method["name"] = key
method[k] = {} # 检查方法是否有参数
method[k]["description"] = v["description"] if "parameters" in value:
if v["type"] == "number": method["parameters"] = {}
method[k]["value"] = 0 for k, v in value["parameters"].items():
elif v["type"] == "boolean": method["parameters"][k] = {
method[k]["value"] = False "description": v["description"],
else: "type": v["type"],
method[k]["value"] = "" }
self.methods.append(method) self.methods.append(method)
def register_device_type(descriptor): def register_device_type(descriptor):
@@ -219,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(
@@ -242,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 = {
@@ -269,19 +275,36 @@ def register_device_type(descriptor):
# 用于接受前端设备推送的搜索iot描述 # 用于接受前端设备推送的搜索iot描述
async def handleIotDescriptors(conn, descriptors): async def handleIotDescriptors(conn, descriptors):
if not conn.use_function_call_mode:
return
wait_max_time = 5 wait_max_time = 5
while conn.func_handler is None or not conn.func_handler.finish_init: while conn.func_handler is None or not conn.func_handler.finish_init:
await asyncio.sleep(1) await asyncio.sleep(1)
wait_max_time -= 1 wait_max_time -= 1
if wait_max_time <= 0: if wait_max_time <= 0:
logger.bind(tag=TAG).error("连接对象没有func_handler") logger.bind(tag=TAG).debug("连接对象没有func_handler")
return return
"""处理物联网描述""" """处理物联网描述"""
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}")
@@ -3,6 +3,7 @@ import time
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()
@@ -35,9 +36,7 @@ async def handleAudioMessage(conn, audio):
if len(conn.asr_audio) < 15: if len(conn.asr_audio) < 15:
conn.asr_server_receive = True conn.asr_server_receive = True
else: else:
text, file_path = await conn.asr.speech_to_text( text, _ = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id)
conn.asr_audio, conn.session_id
)
logger.bind(tag=TAG).info(f"识别文本: {text}") logger.bind(tag=TAG).info(f"识别文本: {text}")
text_len, _ = remove_punctuation_and_length(text) text_len, _ = remove_punctuation_and_length(text)
if text_len > 0: if text_len > 0:
@@ -49,6 +48,18 @@ async def handleAudioMessage(conn, audio):
async def startToChat(conn, text): async def startToChat(conn, text):
if conn.need_bind:
await check_bind_device(conn)
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)
@@ -71,8 +82,8 @@ async def no_voice_close_connect(conn):
conn.client_no_voice_last_time = time.time() * 1000 conn.client_no_voice_last_time = time.time() * 1000
else: else:
no_voice_time = time.time() * 1000 - conn.client_no_voice_last_time no_voice_time = time.time() * 1000 - conn.client_no_voice_last_time
close_connection_no_voice_time = conn.config.get( close_connection_no_voice_time = int(
"close_connection_no_voice_time", 120 conn.config.get("close_connection_no_voice_time", 120)
) )
if ( if (
not conn.close_after_chat not conn.close_after_chat
@@ -85,3 +96,56 @@ 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):
if conn.bind_code:
# 确保bind_code是6位数字
if len(conn.bind_code) != 6:
logger.bind(tag=TAG).error(f"无效的绑定码格式: {conn.bind_code}")
text = "绑定码格式错误,请检查配置。"
await send_stt_message(conn, text)
return
text = f"请登录控制面板,输入{conn.bind_code},绑定设备。"
await send_stt_message(conn, text)
conn.tts_first_text_index = 0
conn.tts_last_text_index = 6
conn.llm_finish_task = True
# 播放提示音
music_path = "config/assets/bind_code.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(music_path)
conn.audio_play_queue.put((opus_packets, text, 0))
# 逐个播放数字
for i in range(6): # 确保只播放6位数字
try:
digit = conn.bind_code[i]
num_path = f"config/assets/bind_code/{digit}.wav"
num_packets, _ = conn.tts.audio_to_opus_data(num_path)
conn.audio_play_queue.put((num_packets, None, i + 1))
except Exception as e:
logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
continue
else:
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
await send_stt_message(conn, text)
conn.tts_first_text_index = 0
conn.tts_last_text_index = 0
conn.llm_finish_task = True
music_path = "config/assets/bind_not_found.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(music_path)
conn.audio_play_queue.put((opus_packets, text, 0))
@@ -3,7 +3,6 @@ import json
import asyncio import asyncio
import time import time
from core.utils.util import ( from core.utils.util import (
remove_punctuation_and_length,
get_string_no_punctuation_or_emoji, get_string_no_punctuation_or_emoji,
) )
@@ -35,4 +35,5 @@ class LLMProviderBase(ABC):
""" """
# For providers that don't support functions, just return regular response # For providers that don't support functions, just return regular response
for token in self.response(session_id, dialogue): for token in self.response(session_id, dialogue):
yield {"type": "content", "content": token} yield token, None

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