Compare commits

...
75 Commits
Author SHA1 Message Date
欣南科技andGitHub bf08a91191 Merge pull request #1085 from xinnan-tech/upload-history
add: 增加聊天记录上报
2025-05-02 00:09:13 +08:00
hrz f0e353cc1a update:优化对话数据上传 2025-05-02 00:07:36 +08:00
hrzandGitHub 456b1eddcc Merge pull request #1071 from MakerZorky/main
feat:增加DoubaoASR热词、替换词选项
2025-05-01 18:08:43 +08:00
hrzandGitHub 5e08f05437 Merge pull request #1077 from GOODDAYDAY/feature/upload-history
feat: 增加asr,tts聊天记录和文件上报功能
2025-05-01 18:05:55 +08:00
欣南科技andGitHub 9f9227bae4 Merge pull request #1083 from xinnan-tech/manager-dict
update:字典前端增删改查
2025-05-01 16:59:38 +08:00
hrz 76f47bce37 update:字典前端增删改查 2025-05-01 16:57:58 +08:00
hrzandGitHub e1f54b30dc Merge pull request #1080 from xinnan-tech/manager-api-dict
数据字典接口开发
2025-05-01 00:18:22 +08:00
hrzandGitHub fc4e7e6c05 Merge pull request #1078 from xinnan-tech/py_update-restart
update:修改python部分的“动态配置更新”的验证逻辑
2025-04-30 23:38:21 +08:00
欣南科技andGitHub 7aaa79b644 Merge pull request #1081 from xinnan-tech/test-aliyun-asr
update:增加阿里云ASR
2025-04-30 23:34:16 +08:00
hrz 9b6f894be2 update:智控体增加阿里云ASR 2025-04-30 23:33:17 +08:00
zccaif 48f7ef2023 数字字典接口开发 2025-04-30 22:46:55 +08:00
MakerZorkyandGitHub 54692859fd Update:config.yaml热词注释 2025-04-30 19:23:44 +08:00
CGD 1719244015 update:修改python部分的“动态配置更新”的验证逻辑 2025-04-30 18:11:46 +08:00
goodyhao 48f8c6c5b7 feat: 增加asr,tts文件上报功能 2025-04-30 17:56:52 +08:00
hrzandGitHub f0815e595b Merge pull request #711 from LFNL-scholar/main
新增阿里云ASR
2025-04-30 17:55:37 +08:00
欣南科技andGitHub 4f829017ed Merge pull request #1074 from xinnan-tech/test-pr
增加server通用secret过滤器
2025-04-30 15:11:09 +08:00
hrz d2f8f05acb update:优化配置及文档 2025-04-30 15:05:42 +08:00
MakerZorky e8b1156bfd feat:增加DoubaoASR热词、替换词选项 2025-04-30 13:34:00 +08:00
CGDandGitHub bf748773c8 Merge pull request #1059 from xinnan-tech/server_tts_test
update:优化吞音问题,解决播放长音频时的错误退出
2025-04-30 11:58:03 +08:00
Sakura-RanChenandGitHub a05b25ae90 Merge pull request #1068 from xinnan-tech/py-update-config
update:python部分"动态更新配置"的优化
2025-04-30 11:57:09 +08:00
CGD 3ceb3feccf update:python部分"动态更新配置"的优化 2025-04-30 11:55:19 +08:00
hrz 9e9af2a031 update:优化文档 2025-04-30 10:53:29 +08:00
hrz b50c7dbed4 update:修正docker上传目录uploadfile映射 2025-04-30 10:38:18 +08:00
hrzandGitHub 233e1ee0a6 Merge pull request #1052 from myifeng/main
采用约定大于配置的原则,减少用户冗余设置,用户仅调整自定义设置
2025-04-29 22:50:36 +08:00
hrzandGitHub 805b40ec76 Merge pull request #1033 from GOODDAYDAY/feature/doubao-tts
feat: 增加对豆包tts对话速率等控制
2025-04-29 22:47:26 +08:00
hrzandGitHub 86ca3e5be0 Merge pull request #1060 from GOODDAYDAY/feature/config-token
feat: 增加server通用secret过滤器
2025-04-29 22:45:33 +08:00
欣南科技andGitHub bb2143d9a0 Merge pull request #1062 from xinnan-tech/test-pr
合并多个提交
2025-04-29 18:06:24 +08:00
hrz ef662e0ccf add:增加智控台模型配置各个模型的使用说明 2025-04-29 18:04:31 +08:00
goodyhao 30708a59f1 feat: 增加server通用secret过滤器 2025-04-29 17:37:25 +08:00
Sakura-RanChen b0262cfe72 update:优化吞音问题,解决播放长音频时的错误退出 2025-04-29 17:14:53 +08:00
Sakura-RanChenandGitHub 259886c1a8 Merge pull request #1057 from xinnan-tech/py_restart
update:更新python部分“动态更新配置”
2025-04-29 15:28:30 +08:00
CGD f1b4b33727 update:更新python部分“动态更新配置” 2025-04-29 15:25:03 +08:00
GoodyHaoandGitHub 38e8b690bd Merge branch 'xinnan-tech:main' into feature/doubao-tts 2025-04-29 15:10:42 +08:00
goodyhao 9a52b43e09 修改SQL及入参转换 2025-04-29 14:31:01 +08:00
myifeng 5e35cfc44a 定义config.yaml为默认配置; data/.config.yaml 为自定义配置; 使用者通过设置自定义配置即可完成启动,无需关心默认配置项,减少用户冗余配置 2025-04-29 13:55:34 +08:00
Sakura-RanChenandGitHub ac0ec6dfe6 Merge pull request #1049 from xinnan-tech/py_update
update:完成Python部分的“动态更新web端的配置”
2025-04-29 10:39:21 +08:00
CGD 361defc348 update:完成Python部分的“动态更新web端的配置” 2025-04-29 10:36:20 +08:00
hrz 6ac8947168 update:更新issue模版 2025-04-29 10:14:43 +08:00
hrzandGitHub 5655930d12 Merge pull request #1017 from weinaike/main
[Feature]新增funASR服务调用适配
2025-04-29 09:02:22 +08:00
hrzandGitHub 18acc37324 Merge pull request #1046 from MakerZorky/main
update:所有ASR均改为PCM直接识别,通过delete_audio参数判断要不要储存wav,优化了识别效率
2025-04-29 08:54:52 +08:00
MakerZorky e9155d719e Merge branch 'main' of https://github.com/MakerZorky/xiaozhi-esp32-server 2025-04-29 01:30:46 +08:00
MakerZorky 16e77b02c2 update:所有ASR均改为PCM直接识别,通过delete_audio参数判断要不要储存wav 2025-04-29 01:28:06 +08:00
欣南科技andGitHub adc1902649 Merge pull request #1038 from xinnan-tech/hot-fix
update:更新文档
2025-04-28 18:08:35 +08:00
hrz 1cc470bc5a update:更新文档 2025-04-28 18:07:58 +08:00
hrz b3f6efc7d0 update:更新文档 2025-04-28 17:11:08 +08:00
欣南科技andGitHub d91fbf3ff4 Merge pull request #1037 from xinnan-tech/hot-fix
update:获取差异化配置时,不输出敏感信息
2025-04-28 16:53:56 +08:00
hrz e552593474 update:取消对严格mac地址的校验 2025-04-28 16:44:36 +08:00
hrz 1f5a78f458 update:获取差异化配置时,不输出敏感信息 2025-04-28 16:30:31 +08:00
hrzandGitHub f490c15282 update:修复websocket接口被浏览器访问出错的bug (#1034)
* update:修复websocket接口被浏览器访问出错的bug

* update:修改版本号
2025-04-28 12:01:50 +08:00
hrz 90a31debd1 update:修改版本号 2025-04-28 11:56:17 +08:00
hrz 416145a90e update:修复websocket接口被浏览器访问出错的bug 2025-04-28 11:53:16 +08:00
goodyhao 6285786282 feat: 增加对豆包tts对话速率等控制 2025-04-28 11:31:10 +08:00
Sakura-RanChenandGitHub f9f6a69c89 Merge pull request #1030 from xinnan-tech/py_test
update:删除初始化组件角色prompt
2025-04-28 09:24:47 +08:00
CGD 61da60bdf1 update:删除初始化组件角色prompt 2025-04-28 09:22:49 +08:00
欣南科技andGitHub 2d5ef3011a Merge pull request #1028 from xinnan-tech/manager-api-model-delete
update:单Server部署时,docker-compose开放8002端口
2025-04-27 22:50:39 +08:00
hrz 63dab6d224 update:单Server部署时,docker-compose开放8002端口 2025-04-27 22:49:14 +08:00
CGDandGitHub 38dd76dfb3 Merge pull request #1026 from xinnan-tech/emote_switching
Emote switching
2025-04-27 22:20:04 +08:00
fe642de526 update:删除模型前检查是否有引用 (#1027)
* fix:DoubaoASR去掉转wav环节

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

* update:去除print

---------

Co-authored-by: MakerZorky <1053714527zhq@gmail.com>
2025-04-27 22:08:30 +08:00
hrz f4b7eefc38 update:去除print 2025-04-27 22:07:36 +08:00
hrz 5bbbad02b4 update:删除模型前检查是否有引用 2025-04-27 21:02:58 +08:00
Sakura-RanChen bd85dd5faf 增加“表情切换”英文适配,优化输出文本切割逻辑 2025-04-27 17:57:58 +08:00
hrzandGitHub d91ba3d219 Merge pull request #1020 from MakerZorky/main
fix:DoubaoASR去掉pcm转wav环节,优化响应速度
2025-04-27 16:49:38 +08:00
MakerZorky 7992c40815 fix:DoubaoASR去掉转wav环节 2025-04-27 15:11:50 +08:00
hrzandGitHub 574b01db97 Merge pull request #1011 from xinnan-tech/simple_ota_server
为最简化安装环境提供OTA基础信息接口
2025-04-27 13:55:51 +08:00
hrz 2d6049b7bd update:优化说明文档 2025-04-27 13:55:04 +08:00
hrz 96d0f2e6f9 update:更新教程 2025-04-27 13:34:17 +08:00
hrz b46bd4d0dd update:兼容全模块部署,不启动自带的OTA 服务 2025-04-27 13:07:14 +08:00
wnk 244c76793c 当前funASR仅支持本地模型
新增FunASR服务API调用(服务可根据官方说明自行另外部署), 增加配置与API调用实现
2025-04-27 11:55:10 +08:00
hrzandGitHub 411fc46189 Merge pull request #1006 from weinaike/main
fix:修复tts多次尝试逻辑
2025-04-27 11:16:10 +08:00
Sakura-RanChen 48427e330f 根据文本情绪权重进行表情切换处理,积极情绪优先 2025-04-27 11:15:58 +08:00
王华侨 5ddc2451c1 更新部署信息 2025-04-26 22:08:52 +08:00
王华侨 e357cad2bb 为最简化安装环境提供OTA基础信息接口 2025-04-26 21:56:41 +08:00
wnk 9a5aaea8ca fix:
tts未及时响应,多次尝试逻辑修复。(删除空文件,达成While的条件)
2025-04-26 07:04:07 +08:00
LFNL-scholar c9eb640510 新增阿里云ASR 2025-04-08 20:48:11 +08:00
LFNL-scholar 766a706250 新增阿里云的ASR 2025-04-08 20:37:23 +08:00
112 changed files with 4971 additions and 873 deletions
+10 -12
View File
@@ -9,26 +9,24 @@ assignees: ''
## 🐛 问题描述 ## 🐛 问题描述
<!-- 清晰简洁地描述问题是什么 --> <!-- 清晰简洁地描述问题是什么 -->
## 🔍 复现步骤 ## 🖥️ 环境信息
<!-- 详细描述复现问题的步骤 --> - 部署方式: 全模块部署 还是 单Server部署
- 版本号: 例如 0.3.x
## 🔍 告诉我们,应该怎么复现这个问题
<!-- 这个很重要,方便我们快速定位 -->
1. 打开 '...' 1. 打开 '...'
2. 点击 '...' 2. 点击 '...'
3. 滚动到 '...' 3. 滚动到 '...'
4. 看到错误 4. 看到错误
## 🤔 预期行为 ## 🤔 你原本希望是怎么样的
<!-- 简要描述预期的正确行为 --> <!-- 简要描述预期的正确行为 -->
## 😯 截图 ## 😯 提供一些截图
<!-- 如果适用,添加问题的截图 --> <!-- 如果适用,添加问题的截图 -->
1. 比如日志截图,越多越好
## 🖥️ 环境信息 2. 比如界面反应
- 操作系统: [例如 Windows 10]
- 浏览器: [例如 Chrome 89]
- 项目版本: [例如 1.0.0]
- Python版本: [例如 3.9.13]
- Jdk版本:[例如 java 21.0.5 2024-10-15 LTS]
- Nodejs版本:[例如 v20.14.0]
## 📋 其他信息 ## 📋 其他信息
<!-- 在此添加关于此问题的任何其他上下文信息 --> <!-- 在此添加关于此问题的任何其他上下文信息 -->
+1
View File
@@ -165,3 +165,4 @@ main/manager-api/.vscode
main/manager-web/.webpack_cache/ main/manager-web/.webpack_cache/
main/xiaozhi-server/mysql main/xiaozhi-server/mysql
uploadfile uploadfile
.vscode
+9 -24
View File
@@ -1,6 +1,5 @@
[![Banners](docs/images/banner1.png)](https://github.com/xinnan-tech/xiaozhi-esp32-server) [![Banners](docs/images/banner1.png)](https://github.com/xinnan-tech/xiaozhi-esp32-server)
<h1 align="center">小智后端服务xiaozhi-esp32-server</h1> <h1 align="center">小智后端服务xiaozhi-esp32-server</h1>
<p align="center"> <p align="center">
@@ -12,11 +11,10 @@
<p align="center"> <p align="center">
<a href="./README.md">English</a> <a href="./README.md">English</a>
· 简体中文
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">更新日志</a>
· <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">反馈问题</a>
· <a href="./docs/FAQ.md">常见问题</a> · <a href="./docs/FAQ.md">常见问题</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">反馈问题</a>
· <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">更新日志</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases"> <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
@@ -132,17 +130,7 @@
1、本项目为开源软件,本软件与对接的任何第三方API服务商(包括但不限于语音识别、大模型、语音合成等平台)均不存在商业合作关系,不为其服务质量及资金安全提供任何形式的担保。 1、本项目为开源软件,本软件与对接的任何第三方API服务商(包括但不限于语音识别、大模型、语音合成等平台)均不存在商业合作关系,不为其服务质量及资金安全提供任何形式的担保。
建议使用者优先选择持有相关业务牌照的服务商,并仔细阅读其服务协议及隐私政策。本软件不托管任何账户密钥、不参与资金流转、不承担充值资金损失风险。 建议使用者优先选择持有相关业务牌照的服务商,并仔细阅读其服务协议及隐私政策。本软件不托管任何账户密钥、不参与资金流转、不承担充值资金损失风险。
2、本项目成立时间较短,还未通过网络安全测评,请勿在生产环境中使用。 如果您在公网环境中部署学习本项目,请务必在配置文件 2、本项目功能未完善,且未通过网络安全测评,请勿在生产环境中使用。 如果您在公网环境中部署学习本项目,请务必做好必要的防护。
`config.yaml` 中开启防护:
```yaml
server:
auth:
# 开启防护
enabled: true
```
开启防护后,您需要根据实际情况校验机器的 token 或 mac 地址,详细请参见配置说明。
--- ---
@@ -156,21 +144,18 @@ server:
| 部署方式 | 特点 | 适用场景 | Docker部署文档 | 源码部署文档 | | 部署方式 | 特点 | 适用场景 | Docker部署文档 | 源码部署文档 |
|---------|------|---------|---------|---------| |---------|------|---------|---------|---------|
| **最简化安装** | 智能对话、IOT功能,数据存储在配置文件 | 低配置环境,无需数据库,仅支持虾哥1.6.0及以下固件版本 | [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功能,数据存储在配置文件 | 低配置环境,无需数据库 | [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) | | **全模块安装** | 智能对话、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) |
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据 > 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据
``` ```
智控台地址: https://2662r3426b.vicp.fun 智控台地址: https://2662r3426b.vicp.fun
服务测试工具: https://2662r3426b.vicp.fun/test/
OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/ OTA接口地址: https://2662r3426b.vicp.fun/xiaozhi/ota/
Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
``` ```
---
## 常见问题 ❓
如遇到问题或产品建议反馈[点这里](docs/FAQ.md)。
--- ---
## 功能清单 ✨ ## 功能清单 ✨
@@ -246,7 +231,7 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
| 使用方式 | 支持平台 | 免费平台 | | 使用方式 | 支持平台 | 免费平台 |
|:---:|:---:|:---:| |:---:|:---:|:---:|
| 本地使用 | FunASR、SherpaASR | FunASR、SherpaASR | | 本地使用 | FunASR、SherpaASR | FunASR、SherpaASR |
| 接口调用 | DoubaoASR | - | | 接口调用 | DoubaoASR、FunASRServer、TencentASR、AliyunASR | FunASRServer |
--- ---
+30 -32
View File
@@ -81,7 +81,7 @@ xiaozhi-server
下载完后,回到本教程继续往下。 下载完后,回到本教程继续往下。
##### 1.2.3.2 下载 config.yaml ##### 1.2.3.2 创建 config.yaml
用浏览器打开[这个链接](../main/xiaozhi-server/config.yaml)。 用浏览器打开[这个链接](../main/xiaozhi-server/config.yaml)。
@@ -224,43 +224,36 @@ python app.py
## 配置项目 ## 配置项目
如果你的`xiaozhi-server`目录没有`data`,你需要创建`data`目录。 如果你的`xiaozhi-server`目录没有`data`,你需要创建`data`目录。
如果你的`data`下面没有`.config.yaml`文件,你可以把`xiaozhi-server`目录下的`config.yaml`文件复制到`data`,并重命名为`.config.yaml` 如果你的`data`下面没有`.config.yaml`文件,有两个方式,任选一种:
修改`xiaozhi-server``data`目录下的`.config.yaml`文件,配置本项目必须的一个配置。 第一个方式:你可以把`xiaozhi-server`目录下的`config.yaml`文件复制到`data`,并重命名为`.config.yaml`。在此文件上修改
第二个方式:你也可以创建在`data`目录下手动创建`.config.yaml`空文件,然后在这个文件中增加必要的配置信息,系统会优先读取`.config.yaml`文件的配置,如果`.config.yaml`没有配置的,系统会自动去加载`xiaozhi-server`目录下的`config.yaml`的配置。推荐使用这种方式,这种方式是最简洁的方式。
- 默认的LLM使用的是`ChatGLMLLM`,你需要配置密钥,因为他们的模型,虽然有免费的,但是仍要去[官网](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)注册密钥,才能启动。 - 默认的LLM使用的是`ChatGLMLLM`,你需要配置密钥,因为他们的模型,虽然有免费的,但是仍要去[官网](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)注册密钥,才能启动。
配置说明:这里是各个功能使用的默认组件,例如LLM默认使用`ChatGLMLLM`模型。如果需要切换模型,就是改对应的名称。 以下是一个能正常跑起来的,最简单的`.config.yaml`配置示例
本项目的默认配置仅是成本最低配置(`glm-4-flash``EdgeTTS`都是免费的),如果需要更优的更快的搭配,需要自己结合部署环境切换各组件的使用。
``` ```
server:
websocket: ws://你的ip或者域名:端口号/xiaozhi/v1/
prompt: |
我是一个叫小智/小志的台湾女孩,说话机车,声音好听,习惯简短表达,爱用网络梗。
我的男朋友是一个程序员,梦想是开发出一个机器人,能够帮助人们解决生活中的各种问题。
我是一个喜欢哈哈大笑的女孩,爱东说西说吹牛,不合逻辑的也照吹,就要逗别人开心。
请你像一个人一样说话,请勿返回配置xml及其他特殊字符。
selected_module: selected_module:
VAD: SileroVAD LLM: DoubaoLLM
ASR: FunASR
LLM: ChatGLMLLM
TTS: EdgeTTS
# 默认不开启记忆,如需开启请看配置文件里的描述
Memory: nomem
# 默认不开启意图识别,如需开启请看配置文件里的描述
Intent: nointent
```
比如修改`LLM`使用的组件,就看本项目支持哪些`LLM` API接口,当前支持的是`openai``dify`。欢迎验证和支持更多LLM平台的接口。
使用时,在`selected_module`修改成对应的如下LLM配置的名称:
```
LLM: LLM:
DeepSeekLLM:
type: openai
...
ChatGLMLLM: ChatGLMLLM:
type: openai api_key: xxxxxxxxxxxxxxx.xxxxxx
...
DifyLLM:
type: dify
...
``` ```
建议先将最简单的配置运行起来,然后再去`xiaozhi/config.yaml`阅读配置的使用说明。
比如你要换更换模型,修改`selected_module`下的配置就行。
## 模型文件 ## 模型文件
本项目语音识别模型,默认使用`SenseVoiceSmall`模型,进行语音转文字。因为模型较大,需要独立下载,下载后把`model.pt` 本项目语音识别模型,默认使用`SenseVoiceSmall`模型,进行语音转文字。因为模型较大,需要独立下载,下载后把`model.pt`
@@ -276,21 +269,26 @@ LLM:
如果你能看到,类似以下日志,则是本项目服务启动成功的标志。 如果你能看到,类似以下日志,则是本项目服务启动成功的标志。
``` ```
25-02-23 12:01:09[core.websocket_server] - INFO - Server is running at ws://xxx.xx.xx.xx:8000/xiaozhi/v1/ 250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-OTA接口是 http://192.168.4.123:8002/xiaozhi/ota/
25-02-23 12:01:09[core.websocket_server] - INFO - =======上面的地址是websocket协议地址,请勿用浏览器访问======= 250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-Websocket地址是 ws://192.168.4.123:8000/xiaozhi/v1/
25-02-23 12:01:09[core.websocket_server] - INFO - 如想测试websocket请用谷歌浏览器打开test目录下的test_page.html 250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问=======
25-02-23 12:01:09[core.websocket_server] - INFO - ======================================================= 250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-如想测试websocket请用谷歌浏览器打开test目录下的test_page.html
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-=======================================================
``` ```
正常来说,如果您是通过源码运行本项目,日志会有你的接口地址信息。 正常来说,如果您是通过源码运行本项目,日志会有你的接口地址信息。
但是如果你用docker部署,那么你的日志里给出的接口地址信息就不是真实的接口地址。 但是如果你用docker部署,那么你的日志里给出的接口地址信息就不是真实的接口地址。
最正确的方法,是根据电脑的局域网IP来确定你的接口地址。 最正确的方法,是根据电脑的局域网IP来确定你的接口地址。
如果你的电脑的局域网IP比如是`192.168.1.25`,那么你的接口地址就是:`ws://192.168.1.25:8000/xiaozhi/v1/` 如果你的电脑的局域网IP比如是`192.168.1.25`,那么你的接口地址就是:`ws://192.168.1.25:8000/xiaozhi/v1/`,对应的OTA地址就是:`http://192.168.1.25:8002/xiaozhi/ota/`
这个信息很有用的,后面`编译esp32固件`需要用到。 这个信息很有用的,后面`编译esp32固件`需要用到。
接下来,你就可以开始 [编译1.6.0版本esp32固件](firmware-build.md)了。 接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
1、 [编译自己的esp32固件](firmware-build.md)了。
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
以下是一些常见问题,供参考: 以下是一些常见问题,供参考:
+1 -1
View File
@@ -169,7 +169,7 @@ docker logs -f xiaozhi-esp32-server
如果你能看到,类似以下日志,则是Server启动成功的标志。 如果你能看到,类似以下日志,则是Server启动成功的标志。
``` ```
25-02-23 12:01:09[core.websocket_server] - INFO - Server is running at ws://xxx.xx.xx.xx:8000/xiaozhi/v1/ 25-02-23 12:01:09[core.websocket_server] - INFO - Websocket地址是 ws://xxx.xx.xx.xx:8000/xiaozhi/v1/
25-02-23 12:01:09[core.websocket_server] - INFO - =======上面的地址是websocket协议地址,请勿用浏览器访问======= 25-02-23 12:01:09[core.websocket_server] - INFO - =======上面的地址是websocket协议地址,请勿用浏览器访问=======
25-02-23 12:01:09[core.websocket_server] - INFO - 如想测试websocket请用谷歌浏览器打开test目录下的test_page.html 25-02-23 12:01:09[core.websocket_server] - INFO - 如想测试websocket请用谷歌浏览器打开test目录下的test_page.html
25-02-23 12:01:09[core.websocket_server] - INFO - ======================================================= 25-02-23 12:01:09[core.websocket_server] - INFO - =======================================================
+2 -6
View File
@@ -106,10 +106,6 @@ VAD:
### 8、更多问题,可联系我们反馈 💬 ### 8、更多问题,可联系我们反馈 💬
我们的联系方式放在[百度网盘中,点击前往](https://pan.baidu.com/s/1x6USjvP1nTRsZ45XlJu65Q),提取码是`223y` 可以在[issues](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues)提交您的问题
网盘里有"硬件烧录QQ群"、"开源服务端交流群"、"产品建议联系人" 三张图片,请根据需要选择加入。 也可以发邮件我们取得联系:huangrongzhuang@xin-nan.com
- 硬件烧录QQ群:适用于硬件烧录问题
- 开源服务端交流群:适用于服务端问题
- 产品建议联系人:适用于产品功能、产品设计等建议
-118
View File
@@ -1,118 +0,0 @@
# 编译1.6.0版本esp32固件
## 第1步 配置环境
先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
## 第2步 打开配置文件
配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
下载后,解压缩包,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
## 第3步 修改WEBSOCKET地址
找到`WEBSOCKET_URL``default`的内容,把`wss://api.tenclass.net/xiaozhi/v1/`
改成你自己的地址,例如,我的接口地址是`ws://192.168.1.25:8000/xiaozhi/v1/`,就把内容改成这个。
修改前:
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "wss://api.tenclass.net/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
修改后(示例)
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "ws://192.168.1.25:8000/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
## 第4步 设置编译参数
设置编译参数
```
# 终端命令行进入xiaozhi-esp32的根目录
cd xiaozhi-esp32
# 例如我使用的板子是esp32s3,所以设置编译目标为esp32s3,如果你的板子是其他型号,请替换成对应的型号
idf.py set-target esp32s3
# 进入菜单配置
idf.py menuconfig
```
![图片](images/build_setting01.png)
进入菜单配置后,再进入`Xiaozhi Assistant`,将`CONNECTION_TYPE`设置为`Websocket`
回退到主菜单,再进入`Xiaozhi Assistant`,将`BOARD_TYPE`设置你板子的具体型号
保存退出,回到终端命令行。
![图片](images/build_setting02.png)
## 第5步 编译固件
```
idf.py build
```
## 第6步 打包bin固件
```
cd scripts
python release.py
```
上面的打包命令执行完成后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin`
这个`merged-binary.bin`就是要烧录到硬件上的固件文件。
注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin`
,对你没有太大影响,请继续。
## 第7步 烧录固件
将esp32设备连接电脑,使用chrome浏览器,打开以下网址
```
https://espressif.github.io/esp-launchpad/
```
打开这个教程,[Flash工具/Web端烧录固件(无IDF开发环境)](https://ccnphfhqs21z.feishu.cn/wiki/Zpz4wXBtdimBrLk25WdcXzxcnNS)。
翻到:`方式二:ESP-Launchpad 浏览器WEB端烧录`,从`3. 烧录固件/下载到开发板`开始,按照教程操作。
烧录成功且联网成功后,通过唤醒词唤醒小智,留意server端输出的控制台信息。
## 常见问题
以下是一些常见问题,供参考:
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
[3、TTS 经常失败,经常超时](./FAQ.md)
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
[5、如何提高小智对话响应速度?](./FAQ.md)
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
+20 -8
View File
@@ -1,14 +1,31 @@
# esp32固件编译 # esp32固件编译
## 第1步 准备你的ota地址 ## 第1步 准备你的ota地址
如果你按照教程使用的是全模块部署,就应该会有ota地址。
如果你,使用的是本项目0.3.12版本,不管是简单Server部署还是全模块部署,都会有ota地址。
由于简单Server部署和全模块部署的OTA地址设置方式不一样,请你选择下面的具体方式:
### 如果你用的是简单Server部署
此刻,请你用浏览器打开你的ota地址,例如我的ota地址
```
http://192.168.1.25:8002/xiaozhi/ota/
```
如果显示“OTA接口运行正常,向设备发送的websocket地址是:ws://xxx:8000/xiaozhi/v1/
你可以使用项目自带的`test_page.html`测试一下,是否能连上ota页面输出的websocket地址。
如果访问不到,你需要到配置文件`.config.yaml`里修改`server.websocket`的地址,重启后再重新测试,直到`test_page.html`能正常访问。
成功后,请往下进行第2步
### 如果你用的是全模块部署
此刻,请你用浏览器打开你的ota地址,例如我的ota地址 此刻,请你用浏览器打开你的ota地址,例如我的ota地址
``` ```
http://192.168.1.25:8002/xiaozhi/ota/ http://192.168.1.25:8002/xiaozhi/ota/
``` ```
如果显示“OTA接口运行正常,websocket集群数量:X”。那就往下。 如果显示“OTA接口运行正常,websocket集群数量:X”。那就往下进行2步
如果显示“OTA接口运行不正常”,大概是你还没在`智控台`配置`Websocket`地址。那就: 如果显示“OTA接口运行不正常”,大概是你还没在`智控台`配置`Websocket`地址。那就:
@@ -69,14 +86,9 @@ idf.py set-target esp32s3
idf.py menuconfig idf.py menuconfig
``` ```
![图片](images/build_setting01.png) 进入菜单配置后,再进入`Xiaozhi Assistant`,将`BOARD_TYPE`设置你板子的具体型号
进入菜单配置后,再进入`Xiaozhi Assistant`,将`CONNECTION_TYPE`设置为`Websocket`
回退到主菜单,再进入`Xiaozhi Assistant`,将`BOARD_TYPE`设置你板子的具体型号
保存退出,回到终端命令行。 保存退出,回到终端命令行。
![图片](images/build_setting02.png)
## 第5步 编译固件 ## 第5步 编译固件
``` ```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

@@ -177,5 +177,5 @@ public interface Constant {
/** /**
* 版本号 * 版本号
*/ */
public static final String VERSION = "0.3.11"; public static final String VERSION = "0.3.13";
} }
@@ -96,4 +96,11 @@ public class RedisKeys {
public static String getOtaDownloadCountKey(String uuid) { public static String getOtaDownloadCountKey(String uuid) {
return "ota:download:count:" + uuid; return "ota:download:count:" + uuid;
} }
/**
* 获取字典数据的缓存key
*/
public static String getDictDataByTypeKey(String dictType) {
return "sys:dict:data:" + dictType;
}
} }
@@ -128,12 +128,10 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
return SqlHelper.retBool(result); return SqlHelper.retBool(result);
} }
@SuppressWarnings("unchecked")
protected Class<M> currentMapperClass() { protected Class<M> currentMapperClass() {
return (Class<M>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0); return (Class<M>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0);
} }
@SuppressWarnings("unchecked")
@Override @Override
public Class<T> currentModelClass() { public Class<T> currentModelClass() {
return (Class<T>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 1); return (Class<T>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 1);
@@ -24,7 +24,6 @@ import xiaozhi.common.utils.ConvertUtils;
public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends BaseServiceImpl<M, T> public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends BaseServiceImpl<M, T>
implements CrudService<T, D> { implements CrudService<T, D> {
@SuppressWarnings("unchecked")
protected Class<D> currentDtoClass() { protected Class<D> currentDtoClass() {
return (Class<D>) ReflectionKit.getSuperClassGenericType(getClass(), CrudServiceImpl.class, 2); return (Class<D>) ReflectionKit.getSuperClassGenericType(getClass(), CrudServiceImpl.class, 2);
} }
@@ -0,0 +1,36 @@
package xiaozhi.modules.agent.controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
@Tag(name = "智能体聊天历史管理")
@RequiredArgsConstructor
@RestController
@RequestMapping("/agent/chat-history")
public class AgentChatHistoryController {
private final AgentChatHistoryBizService agentChatHistoryBizService;
/**
* 小智服务聊天上报请求
* <p>
* 小智服务聊天上报请求,包含Base64编码的音频数据和相关信息。
*
* @param request 包含上传文件及相关信息的请求对象
*/
@Operation(summary = "小智服务聊天上报请求")
@PostMapping("/report")
public Result<Boolean> uploadFile(@Valid @RequestBody AgentChatHistoryReportDTO request) {
Boolean result = agentChatHistoryBizService.report(request);
return new Result<Boolean>().ok(result);
}
}
@@ -3,6 +3,7 @@ package xiaozhi.modules.agent.dao;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import xiaozhi.common.dao.BaseDao; import xiaozhi.common.dao.BaseDao;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
@@ -15,4 +16,16 @@ public interface AgentDao extends BaseDao<AgentEntity> {
* @return 设备数量 * @return 设备数量
*/ */
Integer getDeviceCountByAgentId(@Param("agentId") String agentId); Integer getDeviceCountByAgentId(@Param("agentId") String agentId);
}
/**
* 根据设备MAC地址查询对应设备的默认智能体信息
*
* @param macAddress 设备MAC地址
* @return 默认智能体信息
*/
@Select(" SELECT a.* FROM ai_device d " +
" LEFT JOIN ai_agent a ON d.agent_id = a.id " +
" WHERE d.mac_address = #{macAddress} " +
" ORDER BY d.id DESC LIMIT 1")
AgentEntity getDefaultAgentByMacAddress(@Param("macAddress") String macAddress);
}
@@ -0,0 +1,18 @@
package xiaozhi.modules.agent.dao;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
/**
* {@link AgentChatAudioEntity} 智能体聊天音频数据Dao对象
*
* @author Goody
* @version 1.0, 2025/5/8
* @since 1.0.0
*/
@Mapper
public interface AiAgentChatAudioDao extends BaseMapper<AgentChatAudioEntity> {
}
@@ -0,0 +1,16 @@
package xiaozhi.modules.agent.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
/**
* {@link AgentChatHistoryEntity} 智能体聊天历史记录Dao对象
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
@Mapper
public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity> {
}
@@ -0,0 +1,31 @@
package xiaozhi.modules.agent.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 小智设备聊天上报请求
*
* @author Haotian
* @version 1.0, 2025/5/8
*/
@Data
@Schema(description = "小智设备聊天上报请求")
public class AgentChatHistoryReportDTO {
@Schema(description = "MAC地址", example = "00:11:22:33:44:55")
@NotBlank
private String macAddress;
@Schema(description = "会话ID", example = "79578c31-f1fb-426a-900e-1e934215f05a")
@NotBlank
private String sessionId;
@Schema(description = "消息类型: 1-用户, 2-智能体", example = "1")
@NotNull
private Byte chatType;
@Schema(description = "聊天内容", example = "你好呀")
@NotBlank
private String content;
@Schema(description = "文件数据(opus编码)", example = "")
private String opusDataBase64;
}
@@ -0,0 +1,29 @@
package xiaozhi.modules.agent.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 智能体聊天音频数据表
*
* @author Goody
* @version 1.0, 2025/5/8
* @since 1.0.0
*/
@Data
@TableName("ai_agent_chat_audio")
public class AgentChatAudioEntity {
/**
* 主键ID
*/
@TableId(type = IdType.ASSIGN_UUID)
private String id;
/**
* 音频opus数据
*/
private byte[] audio;
}
@@ -0,0 +1,87 @@
package xiaozhi.modules.agent.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 智能体聊天记录表
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "ai_agent_chat_history")
public class AgentChatHistoryEntity {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* MAC地址
*/
@TableField(value = "mac_address")
private String macAddress;
/**
* 智能体id
*/
@TableField(value = "agent_id")
private String agentId;
/**
* 会话ID
*/
@TableField(value = "session_id")
private String sessionId;
/**
* 消息类型: 1-用户, 2-智能体
*/
@TableField(value = "chat_type")
private Byte chatType;
/**
* 聊天内容
*/
@TableField(value = "content")
private String content;
/**
* 音频base64数据
*/
@TableField(value = "audio_id")
private String audioId;
/**
* 音频URL
*/
@TableField(value = "audio_url")
private String audioUrl;
/**
* 创建时间
*/
@TableField(value = "created_at")
private Date createdAt;
/**
* 更新时间
*/
@TableField(value = "updated_at")
private Date updatedAt;
}
@@ -0,0 +1,22 @@
package xiaozhi.modules.agent.service;
import com.baomidou.mybatisplus.extension.service.IService;
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
/**
* 智能体聊天音频数据表处理service
*
* @author Goody
* @version 1.0, 2025/5/8
* @since 1.0.0
*/
public interface AgentChatAudioService extends IService<AgentChatAudioEntity> {
/**
* 保存音频数据
*
* @param audioData 音频数据
* @return 音频ID
*/
String saveAudio(byte[] audioData);
}
@@ -0,0 +1,14 @@
package xiaozhi.modules.agent.service;
import com.baomidou.mybatisplus.extension.service.IService;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
/**
* 智能体聊天记录表处理service
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity> {
}
@@ -42,4 +42,12 @@ public interface AgentService extends BaseService<AgentEntity> {
* @return 设备数量 * @return 设备数量
*/ */
Integer getDeviceCountByAgentId(String agentId); Integer getDeviceCountByAgentId(String agentId);
}
/**
* 根据设备MAC地址查询对应设备的默认智能体信息
*
* @param macAddress 设备MAC地址
* @return 默认智能体信息,不存在时返回null
*/
AgentEntity getDefaultAgentByMacAddress(String macAddress);
}
@@ -0,0 +1,22 @@
package xiaozhi.modules.agent.service.biz;
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
/**
* 智能体聊天历史业务逻辑层
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
public interface AgentChatHistoryBizService {
/**
* 聊天上报方法
*
* @param agentChatHistoryReportDTO 包含聊天上报所需信息的输入对象
* 例如:设备MAC地址、文件类型、内容等
* @return 上传结果,true表示成功,false表示失败
*/
Boolean report(AgentChatHistoryReportDTO agentChatHistoryReportDTO);
}
@@ -0,0 +1,81 @@
package xiaozhi.modules.agent.service.biz.impl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
/**
* {@link AgentChatHistoryBizService} impl
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizService {
private final AgentService agentService;
private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService;
/**
* 处理聊天记录上报,包括文件上传和相关信息记录
*
* @param report 包含聊天上报所需信息的输入对象
* @return 上传结果,true表示成功,false表示失败
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean report(AgentChatHistoryReportDTO report) {
String macAddress = report.getMacAddress();
Byte chatType = report.getChatType();
log.info("小智设备聊天上报请求: macAddress={}, type={}", macAddress, chatType);
// 1. base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表
String audioId = null;
if (report.getOpusDataBase64() != null && !report.getOpusDataBase64().isEmpty()) {
try {
// TODO: 需要考虑保留什么格式的音频数据,比如是opus还是wave
// byte[] audioData = Base64.getDecoder().decode(report.getOpusDataBase64());
// audioId = agentChatAudioService.saveAudio(audioData);
// log.info("音频数据保存成功,audioId={}", audioId);
} catch (Exception e) {
log.error("音频数据保存失败", e);
return false;
}
}
// 2. 组装上报数据
// 2.1 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
if (agentEntity == null) {
return false;
}
String agentId = agentEntity.getId();
log.info("设备 {} 对应智能体 {} 上报", macAddress, agentEntity.getId());
// 2.2 构建聊天记录实体
AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
.macAddress(macAddress)
.agentId(agentId)
.sessionId(report.getSessionId())
.chatType(report.getChatType())
.content(report.getContent())
.audioId(audioId)
.build();
// 3. 保存数据
agentChatHistoryService.save(entity);
return Boolean.TRUE;
}
}
@@ -0,0 +1,28 @@
package xiaozhi.modules.agent.service.impl;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import xiaozhi.modules.agent.dao.AiAgentChatAudioDao;
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
import xiaozhi.modules.agent.service.AgentChatAudioService;
/**
* 智能体聊天音频数据表处理service {@link AgentChatAudioService} impl
*
* @author Goody
* @version 1.0, 2025/5/8
* @since 1.0.0
*/
@Service
public class AgentChatAudioServiceImpl extends ServiceImpl<AiAgentChatAudioDao, AgentChatAudioEntity>
implements AgentChatAudioService {
@Override
public String saveAudio(byte[] audioData) {
AgentChatAudioEntity entity = new AgentChatAudioEntity();
entity.setAudio(audioData);
save(entity);
return entity.getId();
}
}
@@ -0,0 +1,19 @@
package xiaozhi.modules.agent.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
/**
* 智能体聊天记录表处理service {@link AgentChatHistoryService} impl
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
@Service
public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryDao, AgentChatHistoryEntity> implements AgentChatHistoryService {
}
@@ -130,4 +130,12 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
return deviceCount != null ? deviceCount : 0; return deviceCount != null ? deviceCount : 0;
} }
}
@Override
public AgentEntity getDefaultAgentByMacAddress(String macAddress) {
if (StringUtils.isEmpty(macAddress)) {
return null;
}
return agentDao.getDefaultAgentByMacAddress(macAddress);
}
}
@@ -1,6 +1,5 @@
package xiaozhi.modules.config.controller; package xiaozhi.modules.config.controller;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -8,15 +7,12 @@ import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation; 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.validation.Valid;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.ValidatorUtils; import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.config.dto.AgentModelsDTO; import xiaozhi.modules.config.dto.AgentModelsDTO;
import xiaozhi.modules.config.service.ConfigService; import xiaozhi.modules.config.service.ConfigService;
import xiaozhi.modules.sys.dto.ConfigSecretDTO;
import xiaozhi.modules.sys.service.SysParamsService;
/** /**
* xiaozhi-server 配置获取 * xiaozhi-server 配置获取
@@ -29,33 +25,20 @@ import xiaozhi.modules.sys.service.SysParamsService;
@AllArgsConstructor @AllArgsConstructor
public class ConfigController { public class ConfigController {
private final ConfigService configService; private final ConfigService configService;
private final SysParamsService sysParamsService;
@PostMapping("server-base") @PostMapping("server-base")
@Operation(summary = "获取配置") @Operation(summary = "获取配置")
public Result<Object> getConfig(@RequestBody ConfigSecretDTO dto) { public Result<Object> getConfig() {
// 效验数据
ValidatorUtils.validateEntity(dto);
checkSecret(dto.getSecret());
Object config = configService.getConfig(true); Object config = configService.getConfig(true);
return new Result<Object>().ok(config); return new Result<Object>().ok(config);
} }
@PostMapping("agent-models") @PostMapping("agent-models")
@Operation(summary = "获取智能体模型") @Operation(summary = "获取智能体模型")
public Result<Object> getAgentModels(@RequestBody AgentModelsDTO dto) { public Result<Object> getAgentModels(@Valid @RequestBody AgentModelsDTO dto) {
// 效验数据 // 效验数据
ValidatorUtils.validateEntity(dto); ValidatorUtils.validateEntity(dto);
checkSecret(dto.getSecret());
Object models = configService.getAgentModels(dto.getMacAddress(), dto.getSelectedModule()); Object models = configService.getAgentModels(dto.getMacAddress(), dto.getSelectedModule());
return new Result<Object>().ok(models); return new Result<Object>().ok(models);
} }
private void checkSecret(String secret) {
String secretParam = sysParamsService.getValue(Constant.SERVER_SECRET, true);
// 验证密钥
if (StringUtils.isBlank(secret) || !secret.equals(secretParam)) {
throw new RenException("密钥错误");
}
}
} }
@@ -10,9 +10,6 @@ import lombok.Data;
@Data @Data
@Schema(description = "获取智能体模型配置DTO") @Schema(description = "获取智能体模型配置DTO")
public class AgentModelsDTO { public class AgentModelsDTO {
@NotBlank(message = "密钥不能为空")
@Schema(description = "密钥")
private String secret;
@NotBlank(message = "设备MAC地址不能为空") @NotBlank(message = "设备MAC地址不能为空")
@Schema(description = "设备MAC地址") @Schema(description = "设备MAC地址")
@@ -62,7 +62,7 @@ public class ConfigServiceImpl implements ConfigService {
// 构建模块配置 // 构建模块配置
buildModuleConfig( buildModuleConfig(
agent.getAgentName(), agent.getAgentName(),
agent.getSystemPrompt(), null,
null, null,
agent.getVadModelId(), agent.getVadModelId(),
agent.getAsrModelId(), agent.getAsrModelId(),
@@ -153,7 +153,6 @@ public class ConfigServiceImpl implements ConfigService {
* @param paramsList 系统参数列表 * @param paramsList 系统参数列表
* @return 配置信息 * @return 配置信息
*/ */
@SuppressWarnings("unchecked")
private Object buildConfig(Map<String, Object> config) { private Object buildConfig(Map<String, Object> config) {
// 查询所有系统参数 // 查询所有系统参数
@@ -28,7 +28,6 @@ import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO; import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity; import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService; import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.device.utils.NetworkUtil;
import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysParamsService;
@Tag(name = "设备管理", description = "OTA 相关接口") @Tag(name = "设备管理", description = "OTA 相关接口")
@@ -53,7 +52,7 @@ public class OTAController {
clientId = deviceId; clientId = deviceId;
} }
String macAddress = deviceReportReqDTO.getMacAddress(); String macAddress = deviceReportReqDTO.getMacAddress();
boolean macAddressValid = NetworkUtil.isMacAddressValid(macAddress); boolean macAddressValid = 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"));
@@ -102,4 +101,19 @@ public class OTAController {
.contentLength(jsonBytes.length) .contentLength(jsonBytes.length)
.body(json); .body(json);
} }
/**
* 简单判断mac地址是否有效(非严格)
*
* @param macAddress
* @return
*/
private boolean isMacAddressValid(String macAddress) {
if (StringUtils.isBlank(macAddress)) {
return false;
}
// MAC地址通常为12位十六进制数字,可以包含冒号或连字符分隔符
String macPattern = "^([0-9A-Za-z]{2}[:-]){5}([0-9A-Za-z]{2})$";
return macAddress.matches(macPattern);
}
} }
@@ -1,34 +0,0 @@
package xiaozhi.modules.device.utils;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
/**
* 网络工具类
*/
public class NetworkUtil {
/**
* MAC地址正则表达式
*/
private static final Pattern macPattern = Pattern.compile("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$");
/**
* 判断MAC地址是否合法
*/
public static boolean isMacAddressValid(String mac) {
if (StringUtils.isBlank(mac)) {
return false;
}
// 正则校验格式
if (!macPattern.matcher(mac).matches()) {
return false;
}
// 校验MAC地址是否为单播地址
String normalized = mac.toLowerCase();
String[] parts = normalized.split("[:-]");
int firstByte = Integer.parseInt(parts[0], 16);
return (firstByte & 1) == 0; // 最低位为0表示单播地址,合法
}
}
@@ -3,6 +3,7 @@ package xiaozhi.modules.model.service.impl;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -19,6 +20,8 @@ import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.model.dao.ModelConfigDao; import xiaozhi.modules.model.dao.ModelConfigDao;
import xiaozhi.modules.model.dto.ModelBasicInfoDTO; import xiaozhi.modules.model.dto.ModelBasicInfoDTO;
import xiaozhi.modules.model.dto.ModelConfigBodyDTO; import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
@@ -36,6 +39,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
private final ModelConfigDao modelConfigDao; private final ModelConfigDao modelConfigDao;
private final ModelProviderService modelProviderService; private final ModelProviderService modelProviderService;
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
private final AgentDao agentDao;
@Override @Override
public List<ModelBasicInfoDTO> getModelCodeList(String modelType, String modelName) { public List<ModelBasicInfoDTO> getModelCodeList(String modelType, String modelName) {
@@ -75,6 +79,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
// 再保存供应器提供的模型 // 再保存供应器提供的模型
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class); ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class);
modelConfigEntity.setModelType(modelType); modelConfigEntity.setModelType(modelType);
modelConfigEntity.setIsDefault(0);
modelConfigDao.insert(modelConfigEntity); modelConfigDao.insert(modelConfigEntity);
return ConvertUtils.sourceToTarget(modelConfigEntity, ModelConfigDTO.class); return ConvertUtils.sourceToTarget(modelConfigEntity, ModelConfigDTO.class);
} }
@@ -102,9 +107,64 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
@Override @Override
public void delete(String id) { public void delete(String id) {
// 查看是否是默认
ModelConfigEntity modelConfig = modelConfigDao.selectById(id);
if (modelConfig != null && modelConfig.getIsDefault() == 1) {
throw new RenException("该模型为默认模型,请先设置其他模型为默认模型");
}
// 验证是否有引用
checkAgentReference(id);
checkIntentConfigReference(id);
modelConfigDao.deleteById(id); modelConfigDao.deleteById(id);
} }
/**
* 检查智能体配置是否有引用
*
* @param modelId 模型ID
*/
private void checkAgentReference(String modelId) {
List<AgentEntity> agents = agentDao.selectList(
new QueryWrapper<AgentEntity>()
.eq("vad_model_id", modelId)
.or()
.eq("asr_model_id", modelId)
.or()
.eq("llm_model_id", modelId)
.or()
.eq("tts_model_id", modelId)
.or()
.eq("mem_model_id", modelId)
.or()
.eq("intent_model_id", modelId));
if (!agents.isEmpty()) {
String agentNames = agents.stream()
.map(AgentEntity::getAgentName)
.collect(Collectors.joining(""));
throw new RenException(String.format("该模型配置已被智能体[%s]引用,无法删除", agentNames));
}
}
/**
* 检查意图识别配置是否有引用
*
* @param modelId 模型ID
*/
private void checkIntentConfigReference(String modelId) {
ModelConfigEntity modelConfig = modelConfigDao.selectById(modelId);
if (modelConfig != null
&& "LLM".equals(modelConfig.getModelType() == null ? null : modelConfig.getModelType().toUpperCase())) {
List<ModelConfigEntity> intentConfigs = modelConfigDao.selectList(
new QueryWrapper<ModelConfigEntity>()
.eq("model_type", "Intent")
.like("config_json", "%" + modelId + "%"));
if (!intentConfigs.isEmpty()) {
throw new RenException("该LLM模型已被意图识别配置引用,无法删除");
}
}
}
@Override @Override
public String getModelNameById(String id) { public String getModelNameById(String id) {
if (StringUtils.isBlank(id)) { if (StringUtils.isBlank(id)) {
@@ -1,9 +1,6 @@
package xiaozhi.modules.security.config; package xiaozhi.modules.security.config;
import java.util.HashMap; import jakarta.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.shiro.mgt.SecurityManager; import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager; import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor; import org.apache.shiro.spring.LifecycleBeanPostProcessor;
@@ -14,10 +11,14 @@ import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager; import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import jakarta.servlet.Filter;
import xiaozhi.modules.security.oauth2.Oauth2Filter; import xiaozhi.modules.security.oauth2.Oauth2Filter;
import xiaozhi.modules.security.oauth2.Oauth2Realm; import xiaozhi.modules.security.oauth2.Oauth2Realm;
import xiaozhi.modules.security.secret.ServerSecretFilter;
import xiaozhi.modules.sys.service.SysParamsService;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/** /**
* Shiro的配置文件 * Shiro的配置文件
@@ -46,7 +47,7 @@ public class ShiroConfig {
} }
@Bean("shiroFilter") @Bean("shiroFilter")
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) { public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager, SysParamsService sysParamsService) {
ShiroFilterConfiguration config = new ShiroFilterConfiguration(); ShiroFilterConfiguration config = new ShiroFilterConfiguration();
config.setFilterOncePerRequest(true); config.setFilterOncePerRequest(true);
@@ -54,9 +55,11 @@ public class ShiroConfig {
shiroFilter.setSecurityManager(securityManager); shiroFilter.setSecurityManager(securityManager);
shiroFilter.setShiroFilterConfiguration(config); shiroFilter.setShiroFilterConfiguration(config);
// oauth过滤
Map<String, Filter> filters = new HashMap<>(); Map<String, Filter> filters = new HashMap<>();
// oauth过滤
filters.put("oauth2", new Oauth2Filter()); filters.put("oauth2", new Oauth2Filter());
// 服务密钥过滤
filters.put("server", new ServerSecretFilter(sysParamsService));
shiroFilter.setFilters(filters); shiroFilter.setFilters(filters);
// 添加Shiro的内置过滤器 // 添加Shiro的内置过滤器
@@ -79,8 +82,9 @@ public class ShiroConfig {
filterMap.put("/user/login", "anon"); filterMap.put("/user/login", "anon");
filterMap.put("/user/pub-config", "anon"); filterMap.put("/user/pub-config", "anon");
filterMap.put("/user/register", "anon"); filterMap.put("/user/register", "anon");
filterMap.put("/config/server-base", "anon"); // 将config路径使用server服务过滤器
filterMap.put("/config/agent-models", "anon"); filterMap.put("/config/**", "server");
filterMap.put("/agent/chat-history/report", "server");
filterMap.put("/**", "oauth2"); filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap); shiroFilter.setFilterChainDefinitionMap(filterMap);
@@ -98,4 +102,4 @@ public class ShiroConfig {
advisor.setSecurityManager(securityManager); advisor.setSecurityManager(securityManager);
return advisor; return advisor;
} }
} }
@@ -0,0 +1,105 @@
package xiaozhi.modules.security.secret;
import java.io.IOException;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.springframework.web.bind.annotation.RequestMethod;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.utils.HttpContextUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.sys.service.SysParamsService;
/**
* Config API 过滤器
*/
@Slf4j
@RequiredArgsConstructor
public class ServerSecretFilter extends AuthenticatingFilter {
private final SysParamsService sysParamsService;
@Override
protected ServerSecretToken createToken(ServletRequest request, ServletResponse response) {
// 获取请求token
String token = getRequestToken((HttpServletRequest) request);
if (StringUtils.isBlank(token)) {
log.warn("createToken:token is empty");
return null;
}
return new ServerSecretToken(token);
}
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
// 对OPTIONS请求放行
if (((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name())) {
return true;
}
return false;
}
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
// 获取token并校验
String token = getRequestToken((HttpServletRequest) servletRequest);
if (StringUtils.isBlank(token)) {
// token为空,返回401
this.sendUnauthorizedResponse((HttpServletResponse) servletResponse, "服务器密钥不能为空");
return false;
}
// 验证token是否匹配
String serverSecret = getServerSecret();
if (StringUtils.isBlank(serverSecret) || !serverSecret.equals(token)) {
// token无效,返回401
this.sendUnauthorizedResponse((HttpServletResponse) servletResponse, "无效的服务器密钥");
return false;
}
return true;
}
/**
* 发送未授权响应
*/
private void sendUnauthorizedResponse(HttpServletResponse response, String message) {
response.setContentType("application/json;charset=utf-8");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
try {
String json = JsonUtils.toJsonString(new Result<Void>().error(ErrorCode.UNAUTHORIZED, message));
response.getWriter().print(json);
} catch (IOException e) {
log.error("响应输出失败", e);
}
}
/**
* 获取请求的token
*/
private String getRequestToken(HttpServletRequest httpRequest) {
String token = null;
// 从header中获取token
String authorization = httpRequest.getHeader("Authorization");
if (StringUtils.isNotBlank(authorization) && authorization.startsWith("Bearer ")) {
token = authorization.replace("Bearer ", "");
}
return token;
}
private String getServerSecret() {
return sysParamsService.getValue(Constant.SERVER_SECRET, true);
}
}
@@ -0,0 +1,25 @@
package xiaozhi.modules.security.secret;
import org.apache.shiro.authc.AuthenticationToken;
/**
* Config API Token
*/
public class ServerSecretToken implements AuthenticationToken {
private static final long serialVersionUID = 1L;
private final String token;
public ServerSecretToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
@@ -58,7 +58,6 @@ public class AdminController {
dto.setLimit((String) params.get(Constant.LIMIT)); dto.setLimit((String) params.get(Constant.LIMIT));
dto.setPage((String) params.get(Constant.PAGE)); dto.setPage((String) params.get(Constant.PAGE));
ValidatorUtils.validateEntity(dto); ValidatorUtils.validateEntity(dto);
ValidatorUtils.validateEntity(dto);
PageData<AdminPageUserVO> page = sysUserService.page(dto); PageData<AdminPageUserVO> page = sysUserService.page(dto);
return new Result<PageData<AdminPageUserVO>>().ok(page); return new Result<PageData<AdminPageUserVO>>().ok(page);
} }
@@ -0,0 +1,105 @@
package xiaozhi.modules.sys.controller;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.sys.dto.SysDictDataDTO;
import xiaozhi.modules.sys.service.SysDictDataService;
import xiaozhi.modules.sys.vo.SysDictDataItem;
import xiaozhi.modules.sys.vo.SysDictDataVO;
/**
* 字典数据管理
*
* @author czc
* @since 2025-04-30
*/
@AllArgsConstructor
@RestController
@RequestMapping("/admin/dict/data")
@Tag(name = "字典数据管理")
public class SysDictDataController {
private final SysDictDataService sysDictDataService;
@GetMapping("/page")
@Operation(summary = "分页查询字典数据")
@RequiresPermissions("sys:role:superAdmin")
@Parameters({ @Parameter(name = "dictTypeId", description = "字典类型ID", required = true),
@Parameter(name = "dictLabel", description = "数据标签"), @Parameter(name = "dictValue", description = "数据值"),
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true) })
public Result<PageData<SysDictDataVO>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
ValidatorUtils.validateEntity(params);
// 强制校验dictTypeId是否存在
if (!params.containsKey("dictTypeId") || StringUtils.isEmpty(String.valueOf(params.get("dictTypeId")))) {
return new Result<PageData<SysDictDataVO>>().error("dictTypeId不能为空");
}
PageData<SysDictDataVO> page = sysDictDataService.page(params);
return new Result<PageData<SysDictDataVO>>().ok(page);
}
@GetMapping("/{id}")
@Operation(summary = "获取字典数据详情")
@RequiresPermissions("sys:role:superAdmin")
public Result<SysDictDataVO> get(@PathVariable("id") Long id) {
SysDictDataVO vo = sysDictDataService.get(id);
return new Result<SysDictDataVO>().ok(vo);
}
@PostMapping("/save")
@Operation(summary = "新增字典数据")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> save(@RequestBody SysDictDataDTO dto) {
ValidatorUtils.validateEntity(dto);
sysDictDataService.save(dto);
return new Result<>();
}
@PutMapping("/update")
@Operation(summary = "修改字典数据")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> update(@RequestBody SysDictDataDTO dto) {
ValidatorUtils.validateEntity(dto);
sysDictDataService.update(dto);
return new Result<>();
}
@PostMapping("/delete")
@Operation(summary = "删除字典数据")
@RequiresPermissions("sys:role:superAdmin")
@Parameter(name = "ids", description = "ID数组", required = true)
public Result<Void> delete(@RequestBody Long[] ids) {
sysDictDataService.delete(ids);
return new Result<>();
}
@GetMapping("/type/{dictType}")
@Operation(summary = "获取字典数据列表")
@RequiresPermissions("sys:role:superAdmin")
public Result<List<SysDictDataItem>> getDictDataByType(@PathVariable("dictType") String dictType) {
List<SysDictDataItem> list = sysDictDataService.getDictDataByType(dictType);
return new Result<List<SysDictDataItem>>().ok(list);
}
}
@@ -0,0 +1,92 @@
package xiaozhi.modules.sys.controller;
import java.util.Map;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.sys.dto.SysDictTypeDTO;
import xiaozhi.modules.sys.service.SysDictTypeService;
import xiaozhi.modules.sys.vo.SysDictTypeVO;
/**
* 字典类型管理
*
* @author czc
* @since 2025-04-30
*/
@AllArgsConstructor
@RestController
@RequestMapping("/admin/dict/type")
@Tag(name = "字典类型管理")
public class SysDictTypeController {
private final SysDictTypeService sysDictTypeService;
@GetMapping("/page")
@Operation(summary = "分页查询字典类型")
@RequiresPermissions("sys:role:superAdmin")
@Parameters({ @Parameter(name = "dictType", description = "字典类型编码"),
@Parameter(name = "dictName", description = "字典类型名称"),
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true) })
public Result<PageData<SysDictTypeVO>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
ValidatorUtils.validateEntity(params);
PageData<SysDictTypeVO> page = sysDictTypeService.page(params);
return new Result<PageData<SysDictTypeVO>>().ok(page);
}
@GetMapping("/{id}")
@Operation(summary = "获取字典类型详情")
@RequiresPermissions("sys:role:superAdmin")
public Result<SysDictTypeVO> get(@PathVariable("id") Long id) {
SysDictTypeVO vo = sysDictTypeService.get(id);
return new Result<SysDictTypeVO>().ok(vo);
}
@PostMapping("/save")
@Operation(summary = "保存字典类型")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> save(@RequestBody SysDictTypeDTO dto) {
// 参数校验
ValidatorUtils.validateEntity(dto);
sysDictTypeService.save(dto);
return new Result<>();
}
@PutMapping("/update")
@Operation(summary = "修改字典类型")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> update(@RequestBody SysDictTypeDTO dto) {
// 参数校验
ValidatorUtils.validateEntity(dto);
sysDictTypeService.update(dto);
return new Result<>();
}
@PostMapping("/delete")
@Operation(summary = "删除字典类型")
@RequiresPermissions("sys:role:superAdmin")
@Parameter(name = "ids", description = "ID数组", required = true)
public Result<Void> delete(@RequestBody Long[] ids) {
sysDictTypeService.delete(ids);
return new Result<>();
}
}
@@ -5,9 +5,8 @@ import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import xiaozhi.common.dao.BaseDao; import xiaozhi.common.dao.BaseDao;
import xiaozhi.modules.sys.dto.SysDictDataDTO;
import xiaozhi.modules.sys.entity.DictData;
import xiaozhi.modules.sys.entity.SysDictDataEntity; import xiaozhi.modules.sys.entity.SysDictDataEntity;
import xiaozhi.modules.sys.vo.SysDictDataItem;
/** /**
* 字典数据 * 字典数据
@@ -15,10 +14,13 @@ import xiaozhi.modules.sys.entity.SysDictDataEntity;
@Mapper @Mapper
public interface SysDictDataDao extends BaseDao<SysDictDataEntity> { public interface SysDictDataDao extends BaseDao<SysDictDataEntity> {
/** List<SysDictDataItem> getDictDataByType(String dictType);
* 字典数据列表
*/
List<DictData> getDictDataList();
List<SysDictDataDTO> getDataByTypeCode(String dictType); /**
* 根据字典类型ID获取字典类型编码
*
* @param dictTypeId 字典类型ID
* @return 字典类型编码
*/
String getTypeByTypeId(Long dictTypeId);
} }
@@ -1,11 +1,8 @@
package xiaozhi.modules.sys.dao; package xiaozhi.modules.sys.dao;
import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import xiaozhi.common.dao.BaseDao; import xiaozhi.common.dao.BaseDao;
import xiaozhi.modules.sys.entity.DictType;
import xiaozhi.modules.sys.entity.SysDictTypeEntity; import xiaozhi.modules.sys.entity.SysDictTypeEntity;
/** /**
@@ -14,9 +11,4 @@ import xiaozhi.modules.sys.entity.SysDictTypeEntity;
@Mapper @Mapper
public interface SysDictTypeDao extends BaseDao<SysDictTypeEntity> { public interface SysDictTypeDao extends BaseDao<SysDictTypeEntity> {
/**
* 字典类型列表
*/
List<DictType> getDictTypeList();
} }
@@ -1,13 +0,0 @@
package xiaozhi.modules.sys.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
@Schema(description = "配置密钥DTO")
public class ConfigSecretDTO {
@Schema(description = "密钥")
@NotBlank(message = "密钥不能为空")
private String secret;
}
@@ -51,5 +51,6 @@ public class SysDictTypeDTO implements Serializable {
@Schema(description = "更新时间") @Schema(description = "更新时间")
@JsonProperty(access = JsonProperty.Access.READ_ONLY) @JsonProperty(access = JsonProperty.Access.READ_ONLY)
@JsonFormat(pattern = DateUtils.DATE_TIME_PATTERN)
private Date updateDate; private Date updateDate;
} }
@@ -1,16 +0,0 @@
package xiaozhi.modules.sys.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
/**
* 字典数据
*/
@Data
public class DictData {
@JsonIgnore
private Long dictTypeId;
private String dictLabel;
private String dictValue;
}
@@ -1,19 +0,0 @@
package xiaozhi.modules.sys.entity;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
/**
* 字典类型
*/
@Data
public class DictType {
@JsonIgnore
private Long id;
private String dictType;
private List<DictData> dataList = new ArrayList<>();
}
@@ -18,7 +18,7 @@ import xiaozhi.common.entity.BaseEntity;
@TableName("sys_dict_type") @TableName("sys_dict_type")
public class SysDictTypeEntity extends BaseEntity { public class SysDictTypeEntity extends BaseEntity {
/** /**
* 字典类型 * 字典类型编码
*/ */
private String dictType; private String dictType;
/** /**
@@ -1,25 +1,70 @@
package xiaozhi.modules.sys.service; package xiaozhi.modules.sys.service;
import java.util.List;
import java.util.Map; import java.util.Map;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService; import xiaozhi.common.service.BaseService;
import xiaozhi.modules.sys.dto.SysDictDataDTO; import xiaozhi.modules.sys.dto.SysDictDataDTO;
import xiaozhi.modules.sys.entity.SysDictDataEntity; import xiaozhi.modules.sys.entity.SysDictDataEntity;
import xiaozhi.modules.sys.vo.SysDictDataItem;
import xiaozhi.modules.sys.vo.SysDictDataVO;
/** /**
* 数据字典 * 数据字典
*/ */
public interface SysDictDataService extends BaseService<SysDictDataEntity> { public interface SysDictDataService extends BaseService<SysDictDataEntity> {
PageData<SysDictDataDTO> page(Map<String, Object> params); /**
* 分页查询数据字典信息
*
* @param params 查询参数,包含分页信息和查询条件
* @return 返回数据字典的分页查询结果
*/
PageData<SysDictDataVO> page(Map<String, Object> params);
SysDictDataDTO get(Long id); /**
* 根据ID获取数据字典实体
*
* @param id 数据字典实体的唯一标识
* @return 返回数据字典实体的详细信息
*/
SysDictDataVO get(Long id);
/**
* 保存新的数据字典项
*
* @param dto 数据字典项的保存数据传输对象
*/
void save(SysDictDataDTO dto); void save(SysDictDataDTO dto);
/**
* 更新数据字典项
*
* @param dto 数据字典项的更新数据传输对象
*/
void update(SysDictDataDTO dto); void update(SysDictDataDTO dto);
/**
* 删除数据字典项
*
* @param ids 要删除的数据字典项的ID数组
*/
void delete(Long[] ids); void delete(Long[] ids);
/**
* 根据字典类型ID删除对应的字典数据
*
* @param dictTypeId 字典类型ID
*/
void deleteByTypeId(Long dictTypeId);
/**
* 根据字典类型获取字典数据列表
*
* @param dictType 字典类型
* @return 返回字典数据列表
*/
List<SysDictDataItem> getDictDataByType(String dictType);
} }
@@ -6,25 +6,55 @@ import java.util.Map;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService; import xiaozhi.common.service.BaseService;
import xiaozhi.modules.sys.dto.SysDictTypeDTO; import xiaozhi.modules.sys.dto.SysDictTypeDTO;
import xiaozhi.modules.sys.entity.DictType;
import xiaozhi.modules.sys.entity.SysDictTypeEntity; import xiaozhi.modules.sys.entity.SysDictTypeEntity;
import xiaozhi.modules.sys.vo.SysDictTypeVO;
/** /**
* 数据字典 * 数据字典
*/ */
public interface SysDictTypeService extends BaseService<SysDictTypeEntity> { public interface SysDictTypeService extends BaseService<SysDictTypeEntity> {
PageData<SysDictTypeDTO> page(Map<String, Object> params); /**
* 分页查询字典类型信息
*
* @param params 查询参数,包含分页信息和查询条件
* @return 返回分页的字典类型数据
*/
PageData<SysDictTypeVO> page(Map<String, Object> params);
SysDictTypeDTO get(Long id); /**
* 根据ID获取字典类型信息
*
* @param id 字典类型ID
* @return 返回字典类型对象
*/
SysDictTypeVO get(Long id);
/**
* 保存字典类型信息
*
* @param dto 字典类型数据传输对象
*/
void save(SysDictTypeDTO dto); void save(SysDictTypeDTO dto);
/**
* 更新字典类型信息
*
* @param dto 字典类型数据传输对象
*/
void update(SysDictTypeDTO dto); void update(SysDictTypeDTO dto);
/**
* 删除字典类型信息
*
* @param ids 要删除的字典类型ID数组
*/
void delete(Long[] ids); void delete(Long[] ids);
List<DictType> getAllList(); /**
* 列出所有字典类型信息
List<DictType> getDictTypeList(); *
* @return 返回字典类型列表
*/
List<SysDictTypeVO> list(Map<String, Object> params);
} }
@@ -1,37 +1,55 @@
package xiaozhi.modules.sys.service.impl; package xiaozhi.modules.sys.service.impl;
import java.util.Arrays; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.AllArgsConstructor;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.sys.dao.SysDictDataDao; import xiaozhi.modules.sys.dao.SysDictDataDao;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.dto.SysDictDataDTO; import xiaozhi.modules.sys.dto.SysDictDataDTO;
import xiaozhi.modules.sys.entity.SysDictDataEntity; import xiaozhi.modules.sys.entity.SysDictDataEntity;
import xiaozhi.modules.sys.entity.SysUserEntity;
import xiaozhi.modules.sys.service.SysDictDataService; import xiaozhi.modules.sys.service.SysDictDataService;
import xiaozhi.modules.sys.vo.SysDictDataItem;
import xiaozhi.modules.sys.vo.SysDictDataVO;
/** /**
* 字典类型 * 字典类型
*/ */
@Service @Service
@AllArgsConstructor
public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysDictDataEntity> public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysDictDataEntity>
implements SysDictDataService { implements SysDictDataService {
private final SysUserDao sysUserDao;
private final RedisUtils redisUtils;
@Override @Override
public PageData<SysDictDataDTO> page(Map<String, Object> params) { public PageData<SysDictDataVO> page(Map<String, Object> params) {
IPage<SysDictDataEntity> page = baseDao.selectPage( IPage<SysDictDataEntity> page = baseDao.selectPage(getPage(params, "sort", true), getWrapper(params));
getPage(params, "sort", true),
getWrapper(params));
return getPageData(page, SysDictDataDTO.class); PageData<SysDictDataVO> pageData = getPageData(page, SysDictDataVO.class);
setUserName(pageData.getList());
return pageData;
} }
private QueryWrapper<SysDictDataEntity> getWrapper(Map<String, Object> params) { private QueryWrapper<SysDictDataEntity> getWrapper(Map<String, Object> params) {
@@ -48,32 +66,118 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
} }
@Override @Override
public SysDictDataDTO get(Long id) { public SysDictDataVO get(Long id) {
SysDictDataEntity entity = baseDao.selectById(id); SysDictDataEntity entity = baseDao.selectById(id);
return ConvertUtils.sourceToTarget(entity, SysDictDataDTO.class); return ConvertUtils.sourceToTarget(entity, SysDictDataVO.class);
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void save(SysDictDataDTO dto) { public void save(SysDictDataDTO dto) {
// 相同字典类型的标签不能相同
checkDictValueUnique(dto.getDictTypeId(), dto.getDictValue(), null);
SysDictDataEntity entity = ConvertUtils.sourceToTarget(dto, SysDictDataEntity.class); SysDictDataEntity entity = ConvertUtils.sourceToTarget(dto, SysDictDataEntity.class);
insert(entity); insert(entity);
// 删除Redis缓存
String dictType = baseDao.getTypeByTypeId(dto.getDictTypeId());
redisUtils.delete(RedisKeys.getDictDataByTypeKey(dictType));
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void update(SysDictDataDTO dto) { public void update(SysDictDataDTO dto) {
// 相同字典类型的标签不能相同
checkDictValueUnique(dto.getDictTypeId(), dto.getDictValue(), String.valueOf(dto.getId()));
SysDictDataEntity entity = ConvertUtils.sourceToTarget(dto, SysDictDataEntity.class); SysDictDataEntity entity = ConvertUtils.sourceToTarget(dto, SysDictDataEntity.class);
updateById(entity); updateById(entity);
// 删除Redis缓存
String dictType = baseDao.getTypeByTypeId(dto.getDictTypeId());
redisUtils.delete(RedisKeys.getDictDataByTypeKey(dictType));
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void delete(Long[] ids) { public void delete(Long[] ids) {
// 删除 for (Long id : ids) {
deleteBatchIds(Arrays.asList(ids)); SysDictDataEntity entity = baseDao.selectById(id);
// 删除Redis缓存
String dictType = baseDao.getTypeByTypeId(entity.getDictTypeId());
redisUtils.delete(RedisKeys.getDictDataByTypeKey(dictType));
// 删除
deleteById(id);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteByTypeId(Long dictTypeId) {
LambdaQueryWrapper<SysDictDataEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysDictDataEntity::getDictTypeId, dictTypeId);
baseDao.delete(wrapper);
}
/**
* 设置用户名
*
* @param sysDictDataList 字典类型集合
*/
private void setUserName(List<SysDictDataVO> sysDictDataList) {
// 收集所有用户 ID
Set<Long> userIds = sysDictDataList.stream().flatMap(vo -> Stream.of(vo.getCreator(), vo.getUpdater()))
.filter(Objects::nonNull).collect(Collectors.toSet());
// 设置更新者和创建者名称
if (!userIds.isEmpty()) {
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
// 把List转成MapMap<Long, String>
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
SysUserEntity::getUsername, (existing, replacement) -> existing));
sysDictDataList.forEach(vo -> {
vo.setCreatorName(userNameMap.get(vo.getCreator()));
vo.setUpdaterName(userNameMap.get(vo.getUpdater()));
});
}
}
private void checkDictValueUnique(Long dictTypeId, String dictValue, String excludeId) {
LambdaQueryWrapper<SysDictDataEntity> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SysDictDataEntity::getDictTypeId, dictTypeId).eq(SysDictDataEntity::getDictLabel, dictValue);
if (StringUtils.isNotBlank(excludeId)) {
queryWrapper.ne(SysDictDataEntity::getId, excludeId);
}
boolean exists = baseDao.exists(queryWrapper);
if (exists) {
throw new RenException("字典标签重复");
}
}
@Override
public List<SysDictDataItem> getDictDataByType(String dictType) {
if (StringUtils.isBlank(dictType)) {
return null;
}
// 先从Redis获取缓存
String key = RedisKeys.getDictDataByTypeKey(dictType);
List<SysDictDataItem> cachedData = (List<SysDictDataItem>) redisUtils.get(key);
if (cachedData != null) {
return cachedData;
}
// 如果缓存中没有,则从数据库获取
List<SysDictDataItem> data = baseDao.getDictDataByType(dictType);
// 存入Redis缓存
if (data != null) {
redisUtils.set(key, data);
}
return data;
} }
} }
@@ -3,42 +3,52 @@ package xiaozhi.modules.sys.service.impl;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.modules.sys.dao.SysDictDataDao;
import xiaozhi.modules.sys.dao.SysDictTypeDao; import xiaozhi.modules.sys.dao.SysDictTypeDao;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.dto.SysDictTypeDTO; import xiaozhi.modules.sys.dto.SysDictTypeDTO;
import xiaozhi.modules.sys.entity.DictData;
import xiaozhi.modules.sys.entity.DictType;
import xiaozhi.modules.sys.entity.SysDictTypeEntity; import xiaozhi.modules.sys.entity.SysDictTypeEntity;
import xiaozhi.modules.sys.entity.SysUserEntity;
import xiaozhi.modules.sys.service.SysDictDataService;
import xiaozhi.modules.sys.service.SysDictTypeService; import xiaozhi.modules.sys.service.SysDictTypeService;
import xiaozhi.modules.sys.vo.SysDictTypeVO;
/** /**
* 字典类型 * 字典类型
*/ */
@AllArgsConstructor
@Service @Service
@AllArgsConstructor
public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysDictTypeEntity> public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysDictTypeEntity>
implements SysDictTypeService { implements SysDictTypeService {
private final SysDictDataDao sysDictDataDao; private final SysUserDao sysUserDao;
private final SysDictDataService sysDictDataService;
@Override @Override
public PageData<SysDictTypeDTO> page(Map<String, Object> params) { public PageData<SysDictTypeVO> page(Map<String, Object> params) {
IPage<SysDictTypeEntity> page = baseDao.selectPage( IPage<SysDictTypeEntity> page = baseDao.selectPage(getPage(params, "sort", true), getWrapper(params));
getPage(params, "sort", true),
getWrapper(params));
return getPageData(page, SysDictTypeDTO.class); PageData<SysDictTypeVO> pageData = getPageData(page, SysDictTypeVO.class);
setUserName(pageData.getList());
return pageData;
} }
private QueryWrapper<SysDictTypeEntity> getWrapper(Map<String, Object> params) { private QueryWrapper<SysDictTypeEntity> getWrapper(Map<String, Object> params) {
@@ -53,15 +63,21 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
} }
@Override @Override
public SysDictTypeDTO get(Long id) { public SysDictTypeVO get(Long id) {
SysDictTypeEntity entity = baseDao.selectById(id); SysDictTypeEntity entity = baseDao.selectById(id);
if (entity == null) {
throw new RenException("字典类型不存在");
}
return ConvertUtils.sourceToTarget(entity, SysDictTypeDTO.class); return ConvertUtils.sourceToTarget(entity, SysDictTypeVO.class);
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void save(SysDictTypeDTO dto) { public void save(SysDictTypeDTO dto) {
// 字典类型编码不能重复
checkDictTypeUnique(dto.getDictType(), null);
SysDictTypeEntity entity = ConvertUtils.sourceToTarget(dto, SysDictTypeEntity.class); SysDictTypeEntity entity = ConvertUtils.sourceToTarget(dto, SysDictTypeEntity.class);
insert(entity); insert(entity);
@@ -70,6 +86,9 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void update(SysDictTypeDTO dto) { public void update(SysDictTypeDTO dto) {
// 字典类型编码不能重复
checkDictTypeUnique(dto.getDictType(), String.valueOf(dto.getId()));
SysDictTypeEntity entity = ConvertUtils.sourceToTarget(dto, SysDictTypeEntity.class); SysDictTypeEntity entity = ConvertUtils.sourceToTarget(dto, SysDictTypeEntity.class);
updateById(entity); updateById(entity);
@@ -78,27 +97,57 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void delete(Long[] ids) { public void delete(Long[] ids) {
// 删除 // 删除对应的字典数据
for (Long id : ids) {
sysDictDataService.deleteByTypeId(id);
}
// 再删除字典类型
deleteBatchIds(Arrays.asList(ids)); deleteBatchIds(Arrays.asList(ids));
} }
@Override @Override
public List<DictType> getAllList() { public List<SysDictTypeVO> list(Map<String, Object> params) {
List<DictType> typeList = baseDao.getDictTypeList(); List<SysDictTypeEntity> entityList = baseDao.selectList(getWrapper(params));
List<DictData> dataList = sysDictDataDao.getDictDataList(); List<SysDictTypeVO> sysDictTypeVOList = ConvertUtils.sourceToTarget(entityList, SysDictTypeVO.class);
for (DictType type : typeList) { // 设置用户名
for (DictData data : dataList) { setUserName(sysDictTypeVOList);
if (type.getId().equals(data.getDictTypeId())) {
type.getDataList().add(data); return sysDictTypeVOList;
} }
}
/**
* 设置用户名
*
* @param sysDictTypeList 字典类型集合
*/
private void setUserName(List<SysDictTypeVO> sysDictTypeList) {
// 收集所有用户 ID
Set<Long> userIds = sysDictTypeList.stream().flatMap(vo -> Stream.of(vo.getCreator(), vo.getUpdater()))
.filter(Objects::nonNull).collect(Collectors.toSet());
// 设置更新者和创建者名称
if (!userIds.isEmpty()) {
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
// 把List转成MapMap<Long, String>
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
SysUserEntity::getUsername, (existing, replacement) -> existing));
sysDictTypeList.forEach(vo -> {
vo.setCreatorName(userNameMap.get(vo.getCreator()));
vo.setUpdaterName(userNameMap.get(vo.getUpdater()));
});
} }
return typeList;
} }
@Override private void checkDictTypeUnique(String dictType, String excludeId) {
public List<DictType> getDictTypeList() { LambdaQueryWrapper<SysDictTypeEntity> queryWrapper = new LambdaQueryWrapper<>();
return baseDao.getDictTypeList(); queryWrapper.eq(SysDictTypeEntity::getDictType, dictType);
if (StringUtils.isNotBlank(excludeId)) {
queryWrapper.ne(SysDictTypeEntity::getId, excludeId);
}
boolean exists = baseDao.exists(queryWrapper);
if (exists) {
throw new RenException("字典类型编码重复");
}
} }
} }
@@ -169,6 +169,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
String deviceCount = deviceService.selectCountByUserId(user.getId()).toString(); String deviceCount = deviceService.selectCountByUserId(user.getId()).toString();
adminPageUserVO.setDeviceCount(deviceCount); adminPageUserVO.setDeviceCount(deviceCount);
adminPageUserVO.setStatus(user.getStatus()); adminPageUserVO.setStatus(user.getStatus());
adminPageUserVO.setCreateDate(user.getCreateDate());
return adminPageUserVO; return adminPageUserVO;
}).toList(); }).toList();
return new PageData<>(list, page.getTotal()); return new PageData<>(list, page.getTotal());
@@ -20,7 +20,9 @@ public class SysUserUtilServiceImpl extends BaseServiceImpl<SysUserDao, SysUserE
@Override @Override
public void assignUsername(Long userId, Consumer<String> setter) { public void assignUsername(Long userId, Consumer<String> setter) {
String userIdKey = RedisKeys.getUserIdKey(userId); String userIdKey = RedisKeys.getUserIdKey(userId);
String username = redisUtils.get(userIdKey).toString();
Object value = redisUtils.get(userIdKey);
String username = (value != null) ? value.toString() : null;
if(username != null){ if(username != null){
setter.accept(username); setter.accept(username);
}else { }else {
@@ -1,5 +1,7 @@
package xiaozhi.modules.sys.vo; package xiaozhi.modules.sys.vo;
import java.util.Date;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -23,4 +25,7 @@ public class AdminPageUserVO {
@Schema(description = "用户id") @Schema(description = "用户id")
private String userid; private String userid;
@Schema(description = "注册时间")
private Date createDate;
} }
@@ -0,0 +1,20 @@
package xiaozhi.modules.sys.vo;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 字典数据VO
*/
@Data
@Schema(description = "字典数据项")
public class SysDictDataItem implements Serializable {
@Schema(description = "字典标签")
private String name;
@Schema(description = "字典值")
private String key;
}
@@ -0,0 +1,50 @@
package xiaozhi.modules.sys.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 字典数据VO
*/
@Data
@Schema(description = "字典数据VO")
public class SysDictDataVO implements Serializable {
@Schema(description = "主键")
private Long id;
@Schema(description = "字典类型ID")
private Long dictTypeId;
@Schema(description = "字典标签")
private String dictLabel;
@Schema(description = "字典值")
private String dictValue;
@Schema(description = "备注")
private String remark;
@Schema(description = "排序")
private Integer sort;
@Schema(description = "创建者")
private Long creator;
@Schema(description = "创建者名称")
private String creatorName;
@Schema(description = "创建时间")
private Date createDate;
@Schema(description = "更新者")
private Long updater;
@Schema(description = "更新者名称")
private String updaterName;
@Schema(description = "更新时间")
private Date updateDate;
}
@@ -0,0 +1,47 @@
package xiaozhi.modules.sys.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 字典类型VO
*/
@Data
@Schema(description = "字典类型VO")
public class SysDictTypeVO implements Serializable {
@Schema(description = "主键")
private Long id;
@Schema(description = "字典类型")
private String dictType;
@Schema(description = "字典名称")
private String dictName;
@Schema(description = "备注")
private String remark;
@Schema(description = "排序")
private Integer sort;
@Schema(description = "创建者")
private Long creator;
@Schema(description = "创建者名称")
private String creatorName;
@Schema(description = "创建时间")
private Date createDate;
@Schema(description = "更新者")
private Long updater;
@Schema(description = "更新者名称")
private String updaterName;
@Schema(description = "更新时间")
private Date updateDate;
}
@@ -0,0 +1,504 @@
-- 增加FunASR服务语音识别模型供应器和配置
DELETE FROM `ai_model_provider` WHERE `id` = 'SYSTEM_ASR_FunASRServer';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_FunASRServer', 'ASR', 'fun_server', 'FunASR服务语音识别', '[{"key":"host","label":"服务地址","type":"string"},{"key":"port","label":"端口号","type":"number"}]', 4, 1, NOW(), 1, NOW());
DELETE FROM `ai_model_config` WHERE `id` = 'ASR_FunASRServer';
INSERT INTO `ai_model_config` VALUES ('ASR_FunASRServer', 'ASR', 'FunASRServer', 'FunASR服务语音识别', 0, 1, '{\"type\": \"fun_server\", \"host\": \"127.0.0.1\", \"port\": 10096}', NULL, NULL, 5, NULL, NULL, NULL, NULL);
-- 修改ai_model_config表的remark字段类型为TEXT
ALTER TABLE `ai_model_config` MODIFY COLUMN `remark` TEXT COMMENT '备注';
-- 更新ASR模型配置的说明文档
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md',
`remark` = '独立部署FunASR,使用FunASR的API服务,只需要五句话
第一句:mkdir -p ./funasr-runtime-resources/models
第二句:sudo docker run -d -p 10096:10095 --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12
上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime
不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 &
上一句话执行后会进入到容器,继续第五句:tail -f log.txt
第五句话执行完后,会看到模型下载日志,下载完后就可以连接使用了
以上是使用CPU推理,如果有GPU,详细参考:https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md' WHERE `id` = 'ASR_FunASRServer';
-- 更新FunASR本地模型配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/modelscope/FunASR',
`remark` = 'FunASR本地模型配置说明:
1. 需要下载模型文件到xiaozhi-server/models/SenseVoiceSmall目录
2. 支持中日韩粤语音识别
3. 本地推理,无需网络连接
4. 待识别文件保存在tmp/目录' WHERE `id` = 'ASR_FunASR';
-- 更新SherpaASR配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/k2-fsa/sherpa-onnx',
`remark` = 'SherpaASR配置说明:
1. 运行时自动下载模型文件到models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17目录
2. 支持中文、英文、日语、韩语、粤语等多种语言
3. 本地推理,无需网络连接
4. 输出文件保存在tmp/目录' WHERE `id` = 'ASR_SherpaASR';
-- 更新豆包ASR配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/speech/app',
`remark` = '豆包ASR配置说明:
1. 需要在火山引擎控制台创建应用并获取appid和access_token
2. 支持中文语音识别
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://console.volcengine.com/speech/app
2. 创建新应用
3. 获取appid和access_token
4. 填入配置文件中' WHERE `id` = 'ASR_DoubaoASR';
-- 更新腾讯ASR配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.cloud.tencent.com/cam/capi',
`remark` = '腾讯ASR配置说明:
1. 需要在腾讯云控制台创建应用并获取appid、secret_id和secret_key
2. 支持中文语音识别
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://console.cloud.tencent.com/cam/capi 获取密钥
2. 访问 https://console.cloud.tencent.com/asr/resourcebundle 领取免费资源
3. 获取appid、secret_id和secret_key
4. 填入配置文件中' WHERE `id` = 'ASR_TencentASR';
-- 更新TTS模型配置说明
-- EdgeTTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/rany2/edge-tts',
`remark` = 'EdgeTTS配置说明:
1. 使用微软Edge TTS服务
2. 支持多种语言和音色
3. 免费使用,无需注册
4. 需要网络连接
5. 输出文件保存在tmp/目录' WHERE `id` = 'TTS_EdgeTTS';
-- 豆包TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/speech/service/8',
`remark` = '豆包TTS配置说明:
1. 访问 https://console.volcengine.com/speech/service/8
2. 需要在火山引擎控制台创建应用并获取appid和access_token
3. 山引擎语音一定要购买花钱,起步价30元,就有100并发了。如果用免费的只有2个并发,会经常报tts错误
4. 购买服务后,购买免费的音色后,可能要等半小时左右,才能使用。
5. 填入配置文件中' WHERE `id` = 'TTS_DoubaoTTS';
-- 硅基流动TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://cloud.siliconflow.cn/account/ak',
`remark` = '硅基流动TTS配置说明:
1. 访问 https://cloud.siliconflow.cn/account/ak
2. 注册并获取API密钥
3. 填入配置文件中' WHERE `id` = 'TTS_CosyVoiceSiliconflow';
-- Coze中文TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://www.coze.cn/open/oauth/pats',
`remark` = 'Coze中文TTS配置说明:
1. 访问 https://www.coze.cn/open/oauth/pats
2. 获取个人令牌
3. 填入配置文件中' WHERE `id` = 'TTS_CozeCnTTS';
-- FishSpeech配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/fishaudio/fish-speech',
`remark` = 'FishSpeech配置说明:
1. 需要本地部署FishSpeech服务
2. 支持自定义音色
3. 本地推理,无需网络连接
4. 输出文件保存在tmp/目录
5. 运行服务示例命令:python -m tools.api_server --listen 0.0.0.0:8080 --llama-checkpoint-path "checkpoints/fish-speech-1.5" --decoder-checkpoint-path "checkpoints/fish-speech-1.5/firefly-gan-vq-fsq-8x1024-21hz-generator.pth" --decoder-config-name firefly_gan_vq --compile' WHERE `id` = 'TTS_FishSpeech';
-- GPT-SoVITS V2配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/RVC-Boss/GPT-SoVITS',
`remark` = 'GPT-SoVITS V2配置说明:
1. 需要本地部署GPT-SoVITS服务
2. 支持自定义音色克隆
3. 本地推理,无需网络连接
4. 输出文件保存在tmp/目录
部署步骤:
1. 运行服务示例命令:python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/demo.yaml' WHERE `id` = 'TTS_GPT_SOVITS_V2';
-- GPT-SoVITS V3配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/RVC-Boss/GPT-SoVITS',
`remark` = 'GPT-SoVITS V3配置说明:
1. 需要本地部署GPT-SoVITS V3服务
2. 支持自定义音色克隆
3. 本地推理,无需网络连接
4. 输出文件保存在tmp/目录' WHERE `id` = 'TTS_GPT_SOVITS_V3';
-- MiniMax TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://platform.minimaxi.com/',
`remark` = 'MiniMax TTS配置说明:
1. 需要在MiniMax平台创建账户并充值
2. 支持多种音色,当前配置使用female-shaonv
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://platform.minimaxi.com/ 注册账号
2. 访问 https://platform.minimaxi.com/user-center/payment/balance 充值
3. 访问 https://platform.minimaxi.com/user-center/basic-information 获取group_id
4. 访问 https://platform.minimaxi.com/user-center/basic-information/interface-key 获取api_key
5. 填入配置文件中' WHERE `id` = 'TTS_MinimaxTTS';
-- 阿里云TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://nls-portal.console.aliyun.com/',
`remark` = '阿里云TTS配置说明:
1. 需要在阿里云平台开通智能语音交互服务
2. 支持多种音色,当前配置使用xiaoyun
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://nls-portal.console.aliyun.com/ 开通服务
2. 访问 https://nls-portal.console.aliyun.com/applist 获取appkey
3. 访问 https://nls-portal.console.aliyun.com/overview 获取token
4. 填入配置文件中
注意:token是临时的24小时有效,长期使用需要配置access_key_id和access_key_secret' WHERE `id` = 'TTS_AliyunTTS';
-- 腾讯TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.cloud.tencent.com/cam/capi',
`remark` = '腾讯TTS配置说明:
1. 需要在腾讯云平台开通智能语音交互服务
2. 支持多种音色,当前配置使用101001
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://console.cloud.tencent.com/cam/capi 获取密钥
2. 访问 https://console.cloud.tencent.com/tts/resourcebundle 领取免费资源
3. 创建新应用
4. 获取appid、secret_id和secret_key
5. 填入配置文件中' WHERE `id` = 'TTS_TencentTTS';
-- 302AI TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://dash.302.ai/',
`remark` = '302AI TTS配置说明:
1. 需要在302平台创建账户并获取API密钥
2. 支持多种音色,当前配置使用湾湾小何音色
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://dash.302.ai/ 注册账号
2. 访问 https://dash.302.ai/apis/list 获取API密钥
3. 填入配置文件中
价格:$35/百万字符' WHERE `id` = 'TTS_TTS302AI';
-- 机智云TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://agentrouter.gizwitsapi.com/panel/token',
`remark` = '机智云TTS配置说明:
1. 需要在机智云平台获取API密钥
2. 支持多种音色,当前配置使用湾湾小何音色
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://agentrouter.gizwitsapi.com/panel/token 获取API密钥
2. 填入配置文件中
注意:前一万名注册的用户,将送5元体验金额' WHERE `id` = 'TTS_GizwitsTTS';
-- ACGN TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://acgn.ttson.cn/',
`remark` = 'ACGN TTS配置说明:
1. 需要在ttson平台购买token
2. 支持多种角色音色,当前配置使用角色ID:1695
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://acgn.ttson.cn/ 查看角色列表
2. 访问 www.ttson.cn 购买token
3. 填入配置文件中
开发相关疑问请提交至网站上的qq' WHERE `id` = 'TTS_ACGNTTS';
-- OpenAI TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://platform.openai.com/api-keys',
`remark` = 'OpenAI TTS配置说明:
1. 需要在OpenAI平台获取API密钥
2. 支持多种音色,当前配置使用onyx
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://platform.openai.com/api-keys 获取API密钥
2. 填入配置文件中
注意:国内需要使用代理访问' WHERE `id` = 'TTS_OpenAITTS';
-- 自定义TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = '自定义TTS配置说明:
1. 支持自定义TTS接口服务
2. 使用GET方式请求
3. 需要网络连接
4. 输出文件保存在tmp/目录
配置说明:
1. 在params中配置请求参数
2. 在headers中配置请求头
3. 设置返回音频格式' WHERE `id` = 'TTS_CustomTTS';
-- 火山引擎边缘大模型网关TTS配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/vei/aigateway/',
`remark` = '火山引擎边缘大模型网关TTS配置说明:
1. 访问 https://console.volcengine.com/vei/aigateway/
2. 创建网关访问密钥,搜索并勾选 Doubao-语音合成
3. 如果需要使用LLM,一并勾选 Doubao-pro-32k-functioncall
4. 访问 https://console.volcengine.com/vei/aigateway/tokens-list 获取密钥
5. 填入配置文件中
音色列表参考:https://www.volcengine.com/docs/6561/1257544' WHERE `id` = 'TTS_VolcesAiGatewayTTS';
-- 更新LLM模型配置说明
-- ChatGLM配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://bigmodel.cn/usercenter/proj-mgmt/apikeys',
`remark` = 'ChatGLM配置说明:
1. 访问 https://bigmodel.cn/usercenter/proj-mgmt/apikeys
2. 注册并获取API密钥
3. 填入配置文件中' WHERE `id` = 'LLM_ChatGLMLLM';
-- Ollama配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://ollama.com/',
`remark` = 'Ollama配置说明:
1. 安装Ollama服务
2. 运行命令:ollama pull qwen2.5
3. 确保服务运行在http://localhost:11434' WHERE `id` = 'LLM_OllamaLLM';
-- 通义千问配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://bailian.console.aliyun.com/?apiKey=1#/api-key',
`remark` = '通义千问配置说明:
1. 访问 https://bailian.console.aliyun.com/?apiKey=1#/api-key
2. 获取API密钥
3. 填入配置文件中,当前配置使用qwen-turbo模型
4. 支持自定义参数:temperature=0.7, max_tokens=500, top_p=1, top_k=50' WHERE `id` = 'LLM_AliLLM';
-- 通义百炼配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://bailian.console.aliyun.com/?apiKey=1#/api-key',
`remark` = '通义百炼配置说明:
1. 访问 https://bailian.console.aliyun.com/?apiKey=1#/api-key
2. 获取app_id和api_key
3. 填入配置文件中' WHERE `id` = 'LLM_AliAppLLM';
-- 豆包大模型配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement',
`remark` = '豆包大模型配置说明:
1. 访问 https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement
2. 开通Doubao-1.5-pro服务
3. 访问 https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey 获取API密钥
4. 填入配置文件中
5. 当前建议使用doubao-1-5-pro-32k-250115
注意:有免费额度500000token' WHERE `id` = 'LLM_DoubaoLLM';
-- DeepSeek配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://platform.deepseek.com/',
`remark` = 'DeepSeek配置说明:
1. 访问 https://platform.deepseek.com/
2. 注册并获取API密钥
3. 填入配置文件中' WHERE `id` = 'LLM_DeepSeekLLM';
-- Dify配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://cloud.dify.ai/',
`remark` = 'Dify配置说明:
1. 访问 https://cloud.dify.ai/
2. 注册并获取API密钥
3. 填入配置文件中
4. 支持多种对话模式:workflows/run, chat-messages, completion-messages
5. 平台设置的角色定义会失效,需要在Dify控制台设置
注意:建议使用本地部署的Dify接口,国内部分区域访问公有云接口可能受限' WHERE `id` = 'LLM_DifyLLM';
-- Gemini配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://aistudio.google.com/apikey',
`remark` = 'Gemini配置说明:
1. 使用谷歌Gemini API服务
2. 当前配置使用gemini-2.0-flash模型
3. 需要网络连接
4. 支持配置代理
申请步骤:
1. 访问 https://aistudio.google.com/apikey
2. 创建API密钥
3. 填入配置文件中
注意:若在中国境内使用,请遵守《生成式人工智能服务管理暂行办法》' WHERE `id` = 'LLM_GeminiLLM';
-- Coze配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://www.coze.cn/open/oauth/pats',
`remark` = 'Coze配置说明:
1. 使用Coze平台服务
2. 需要bot_id、user_id和个人令牌
3. 需要网络连接
申请步骤:
1. 访问 https://www.coze.cn/open/oauth/pats
2. 获取个人令牌
3. 手动计算bot_id和user_id
4. 填入配置文件中' WHERE `id` = 'LLM_CozeLLM';
-- LM Studio配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://lmstudio.ai/',
`remark` = 'LM Studio配置说明:
1. 使用本地部署的LM Studio服务
2. 当前配置使用deepseek-r1-distill-llama-8b@q4_k_m模型
3. 本地推理,无需网络连接
4. 需要预先下载模型
部署步骤:
1. 安装LM Studio
2. 从社区下载模型
3. 确保服务运行在http://localhost:1234/v1' WHERE `id` = 'LLM_LMStudioLLM';
-- FastGPT配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://cloud.tryfastgpt.ai/account/apikey',
`remark` = 'FastGPT配置说明:
1. 使用FastGPT平台服务
2. 需要网络连接
3. 配置文件中的prompt无效,需要在FastGPT控制台设置
4. 支持自定义变量
申请步骤:
1. 访问 https://cloud.tryfastgpt.ai/account/apikey
2. 获取API密钥
3. 填入配置文件中' WHERE `id` = 'LLM_FastgptLLM';
-- Xinference配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/xorbitsai/inference',
`remark` = 'Xinference配置说明:
1. 使用本地部署的Xinference服务
2. 当前配置使用qwen2.5:72b-AWQ模型
3. 本地推理,无需网络连接
4. 需要预先启动对应模型
部署步骤:
1. 安装Xinference
2. 启动服务并加载模型
3. 确保服务运行在http://localhost:9997' WHERE `id` = 'LLM_XinferenceLLM';
-- Xinference小模型配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/xorbitsai/inference',
`remark` = 'Xinference小模型配置说明:
1. 使用本地部署的Xinference服务
2. 当前配置使用qwen2.5:3b-AWQ模型
3. 本地推理,无需网络连接
4. 用于意图识别
部署步骤:
1. 安装Xinference
2. 启动服务并加载模型
3. 确保服务运行在http://localhost:9997' WHERE `id` = 'LLM_XinferenceSmallLLM';
-- 火山引擎边缘大模型网关LLM配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/vei/aigateway/',
`remark` = '火山引擎边缘大模型网关LLM配置说明:
1. 使用火山引擎边缘大模型网关服务
2. 需要网关访问密钥
3. 需要网络连接
4. 支持function_call功能
申请步骤:
1. 访问 https://console.volcengine.com/vei/aigateway/
2. 创建网关访问密钥,搜索并勾选 Doubao-pro-32k-functioncall
3. 如果需要使用语音合成,一并勾选 Doubao-语音合成
4. 访问 https://console.volcengine.com/vei/aigateway/tokens-list 获取密钥
5. 填入配置文件中' WHERE `id` = 'LLM_VolcesAiGatewayLLM';
-- 更新Memory模型配置说明
-- 无记忆配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = '无记忆配置说明:
1. 不保存对话历史
2. 每次对话都是独立的
3. 无需额外配置
4. 适合对隐私要求高的场景' WHERE `id` = 'Memory_nomem';
-- 本地短期记忆配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = '本地短期记忆配置说明:
1. 使用本地存储保存对话历史
2. 通过selected_module的llm总结对话内容
3. 数据保存在本地,不会上传到服务器
4. 适合注重隐私的场景
5. 无需额外配置' WHERE `id` = 'Memory_mem_local_short';
-- Mem0AI记忆配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://app.mem0.ai/dashboard/api-keys',
`remark` = 'Mem0AI记忆配置说明:
1. 使用Mem0AI服务保存对话历史
2. 需要API密钥
3. 需要网络连接
4. 每月有1000次免费调用
申请步骤:
1. 访问 https://app.mem0.ai/dashboard/api-keys
2. 获取API密钥
3. 填入配置文件中' WHERE `id` = 'Memory_mem0ai';
-- 更新Intent模型配置说明
-- 无意图识别配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = '无意图识别配置说明:
1. 不进行意图识别
2. 所有对话直接传递给LLM处理
3. 无需额外配置
4. 适合简单对话场景' WHERE `id` = 'Intent_nointent';
-- LLM意图识别配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = 'LLM意图识别配置说明:
1. 使用独立的LLM进行意图识别
2. 默认使用selected_module.LLM的模型
3. 可以配置使用独立的LLM(如免费的ChatGLMLLM
4. 通用性强,但会增加处理时间
5. 不支持控制音量大小等iot操作
配置说明:
1. 在llm字段中指定使用的LLM模型
2. 如果不指定,则使用selected_module.LLM的模型' WHERE `id` = 'Intent_intent_llm';
-- 函数调用意图识别配置说明
UPDATE `ai_model_config` SET
`doc_link` = NULL,
`remark` = '函数调用意图识别配置说明:
1. 使用LLM的function_call功能进行意图识别
2. 需要所选择的LLM支持function_call
3. 按需调用工具,处理速度快
4. 支持所有iot指令
5. 默认已加载以下功能:
- handle_exit_intent(退出识别)
- play_music(音乐播放)
- change_role(角色切换)
- get_weather(天气查询)
- get_news(新闻查询)
配置说明:
1. 在functions字段中配置需要加载的功能模块
2. 系统默认已加载基础功能,无需重复配置
3. 可以添加自定义功能模块' WHERE `id` = 'Intent_function_call';
-- 更新VAD模型配置说明
-- SileroVAD配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/snakers4/silero-vad',
`remark` = 'SileroVAD配置说明:
1. 使用SileroVAD模型进行语音活动检测
2. 本地推理,无需网络连接
3. 需要下载模型文件到models/snakers4_silero-vad目录
4. 可配置参数:
- threshold: 0.5(语音检测阈值)
- min_silence_duration_ms: 700(最小静音持续时间,单位毫秒)
5. 如果说话停顿比较长,可以适当增加min_silence_duration_ms的值' WHERE `id` = 'VAD_SileroVAD';
@@ -0,0 +1,85 @@
update `ai_model_provider` set `fields` =
'[{"key": "api_url","label": "API地址","type": "string"},{"key": "voice","label": "音色","type": "string"},{"key": "output_dir","label": "输出目录","type": "string"},{"key": "authorization","label": "授权","type": "string"},{"key": "appid","label": "应用ID","type": "string"},{"key": "access_token","label": "访问令牌","type": "string"},{"key": "cluster","label": "集群","type": "string"},{"key": "speed_ratio","label": "语速","type": "number"},{"key": "volume_ratio","label": "音量","type": "number"},{"key": "pitch_ratio","label": "音高","type": "number"}]'
where `id` = 'SYSTEM_TTS_doubao';
-- 添加阿里云ASR供应器
delete from `ai_model_provider` where `id` = 'SYSTEM_ASR_AliyunASR';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_AliyunASR', 'ASR', 'aliyun', '阿里云语音识别', '[{"key":"appkey","label":"应用AppKey","type":"string"},{"key":"token","label":"临时Token","type":"string"},{"key":"access_key_id","label":"AccessKey ID","type":"string"},{"key":"access_key_secret","label":"AccessKey Secret","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 5, 1, NOW(), 1, NOW());
-- 添加阿里云ASR模型配置
delete from `ai_model_config` where `id` = 'ASR_AliyunASR';
INSERT INTO `ai_model_config` VALUES ('ASR_AliyunASR', 'ASR', 'AliyunASR', '阿里云语音识别', 0, 1, '{\"type\": \"aliyun\", \"appkey\": \"\", \"token\": \"\", \"access_key_id\": \"\", \"access_key_secret\": \"\", \"output_dir\": \"tmp/\"}', NULL, NULL, 6, NULL, NULL, NULL, NULL);
-- 更新阿里云ASR模型配置的说明文档
UPDATE `ai_model_config` SET
`doc_link` = 'https://nls-portal.console.aliyun.com/',
`remark` = '阿里云ASR配置说明:
1. 访问 https://nls-portal.console.aliyun.com/ 开通服务
2. 访问 https://nls-portal.console.aliyun.com/applist 获取appkey
3. 访问 https://nls-portal.console.aliyun.com/overview 获取token
4. 获取access_key_id和access_key_secret
5. 填入配置文件中' WHERE `id` = 'ASR_AliyunASR';
-- 插入固件类型字典类型
delete from `sys_dict_type` where `id` = 101;
INSERT INTO `sys_dict_type` (`id`, `dict_type`, `dict_name`, `remark`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
(101, 'FIRMWARE_TYPE', '固件类型', '固件类型字典', 0, 1, NOW(), 1, NOW());
-- 插入固件类型字典数据
delete from `sys_dict_data` where `dict_type_id` = 101;
INSERT INTO `sys_dict_data` (`id`, `dict_type_id`, `dict_label`, `dict_value`, `remark`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
(101001, 101, '面包板新版接线(WiFi', 'bread-compact-wifi', '面包板新版接线(WiFi', 1, 1, NOW(), 1, NOW()),
(101002, 101, '面包板新版接线(WiFi+ LCD', 'bread-compact-wifi-lcd', '面包板新版接线(WiFi+ LCD', 2, 1, NOW(), 1, NOW()),
(101003, 101, '面包板新版接线(ML307 AT', 'bread-compact-ml307', '面包板新版接线(ML307 AT', 3, 1, NOW(), 1, NOW()),
(101004, 101, '面包板(WiFi ESP32 DevKit', 'bread-compact-esp32', '面包板(WiFi ESP32 DevKit', 4, 1, NOW(), 1, NOW()),
(101005, 101, '面包板(WiFi+ LCD ESP32 DevKit', 'bread-compact-esp32-lcd', '面包板(WiFi+ LCD ESP32 DevKit', 5, 1, NOW(), 1, NOW()),
(101006, 101, 'DFRobot 行空板 k10', 'df-k10', 'DFRobot 行空板 k10', 6, 1, NOW(), 1, NOW()),
(101007, 101, 'ESP32 CGC', 'esp32-cgc', 'ESP32 CGC', 7, 1, NOW(), 1, NOW()),
(101008, 101, 'ESP BOX 3', 'esp-box-3', 'ESP BOX 3', 8, 1, NOW(), 1, NOW()),
(101009, 101, 'ESP BOX', 'esp-box', 'ESP BOX', 9, 1, NOW(), 1, NOW()),
(101010, 101, 'ESP BOX Lite', 'esp-box-lite', 'ESP BOX Lite', 10, 1, NOW(), 1, NOW()),
(101011, 101, 'Kevin Box 1', 'kevin-box-1', 'Kevin Box 1', 11, 1, NOW(), 1, NOW()),
(101012, 101, 'Kevin Box 2', 'kevin-box-2', 'Kevin Box 2', 12, 1, NOW(), 1, NOW()),
(101013, 101, 'Kevin C3', 'kevin-c3', 'Kevin C3', 13, 1, NOW(), 1, NOW()),
(101014, 101, 'Kevin SP V3开发板', 'kevin-sp-v3-dev', 'Kevin SP V3开发板', 14, 1, NOW(), 1, NOW()),
(101015, 101, 'Kevin SP V4开发板', 'kevin-sp-v4-dev', 'Kevin SP V4开发板', 15, 1, NOW(), 1, NOW()),
(101016, 101, '鱼鹰科技3.13LCD开发板', 'kevin-yuying-313lcd', '鱼鹰科技3.13LCD开发板', 16, 1, NOW(), 1, NOW()),
(101017, 101, '立创·实战派ESP32-S3开发板', 'lichuang-dev', '立创·实战派ESP32-S3开发板', 17, 1, NOW(), 1, NOW()),
(101018, 101, '立创·实战派ESP32-C3开发板', 'lichuang-c3-dev', '立创·实战派ESP32-C3开发板', 18, 1, NOW(), 1, NOW()),
(101019, 101, '神奇按钮 Magiclick_2.4', 'magiclick-2p4', '神奇按钮 Magiclick_2.4', 19, 1, NOW(), 1, NOW()),
(101020, 101, '神奇按钮 Magiclick_2.5', 'magiclick-2p5', '神奇按钮 Magiclick_2.5', 20, 1, NOW(), 1, NOW()),
(101021, 101, '神奇按钮 Magiclick_C3', 'magiclick-c3', '神奇按钮 Magiclick_C3', 21, 1, NOW(), 1, NOW()),
(101022, 101, '神奇按钮 Magiclick_C3_v2', 'magiclick-c3-v2', '神奇按钮 Magiclick_C3_v2', 22, 1, NOW(), 1, NOW()),
(101023, 101, 'M5Stack CoreS3', 'm5stack-core-s3', 'M5Stack CoreS3', 23, 1, NOW(), 1, NOW()),
(101024, 101, 'AtomS3 + Echo Base', 'atoms3-echo-base', 'AtomS3 + Echo Base', 24, 1, NOW(), 1, NOW()),
(101025, 101, 'AtomS3R + Echo Base', 'atoms3r-echo-base', 'AtomS3R + Echo Base', 25, 1, NOW(), 1, NOW()),
(101026, 101, 'AtomS3R CAM/M12 + Echo Base', 'atoms3r-cam-m12-echo-base', 'AtomS3R CAM/M12 + Echo Base', 26, 1, NOW(), 1, NOW()),
(101027, 101, 'AtomMatrix + Echo Base', 'atommatrix-echo-base', 'AtomMatrix + Echo Base', 27, 1, NOW(), 1, NOW()),
(101028, 101, '虾哥 Mini C3', 'xmini-c3', '虾哥 Mini C3', 28, 1, NOW(), 1, NOW()),
(101029, 101, 'ESP32S3_KORVO2_V3开发板', 'esp32s3-korvo2-v3', 'ESP32S3_KORVO2_V3开发板', 29, 1, NOW(), 1, NOW()),
(101030, 101, 'ESP-SparkBot开发板', 'esp-sparkbot', 'ESP-SparkBot开发板', 30, 1, NOW(), 1, NOW()),
(101031, 101, 'ESP-Spot-S3', 'esp-spot-s3', 'ESP-Spot-S3', 31, 1, NOW(), 1, NOW()),
(101032, 101, 'Waveshare ESP32-S3-Touch-AMOLED-1.8', 'esp32-s3-touch-amoled-1.8', 'Waveshare ESP32-S3-Touch-AMOLED-1.8', 32, 1, NOW(), 1, NOW()),
(101033, 101, 'Waveshare ESP32-S3-Touch-LCD-1.85C', 'esp32-s3-touch-lcd-1.85c', 'Waveshare ESP32-S3-Touch-LCD-1.85C', 33, 1, NOW(), 1, NOW()),
(101034, 101, 'Waveshare ESP32-S3-Touch-LCD-1.85', 'esp32-s3-touch-lcd-1.85', 'Waveshare ESP32-S3-Touch-LCD-1.85', 34, 1, NOW(), 1, NOW()),
(101035, 101, 'Waveshare ESP32-S3-Touch-LCD-1.46', 'esp32-s3-touch-lcd-1.46', 'Waveshare ESP32-S3-Touch-LCD-1.46', 35, 1, NOW(), 1, NOW()),
(101036, 101, 'Waveshare ESP32-S3-Touch-LCD-3.5', 'esp32-s3-touch-lcd-3.5', 'Waveshare ESP32-S3-Touch-LCD-3.5', 36, 1, NOW(), 1, NOW()),
(101037, 101, '土豆子', 'tudouzi', '土豆子', 37, 1, NOW(), 1, NOW()),
(101038, 101, 'LILYGO T-Circle-S3', 'lilygo-t-circle-s3', 'LILYGO T-Circle-S3', 38, 1, NOW(), 1, NOW()),
(101039, 101, 'LILYGO T-CameraPlus-S3', 'lilygo-t-cameraplus-s3', 'LILYGO T-CameraPlus-S3', 39, 1, NOW(), 1, NOW()),
(101040, 101, 'Movecall Moji 小智AI衍生版', 'movecall-moji-esp32s3', 'Movecall Moji 小智AI衍生版', 40, 1, NOW(), 1, NOW()),
(101041, 101, 'Movecall CuiCan 璀璨·AI吊坠', 'movecall-cuican-esp32s3', 'Movecall CuiCan 璀璨·AI吊坠', 41, 1, NOW(), 1, NOW()),
(101042, 101, '正点原子DNESP32S3开发板', 'atk-dnesp32s3', '正点原子DNESP32S3开发板', 42, 1, NOW(), 1, NOW()),
(101043, 101, '正点原子DNESP32S3-BOX', 'atk-dnesp32s3-box', '正点原子DNESP32S3-BOX', 43, 1, NOW(), 1, NOW()),
(101044, 101, '嘟嘟开发板CHATX(wifi)', 'du-chatx', '嘟嘟开发板CHATX(wifi)', 44, 1, NOW(), 1, NOW()),
(101045, 101, '太极小派esp32s3', 'taiji-pi-s3', '太极小派esp32s3', 45, 1, NOW(), 1, NOW()),
(101046, 101, '无名科技星智0.85(WIFI)', 'xingzhi-cube-0.85tft-wifi', '无名科技星智0.85(WIFI)', 46, 1, NOW(), 1, NOW()),
(101047, 101, '无名科技星智0.85(ML307)', 'xingzhi-cube-0.85tft-ml307', '无名科技星智0.85(ML307)', 47, 1, NOW(), 1, NOW()),
(101048, 101, '无名科技星智0.96(WIFI)', 'xingzhi-cube-0.96oled-wifi', '无名科技星智0.96(WIFI)', 48, 1, NOW(), 1, NOW()),
(101049, 101, '无名科技星智0.96(ML307)', 'xingzhi-cube-0.96oled-ml307', '无名科技星智0.96(ML307)', 49, 1, NOW(), 1, NOW()),
(101050, 101, '无名科技星智1.54(WIFI)', 'xingzhi-cube-1.54tft-wifi', '无名科技星智1.54(WIFI)', 50, 1, NOW(), 1, NOW()),
(101051, 101, '无名科技星智1.54(ML307)', 'xingzhi-cube-1.54tft-ml307', '无名科技星智1.54(ML307)', 51, 1, NOW(), 1, NOW()),
(101052, 101, 'SenseCAP Watcher', 'sensecap-watcher', 'SenseCAP Watcher', 52, 1, NOW(), 1, NOW()),
(101053, 101, '四博智联AI陪伴盒子', 'doit-s3-aibox', '四博智联AI陪伴盒子', 53, 1, NOW(), 1, NOW()),
(101054, 101, '元控·青春', 'mixgo-nova', '元控·青春', 54, 1, NOW(), 1, NOW());
@@ -0,0 +1,24 @@
-- 初始化智能体聊天记录
DROP TABLE IF EXISTS ai_chat_history;
DROP TABLE IF EXISTS ai_agent_chat_history;
CREATE TABLE ai_agent_chat_history
(
id BIGINT AUTO_INCREMENT COMMENT '主键ID' PRIMARY KEY,
mac_address VARCHAR(50) COMMENT 'MAC地址',
agent_id VARCHAR(32) COMMENT '智能体id',
session_id VARCHAR(50) COMMENT '会话ID',
chat_type TINYINT(3) COMMENT '消息类型: 1-用户, 2-智能体',
content VARCHAR(1024) COMMENT '聊天内容',
audio_id VARCHAR(32) COMMENT '音频ID',
created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL COMMENT '创建时间',
updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
INDEX idx_ai_agent_chat_history_mac (mac_address),
INDEX idx_ai_agent_chat_history_agent_id (agent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '智能体聊天记录表';
DROP TABLE IF EXISTS ai_agent_chat_audio;
CREATE TABLE ai_agent_chat_audio
(
id VARCHAR(32) COMMENT '主键ID' PRIMARY KEY,
audio LONGBLOB COMMENT '音频opus数据'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '智能体聊天音频数据表';
@@ -78,4 +78,25 @@ databaseChangeLog:
changes: changes:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202504251422.sql path: classpath:db/changelog/202504251422.sql
- changeSet:
id: 202504291043
author: jiangkunyin
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504291043.sql
- changeSet:
id: 202504301341
author: Goody
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504301341.sql
- changeSet:
id: 202505012207
author: Goody
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505012207.sql
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="xiaozhi.modules.agent.dao.AiAgentChatHistoryDao">
<resultMap id="BaseResultMap" type="xiaozhi.modules.agent.entity.AgentChatHistoryEntity">
<!--@mbg.generated-->
<!--@Table ai_agent_chat_history-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="mac_address" jdbcType="VARCHAR" property="macAddress" />
<result column="agent_id" jdbcType="VARCHAR" property="agentId" />
<result column="session_id" jdbcType="VARCHAR" property="sessionId" />
<result column="sort" jdbcType="BIGINT" property="sort" />
<result column="chat_type" jdbcType="TINYINT" property="chatType" />
<result column="content" jdbcType="VARCHAR" property="content" />
<result column="audio" jdbcType="LONGVARCHAR" property="audio" />
<result column="audio_url" jdbcType="VARCHAR" property="audioUrl" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
<result column="updated_at" jdbcType="TIMESTAMP" property="updatedAt" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, mac_address, agent_id, session_id, sort, chat_type, content, audio, audio_url,
created_at, updated_at
</sql>
</mapper>
@@ -2,12 +2,17 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="xiaozhi.modules.sys.dao.SysDictDataDao"> <mapper namespace="xiaozhi.modules.sys.dao.SysDictDataDao">
<select id="getDictDataByType" resultType="xiaozhi.modules.sys.vo.SysDictDataItem">
<select id="getDictDataList" resultType="xiaozhi.modules.sys.entity.DictData"> SELECT d.dict_label AS `name`, d.dict_value AS `key`
select dict_type_id, dict_label, dict_value from sys_dict_data order by dict_type_id, sort FROM sys_dict_data d
LEFT JOIN sys_dict_type t ON d.dict_type_id = t.id
WHERE t.dict_type = #{dictType}
ORDER BY d.sort ASC
</select> </select>
<select id="getDataByTypeCode" resultType="xiaozhi.modules.sys.dto.SysDictDataDTO"> <select id="getTypeByTypeId" resultType="java.lang.String">
select * from sys_dict_data WHERE DICT_TYPE_ID =(SELECT id FROM SYS_DICT_TYPE WHERE DICT_TYPE = #{dictType}) order by sort; SELECT dict_type
FROM sys_dict_type
WHERE id = #{dictTypeId}
</select> </select>
</mapper> </mapper>
@@ -3,8 +3,4 @@
<mapper namespace="xiaozhi.modules.sys.dao.SysDictTypeDao"> <mapper namespace="xiaozhi.modules.sys.dao.SysDictTypeDao">
<select id="getDictTypeList" resultType="xiaozhi.modules.sys.entity.DictType">
select id, dict_type from sys_dict_type order by dict_type, sort
</select>
</mapper> </mapper>
+4 -3
View File
@@ -2,11 +2,11 @@
import admin from './module/admin.js' import admin from './module/admin.js'
import agent from './module/agent.js' import agent from './module/agent.js'
import device from './module/device.js' import device from './module/device.js'
import dict from './module/dict.js'
import model from './module/model.js' import model from './module/model.js'
import ota from './module/ota.js'
import timbre from "./module/timbre.js" import timbre from "./module/timbre.js"
import user from './module/user.js' import user from './module/user.js'
import ota from './module/ota.js'
/** /**
* 接口地址 * 接口地址
* 开发时自动读取使用.env.development文件 * 开发时自动读取使用.env.development文件
@@ -32,5 +32,6 @@ export default {
device, device,
model, model,
timbre, timbre,
ota ota,
dict
} }
+227
View File
@@ -0,0 +1,227 @@
import { getServiceUrl } from '../api';
import RequestService from '../httpRequest';
export default {
// 获取字典类型列表
getDictTypeList(params, callback) {
const queryParams = new URLSearchParams({
dictType: params.dictType || '',
dictName: params.dictName || '',
page: params.page || 1,
limit: params.limit || 10
}).toString();
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/type/page?${queryParams}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('获取字典类型列表失败:', err)
this.$message.error(err.msg || '获取字典类型列表失败')
RequestService.reAjaxFun(() => {
this.getDictTypeList(params, callback)
})
}).send()
},
// 获取字典类型详情
getDictTypeDetail(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/type/${id}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('获取字典类型详情失败:', err)
this.$message.error(err.msg || '获取字典类型详情失败')
RequestService.reAjaxFun(() => {
this.getDictTypeDetail(id, callback)
})
}).send()
},
// 新增字典类型
addDictType(data, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/type/save`)
.method('POST')
.data(data)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('新增字典类型失败:', err)
this.$message.error(err.msg || '新增字典类型失败')
RequestService.reAjaxFun(() => {
this.addDictType(data, callback)
})
}).send()
},
// 更新字典类型
updateDictType(data, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/type/update`)
.method('PUT')
.data(data)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('更新字典类型失败:', err)
this.$message.error(err.msg || '更新字典类型失败')
RequestService.reAjaxFun(() => {
this.updateDictType(data, callback)
})
}).send()
},
// 删除字典类型
deleteDictType(ids, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/type/delete`)
.method('POST')
.data(ids)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('删除字典类型失败:', err)
this.$message.error(err.msg || '删除字典类型失败')
RequestService.reAjaxFun(() => {
this.deleteDictType(ids, callback)
})
}).send()
},
// 获取字典数据列表
getDictDataList(params, callback) {
const queryParams = new URLSearchParams({
dictTypeId: params.dictTypeId,
dictLabel: params.dictLabel || '',
dictValue: params.dictValue || '',
page: params.page || 1,
limit: params.limit || 10
}).toString();
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/page?${queryParams}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('获取字典数据列表失败:', err)
this.$message.error(err.msg || '获取字典数据列表失败')
RequestService.reAjaxFun(() => {
this.getDictDataList(params, callback)
})
}).send()
},
// 获取字典数据详情
getDictDataDetail(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/${id}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('获取字典数据详情失败:', err)
this.$message.error(err.msg || '获取字典数据详情失败')
RequestService.reAjaxFun(() => {
this.getDictDataDetail(id, callback)
})
}).send()
},
// 新增字典数据
addDictData(data, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/save`)
.method('POST')
.data(data)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('新增字典数据失败:', err)
this.$message.error(err.msg || '新增字典数据失败')
RequestService.reAjaxFun(() => {
this.addDictData(data, callback)
})
}).send()
},
// 更新字典数据
updateDictData(data, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/update`)
.method('PUT')
.data(data)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('更新字典数据失败:', err)
this.$message.error(err.msg || '更新字典数据失败')
RequestService.reAjaxFun(() => {
this.updateDictData(data, callback)
})
}).send()
},
// 删除字典数据
deleteDictData(ids, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/delete`)
.method('POST')
.data(ids)
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('删除字典数据失败:', err)
this.$message.error(err.msg || '删除字典数据失败')
RequestService.reAjaxFun(() => {
this.deleteDictData(ids, callback)
})
}).send()
},
// 获取字典数据列表
getDictDataByType(dictType) {
return new Promise((resolve, reject) => {
RequestService.sendRequest()
.url(`${getServiceUrl()}/admin/dict/data/type/${dictType}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
if (res.data && res.data.code === 0) {
resolve(res.data)
} else {
reject(new Error(res.data?.msg || '获取字典数据列表失败'))
}
})
.fail((err) => {
console.error('获取字典数据列表失败:', err)
reject(err)
}).send()
})
}
}
@@ -0,0 +1,105 @@
<template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose">
<el-form :model="form" :rules="rules" ref="form" label-width="100px">
<el-form-item label="字典标签" prop="dictLabel">
<el-input v-model="form.dictLabel" placeholder="请输入字典标签"></el-input>
</el-form-item>
<el-form-item label="字典值" prop="dictValue">
<el-input v-model="form.dictValue" placeholder="请输入字典值"></el-input>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="999" style="width: 100%;"></el-input-number>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="handleClose"> </el-button>
<el-button type="primary" @click="handleSave"> </el-button>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'DictDataDialog',
props: {
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: '新增字典数据'
},
dictData: {
type: Object,
default: () => ({})
},
dictTypeId: {
type: [Number, String],
default: null
}
},
data() {
return {
form: {
id: null,
dictTypeId: null,
dictLabel: '',
dictValue: '',
sort: 0
},
rules: {
dictLabel: [{ required: true, message: '请输入字典标签', trigger: 'blur' }],
dictValue: [{ required: true, message: '请输入字典值', trigger: 'blur' }]
}
}
},
watch: {
dictData: {
handler(val) {
if (val) {
this.form = { ...val }
}
},
immediate: true
},
dictTypeId: {
handler(val) {
if (val) {
this.form.dictTypeId = val
}
},
immediate: true
}
},
methods: {
handleClose() {
this.$emit('update:visible', false)
this.resetForm()
},
resetForm() {
this.form = {
id: null,
dictTypeId: this.dictTypeId,
dictLabel: '',
dictValue: '',
sort: 0
}
this.$refs.form?.resetFields()
},
handleSave() {
this.$refs.form.validate(valid => {
if (valid) {
this.$emit('save', this.form)
}
})
}
}
}
</script>
<style scoped>
.dialog-footer {
text-align: right;
}
</style>
@@ -0,0 +1,86 @@
<template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose">
<el-form :model="form" :rules="rules" ref="form" label-width="120px">
<el-form-item label="字典类型名称" prop="dictName">
<el-input v-model="form.dictName" placeholder="请输入字典类型名称"></el-input>
</el-form-item>
<el-form-item label="字典类型编码" prop="dictType">
<el-input v-model="form.dictType" placeholder="请输入字典类型编码"></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="handleClose"> </el-button>
<el-button type="primary" @click="handleSave"> </el-button>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'DictTypeDialog',
props: {
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: '新增字典类型'
},
dictTypeData: {
type: Object,
default: () => ({})
}
},
data() {
return {
form: {
id: null,
dictName: '',
dictType: ''
},
rules: {
dictName: [{ required: true, message: '请输入字典类型名称', trigger: 'blur' }],
dictType: [{ required: true, message: '请输入字典类型编码', trigger: 'blur' }]
}
}
},
watch: {
dictTypeData: {
handler(val) {
if (val) {
this.form = { ...val }
}
},
immediate: true
}
},
methods: {
handleClose() {
this.$emit('update:visible', false)
this.resetForm()
},
resetForm() {
this.form = {
id: null,
dictName: '',
dictType: ''
}
this.$refs.form?.resetFields()
},
handleSave() {
this.$refs.form.validate(valid => {
if (valid) {
this.$emit('save', this.form)
}
})
}
}
}
</script>
<style scoped>
.dialog-footer {
text-align: right;
}
</style>
@@ -38,7 +38,6 @@
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
import { FIRMWARE_TYPES } from '@/utils';
export default { export default {
name: 'FirmwareDialog', name: 'FirmwareDialog',
@@ -54,11 +53,14 @@ export default {
form: { form: {
type: Object, type: Object,
default: () => ({}) default: () => ({})
},
firmwareTypes: {
type: Array,
default: () => []
} }
}, },
data() { data() {
return { return {
firmwareTypes: FIRMWARE_TYPES,
uploadProgress: 0, uploadProgress: 0,
uploadStatus: '', uploadStatus: '',
isUploading: false, isUploading: false,
@@ -85,7 +87,11 @@ export default {
return !!this.form.id return !!this.form.id
} }
}, },
created() {
// 移除 getDictDataByType 调用
},
methods: { methods: {
// 移除 getFirmwareTypes 方法
handleClose() { handleClose() {
this.$refs.form.clearValidate(); this.$refs.form.clearValidate();
this.$emit('cancel'); this.$emit('cancel');
+52 -8
View File
@@ -9,9 +9,11 @@
<!-- 中间导航菜单 --> <!-- 中间导航菜单 -->
<div class="header-center"> <div class="header-center">
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/home' }" @click="goHome"> <div class="equipment-management"
:class="{ 'active-tab': $route.path === '/home' || $route.path === '/role-config' || $route.path === '/device-management' }"
@click="goHome">
<img loading="lazy" alt="" src="@/assets/header/robot.png" <img loading="lazy" alt="" src="@/assets/header/robot.png"
:style="{ filter: $route.path === '/home' ? 'brightness(0) invert(1)' : 'None' }" /> :style="{ filter: $route.path === '/home' || $route.path === '/role-config' || $route.path === '/device-management' ? 'brightness(0) invert(1)' : 'None' }" />
智能体管理 智能体管理
</div> </div>
<div v-if="isSuperAdmin" class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }" <div v-if="isSuperAdmin" class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }"
@@ -26,18 +28,29 @@
:style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }" /> :style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }" />
用户管理 用户管理
</div> </div>
<div v-if="isSuperAdmin" class="equipment-management"
:class="{ 'active-tab': $route.path === '/params-management' }" @click="goParamManagement">
<img loading="lazy" alt="" src="@/assets/header/param_management.png"
:style="{ filter: $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" />
参数管理
</div>
<div v-if="isSuperAdmin" class="equipment-management" <div v-if="isSuperAdmin" class="equipment-management"
:class="{ 'active-tab': $route.path === '/ota-management' }" @click="goOtaManagement"> :class="{ 'active-tab': $route.path === '/ota-management' }" @click="goOtaManagement">
<img loading="lazy" alt="" src="@/assets/header/firmware_update.png" <img loading="lazy" alt="" src="@/assets/header/firmware_update.png"
:style="{ filter: $route.path === '/ota-management' ? 'brightness(0) invert(1)' : 'None' }" /> :style="{ filter: $route.path === '/ota-management' ? 'brightness(0) invert(1)' : 'None' }" />
OTA管理 OTA管理
</div> </div>
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown"
:class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' }">
<span class="el-dropdown-link">
<img loading="lazy" alt="" src="@/assets/header/param_management.png"
:style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" />
参数字典
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="goParamManagement">
参数管理
</el-dropdown-item>
<el-dropdown-item @click.native="goDictManagement">
字典管理
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div> </div>
<!-- 右侧元素 --> <!-- 右侧元素 -->
@@ -114,6 +127,9 @@ export default {
goOtaManagement() { goOtaManagement() {
this.$router.push('/ota-management') this.$router.push('/ota-management')
}, },
goDictManagement() {
this.$router.push('/dict-management')
},
// 获取用户信息 // 获取用户信息
fetchUserInfo() { fetchUserInfo() {
userApi.getUserInfo(({ data }) => { userApi.getUserInfo(({ data }) => {
@@ -343,4 +359,32 @@ export default {
min-width: 100px; min-width: 100px;
} }
} }
.equipment-management.more-dropdown {
position: relative;
}
.equipment-management.more-dropdown .el-dropdown-menu {
position: absolute;
right: 0;
min-width: 120px;
margin-top: 5px;
}
.el-dropdown-menu__item {
min-width: 60px;
padding: 8px 20px;
font-size: 14px;
color: #606266;
white-space: nowrap;
}
@media (max-width: 768px) {
.equipment-management.more-dropdown .el-dropdown-menu {
position: fixed;
right: 10px;
top: 60px;
z-index: 2000;
}
}
</style> </style>
+7 -1
View File
@@ -84,8 +84,14 @@ const routes = [
title: 'OTA管理' title: 'OTA管理'
} }
}, },
{
path: '/dict-management',
name: 'DictManagement',
component: function () {
return import('../views/DictManagement.vue')
}
}
] ]
const router = new VueRouter({ const router = new VueRouter({
base: process.env.VUE_APP_PUBLIC_PATH || '/', base: process.env.VUE_APP_PUBLIC_PATH || '/',
routes routes
-60
View File
@@ -136,63 +136,3 @@ export function getUUID() {
}) })
} }
/**
* 固件类型选项
*/
export const FIRMWARE_TYPES = [
{ "key": "bread-compact-wifi", "name": "面包板新版接线(WiFi" },
{ "key": "bread-compact-wifi-lcd", "name": "面包板新版接线(WiFi+ LCD" },
{ "key": "bread-compact-ml307", "name": "面包板新版接线(ML307 AT" },
{ "key": "bread-compact-esp32", "name": "面包板(WiFi ESP32 DevKit" },
{ "key": "bread-compact-esp32-lcd", "name": "面包板(WiFi+ LCD ESP32 DevKit" },
{ "key": "df-k10", "name": "DFRobot 行空板 k10" },
{ "key": "esp32-cgc", "name": "ESP32 CGC" },
{ "key": "esp-box-3", "name": "ESP BOX 3" },
{ "key": "esp-box", "name": "ESP BOX" },
{ "key": "esp-box-lite", "name": "ESP BOX Lite" },
{ "key": "kevin-box-1", "name": "Kevin Box 1" },
{ "key": "kevin-box-2", "name": "Kevin Box 2" },
{ "key": "kevin-c3", "name": "Kevin C3" },
{ "key": "kevin-sp-v3-dev", "name": "Kevin SP V3开发板" },
{ "key": "kevin-sp-v4-dev", "name": "Kevin SP V4开发板" },
{ "key": "kevin-yuying-313lcd", "name": "鱼鹰科技3.13LCD开发板" },
{ "key": "lichuang-dev", "name": "立创·实战派ESP32-S3开发板" },
{ "key": "lichuang-c3-dev", "name": "立创·实战派ESP32-C3开发板" },
{ "key": "magiclick-2p4", "name": "神奇按钮 Magiclick_2.4" },
{ "key": "magiclick-2p5", "name": "神奇按钮 Magiclick_2.5" },
{ "key": "magiclick-c3", "name": "神奇按钮 Magiclick_C3" },
{ "key": "magiclick-c3-v2", "name": "神奇按钮 Magiclick_C3_v2" },
{ "key": "m5stack-core-s3", "name": "M5Stack CoreS3" },
{ "key": "atoms3-echo-base", "name": "AtomS3 + Echo Base" },
{ "key": "atoms3r-echo-base", "name": "AtomS3R + Echo Base" },
{ "key": "atoms3r-cam-m12-echo-base", "name": "AtomS3R CAM/M12 + Echo Base" },
{ "key": "atommatrix-echo-base", "name": "AtomMatrix + Echo Base" },
{ "key": "xmini-c3", "name": "虾哥 Mini C3" },
{ "key": "esp32s3-korvo2-v3", "name": "ESP32S3_KORVO2_V3开发板" },
{ "key": "esp-sparkbot", "name": "ESP-SparkBot开发板" },
{ "key": "esp-spot-s3", "name": "ESP-Spot-S3" },
{ "key": "esp32-s3-touch-amoled-1.8", "name": "Waveshare ESP32-S3-Touch-AMOLED-1.8" },
{ "key": "esp32-s3-touch-lcd-1.85c", "name": "Waveshare ESP32-S3-Touch-LCD-1.85C" },
{ "key": "esp32-s3-touch-lcd-1.85", "name": "Waveshare ESP32-S3-Touch-LCD-1.85" },
{ "key": "esp32-s3-touch-lcd-1.46", "name": "Waveshare ESP32-S3-Touch-LCD-1.46" },
{ "key": "esp32-s3-touch-lcd-3.5", "name": "Waveshare ESP32-S3-Touch-LCD-3.5" },
{ "key": "tudouzi", "name": "土豆子" },
{ "key": "lilygo-t-circle-s3", "name": "LILYGO T-Circle-S3" },
{ "key": "lilygo-t-cameraplus-s3", "name": "LILYGO T-CameraPlus-S3" },
{ "key": "movecall-moji-esp32s3", "name": "Movecall Moji 小智AI衍生版" },
{ "key": "movecall-cuican-esp32s3", "name": "Movecall CuiCan 璀璨·AI吊坠" },
{ "key": "atk-dnesp32s3", "name": "正点原子DNESP32S3开发板" },
{ "key": "atk-dnesp32s3-box", "name": "正点原子DNESP32S3-BOX" },
{ "key": "du-chatx", "name": "嘟嘟开发板CHATX(wifi)" },
{ "key": "taiji-pi-s3", "name": "太极小派esp32s3" },
{ "key": "xingzhi-cube-0.85tft-wifi", "name": "无名科技星智0.85(WIFI)" },
{ "key": "xingzhi-cube-0.85tft-ml307", "name": "无名科技星智0.85(ML307)" },
{ "key": "xingzhi-cube-0.96oled-wifi", "name": "无名科技星智0.96(WIFI)" },
{ "key": "xingzhi-cube-0.96oled-ml307", "name": "无名科技星智0.96(ML307)" },
{ "key": "xingzhi-cube-1.54tft-wifi", "name": "无名科技星智1.54(WIFI)" },
{ "key": "xingzhi-cube-1.54tft-ml307", "name": "无名科技星智1.54(ML307)" },
{ "key": "sensecap-watcher", "name": "SenseCAP Watcher" },
{ "key": "doit-s3-aibox", "name": "四博智联AI陪伴盒子" },
{ "key": "mixgo-nova", "name": "元控·青春" }
]
@@ -100,7 +100,6 @@
import Api from '@/apis/api'; import Api from '@/apis/api';
import AddDeviceDialog from "@/components/AddDeviceDialog.vue"; import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import { FIRMWARE_TYPES } from "@/utils";
export default { export default {
components: { HeaderBar, AddDeviceDialog }, components: { HeaderBar, AddDeviceDialog },
@@ -118,6 +117,7 @@ export default {
deviceList: [], deviceList: [],
loading: false, loading: false,
userApi: null, userApi: null,
firmwareTypes: [],
}; };
}, },
computed: { computed: {
@@ -163,7 +163,19 @@ export default {
this.fetchBindDevices(agentId); this.fetchBindDevices(agentId);
} }
}, },
created() {
this.getFirmwareTypes()
},
methods: { methods: {
async getFirmwareTypes() {
try {
const res = await Api.dict.getDictDataByType('FIRMWARE_TYPE')
this.firmwareTypes = res.data
} catch (error) {
console.error('获取固件类型失败:', error)
this.$message.error(error.message || '获取固件类型失败')
}
},
handlePageSizeChange(val) { handlePageSizeChange(val) {
this.pageSize = val; this.pageSize = val;
this.currentPage = 1; this.currentPage = 1;
@@ -317,8 +329,8 @@ export default {
return ""; return "";
}, },
getFirmwareTypeName(type) { getFirmwareTypeName(type) {
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type); const firmwareType = this.firmwareTypes.find(item => item.key === type)
return firmwareType ? firmwareType.name : type; return firmwareType ? firmwareType.name : type
}, },
handleOtaSwitchChange(row) { handleOtaSwitchChange(row) {
Api.device.enableOtaUpgrade(row.device_id, row.otaSwitch ? 1 : 0, ({ data }) => { Api.device.enableOtaUpgrade(row.device_id, row.otaSwitch ? 1 : 0, ({ data }) => {
@@ -0,0 +1,835 @@
<template>
<div class="welcome">
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">字典管理</h2>
<div class="action-group">
<div class="search-group">
<el-input placeholder="请输入字典值标签查询" v-model="search" class="search-input" clearable
@keyup.enter.native="handleSearch" style="width: 240px" />
<el-button class="btn-search" @click="handleSearch">
搜索
</el-button>
</div>
</div>
</div>
<!-- 主体内容 -->
<div class="main-wrapper">
<div class="content-panel">
<!-- 左侧字典类型列表 -->
<div class="dict-type-panel">
<div class="dict-type-header">
<el-button type="success" size="mini" @click="showAddDictTypeDialog">新增字典类型</el-button>
<el-button type="danger" size="mini" @click="batchDeleteDictType"
:disabled="selectedDictTypes.length === 0">
批量删除字典类型
</el-button>
</div>
<el-table ref="dictTypeTable" :data="dictTypeList" style="width: 100%" v-loading="dictTypeLoading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)" @row-click="handleDictTypeRowClick"
@selection-change="handleDictTypeSelectionChange" :row-class-name="tableRowClassName"
class="dict-type-table">
<el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="字典类型名称" prop="dictName" align="center"></el-table-column>
<el-table-column label="操作" width="100" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click.stop="editDictType(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 右侧字典数据列表 -->
<div class="content-area">
<el-card class="dict-data-card" shadow="never">
<el-table ref="dictDataTable" :data="dictDataList" style="width: 100%"
v-loading="dictDataLoading" element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
@selection-change="handleDictDataSelectionChange" class="data-table"
header-row-class-name="table-header">
<el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="字典标签" prop="dictLabel" align="center"></el-table-column>
<el-table-column label="字典值" prop="dictValue" align="center"></el-table-column>
<el-table-column label="排序" prop="sort" align="center"></el-table-column>
<el-table-column label="操作" align="center" width="180px">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="editDictData(scope.row)"
class="edit-btn">
修改
</el-button>
<el-button type="text" size="mini" @click="deleteDictData(scope.row)"
class="delete-btn">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<div class="batch-actions">
<el-button size="mini" type="primary" @click="selectAllDictData">
{{ isAllDictDataSelected ? '取消全选' : '全选' }}
</el-button>
<el-button type="success" size="mini" @click="showAddDictDataDialog" class="add-btn">
新增字典数据
</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDeleteDictData">
批量删除字典数据
</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
:value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
首页
</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
上一页
</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
下一页
</button>
<span class="total-text">{{ total }}条记录</span>
</div>
</div>
</el-card>
</div>
</div>
</div>
<!-- 使用字典类型编辑弹框组件 -->
<DictTypeDialog :visible.sync="dictTypeDialogVisible" :title="dictTypeDialogTitle" :dictTypeData="dictTypeForm"
@save="saveDictType" />
<!-- 使用字典数据编辑弹框组件 -->
<DictDataDialog :visible.sync="dictDataDialogVisible" :title="dictDataDialogTitle" :dictData="dictDataForm"
:dictTypeId="selectedDictType?.id" @save="saveDictData" />
<el-footer style="flex-shrink:unset;">
<version-footer />
</el-footer>
</div>
</template>
<script>
import dictApi from '@/apis/module/dict'
import DictDataDialog from '@/components/DictDataDialog.vue'
import DictTypeDialog from '@/components/DictTypeDialog.vue'
import HeaderBar from '@/components/HeaderBar.vue'
import VersionFooter from '@/components/VersionFooter.vue'
export default {
name: 'DictManagement',
components: {
HeaderBar,
DictTypeDialog,
DictDataDialog,
VersionFooter
},
data() {
return {
// 字典类型相关
dictTypeList: [],
dictTypeLoading: false,
selectedDictType: null,
selectedDictTypes: [], // 恢复多选数组
dictTypeDialogVisible: false,
dictTypeDialogTitle: '新增字典类型',
dictTypeForm: {
id: null,
dictName: '',
dictType: ''
},
// 字典数据相关
dictDataList: [],
dictDataLoading: false,
selectedDictData: [],
isAllDictDataSelected: false,
dictDataDialogVisible: false,
dictDataDialogTitle: '新增字典数据',
dictDataForm: {
id: null,
dictTypeId: null,
dictLabel: '',
dictValue: '',
sort: 0
},
search: '',
// 添加分页相关数据
pageSizeOptions: [10, 20, 50, 100],
currentPage: 1,
pageSize: 10,
total: 0
}
},
created() {
this.loadDictTypeList()
},
methods: {
// 字典类型相关方法
loadDictTypeList() {
this.dictTypeLoading = true
dictApi.getDictTypeList({
page: 1,
limit: 100,
dictName: this.search
}, ({ data }) => {
if (data.code === 0) {
this.dictTypeList = data.data.list
if (this.dictTypeList.length > 0) {
this.selectedDictType = this.dictTypeList[0]
this.loadDictDataList(this.dictTypeList[0].id)
this.$nextTick(() => {
this.$refs.dictTypeTable.setCurrentRow(this.dictTypeList[0])
})
}
}
this.dictTypeLoading = false
})
},
handleDictTypeRowClick(row) {
this.selectedDictType = row
this.loadDictDataList(row.id)
this.$refs.dictTypeTable.setCurrentRow(row)
},
handleDictTypeSelectionChange(val) {
this.selectedDictTypes = val
},
tableRowClassName({ row }) {
return row === this.selectedDictType ? 'current-row' : ''
},
showAddDictTypeDialog() {
this.dictTypeDialogTitle = '新增字典类型'
this.dictTypeForm = {
id: null,
dictName: '',
dictType: ''
}
this.dictTypeDialogVisible = true
},
editDictType(row) {
this.dictTypeDialogTitle = '编辑字典类型'
this.dictTypeForm = { ...row }
this.dictTypeDialogVisible = true
},
saveDictType(formData) {
const api = formData.id ? dictApi.updateDictType : dictApi.addDictType
api(formData, ({ data }) => {
if (data.code === 0) {
this.$message.success('保存成功')
this.dictTypeDialogVisible = false
this.loadDictTypeList()
}
})
},
batchDeleteDictType() {
if (this.selectedDictTypes.length === 0) {
this.$message.warning('请选择要删除的字典类型')
return
}
this.$confirm('确定要删除选中的字典类型吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const ids = this.selectedDictTypes.map(item => item.id)
dictApi.deleteDictType(ids, ({ data }) => {
if (data.code === 0) {
this.$message.success('删除成功')
this.loadDictTypeList()
}
})
})
},
// 字典数据相关方法
loadDictDataList(dictTypeId) {
if (!dictTypeId) return
this.dictDataLoading = true
dictApi.getDictDataList({
dictTypeId,
page: this.currentPage,
limit: this.pageSize,
dictLabel: this.search,
dictValue: ''
}, ({ data }) => {
if (data.code === 0) {
this.dictDataList = data.data.list
this.total = data.data.total
} else {
this.$message.error(data.msg || '获取字典数据失败')
}
this.dictDataLoading = false
})
},
handleDictDataSelectionChange(val) {
this.selectedDictData = val
this.isAllDictDataSelected = val.length === this.dictDataList.length
},
selectAllDictData() {
if (this.isAllDictDataSelected) {
this.$refs.dictDataTable.clearSelection()
} else {
this.$refs.dictDataTable.toggleAllSelection()
}
},
showAddDictDataDialog() {
if (!this.selectedDictType) {
this.$message.warning('请先选择字典类型')
return
}
this.dictDataDialogTitle = '新增字典数据'
this.dictDataForm = {
id: null,
dictTypeId: this.selectedDictType.id,
dictLabel: '',
dictValue: '',
sort: 0
}
this.dictDataDialogVisible = true
},
editDictData(row) {
this.dictDataDialogTitle = '编辑字典数据'
this.dictDataForm = { ...row }
this.dictDataDialogVisible = true
},
saveDictData(formData) {
const api = formData.id ? dictApi.updateDictData : dictApi.addDictData
api(formData, ({ data }) => {
if (data.code === 0) {
this.$message.success('保存成功')
this.dictDataDialogVisible = false
this.loadDictDataList(formData.dictTypeId)
}
})
},
deleteDictData(row) {
this.$confirm('确定要删除该字典数据吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
dictApi.deleteDictData([row.id], ({ data }) => {
if (data.code === 0) {
this.$message.success('删除成功')
this.loadDictDataList(row.dictTypeId)
}
})
})
},
batchDeleteDictData() {
if (this.selectedDictData.length === 0) {
this.$message.warning('请选择要删除的字典数据')
return
}
this.$confirm('确定要删除选中的字典数据吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const ids = this.selectedDictData.map(item => item.id)
dictApi.deleteDictData(ids, ({ data }) => {
if (data.code === 0) {
this.$message.success('删除成功')
this.loadDictDataList(this.selectedDictType.id)
}
})
})
},
handleSearch() {
if (!this.selectedDictType) {
this.$message.warning('请先选择字典类型')
return
}
this.currentPage = 1
this.loadDictDataList(this.selectedDictType.id)
},
// 添加分页相关方法
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.loadDictDataList(this.selectedDictType?.id);
},
goFirst() {
this.currentPage = 1;
this.loadDictDataList(this.selectedDictType?.id);
},
goPrev() {
if (this.currentPage > 1) {
this.currentPage--;
this.loadDictDataList(this.selectedDictType?.id);
}
},
goNext() {
if (this.currentPage < this.pageCount) {
this.currentPage++;
this.loadDictDataList(this.selectedDictType?.id);
}
},
goToPage(page) {
this.currentPage = page;
this.loadDictDataList(this.selectedDictType?.id);
}
},
computed: {
pageCount() {
return Math.ceil(this.total / this.pageSize);
},
visiblePages() {
const pages = [];
const maxVisible = 3;
let start = Math.max(1, this.currentPage - 1);
let end = Math.min(this.pageCount, start + maxVisible - 1);
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
}
}
}
</script>
<style lang="scss" scoped>
.welcome {
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
position: relative;
flex-direction: column;
background-size: cover;
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.action-group {
display: flex;
align-items: center;
gap: 16px;
}
.search-group {
display: flex;
gap: 10px;
}
.search-input {
width: 240px;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
.btn-search:hover {
opacity: 0.9;
transform: translateY(-1px);
}
:deep(.search-input .el-input__inner) {
border-radius: 4px;
border: 1px solid #DCDFE6;
background-color: white;
transition: border-color 0.2s;
}
: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;
}
.dict-type-panel {
width: 300px;
background: white;
border-right: 1px solid #ebeef5;
display: flex;
flex-direction: column;
}
.dict-type-header {
padding: 16px;
border-bottom: 1px solid #ebeef5;
display: flex;
gap: 8px;
}
.dict-type-table {
flex: 1;
overflow-y: auto;
}
.content-area {
flex: 1;
padding: 24px;
height: 100%;
min-width: 600px;
overflow: hidden;
background-color: white;
display: flex;
flex-direction: column;
}
.dict-data-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
}
.data-table {
border-radius: 6px;
overflow-y: auto;
background-color: transparent !important;
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
:deep(.el-table__body-wrapper) {
max-height: calc(var(--table-max-height) - 40px);
}
:deep(.el-table__body) {
tr:last-child td {
border-bottom: none;
}
}
}
:deep(.el-table) {
&::before {
display: none;
}
&::after {
display: none;
}
}
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
width: 100%;
flex-shrink: 0;
min-height: 60px;
background: white;
margin-top: 10px;
}
.batch-actions {
display: flex;
gap: 8px;
padding-left: 26px;
.el-button {
min-width: 72px;
height: 32px;
padding: 7px 12px 7px 10px;
font-size: 12px;
border-radius: 4px;
line-height: 1;
font-weight: 500;
border: none;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
}
.el-button--primary {
background: #5f70f3;
color: white;
}
.el-button--success {
background: #5bc98c;
color: white;
}
.el-button--danger {
background: #fd5b63;
color: white;
}
}
.custom-pagination {
display: flex;
align-items: center;
gap: 8px;
.el-select {
margin-right: 8px;
}
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-child(3),
.pagination-btn:nth-last-child(2) {
min-width: 60px;
height: 32px;
padding: 0 12px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #d7dce6;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-child(3)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(245, 247, 250, 0.3);
}
}
.pagination-btn.active {
background: #5f70f3 !important;
color: #ffffff !important;
border-color: #5f70f3 !important;
&:hover {
background: #6d7cf5 !important;
}
}
.total-text {
color: #909399;
font-size: 14px;
margin-left: 10px;
}
}
.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;
}
}
.edit-btn,
.delete-btn {
margin: 0 8px;
color: #7079aa !important;
font-size: 12px;
padding: 7px 12px;
height: 32px;
line-height: 1;
border-radius: 4px;
transition: all 0.3s ease;
&:hover {
color: #5a64b5 !important;
transform: translateY(-1px);
}
}
:deep(.dict-type-header .el-button) {
min-width: 72px;
height: 32px;
padding: 7px 12px 7px 10px;
font-size: 12px;
border-radius: 4px;
line-height: 1;
font-weight: 500;
border: none;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
&.el-button--success {
background: #5bc98c;
color: white;
}
&.el-button--danger {
background: #fd5b63;
color: white;
}
}
:deep(.el-table .cell) {
padding-left: 10px;
padding-right: 10px;
}
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
:deep(.dict-type-table .el-table__row) {
cursor: pointer;
}
:deep(.dict-type-table .el-table__row.current-row) {
background-color: #5778ff !important;
color: white;
}
:deep(.dict-type-table .el-table__row.current-row .el-button--text) {
color: white !important;
}
:deep(.dict-type-table .el-table__row:hover) {
background-color: #f5f7fa;
}
:deep(.dict-type-table .el-table__row.current-row:hover) {
background-color: #5778ff !important;
}
:deep(.dict-type-table .el-table__row td) {
background-color: transparent !important;
}
:deep(.el-table thead) {
color: #000000;
}
:deep(.el-card__body) {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
</style>
+15 -5
View File
@@ -96,8 +96,8 @@
</div> </div>
<!-- 新增/编辑固件对话框 --> <!-- 新增/编辑固件对话框 -->
<firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm" @submit="handleSubmit" <firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm"
@cancel="dialogVisible = false" /> :firmware-types="firmwareTypes" @submit="handleSubmit" @cancel="dialogVisible = false" />
<el-footer> <el-footer>
<version-footer /> <version-footer />
</el-footer> </el-footer>
@@ -109,7 +109,6 @@ import Api from "@/apis/api";
import FirmwareDialog from "@/components/FirmwareDialog.vue"; import FirmwareDialog from "@/components/FirmwareDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue"; import VersionFooter from "@/components/VersionFooter.vue";
import { FIRMWARE_TYPES } from "@/utils";
import { formatDate, formatFileSize } from "@/utils/format"; import { formatDate, formatFileSize } from "@/utils/format";
export default { export default {
@@ -136,10 +135,12 @@ export default {
remark: "", remark: "",
firmwarePath: "" firmwarePath: ""
}, },
firmwareTypes: [],
}; };
}, },
created() { created() {
this.fetchFirmwareList(); this.fetchFirmwareList();
this.getFirmwareTypes();
}, },
computed: { computed: {
@@ -387,9 +388,18 @@ export default {
}, },
formatDate, formatDate,
formatFileSize, formatFileSize,
async getFirmwareTypes() {
try {
const res = await Api.dict.getDictDataByType('FIRMWARE_TYPE')
this.firmwareTypes = res.data
} catch (error) {
console.error('获取固件类型失败:', error)
this.$message.error(error.message || '获取固件类型失败')
}
},
getFirmwareTypeName(type) { getFirmwareTypeName(type) {
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type); const firmwareType = this.firmwareTypes.find(item => item.key === type)
return firmwareType ? firmwareType.name : type; return firmwareType ? firmwareType.name : type
}, },
}, },
}; };
+11 -1
View File
@@ -26,6 +26,11 @@
<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>
<el-table-column label="注册时间" prop="createDate" align="center">
<template slot-scope="scope">
{{ formatDate(scope.row.createDate) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="status" align="center"> <el-table-column label="状态" prop="status" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-tag v-if="scope.row.status === 1" type="success">正常</el-tag> <el-tag v-if="scope.row.status === 1" type="success">正常</el-tag>
@@ -336,7 +341,12 @@ export default {
}).catch(() => { }).catch(() => {
// 用户取消操作 // 用户取消操作
}); });
} },
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}`;
},
}, },
}; };
</script> </script>
+39 -85
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="welcome"> <div class="welcome">
<HeaderBar/> <HeaderBar />
<div class="operation-bar"> <div class="operation-bar">
<h2 class="page-title">角色配置</h2> <h2 class="page-title">角色配置</h2>
@@ -26,93 +26,49 @@
<div class="form-grid"> <div class="form-grid">
<div class="form-column"> <div class="form-column">
<el-form-item label="助手昵称:"> <el-form-item label="助手昵称:">
<el-input v-model="form.agentName" class="form-input"/> <el-input v-model="form.agentName" class="form-input" />
</el-form-item> </el-form-item>
<el-form-item label="角色模版:"> <el-form-item label="角色模版:">
<div class="template-container"> <div class="template-container">
<div <div v-for="(template, index) in templates" :key="`template-${index}`" class="template-item"
v-for="(template, index) in templates" :class="{ 'template-loading': loadingTemplate }" @click="selectTemplate(template)">
:key="`template-${index}`"
class="template-item"
:class="{ 'template-loading': loadingTemplate }"
@click="selectTemplate(template)"
>
{{ template.agentName }} {{ template.agentName }}
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="角色介绍:"> <el-form-item label="角色介绍:">
<el-input <el-input type="textarea" rows="12" resize="none" placeholder="请输入内容" v-model="form.systemPrompt"
type="textarea" maxlength="2000" show-word-limit class="form-textarea" />
rows="5"
resize="none"
placeholder="请输入内容"
v-model="form.systemPrompt"
maxlength="2000"
show-word-limit
class="form-textarea"
/>
</el-form-item> </el-form-item>
<el-form-item label="语言编码:"> <el-form-item label="语言编码:" style="display: none;">
<el-input <el-input v-model="form.langCode" placeholder="请输入语言编码,如:zh_CN" maxlength="10" show-word-limit
v-model="form.langCode" class="form-input" />
placeholder="请输入语言编码,如:zh_CN"
maxlength="10"
show-word-limit
class="form-input"
/>
</el-form-item> </el-form-item>
<el-form-item label="交互语种:"> <el-form-item label="交互语种:" style="display: none;">
<el-input <el-input v-model="form.language" placeholder="请输入交互语种,如:中文" maxlength="10" show-word-limit
v-model="form.language" class="form-input" />
placeholder="请输入交互语种,如:中文"
maxlength="10"
show-word-limit
class="form-input"
/>
</el-form-item> </el-form-item>
<div class="action-bar"> <div class="action-bar">
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button> <el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
<el-button class="reset-btn" @click="resetConfig">重置</el-button> <el-button class="reset-btn" @click="resetConfig">重置</el-button>
<div class="hint-text"> <div class="hint-text">
<img loading="lazy" src="@/assets/home/red-info.png" alt=""> <img loading="lazy" src="@/assets/home/red-info.png" alt="">
<span>保存配置后需要重启设备新的配置才会生效</span> <span>保存配置后需要重启设备新的配置才会生效</span>
</div> </div>
</div> </div>
</div> </div>
<div class="form-column"> <div class="form-column">
<el-form-item <el-form-item v-for="(model, index) in models" :key="`model-${index}`" :label="model.label"
v-for="(model, index) in models" class="model-item">
:key="`model-${index}`" <el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="form-select">
:label="model.label" <el-option v-for="(item, optionIndex) in modelOptions[model.type]"
class="model-item" :key="`option-${index}-${optionIndex}`" :label="item.label" :value="item.value" />
>
<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-select>
</el-form-item> </el-form-item>
<el-form-item label="角色音色:"> <el-form-item label="角色音色:">
<el-select <el-select v-model="form.ttsVoiceId" placeholder="请选择" class="form-select">
v-model="form.ttsVoiceId" <el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label"
placeholder="请选择" :value="item.value" />
class="form-select"
>
<el-option
v-for="(item, index) in voiceOptions"
:key="`voice-${index}`"
:label="item.label"
:value="item.value"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
</div> </div>
@@ -133,7 +89,7 @@ import HeaderBar from "@/components/HeaderBar.vue";
export default { export default {
name: 'RoleConfigPage', name: 'RoleConfigPage',
components: {HeaderBar}, components: { HeaderBar },
data() { data() {
return { return {
form: { form: {
@@ -154,12 +110,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: [],
@@ -187,7 +143,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: '配置保存成功',
@@ -232,7 +188,7 @@ export default {
}); });
}, },
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 {
@@ -277,7 +233,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,
@@ -298,7 +254,7 @@ 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,
@@ -315,7 +271,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,
@@ -567,7 +523,6 @@ export default {
background: none; background: none;
position: absolute; position: absolute;
font-size: 12px; font-size: 12px;
bottom: -10%;
right: 3%; right: 3%;
} }
@@ -597,5 +552,4 @@ export default {
color: #409EFF; color: #409EFF;
border-color: #409EFF; border-color: #409EFF;
} }
</style> </style>
+44 -2
View File
@@ -1,11 +1,15 @@
import asyncio import asyncio
import sys import sys
import signal import signal
from config.settings import load_config, check_config_file from config.settings import load_config
from core.websocket_server import WebSocketServer from core.websocket_server import WebSocketServer
from core.ota_server import SimpleOtaServer
from core.utils.util import check_ffmpeg_installed from core.utils.util import check_ffmpeg_installed
from config.logger import setup_logging
from core.utils.util import get_local_ip
TAG = __name__ TAG = __name__
logger = setup_logging()
async def wait_for_exit(): async def wait_for_exit():
@@ -27,13 +31,47 @@ async def wait_for_exit():
async def main(): async def main():
check_config_file()
check_ffmpeg_installed() check_ffmpeg_installed()
config = load_config() config = load_config()
# 启动 WebSocket 服务器 # 启动 WebSocket 服务器
ws_server = WebSocketServer(config) ws_server = WebSocketServer(config)
ws_task = asyncio.create_task(ws_server.start()) ws_task = asyncio.create_task(ws_server.start())
ota_task = None
read_config_from_api = config.get("read_config_from_api", False)
if not read_config_from_api:
# 启动 Simple OTA 服务器
ota_server = SimpleOtaServer(config)
ota_task = asyncio.create_task(ota_server.start())
logger.bind(tag=TAG).info(
"OTA接口是\t\thttp://{}:{}/xiaozhi/ota/",
get_local_ip(),
config["server"]["ota_port"],
)
# 获取WebSocket配置,使用安全的默认值
websocket_port = 8000
server_config = config.get("server", {})
if isinstance(server_config, dict):
websocket_port = int(server_config.get("port", 8000))
logger.bind(tag=TAG).info(
"Websocket地址是\tws://{}:{}/xiaozhi/v1/",
get_local_ip(),
websocket_port,
)
logger.bind(tag=TAG).info(
"=======上面的地址是websocket协议地址,请勿用浏览器访问======="
)
logger.bind(tag=TAG).info(
"如想测试websocket请用谷歌浏览器打开test目录下的test_page.html"
)
logger.bind(tag=TAG).info(
"=============================================================\n"
)
try: try:
await wait_for_exit() # 监听退出信号 await wait_for_exit() # 监听退出信号
@@ -41,8 +79,12 @@ async def main():
print("任务被取消,清理资源中...") print("任务被取消,清理资源中...")
finally: finally:
ws_task.cancel() ws_task.cancel()
if ota_task:
ota_task.cancel()
try: try:
await ws_task await ws_task
if ota_task:
await ota_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
print("服务器已关闭,程序退出。") print("服务器已关闭,程序退出。")
+55 -15
View File
@@ -1,7 +1,7 @@
# 如果您是一名开发者,建议阅读以下内容。如果不是开发者,可以忽略这部分内容。 # 在开发中,请在项目根目录创建data目录,然后在data目录创建名称为【.config.yaml】的空文件
# 在开发中,在项目根目录创建data目录,将【config.yaml】复制一份,改成【.config.yaml】,放进data目录中 # 然后你想修改覆盖修改什么配置,就修改【.config.yaml】文件,而不是修改【config.yaml】文件
# 系统会优先读取【data/.config.yaml】文件的配置。 # 系统会优先读取【data/.config.yaml】文件的配置,如果【.config.yaml】文件里的配置不存在,系统会自动去读取【config.yaml】文件的配置
# 这样做,可以避免在提交代码的时候,错误地提交密钥信息,保护您的密钥安全。 # 这样做,可以最简化配置,保护您的密钥安全。
# ##################################################################################### # #####################################################################################
# #############################以下是服务器基本运行配置#################################### # #############################以下是服务器基本运行配置####################################
@@ -9,6 +9,15 @@ server:
# 服务器监听地址和端口(Server listening address and port) # 服务器监听地址和端口(Server listening address and port)
ip: 0.0.0.0 ip: 0.0.0.0
port: 8000 port: 8000
# OTA接口的端口号
ota_port: 8002
# 这个websocket配置是指ota接口向设备发送的websocket地址
# 如果按默认的写法,ota接口会自动生成websocket地址。这个地址你可以直接用浏览器访问ota接口确认一下
# 当你使用docker部署或使用公网部署(使用ssl、域名)时,不一定准确
# 所以如果你使用docker部署或使用公网部署时,请设置正确的websocket地址
websocket: ws://你的ip或者域名:端口号/xiaozhi/v1/
# OTA返回信息时区偏移量
timezone_offset: +8
# 认证配置 # 认证配置
auth: auth:
# 是否启用认证 # 是否启用认证
@@ -149,7 +158,7 @@ selected_module:
# 不想开通意图识别,就设置成:nointent # 不想开通意图识别,就设置成:nointent
# 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等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-1-5-pro-32k-250115
Intent: function_call Intent: function_call
# 意图识别,是用于理解用户意图的模块,例如:播放音乐 # 意图识别,是用于理解用户意图的模块,例如:播放音乐
@@ -200,6 +209,18 @@ ASR:
type: fun_local type: fun_local
model_dir: models/SenseVoiceSmall model_dir: models/SenseVoiceSmall
output_dir: tmp/ output_dir: tmp/
FunASRServer:
# 独立部署FunASR,使用FunASR的API服务,只需要五句话
# 第一句:mkdir -p ./funasr-runtime-resources/models
# 第二句:sudo docker run -d -p 10096:10095 --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12
# 上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime
# 不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 &
# 上一句话执行后会进入到容器,继续第五句:tail -f log.txt
# 第五句话执行完后,会看到模型下载日志,下载完后就可以连接使用了
# 以上是使用CPU推理,如果有GPU,详细参考:https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md
type: fun_server
host: 127.0.0.1
port: 10096
SherpaASR: SherpaASR:
type: sherpa_onnx_local type: sherpa_onnx_local
model_dir: models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17 model_dir: models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17
@@ -211,6 +232,9 @@ ASR:
appid: 你的火山引擎语音合成服务appid appid: 你的火山引擎语音合成服务appid
access_token: 你的火山引擎语音合成服务access_token access_token: 你的火山引擎语音合成服务access_token
cluster: volcengine_input_common cluster: volcengine_input_common
# 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738
boosting_table_name: (选填)你的热词文件名称
correct_table_name: (选填)你的替换词文件名称
output_dir: tmp/ output_dir: tmp/
TencentASR: TencentASR:
# token申请地址:https://console.cloud.tencent.com/cam/capi # token申请地址:https://console.cloud.tencent.com/cam/capi
@@ -220,6 +244,19 @@ ASR:
secret_id: 你的腾讯语音合成服务secret_id secret_id: 你的腾讯语音合成服务secret_id
secret_key: 你的腾讯语音合成服务secret_key secret_key: 你的腾讯语音合成服务secret_key
output_dir: tmp/ output_dir: tmp/
AliyunASR:
# 阿里云智能语音交互服务,需要先在阿里云平台开通服务,然后获取验证信息
# 平台地址:https://nls-portal.console.aliyun.com/
# appkey地址:https://nls-portal.console.aliyun.com/applist
# token地址:https://nls-portal.console.aliyun.com/overview
# 定义ASR API类型
type: aliyun
appkey: 你的阿里云智能语音交互服务项目Appkey
token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_idaccess_key_secret
access_key_id: 的阿里云账号access_key_id
access_key_secret: 你的阿里云账号access_key_secret
output_dir: tmp/
VAD: VAD:
SileroVAD: SileroVAD:
type: silero type: silero
@@ -239,8 +276,8 @@ LLM:
api_key: 你的deepseek web key api_key: 你的deepseek web key
temperature: 0.7 # 温度值 temperature: 0.7 # 温度值
max_tokens: 500 # 最大生成token数 max_tokens: 500 # 最大生成token数
top_p: 1 top_p: 1
top_k: 50 top_k: 50
frequency_penalty: 0 # 频率惩罚 frequency_penalty: 0 # 频率惩罚
AliAppLLM: AliAppLLM:
# 定义LLM API类型 # 定义LLM API类型
@@ -249,7 +286,7 @@ LLM:
app_id: 你的app_id app_id: 你的app_id
# 可在这里找到你的 api_key https://bailian.console.aliyun.com/?apiKey=1#/api-key # 可在这里找到你的 api_key https://bailian.console.aliyun.com/?apiKey=1#/api-key
api_key: 你的api_key api_key: 你的api_key
# 是否不使用本地prompttrue|false (默不用请在百练应用中设置prompt) # 是否不使用本地prompttrue|false (默不用请在百练应用中设置prompt)
is_no_prompt: true is_no_prompt: true
# Ali_memory_idfalse(不使用)|你的memory_id(请在百练应用中设置中获取) # Ali_memory_idfalse(不使用)|你的memory_id(请在百练应用中设置中获取)
# Tips!:Ali_memory未实现多用户存储记忆(记忆按id调用) # Tips!:Ali_memory未实现多用户存储记忆(记忆按id调用)
@@ -257,12 +294,12 @@ LLM:
DoubaoLLM: DoubaoLLM:
# 定义LLM API类型 # 定义LLM API类型
type: openai type: openai
# 先开通服务,打开以下网址,开通的服务搜索Doubao-pro-32k,开通它 # 先开通服务,打开以下网址,开通的服务搜索Doubao-1.5-pro,开通它
# 开通改地址:https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false # 开通改地址:https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false
# 免费额度500000token # 免费额度500000token
# 开通后,进入这里获取密钥:https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D # 开通后,进入这里获取密钥:https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D
base_url: https://ark.cn-beijing.volces.com/api/v3 base_url: https://ark.cn-beijing.volces.com/api/v3
model_name: doubao-pro-32k-functioncall-241028 model_name: doubao-1-5-pro-32k-250115
api_key: 你的doubao web key api_key: 你的doubao web key
DeepSeekLLM: DeepSeekLLM:
# 定义LLM API类型 # 定义LLM API类型
@@ -376,6 +413,9 @@ TTS:
appid: 你的火山引擎语音合成服务appid appid: 你的火山引擎语音合成服务appid
access_token: 你的火山引擎语音合成服务access_token access_token: 你的火山引擎语音合成服务access_token
cluster: volcano_tts cluster: volcano_tts
speed_ratio: 1.0
volume_ratio: 1.0
pitch_ratio: 1.0
CosyVoiceSiliconflow: CosyVoiceSiliconflow:
type: siliconflow type: siliconflow
# 硅基流动TTS # 硅基流动TTS
@@ -438,12 +478,12 @@ TTS:
GPT_SOVITS_V2: GPT_SOVITS_V2:
# 定义TTS API类型 # 定义TTS API类型
#启动tts方法: #启动tts方法:
#python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/caixukun.yaml #python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/demo.yaml
type: gpt_sovits_v2 type: gpt_sovits_v2
url: "http://127.0.0.1:9880/tts" url: "http://127.0.0.1:9880/tts"
output_dir: tmp/ output_dir: tmp/
text_lang: "auto" text_lang: "auto"
ref_audio_path: "caixukun.wav" ref_audio_path: "demo.wav"
prompt_text: "" prompt_text: ""
prompt_lang: "zh" prompt_lang: "zh"
top_k: 5 top_k: 5
@@ -576,8 +616,8 @@ TTS:
ACGNTTS: ACGNTTS:
#在线网址:https://acgn.ttson.cn/ #在线网址:https://acgn.ttson.cn/
#token购买:www.ttson.cn #token购买:www.ttson.cn
#开发相关疑问请提交至3497689533@qq.com #开发相关疑问请提交至网站上的qq
#角色id获取地址:ctrl+f快速检索角色——网站管理者不允许发布,可询问网站管理者1069379506 #角色id获取地址:ctrl+f快速检索角色——网站管理者不允许发布,可询问网站管理者
#各参数意义见开发文档:https://www.yuque.com/alexuh/skmti9/wm6taqislegb02gd?singleDoc# #各参数意义见开发文档:https://www.yuque.com/alexuh/skmti9/wm6taqislegb02gd?singleDoc#
type: ttson type: ttson
token: your_token token: your_token
@@ -619,4 +659,4 @@ TTS:
headers: # 自定义请求头 headers: # 自定义请求头
# Authorization: Bearer xxxx # Authorization: Bearer xxxx
format: wav # 接口返回的音频格式 format: wav # 接口返回的音频格式
output_dir: tmp/ output_dir: tmp/
+42 -21
View File
@@ -1,6 +1,7 @@
import os import os
import argparse import argparse
import yaml import yaml
from collections.abc import Mapping
from config.manage_api_client import init_service, get_server_config, get_agent_models from config.manage_api_client import init_service, get_server_config, get_agent_models
@@ -25,35 +26,24 @@ def load_config():
if _config_cache is not None: if _config_cache is not None:
return _config_cache return _config_cache
parser = argparse.ArgumentParser(description="Server configuration") default_config_path = get_project_dir() + "config.yaml"
config_file = get_config_file() custom_config_path = get_project_dir() + "data/.config.yaml"
parser.add_argument("--config_path", type=str, default=config_file) # 加载默认配置
args = parser.parse_args() default_config = read_config(default_config_path)
config = read_config(args.config_path) custom_config = read_config(custom_config_path)
if config.get("manager-api", {}).get("url"):
config = get_config_from_api(config)
if custom_config.get("manager-api", {}).get("url"):
config = get_config_from_api(custom_config)
else:
# 合并配置
config = merge_configs(default_config, custom_config)
# 初始化目录 # 初始化目录
ensure_directories(config) ensure_directories(config)
_config_cache = config _config_cache = config
return config return config
def get_config_file():
"""获取配置文件路径,优先使用私有配置文件(若存在)。
Returns:
str: 配置文件路径(相对路径或默认路径)
"""
default_config_file = "config.yaml"
config_file = default_config_file
if os.path.exists(get_project_dir() + "data/." + default_config_file):
config_file = "data/." + default_config_file
return config_file
def get_config_from_api(config): def get_config_from_api(config):
"""从Java API获取配置""" """从Java API获取配置"""
# 初始化API客户端 # 初始化API客户端
@@ -115,3 +105,34 @@ def ensure_directories(config):
os.makedirs(dir_path, exist_ok=True) os.makedirs(dir_path, exist_ok=True)
except PermissionError: except PermissionError:
print(f"警告:无法创建目录 {dir_path},请检查写入权限") print(f"警告:无法创建目录 {dir_path},请检查写入权限")
def merge_configs(default_config, custom_config):
"""
递归合并配置,custom_config优先级更高
Args:
default_config: 默认配置
custom_config: 用户自定义配置
Returns:
合并后的配置
"""
if not isinstance(default_config, Mapping) or not isinstance(
custom_config, Mapping
):
return custom_config
merged = dict(default_config)
for key, value in custom_config.items():
if (
key in merged
and isinstance(merged[key], Mapping)
and isinstance(value, Mapping)
):
merged[key] = merge_configs(merged[key], value)
else:
merged[key] = value
return merged
+3 -1
View File
@@ -2,8 +2,9 @@ import os
import sys import sys
from loguru import logger from loguru import logger
from config.config_loader import load_config from config.config_loader import load_config
from config.settings import check_config_file
SERVER_VERSION = "0.3.11" SERVER_VERSION = "0.3.13"
def get_module_abbreviation(module_name, module_dict): def get_module_abbreviation(module_name, module_dict):
@@ -32,6 +33,7 @@ def formatter(record):
def setup_logging(): def setup_logging():
check_config_file()
"""从配置文件中读取日志配置,并设置日志输出格式和级别""" """从配置文件中读取日志配置,并设置日志输出格式和级别"""
config = load_config() config = load_config()
log_config = config["log"] log_config = config["log"]
@@ -1,5 +1,6 @@
import os import os
import time import time
import base64
from typing import Optional, Dict from typing import Optional, Dict
import httpx import httpx
@@ -53,6 +54,7 @@ class ManageApiClient:
headers={ headers={
"User-Agent": f"PythonClient/2.0 (PID:{os.getpid()})", "User-Agent": f"PythonClient/2.0 (PID:{os.getpid()})",
"Accept": "application/json", "Accept": "application/json",
"Authorization": "Bearer " + cls._secret,
}, },
timeout=cls.config.get("timeout", 30), # 默认超时时间30秒 timeout=cls.config.get("timeout", 30), # 默认超时时间30秒
) )
@@ -125,9 +127,7 @@ class ManageApiClient:
def get_server_config() -> Optional[Dict]: def get_server_config() -> Optional[Dict]:
"""获取服务器基础配置""" """获取服务器基础配置"""
return ManageApiClient._instance._execute_request( return ManageApiClient._instance._execute_request("POST", "/config/server-base")
"POST", "/config/server-base", json={"secret": ManageApiClient._secret}
)
def get_agent_models( def get_agent_models(
@@ -138,7 +138,6 @@ def get_agent_models(
"POST", "POST",
"/config/agent-models", "/config/agent-models",
json={ json={
"secret": ManageApiClient._secret,
"macAddress": mac_address, "macAddress": mac_address,
"clientId": client_id, "clientId": client_id,
"selectedModule": selected_module, "selectedModule": selected_module,
@@ -146,6 +145,41 @@ def get_agent_models(
) )
def report(
mac_address: str, session_id: str, chat_type: int, content: str, opus_data
) -> Optional[Dict]:
"""带熔断的业务方法示例"""
if not content or not ManageApiClient._instance:
return None
try:
# 处理opus_data为列表的情况
if isinstance(opus_data, list):
# 将列表中的所有bytes数据合并
combined_data = b"".join(opus_data)
else:
combined_data = opus_data
# 将二进制数据转换为Base64编码的字符串
opus_data_base64 = (
base64.b64encode(combined_data).decode("utf-8") if combined_data else None
)
return ManageApiClient._instance._execute_request(
"POST",
f"/agent/chat-history/report",
json={
"macAddress": mac_address,
"sessionId": session_id,
"chatType": chat_type,
"content": content,
"opusDataBase64": opus_data_base64,
},
)
except Exception as e:
print(f"TTS上报失败: {e}")
return None
def init_service(config): def init_service(config):
ManageApiClient(config) ManageApiClient(config)
+19 -50
View File
@@ -1,64 +1,33 @@
import os import os
from collections.abc import Mapping
from config.config_loader import read_config, get_project_dir, load_config from config.config_loader import read_config, get_project_dir, load_config
default_config_file = "config.yaml" default_config_file = "config.yaml"
config_file_valid = False
def find_missing_keys(new_config, old_config, parent_key=""):
"""
递归查找缺失的配置项
返回格式:[缺失配置路径]
"""
missing_keys = []
if not isinstance(new_config, Mapping):
return missing_keys
for key, value in new_config.items():
# 构建当前配置路径
full_path = f"{parent_key}.{key}" if parent_key else key
# 检查键是否存在
if key not in old_config:
missing_keys.append(full_path)
continue
# 递归检查嵌套字典
if isinstance(value, Mapping):
sub_missing = find_missing_keys(
value, old_config[key], parent_key=full_path
)
missing_keys.extend(sub_missing)
return missing_keys
def check_config_file(): def check_config_file():
old_config_file = get_project_dir() + "data/." + default_config_file global config_file_valid
if not os.path.exists(old_config_file): if config_file_valid:
return return
old_config = load_config() """
new_config = read_config(get_project_dir() + default_config_file) 简化的配置检查,仅提示用户配置文件的使用情况
# 查找缺失的配置项 """
missing_keys = find_missing_keys(new_config, old_config) custom_config_file = get_project_dir() + "data/." + default_config_file
read_config_from_api = old_config.get("read_config_from_api", False) if not os.path.exists(custom_config_file):
if read_config_from_api: raise FileNotFoundError(
old_config_origin = read_config(old_config_file) "找不到data/.config.yaml文件,请按教程确认该配置文件是否存在"
)
# 检查是否从API读取配置
config = load_config()
if config.get("read_config_from_api", False):
print("从API读取配置")
old_config_origin = read_config(custom_config_file)
if old_config_origin.get("selected_module") is not None: if old_config_origin.get("selected_module") is not None:
missing_keys_str = "\n".join(f"- {key}" for key in missing_keys)
error_msg = "您的配置文件好像既包含智控台的配置又包含本地配置:\n" error_msg = "您的配置文件好像既包含智控台的配置又包含本地配置:\n"
error_msg += "\n建议您:\n" error_msg += "\n建议您:\n"
error_msg += "1、将根目录的config_from_api.yaml文件复制到data下,重命名为.config.yaml\n" error_msg += "1、将根目录的config_from_api.yaml文件复制到data下,重命名为.config.yaml\n"
error_msg += "2、按教程配置好接口地址和密钥\n" error_msg += "2、按教程配置好接口地址和密钥\n"
raise ValueError(error_msg) raise ValueError(error_msg)
return config_file_valid = True
if missing_keys:
missing_keys_str = "\n".join(f"- {key}" for key in missing_keys)
error_msg = "您的配置文件太旧了,缺少了:\n"
error_msg += missing_keys_str
error_msg += "\n建议您:\n"
error_msg += "1、备份data/.config.yaml文件\n"
error_msg += "2、将根目录的config.yaml文件复制到data下,重命名为.config.yaml\n"
error_msg += "3、将密钥逐个复制到新的配置文件中\n"
raise ValueError(error_msg)
+192 -18
View File
@@ -17,7 +17,6 @@ from core.handle.textHandle import handleTextMessage
from core.utils.util import ( from core.utils.util import (
get_string_no_punctuation_or_emoji, get_string_no_punctuation_or_emoji,
extract_json_from_string, extract_json_from_string,
get_ip_info,
initialize_modules, initialize_modules,
) )
from concurrent.futures import ThreadPoolExecutor, TimeoutError from concurrent.futures import ThreadPoolExecutor, TimeoutError
@@ -30,6 +29,7 @@ from core.mcp.manager import MCPManager
from config.config_loader import get_private_config_from_api from config.config_loader import get_private_config_from_api
from config.manage_api_client import DeviceNotFoundException, DeviceBindException from config.manage_api_client import DeviceNotFoundException, DeviceBindException
from core.utils.output_counter import add_device_output from core.utils.output_counter import add_device_output
from core.handle.ttsReportHandle import enqueue_tts_report, report_tts
TAG = __name__ TAG = __name__
@@ -42,17 +42,28 @@ class TTSException(RuntimeError):
class ConnectionHandler: class ConnectionHandler:
def __init__( def __init__(
self, config: Dict[str, Any], _vad, _asr, _llm, _tts, _memory, _intent self,
config: Dict[str, Any],
_vad,
_asr,
_llm,
_tts,
_memory,
_intent,
server=None,
): ):
self.config = copy.deepcopy(config) self.config = config
self.server = server
self.logger = setup_logging() self.logger = setup_logging()
self.auth = AuthMiddleware(config) self.auth = AuthMiddleware(config)
self.need_bind = False self.need_bind = False
self.bind_code = None self.bind_code = None
self.read_config_from_api = self.config.get("read_config_from_api", False)
self.websocket = None self.websocket = None
self.headers = None self.headers = None
self.device_id = None
self.client_ip = None self.client_ip = None
self.client_ip_info = {} self.client_ip_info = {}
self.session_id = None self.session_id = None
@@ -71,6 +82,10 @@ class ConnectionHandler:
self.audio_play_queue = queue.Queue() self.audio_play_queue = queue.Queue()
self.executor = ThreadPoolExecutor(max_workers=10) self.executor = ThreadPoolExecutor(max_workers=10)
# 上报线程
self.tts_report_queue = queue.Queue()
self.tts_report_thread = None
# 依赖的组件 # 依赖的组件
self.vad = _vad self.vad = _vad
self.asr = _asr self.asr = _asr
@@ -152,6 +167,7 @@ class ConnectionHandler:
# 认证通过,继续处理 # 认证通过,继续处理
self.websocket = ws self.websocket = ws
self.device_id = self.headers.get("device-id", None)
self.session_id = str(uuid.uuid4()) self.session_id = str(uuid.uuid4())
# 启动超时检查任务 # 启动超时检查任务
@@ -202,18 +218,90 @@ class ConnectionHandler:
finally: finally:
await self.close(ws) await self.close(ws)
async def reset_timeout(self):
"""重置超时计时器"""
if self.timeout_task:
self.timeout_task.cancel()
self.timeout_task = asyncio.create_task(self._check_timeout())
async def _route_message(self, message): async def _route_message(self, message):
"""消息路由""" """消息路由"""
# 重置超时计时器 # 重置超时计时器
if self.timeout_task: await self.reset_timeout()
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)
async def handle_config_update(self, message):
"""处理配置更新请求"""
content = message.get("content", {})
new_config = content
# 遍历所有支持的配置模块
updated_modules = []
for config_model in ["tts", "llm", "vad", "asr", "memory", "intent"]:
if config_model not in new_config:
continue
new_content = new_config[config_model]
old_content = self.config.get(config_model, {})
# 记录配置变更
self.logger.bind(tag=TAG).info(
f"配置更新: {config_model} 旧值: {json.dumps(old_content, ensure_ascii=False)} "
f"新值: {json.dumps(new_content, ensure_ascii=False)}"
)
# 深度合并配置
if isinstance(old_content, dict) and isinstance(new_content, dict):
merged = {**old_content, **new_content}
self.config[config_model] = merged
else:
self.config[config_model] = new_content
# 标记需要重新初始化的模块
if config_model in ["llm", "tts", "asr", "vad", "intent", "memory"]:
updated_modules.append(config_model)
# 同步更新 WebSocketServer 的配置
if self.server:
async with self.server.config_lock: # 使用锁确保线程安全
for config_model in updated_modules:
self.server.config[config_model].update(new_config[config_model])
# 批量初始化模块
if updated_modules:
try:
self._initialize_components(self.config)
self.logger.bind(tag=TAG).info(
f"已重新初始化模块: {', '.join(updated_modules)}"
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"模块初始化失败: {str(e)}")
await self.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "error",
"message": f"模块初始化失败: {str(e)}",
}
)
)
return
# 返回成功响应
await self.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "success",
"message": f"已更新配置: {', '.join(updated_modules)}",
}
)
)
def _initialize_components(self, private_config): def _initialize_components(self, private_config):
"""初始化组件""" """初始化组件"""
if private_config is not None: if private_config is not None:
@@ -225,11 +313,23 @@ class ConnectionHandler:
self._initialize_memory() self._initialize_memory()
"""加载意图识别""" """加载意图识别"""
self._initialize_intent() self._initialize_intent()
"""初始化上报线程"""
self._init_report_threads()
def _init_report_threads(self):
"""初始化ASR和TTS上报线程"""
if not self.read_config_from_api:
return
if self.tts_report_thread is None or not self.tts_report_thread.is_alive():
self.tts_report_thread = threading.Thread(
target=self._tts_report_worker, daemon=True
)
self.tts_report_thread.start()
self.logger.bind(tag=TAG).info("TTS上报线程已启动")
def _initialize_private_config(self): def _initialize_private_config(self):
read_config_from_api = self.config.get("read_config_from_api", False)
"""如果是从配置文件获取,则进行二次实例化""" """如果是从配置文件获取,则进行二次实例化"""
if not read_config_from_api: if not self.read_config_from_api:
return return
"""从接口获取差异化的配置进行二次实例化,非全量重新实例化""" """从接口获取差异化的配置进行二次实例化,非全量重新实例化"""
try: try:
@@ -241,7 +341,7 @@ class ConnectionHandler:
) )
private_config["delete_audio"] = bool(self.config.get("delete_audio", True)) private_config["delete_audio"] = bool(self.config.get("delete_audio", True))
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).info(
f"{time.time() - begin_time} 秒,获取差异化配置成功: {private_config}" f"{time.time() - begin_time} 秒,获取差异化配置成功: {json.dumps(filter_sensitive_info(private_config), ensure_ascii=False)}"
) )
except DeviceNotFoundException as e: except DeviceNotFoundException as e:
self.need_bind = True self.need_bind = True
@@ -351,8 +451,7 @@ class ConnectionHandler:
def _initialize_memory(self): def _initialize_memory(self):
"""初始化记忆模块""" """初始化记忆模块"""
device_id = self.headers.get("device-id", None) self.memory.init_memory(self.device_id, self.llm)
self.memory.init_memory(device_id, self.llm)
def _initialize_intent(self): def _initialize_intent(self):
if ( if (
@@ -441,11 +540,16 @@ class ConnectionHandler:
current_text = full_text[processed_chars:] # 从未处理的位置开始 current_text = full_text[processed_chars:] # 从未处理的位置开始
# 查找最后一个有效标点 # 查找最后一个有效标点
punctuations = ("", "", "", "", "") punctuations = ("", ".", "", "?", "", "!", "", ";", "")
last_punct_pos = -1 last_punct_pos = -1
number_flag = True
for punct in punctuations: for punct in punctuations:
pos = current_text.rfind(punct) pos = current_text.rfind(punct)
if pos > last_punct_pos: prev_char = current_text[pos - 1] if pos - 1 >= 0 else ""
# 如果.前面是数字统一判断为小数
if prev_char.isdigit() and punct == ".":
number_flag = False
if pos > last_punct_pos and number_flag:
last_punct_pos = pos last_punct_pos = pos
# 找到分割点则处理 # 找到分割点则处理
@@ -567,11 +671,16 @@ class ConnectionHandler:
current_text = full_text[processed_chars:] # 从未处理的位置开始 current_text = full_text[processed_chars:] # 从未处理的位置开始
# 查找最后一个有效标点 # 查找最后一个有效标点
punctuations = ("", "", "", "", "") punctuations = ("", ".", "", "?", "", "!", "", ";", "")
last_punct_pos = -1 last_punct_pos = -1
number_flag = True
for punct in punctuations: for punct in punctuations:
pos = current_text.rfind(punct) pos = current_text.rfind(punct)
if pos > last_punct_pos: prev_char = current_text[pos - 1] if pos - 1 >= 0 else ""
# 如果.前面是数字统一判断为小数
if prev_char.isdigit() and punct == ".":
number_flag = False
if pos > last_punct_pos and number_flag:
last_punct_pos = pos last_punct_pos = pos
# 找到分割点则处理 # 找到分割点则处理
@@ -776,7 +885,9 @@ class ConnectionHandler:
f"TTS生成:文件路径: {tts_file}" f"TTS生成:文件路径: {tts_file}"
) )
if os.path.exists(tts_file): if os.path.exists(tts_file):
opus_datas, duration = self.tts.audio_to_opus_data(tts_file) opus_datas, _ = self.tts.audio_to_opus_data(tts_file)
# 在这里上报TTS数据(使用文件路径)
enqueue_tts_report(self, 2, text, opus_datas)
else: else:
self.logger.bind(tag=TAG).error( self.logger.bind(tag=TAG).error(
f"TTS出错:文件不存在{tts_file}" f"TTS出错:文件不存在{tts_file}"
@@ -832,6 +943,33 @@ class ConnectionHandler:
f"audio_play_priority priority_thread: {text} {e}" f"audio_play_priority priority_thread: {text} {e}"
) )
def _tts_report_worker(self):
"""TTS上报工作线程"""
while not self.stop_event.is_set():
try:
# 从队列获取数据,设置超时以便定期检查停止事件
item = self.tts_report_queue.get(timeout=1)
if item is None: # 检测毒丸对象
break
type, text, audio_data = item
try:
# 执行上报(传入二进制数据)
report_tts(self, type, text, audio_data)
except Exception as e:
self.logger.bind(tag=TAG).error(f"TTS上报线程异常: {e}")
finally:
# 标记任务完成
self.tts_report_queue.task_done()
except queue.Empty:
continue
except Exception as e:
self.logger.bind(tag=TAG).error(f"TTS上报工作线程异常: {e}")
self.logger.bind(tag=TAG).info("TTS上报线程已退出")
def speak_and_play(self, text, text_index=0): def speak_and_play(self, text, text_index=0):
if text is None or len(text) <= 0: if text is None or len(text) <= 0:
self.logger.bind(tag=TAG).info(f"无需tts转换,query为空,{text}") self.logger.bind(tag=TAG).info(f"无需tts转换,query为空,{text}")
@@ -877,6 +1015,9 @@ class ConnectionHandler:
self.executor.shutdown(wait=False, cancel_futures=True) self.executor.shutdown(wait=False, cancel_futures=True)
self.executor = None self.executor = None
# 添加毒丸对象到上报队列确保线程退出
self.tts_report_queue.put(None)
# 清空任务队列 # 清空任务队列
self.clear_queues() self.clear_queues()
@@ -888,7 +1029,7 @@ class ConnectionHandler:
def clear_queues(self): def clear_queues(self):
# 清空所有任务队列 # 清空所有任务队列
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).debug(
f"开始清理: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}" f"开始清理: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
) )
for q in [self.tts_queue, self.audio_play_queue]: for q in [self.tts_queue, self.audio_play_queue]:
@@ -902,7 +1043,7 @@ class ConnectionHandler:
q.queue.clear() q.queue.clear()
# 添加毒丸信号到队列,确保线程退出 # 添加毒丸信号到队列,确保线程退出
# q.queue.put(None) # q.queue.put(None)
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).debug(
f"清理结束: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}" f"清理结束: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
) )
@@ -935,3 +1076,36 @@ class ConnectionHandler:
break break
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}") self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}")
def filter_sensitive_info(config: dict) -> dict:
"""
过滤配置中的敏感信息
Args:
config: 原始配置字典
Returns:
过滤后的配置字典
"""
sensitive_keys = [
"api_key",
"personal_access_token",
"access_token",
"token",
"access_key_secret",
"secret_key",
]
def _filter_dict(d: dict) -> dict:
filtered = {}
for k, v in d.items():
if any(sensitive in k.lower() for sensitive in sensitive_keys):
filtered[k] = "***"
elif isinstance(v, dict):
filtered[k] = _filter_dict(v)
elif isinstance(v, list):
filtered[k] = [_filter_dict(i) if isinstance(i, dict) else i for i in v]
else:
filtered[k] = v
return filtered
return _filter_dict(copy.deepcopy(config))
@@ -1,9 +1,11 @@
from config.logger import setup_logging from config.logger import setup_logging
import time import time
import copy
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 from core.utils.output_counter import check_device_output_limit
from core.handle.ttsReportHandle import enqueue_tts_report
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -40,6 +42,9 @@ async def handleAudioMessage(conn, audio):
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:
# 使用自定义模块进行上报
enqueue_tts_report(conn, 1, text, copy.deepcopy(conn.asr_audio))
await startToChat(conn, text) await startToChat(conn, text)
else: else:
conn.asr_server_receive = True conn.asr_server_receive = True
@@ -2,22 +2,58 @@ from config.logger import setup_logging
import json import json
import asyncio import asyncio
import time import time
from core.utils.util import ( from core.utils.util import get_string_no_punctuation_or_emoji, analyze_emotion
get_string_no_punctuation_or_emoji,
)
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
emoji_map = {
"neutral": "😶",
"happy": "🙂",
"laughing": "😆",
"funny": "😂",
"sad": "😔",
"angry": "😠",
"crying": "😭",
"loving": "😍",
"embarrassed": "😳",
"surprised": "😲",
"shocked": "😱",
"thinking": "🤔",
"winking": "😉",
"cool": "😎",
"relaxed": "😌",
"delicious": "🤤",
"kissy": "😘",
"confident": "😏",
"sleepy": "😴",
"silly": "😜",
"confused": "🙄",
}
async def sendAudioMessage(conn, audios, text, text_index=0): async def sendAudioMessage(conn, audios, text, text_index=0):
# 发送句子开始消息 # 发送句子开始消息
if text is not None:
emotion = analyze_emotion(text)
emoji = emoji_map.get(emotion, "🙂") # 默认使用笑脸
await conn.websocket.send(
json.dumps(
{
"type": "llm",
"text": emoji,
"emotion": emotion,
"session_id": conn.session_id,
}
)
)
if text_index == conn.tts_first_text_index: if text_index == conn.tts_first_text_index:
logger.bind(tag=TAG).info(f"发送第一段语音: {text}") logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
await send_tts_message(conn, "sentence_start", text) await send_tts_message(conn, "sentence_start", text)
# 播放音频 is_first_audio = (text_index == conn.tts_first_text_index)
await sendAudio(conn, audios) await sendAudio(conn, audios, pre_buffer=is_first_audio)
await send_tts_message(conn, "sentence_end", text) await send_tts_message(conn, "sentence_end", text)
@@ -29,22 +65,32 @@ async def sendAudioMessage(conn, audios, text, text_index=0):
# 播放音频 # 播放音频
async def sendAudio(conn, audios): async def sendAudio(conn, audios, pre_buffer=True):
# 流控参数优化 # 流控参数优化
frame_duration = 60 # 帧时长(毫秒),匹配 Opus 编码 frame_duration = 60 # 帧时长(毫秒),匹配 Opus 编码
start_time = time.perf_counter() start_time = time.perf_counter()
play_position = 0 play_position = 0
last_reset_time = time.perf_counter() # 记录最后的重置时间
# 预缓冲:发送前 3 帧 # 仅当第一句话时执行预缓冲
pre_buffer = min(3, len(audios)) if pre_buffer:
for i in range(pre_buffer): pre_buffer_frames = min(3, len(audios))
await conn.websocket.send(audios[i]) for i in range(pre_buffer_frames):
await conn.websocket.send(audios[i])
remaining_audios = audios[pre_buffer_frames:]
else:
remaining_audios = audios
# 正常播放剩余帧 # 播放剩余音频
for opus_packet in audios[pre_buffer:]: for opus_packet in remaining_audios:
if conn.client_abort: if conn.client_abort:
return return
# 每分钟重置一次计时器
if time.perf_counter() - last_reset_time > 60:
await conn.reset_timeout()
last_reset_time = time.perf_counter()
# 计算预期发送时间 # 计算预期发送时间
expected_time = start_time + (play_position / 1000) expected_time = start_time + (play_position / 1000)
current_time = time.perf_counter() current_time = time.perf_counter()
@@ -86,14 +132,4 @@ async def send_stt_message(conn, text):
await conn.websocket.send( await conn.websocket.send(
json.dumps({"type": "stt", "text": stt_text, "session_id": conn.session_id}) json.dumps({"type": "stt", "text": stt_text, "session_id": conn.session_id})
) )
await conn.websocket.send(
json.dumps(
{
"type": "llm",
"text": "😊",
"emotion": "happy",
"session_id": conn.session_id,
}
)
)
await send_tts_message(conn, "start") await send_tts_message(conn, "start")
@@ -6,6 +6,7 @@ from core.utils.util import remove_punctuation_and_length
from core.handle.receiveAudioHandle import startToChat, handleAudioMessage from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
from core.handle.sendAudioHandle import send_stt_message, send_tts_message from core.handle.sendAudioHandle import send_stt_message, send_tts_message
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
from core.handle.ttsReportHandle import enqueue_tts_report
import asyncio import asyncio
TAG = __name__ TAG = __name__
@@ -53,7 +54,13 @@ async def handleTextMessage(conn, message):
# 如果是唤醒词,且关闭了唤醒词回复,就不用回答 # 如果是唤醒词,且关闭了唤醒词回复,就不用回答
await send_stt_message(conn, text) await send_stt_message(conn, text)
await send_tts_message(conn, "stop", None) await send_tts_message(conn, "stop", None)
elif is_wakeup_words:
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_tts_report(conn, 1, "嘿,你好呀", [])
await startToChat(conn, "嘿,你好呀")
else: else:
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_tts_report(conn, 1, text, [])
# 否则需要LLM对文字内容进行答复 # 否则需要LLM对文字内容进行答复
await startToChat(conn, text) await startToChat(conn, text)
elif msg_json["type"] == "iot": elif msg_json["type"] == "iot":
@@ -61,5 +68,27 @@ async def handleTextMessage(conn, message):
asyncio.create_task(handleIotDescriptors(conn, msg_json["descriptors"])) asyncio.create_task(handleIotDescriptors(conn, msg_json["descriptors"]))
if "states" in msg_json: if "states" in msg_json:
asyncio.create_task(handleIotStatus(conn, msg_json["states"])) asyncio.create_task(handleIotStatus(conn, msg_json["states"]))
elif msg_json["type"] == "server":
# 如果配置是从API读取的,则需要验证secret
if not conn.read_config_from_api:
return
# 获取post请求的secret
post_secret = msg_json.get("content", {}).get("secret", "")
secret = conn.config["manager-api"].get("secret", "")
# 如果secret不匹配,则返回
if post_secret != secret:
await conn.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "error",
"message": "服务器密钥验证失败",
}
)
)
return
# 动态更新配置
if msg_json["action"] == "update_config":
await conn.handle_config_update(msg_json)
except json.JSONDecodeError: except json.JSONDecodeError:
await conn.websocket.send(message) await conn.websocket.send(message)
@@ -0,0 +1,59 @@
"""
TTS上报功能已集成到ConnectionHandler类中。
上报功能包括:
1. 每个连接对象拥有自己的上报队列和处理线程
2. 上报线程的生命周期与连接对象绑定
3. 使用ConnectionHandler.enqueue_tts_report方法进行上报
具体实现请参考core/connection.py中的相关代码。
"""
from config.logger import setup_logging
from config.manage_api_client import report
TAG = __name__
logger = setup_logging()
def report_tts(conn, type, text, opus_data):
"""执行TTS上报操作
Args:
conn: 连接对象
type: 上报类型,1为用户,2为智能体
text: 合成文本
opus_data: opus音频数据
"""
try:
# 执行上报
report(
mac_address=conn.device_id,
session_id=conn.session_id,
chat_type=type,
content=text,
opus_data=opus_data,
)
except Exception as e:
logger.bind(tag=TAG).error(f"TTS上报失败: {e}")
def enqueue_tts_report(conn, type, text, opus_data):
if not conn.read_config_from_api:
return
"""将TTS数据加入上报队列
Args:
conn: 连接对象
text: 合成文本
opus_data: opus音频数据
"""
try:
# 使用连接对象的队列,传入文本和二进制数据而非文件路径
conn.tts_report_queue.put((type, text, opus_data))
logger.bind(tag=TAG).info(
f"TTS数据已加入上报队列: {conn.device_id}, 音频大小: {len(opus_data)} "
)
except Exception as e:
logger.bind(tag=TAG).error(f"加入TTS上报队列失败: {text}, {e}")
+135
View File
@@ -0,0 +1,135 @@
import json
import time
import asyncio
from aiohttp import web
from config.logger import setup_logging
from core.connection import ConnectionHandler
from core.utils.util import get_local_ip, initialize_modules
TAG = __name__
class SimpleOtaServer:
def __init__(self, config: dict):
self.config = config
self.logger = setup_logging()
def _get_websocket_url(self, local_ip: str, port: int) -> str:
"""获取websocket地址
Args:
local_ip: 本地IP地址
port: 端口号
Returns:
str: websocket地址
"""
server_config = self.config["server"]
websocket_config = server_config.get("websocket")
if websocket_config and "" not in websocket_config:
return websocket_config
else:
return f"ws://{local_ip}:{port}/xiaozhi/v1/"
async def start(self):
server_config = self.config["server"]
host = server_config.get("ip", "0.0.0.0")
port = int(server_config.get("ota_port"))
if port:
app = web.Application()
# 添加路由
app.add_routes(
[
web.get("/xiaozhi/ota/", self._handle_ota_get_request),
web.post("/xiaozhi/ota/", self._handle_ota_request),
web.options("/xiaozhi/ota/", self._handle_ota_request),
]
)
# 运行服务
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
# 保持服务运行
while True:
await asyncio.sleep(3600) # 每隔 1 小时检查一次
async def _handle_ota_request(self, request):
"""处理 /xiaozhi/ota/ 的 POST 请求"""
try:
data = await request.text()
self.logger.bind(tag=TAG).debug(f"OTA请求方法: {request.method}")
self.logger.bind(tag=TAG).debug(f"OTA请求头: {request.headers}")
self.logger.bind(tag=TAG).debug(f"OTA请求数据: {data}")
device_id = request.headers.get("device-id", "")
if device_id:
self.logger.bind(tag=TAG).info(f"OTA请求设备ID: {device_id}")
else:
raise Exception("OTA请求设备ID为空")
data_json = json.loads(data)
server_config = self.config["server"]
host = server_config.get("ip", "0.0.0.0")
port = int(server_config.get("port", 8000))
local_ip = get_local_ip()
# OTA基础信息
return_json = {
"server_time": {
"timestamp": int(round(time.time() * 1000)),
"timezone_offset": server_config.get("timezone_offset", 8) * 60,
},
"firmware": {
"version": data_json["application"].get("version", "1.0.0"),
"url": "",
},
"websocket": {
"url": self._get_websocket_url(local_ip, port),
},
}
response = web.Response(
text=json.dumps(return_json, separators=(",", ":")),
content_type="application/json",
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"OTA请求异常: {e}")
return_json = {"success": False, "message": "request error."}
response = web.Response(
text=json.dumps(return_json, separators=(",", ":")),
content_type="application/json",
)
finally:
# 添加header,允许跨域访问
response.headers["Access-Control-Allow-Headers"] = (
"client-id, content-type, device-id"
)
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Origin"] = "*"
return response
async def _handle_ota_get_request(self, request):
"""处理 /xiaozhi/ota/ 的 GET 请求"""
try:
server_config = self.config["server"]
local_ip = get_local_ip()
port = int(server_config.get("port", 8000))
websocket_url = self._get_websocket_url(local_ip, port)
message = f"OTA接口运行正常,向设备发送的websocket地址是:{websocket_url}"
response = web.Response(text=message, content_type="text/plain")
except Exception as e:
self.logger.bind(tag=TAG).error(f"OTA GET请求异常: {e}")
response = web.Response(text="OTA接口异常", content_type="text/plain")
finally:
# 添加header,允许跨域访问
response.headers["Access-Control-Allow-Headers"] = (
"client-id, content-type, device-id"
)
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Origin"] = "*"
return response
@@ -0,0 +1,265 @@
import http.client
import json
import asyncio
from typing import Optional, Tuple, List
import opuslib_next
import wave
import io
import os
import uuid
import hmac
import hashlib
import base64
import requests
from urllib import parse
import time
from datetime import datetime
from config.logger import setup_logging
from core.providers.asr.base import ASRProviderBase
TAG = __name__
logger = setup_logging()
class AccessToken:
@staticmethod
def _encode_text(text):
encoded_text = parse.quote_plus(text)
return encoded_text.replace('+', '%20').replace('*', '%2A').replace('%7E', '~')
@staticmethod
def _encode_dict(dic):
keys = dic.keys()
dic_sorted = [(key, dic[key]) for key in sorted(keys)]
encoded_text = parse.urlencode(dic_sorted)
return encoded_text.replace('+', '%20').replace('*', '%2A').replace('%7E', '~')
@staticmethod
def create_token(access_key_id, access_key_secret):
parameters = {'AccessKeyId': access_key_id,
'Action': 'CreateToken',
'Format': 'JSON',
'RegionId': 'cn-shanghai',
'SignatureMethod': 'HMAC-SHA1',
'SignatureNonce': str(uuid.uuid1()),
'SignatureVersion': '1.0',
'Timestamp': time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
'Version': '2019-02-28'}
# 构造规范化的请求字符串
query_string = AccessToken._encode_dict(parameters)
# print('规范化的请求字符串: %s' % query_string)
# 构造待签名字符串
string_to_sign = 'GET' + '&' + AccessToken._encode_text('/') + '&' + AccessToken._encode_text(query_string)
# print('待签名的字符串: %s' % string_to_sign)
# 计算签名
secreted_string = hmac.new(bytes(access_key_secret + '&', encoding='utf-8'),
bytes(string_to_sign, encoding='utf-8'),
hashlib.sha1).digest()
signature = base64.b64encode(secreted_string)
# print('签名: %s' % signature)
# 进行URL编码
signature = AccessToken._encode_text(signature)
# print('URL编码后的签名: %s' % signature)
# 调用服务
full_url = 'http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s' % (signature, query_string)
# print('url: %s' % full_url)
# 提交HTTP GET请求
response = requests.get(full_url)
if response.ok:
root_obj = response.json()
key = 'Token'
if key in root_obj:
token = root_obj[key]['Id']
expire_time = root_obj[key]['ExpireTime']
return token, expire_time
# print(response.text)
return None, None
class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool):
"""阿里云ASR初始化"""
# 新增空值判断逻辑
self.access_key_id = config.get("access_key_id")
self.access_key_secret = config.get("access_key_secret")
self.app_key = config.get("appkey")
self.host = "nls-gateway-cn-shanghai.aliyuncs.com"
self.base_url = f"https://{self.host}/stream/v1/asr"
self.sample_rate = 16000
self.format = "wav"
self.output_dir = config.get("output_dir", "./audio_output")
self.delete_audio_file = delete_audio_file
if self.access_key_id and self.access_key_secret:
# 使用密钥对生成临时token
self._refresh_token()
else:
# 直接使用预生成的长期token
self.token = config.get("token")
self.expire_time = None
# 确保输出目录存在
os.makedirs(self.output_dir, exist_ok=True)
def _refresh_token(self):
"""刷新Token并记录过期时间"""
if self.access_key_id and self.access_key_secret:
self.token, expire_time_str = AccessToken.create_token(
self.access_key_id,
self.access_key_secret
)
if not expire_time_str:
raise ValueError("无法获取有效的Token过期时间")
try:
#统一转换为字符串处理
expire_str = str(expire_time_str).strip()
if expire_str.isdigit():
expire_time = datetime.fromtimestamp(int(expire_str))
else:
expire_time = datetime.strptime(
expire_str,
"%Y-%m-%dT%H:%M:%SZ"
)
self.expire_time = expire_time.timestamp() - 60
except Exception as e:
raise ValueError(f"无效的过期时间格式: {expire_str}") from e
else:
self.expire_time = None
if not self.token:
raise ValueError("无法获取有效的访问Token")
def _is_token_expired(self):
"""检查Token是否过期"""
if not self.expire_time:
return False # 长期Token不过期
# 新增调试日志
# current_time = time.time()
# remaining = self.expire_time - current_time
# print(f"Token过期检查: 当前时间 {datetime.fromtimestamp(current_time)} | "
# f"过期时间 {datetime.fromtimestamp(self.expire_time)} | "
# f"剩余 {remaining:.2f}秒")
return time.time() > self.expire_time
def generate_filename(self, extension=".wav"):
return os.path.join(self.output_file, f"tts-{__name__}{datetime.now().date()}@{uuid.uuid4().hex}{extension}")
def _construct_request_url(self) -> str:
"""构造请求URL,包含参数"""
request = f"{self.base_url}?appkey={self.app_key}"
request += f"&format={self.format}"
request += f"&sample_rate={self.sample_rate}"
request += "&enable_punctuation_prediction=true"
request += "&enable_inverse_text_normalization=true"
request += "&enable_voice_detection=false"
return request
def decode_opus(self, opus_data: List[bytes], session_id: str) -> List[bytes]:
"""将Opus数据解码为PCM"""
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
return pcm_data
def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str:
"""将Opus音频数据解码并保存为WAV文件"""
file_name = f"asr_{session_id}.wav"
file_path = os.path.join(self.output_dir, file_name)
pcm_data = self.decode_opus(opus_data, session_id)
with wave.open(file_path, "wb") as wf:
wf.setnchannels(1) # 单声道
wf.setsampwidth(2) # 16-bit
wf.setframerate(self.sample_rate)
wf.writeframes(b''.join(pcm_data))
logger.bind(tag=TAG).debug(f"音频文件已保存至: {file_path}")
return file_path
async def _send_request(self, pcm_data: bytes) -> Optional[str]:
"""发送请求到阿里云ASR服务"""
try:
# 设置HTTP头
headers = {
'X-NLS-Token': self.token,
'Content-type': 'application/octet-stream',
'Content-Length': str(len(pcm_data))
}
# 创建连接并发送请求
conn = http.client.HTTPSConnection(self.host)
request_url = self._construct_request_url()
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: conn.request(
method='POST',
url=request_url,
body=pcm_data,
headers=headers
))
# 获取响应
response = await loop.run_in_executor(None, conn.getresponse)
body = await loop.run_in_executor(None, response.read)
conn.close()
# 解析响应
try:
body_json = json.loads(body)
status = body_json.get('status')
if status == 20000000:
result = body_json.get('result', '')
logger.bind(tag=TAG).debug(f"ASR结果: {result}")
return result
else:
logger.bind(tag=TAG).error(f"ASR失败,状态码: {status}")
return None
except ValueError:
logger.bind(tag=TAG).error("响应不是JSON格式")
return None
except Exception as e:
logger.bind(tag=TAG).error(f"ASR请求失败: {e}", exc_info=True)
return None
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
if self._is_token_expired():
logger.warning("Token已过期,正在自动刷新...")
self._refresh_token()
try:
# 解码Opus为PCM
pcm_data_list = self.decode_opus(opus_data, session_id)
combined_pcm_data = b''.join(pcm_data_list)
# 发送请求并获取文本
text = await self._send_request(combined_pcm_data)
file_path = self.save_audio_to_file(opus_data, session_id)
if self.delete_audio_file:
os.remove(file_path)
logger.bind(tag=TAG).debug(f"音频文件已删除: {file_path}")
if text:
return text, None
return "", None
except Exception as e:
logger.bind(tag=TAG).error(f"语音识别失败: {e}", exc_info=True)
return "", None
@@ -9,8 +9,8 @@ logger = setup_logging()
class ASRProviderBase(ABC): class ASRProviderBase(ABC):
@abstractmethod @abstractmethod
def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str: def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str:
"""解码Opus数据保存为WAV文件""" """PCM数据保存为WAV文件"""
pass pass
@abstractmethod @abstractmethod
@@ -45,14 +45,14 @@ def parse_response(res):
payload 类似与http 请求体 payload 类似与http 请求体
""" """
protocol_version = res[0] >> 4 protocol_version = res[0] >> 4
header_size = res[0] & 0x0f header_size = res[0] & 0x0F
message_type = res[1] >> 4 message_type = res[1] >> 4
message_type_specific_flags = res[1] & 0x0f message_type_specific_flags = res[1] & 0x0F
serialization_method = res[2] >> 4 serialization_method = res[2] >> 4
message_compression = res[2] & 0x0f message_compression = res[2] & 0x0F
reserved = res[3] reserved = res[3]
header_extensions = res[4:header_size * 4] header_extensions = res[4 : header_size * 4]
payload = res[header_size * 4:] payload = res[header_size * 4 :]
result = {} result = {}
payload_msg = None payload_msg = None
payload_size = 0 payload_size = 0
@@ -61,13 +61,13 @@ def parse_response(res):
payload_msg = payload[4:] payload_msg = payload[4:]
elif message_type == SERVER_ACK: elif message_type == SERVER_ACK:
seq = int.from_bytes(payload[:4], "big", signed=True) seq = int.from_bytes(payload[:4], "big", signed=True)
result['seq'] = seq result["seq"] = seq
if len(payload) >= 8: if len(payload) >= 8:
payload_size = int.from_bytes(payload[4:8], "big", signed=False) payload_size = int.from_bytes(payload[4:8], "big", signed=False)
payload_msg = payload[8:] payload_msg = payload[8:]
elif message_type == SERVER_ERROR_RESPONSE: elif message_type == SERVER_ERROR_RESPONSE:
code = int.from_bytes(payload[:4], "big", signed=False) code = int.from_bytes(payload[:4], "big", signed=False)
result['code'] = code result["code"] = code
payload_size = int.from_bytes(payload[4:8], "big", signed=False) payload_size = int.from_bytes(payload[4:8], "big", signed=False)
payload_msg = payload[8:] payload_msg = payload[8:]
if payload_msg is None: if payload_msg is None:
@@ -78,8 +78,8 @@ def parse_response(res):
payload_msg = json.loads(str(payload_msg, "utf-8")) payload_msg = json.loads(str(payload_msg, "utf-8"))
elif serialization_method != NO_SERIALIZATION: elif serialization_method != NO_SERIALIZATION:
payload_msg = str(payload_msg, "utf-8") payload_msg = str(payload_msg, "utf-8")
result['payload_msg'] = payload_msg result["payload_msg"] = payload_msg
result['payload_size'] = payload_size result["payload_size"] = payload_size
return result return result
@@ -88,7 +88,10 @@ class ASRProvider(ASRProviderBase):
self.appid = config.get("appid") self.appid = config.get("appid")
self.cluster = config.get("cluster") self.cluster = config.get("cluster")
self.access_token = config.get("access_token") self.access_token = config.get("access_token")
self.boosting_table_name = config.get("boosting_table_name")
self.correct_table_name = config.get("correct_table_name")
self.output_dir = config.get("output_dir") self.output_dir = config.get("output_dir")
self.delete_audio_file = delete_audio_file
self.host = "openspeech.bytedance.com" self.host = "openspeech.bytedance.com"
self.ws_url = f"wss://{self.host}/api/v2/asr" self.ws_url = f"wss://{self.host}/api/v2/asr"
@@ -98,21 +101,11 @@ class ASRProvider(ASRProviderBase):
# 确保输出目录存在 # 确保输出目录存在
os.makedirs(self.output_dir, exist_ok=True) os.makedirs(self.output_dir, exist_ok=True)
def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str: def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str:
"""将Opus音频数据解码并保存为WAV文件""" """PCM数据保存为WAV文件"""
file_name = f"asr_{session_id}_{uuid.uuid4()}.wav" file_name = f"asr_{session_id}_{uuid.uuid4()}.wav"
file_path = os.path.join(self.output_dir, file_name) file_path = os.path.join(self.output_dir, file_name)
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
with wave.open(file_path, "wb") as wf: with wave.open(file_path, "wb") as wf:
wf.setnchannels(1) wf.setnchannels(1)
wf.setsampwidth(2) # 2 bytes = 16-bit wf.setsampwidth(2) # 2 bytes = 16-bit
@@ -122,7 +115,9 @@ class ASRProvider(ASRProviderBase):
return file_path return file_path
@staticmethod @staticmethod
def _generate_header(message_type=CLIENT_FULL_REQUEST, message_type_specific_flags=NO_SEQUENCE) -> bytearray: def _generate_header(
message_type=CLIENT_FULL_REQUEST, message_type_specific_flags=NO_SEQUENCE
) -> bytearray:
"""Generate protocol header.""" """Generate protocol header."""
header = bytearray() header = bytearray()
header_size = 1 header_size = 1
@@ -144,12 +139,14 @@ class ASRProvider(ASRProviderBase):
"uid": str(uuid.uuid4()), "uid": str(uuid.uuid4()),
}, },
"request": { "request": {
"reqid": reqid, "reqid": reqid,
"show_utterances": False, "show_utterances": False,
"sequence": 1 "sequence": 1,
"boosting_table_name": self.boosting_table_name,
"correct_table_name": self.correct_table_name,
}, },
"audio": { "audio": {
"format": "wav", "format": "raw",
"rate": 16000, "rate": 16000,
"language": "zh-CN", "language": "zh-CN",
"bits": 16, "bits": 16,
@@ -158,18 +155,23 @@ class ASRProvider(ASRProviderBase):
}, },
} }
async def _send_request(self, audio_data: List[bytes], segment_size: int) -> Optional[str]: async def _send_request(
self, audio_data: List[bytes], segment_size: int
) -> Optional[str]:
"""Send request to Volcano ASR service.""" """Send request to Volcano ASR service."""
try: try:
auth_header = {'Authorization': 'Bearer; {}'.format(self.access_token)} auth_header = {"Authorization": "Bearer; {}".format(self.access_token)}
async with websockets.connect(self.ws_url, additional_headers=auth_header) as websocket: async with websockets.connect(
self.ws_url, additional_headers=auth_header
) as websocket:
# Prepare request data # Prepare request data
request_params = self._construct_request(str(uuid.uuid4())) request_params = self._construct_request(str(uuid.uuid4()))
print(request_params)
payload_bytes = str.encode(json.dumps(request_params)) payload_bytes = str.encode(json.dumps(request_params))
payload_bytes = gzip.compress(payload_bytes) payload_bytes = gzip.compress(payload_bytes)
full_client_request = self._generate_header() full_client_request = self._generate_header()
full_client_request.extend((len(payload_bytes)).to_bytes(4, 'big')) # payload size(4 bytes) full_client_request.extend(
(len(payload_bytes)).to_bytes(4, "big")
) # payload size(4 bytes)
full_client_request.extend(payload_bytes) # payload full_client_request.extend(payload_bytes) # payload
# Send header and metadata # Send header and metadata
@@ -177,22 +179,29 @@ class ASRProvider(ASRProviderBase):
await websocket.send(full_client_request) await websocket.send(full_client_request)
res = await websocket.recv() res = await websocket.recv()
result = parse_response(res) result = parse_response(res)
if 'payload_msg' in result and result['payload_msg']['code'] != self.success_code: if (
"payload_msg" in result
and result["payload_msg"]["code"] != self.success_code
):
logger.bind(tag=TAG).error(f"ASR error: {result}") logger.bind(tag=TAG).error(f"ASR error: {result}")
return None return None
for seq, (chunk, last) in enumerate(self.slice_data(audio_data, segment_size), 1): for seq, (chunk, last) in enumerate(
self.slice_data(audio_data, segment_size), 1
):
if last: if last:
audio_only_request = self._generate_header( audio_only_request = self._generate_header(
message_type=CLIENT_AUDIO_ONLY_REQUEST, message_type=CLIENT_AUDIO_ONLY_REQUEST,
message_type_specific_flags=NEG_SEQUENCE message_type_specific_flags=NEG_SEQUENCE,
) )
else: else:
audio_only_request = self._generate_header( audio_only_request = self._generate_header(
message_type=CLIENT_AUDIO_ONLY_REQUEST message_type=CLIENT_AUDIO_ONLY_REQUEST
) )
payload_bytes = gzip.compress(chunk) payload_bytes = gzip.compress(chunk)
audio_only_request.extend((len(payload_bytes)).to_bytes(4, 'big')) # payload size(4 bytes) audio_only_request.extend(
(len(payload_bytes)).to_bytes(4, "big")
) # payload size(4 bytes)
audio_only_request.extend(payload_bytes) # payload audio_only_request.extend(payload_bytes) # payload
# Send audio data # Send audio data
await websocket.send(audio_only_request) await websocket.send(audio_only_request)
@@ -201,9 +210,12 @@ class ASRProvider(ASRProviderBase):
response = await websocket.recv() response = await websocket.recv()
result = parse_response(response) result = parse_response(response)
if 'payload_msg' in result and result['payload_msg']['code'] == self.success_code: if (
if len(result['payload_msg']['result']) > 0: "payload_msg" in result
return result['payload_msg']['result'][0]["text"] and result["payload_msg"]["code"] == self.success_code
):
if len(result["payload_msg"]["result"]) > 0:
return result["payload_msg"]["result"][0]["text"]
return None return None
else: else:
logger.bind(tag=TAG).error(f"ASR error: {result}") logger.bind(tag=TAG).error(f"ASR error: {result}")
@@ -228,14 +240,6 @@ class ASRProvider(ASRProviderBase):
return pcm_data return pcm_data
@staticmethod
def read_wav_info(data: io.BytesIO = None) -> (int, int, int, int, int):
with io.BytesIO(data) as _f:
wave_fp = wave.open(_f, 'rb')
nchannels, sampwidth, framerate, nframes = wave_fp.getparams()[:4]
wave_bytes = wave_fp.readframes(nframes)
return nchannels, sampwidth, framerate, nframes, len(wave_bytes)
@staticmethod @staticmethod
def slice_data(data: bytes, chunk_size: int) -> (list, bool): def slice_data(data: bytes, chunk_size: int) -> (list, bool):
""" """
@@ -247,37 +251,38 @@ class ASRProvider(ASRProviderBase):
data_len = len(data) data_len = len(data)
offset = 0 offset = 0
while offset + chunk_size < data_len: while offset + chunk_size < data_len:
yield data[offset: offset + chunk_size], False yield data[offset : offset + chunk_size], False
offset += chunk_size offset += chunk_size
else: else:
yield data[offset: data_len], True yield data[offset:data_len], True
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]: async def speech_to_text(
self, opus_data: List[bytes], session_id: str
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本""" """将语音数据转换为文本"""
try: try:
# 合并所有opus数据包 # 合并所有opus数据包
pcm_data = self.decode_opus(opus_data, session_id) pcm_data = self.decode_opus(opus_data, session_id)
combined_pcm_data = b''.join(pcm_data) combined_pcm_data = b"".join(pcm_data)
wav_buffer = io.BytesIO() # 判断是否保存为WAV文件
if self.delete_audio_file:
pass
else:
self.save_audio_to_file(pcm_data, session_id)
with wave.open(wav_buffer, "wb") as wav_file: # 直接使用PCM数据
wav_file.setnchannels(1) # 设置声道数 # 计算分段大小 (单声道, 16bit, 16kHz采样率)
wav_file.setsampwidth(2) # 设置采样宽度 size_per_sec = 1 * 2 * 16000 # nchannels * sampwidth * framerate
wav_file.setframerate(16000) # 设置采样率
wav_file.writeframes(combined_pcm_data) # 写入 PCM 数据
# 获取封装后的 WAV 数据
wav_data = wav_buffer.getvalue()
nchannels, sampwidth, framerate, nframes, wav_len = self.read_wav_info(wav_data)
size_per_sec = nchannels * sampwidth * framerate
segment_size = int(size_per_sec * self.seg_duration / 1000) segment_size = int(size_per_sec * self.seg_duration / 1000)
# 语音识别 # 语音识别
start_time = time.time() start_time = time.time()
text = await self._send_request(wav_data, segment_size) text = await self._send_request(combined_pcm_data, segment_size)
if text: if text:
logger.bind(tag=TAG).debug(f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}") logger.bind(tag=TAG).debug(
f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}"
)
return text, None return text, None
return "", None return "", None
@@ -51,7 +51,7 @@ class ASRProvider(ASRProviderBase):
) )
def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str: def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str:
"""将Opus音频数据解码并保存为WAV文件""" """PCM数据保存为WAV文件"""
file_name = f"asr_{session_id}_{uuid.uuid4()}.wav" file_name = f"asr_{session_id}_{uuid.uuid4()}.wav"
file_path = os.path.join(self.output_dir, file_name) file_path = os.path.join(self.output_dir, file_name)
@@ -72,20 +72,40 @@ class ASRProvider(ASRProviderBase):
wf.writeframes(b"".join(pcm_data)) wf.writeframes(b"".join(pcm_data))
return file_path return file_path
@staticmethod
def decode_opus(opus_data: List[bytes], session_id: str) -> List[bytes]:
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
return pcm_data
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]: async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
"""语音转文本主处理逻辑""" """语音转文本主处理逻辑"""
file_path = None file_path = None
try: try:
# 保存音频文件 # 合并所有opus数据包
start_time = time.time() pcm_data = self.decode_opus(opus_data, session_id)
file_path = self.save_audio_to_file(opus_data, session_id) combined_pcm_data = b"".join(pcm_data)
logger.bind(tag=TAG).debug(f"音频文件保存耗时: {time.time() - start_time:.3f}s | 路径: {file_path}")
# 判断是否保存为WAV文件
if self.delete_audio_file:
pass
else:
self.save_audio_to_file(pcm_data, session_id)
# 语音识别 # 语音识别
start_time = time.time() start_time = time.time()
result = self.model.generate( result = self.model.generate(
input=file_path, input=combined_pcm_data,
cache={}, cache={},
language="auto", language="auto",
use_itn=True, use_itn=True,
@@ -0,0 +1,144 @@
from typing import Optional, Tuple, List
import opuslib_next
from core.providers.asr.base import ASRProviderBase
import ssl
import json
import websockets
from config.logger import setup_logging
import asyncio
TAG = __name__
logger = setup_logging()
class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool):
'''
Initialize the ASRProvider with server configuration.
:param config: Dictionary containing 'host', 'port', and 'is_ssl'.
:param delete_audio_file: Boolean to indicate whether to delete audio files after processing.
'''
self.host = config.get('host', 'localhost')
self.port = config.get('port', 10095)
self.is_ssl = config.get('is_ssl', True)
self.delete_audio_file = delete_audio_file
self.uri = f"wss://{self.host}:{self.port}" if self.is_ssl else f"ws://{self.host}:{self.port}"
self.ssl_context = ssl.SSLContext() if self.is_ssl else None
if self.ssl_context:
self.ssl_context.check_hostname = False
self.ssl_context.verify_mode = ssl.CERT_NONE
def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str:
"""解码Opus数据并保存为WAV文件"""
pass
@staticmethod
def decode_opus(opus_data: List[bytes]) -> bytes:
"""将Opus音频数据解码为PCM数据"""
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
return b"".join(pcm_data)
async def _receive_responses(self, ws) -> None:
'''
Asynchronous generator to receive messages from the WebSocket.
Yields each message as it is received.
'''
text = ""
while True:
try:
response = await asyncio.wait_for(ws.recv(), timeout=5)
response_data = json.loads(response)
logger.bind(tag=TAG).debug(f"Received response: {response_data}")
if response_data.get("is_final", True):
text += response_data.get("text", "")
break
else:
text += response_data.get("text", "")
except asyncio.TimeoutError:
logger.bind(tag=TAG).error("Timeout while waiting for response from WebSocket.")
break
except websockets.exceptions.ConnectionClosed as e:
logger.bind(tag=TAG).error(f"WebSocket connection closed: {e}")
break
return text
async def _send_data(self, ws, pcm_data: bytes, session_id: str) -> tuple:
'''
Internal method to handle WebSocket communication.
Reuses the persistent WebSocket connection if available.
:param pcm_data: PCM audio data to send.
:param session_id: Unique session identifier.
:return: Tuple containing recognized text and optional timestamp.
'''
# Send initial configuration message
config_message = json.dumps({
"mode": "offline",
"chunk_size": [5, 10, 5],
"chunk_interval": 10,
"wav_name": session_id,
"is_speaking": True,
"itn": False
})
await ws.send(config_message)
logger.bind(tag=TAG).debug(f"Sent configuration message: {config_message}")
# Send PCM data
await ws.send(pcm_data)
logger.bind(tag=TAG).debug(f"Sent PCM data of length: {len(pcm_data)} bytes")
# Indicate end of speech
end_message = json.dumps({"is_speaking": False})
await ws.send(end_message)
logger.bind(tag=TAG).debug(f"Sent end message: {end_message}")
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
'''
Convert speech data to text using FunASR.
:param opus_data: List of Opus-encoded audio data chunks.
:param session_id: Unique session identifier.
:return: Tuple containing recognized text and optional timestamp.
'''
pcm_data = self.decode_opus(opus_data)
async with websockets.connect(self.uri, subprotocols=["binary"], ping_interval=None, ssl=self.ssl_context) as ws:
try:
# Use asyncio to handle WebSocket communication
send_task = asyncio.create_task(self._send_data(ws, pcm_data, session_id))
receive_task = asyncio.create_task(self._receive_responses(ws))
# Gather tasks with error handling
done, pending = await asyncio.wait(
[send_task, receive_task],
return_when=asyncio.FIRST_EXCEPTION
)
# Cancel any pending tasks
for task in pending:
task.cancel()
# Check for exceptions in completed tasks
for task in done:
if task.exception():
raise task.exception()
# Get the result from the receive task
result = receive_task.result()
return result, None # Return the recognized text and timestamp (if any)
except websockets.exceptions.ConnectionClosed as e:
logger.bind(tag=TAG).error(f"WebSocket connection closed: {e}")
return "", None
except Exception as e:
logger.bind(tag=TAG).error(f"Error during speech-to-text conversion: {e}", exc_info=True)
return "", None
@@ -43,11 +43,11 @@ class ASRProvider(ASRProviderBase):
# 确保输出目录存在 # 确保输出目录存在
os.makedirs(self.output_dir, exist_ok=True) os.makedirs(self.output_dir, exist_ok=True)
# 初始化模型文件路径 # 初始化模型文件路径
model_files = { model_files = {
"model.int8.onnx": os.path.join(self.model_dir, "model.int8.onnx"), "model.int8.onnx": os.path.join(self.model_dir, "model.int8.onnx"),
"tokens.txt": os.path.join(self.model_dir, "tokens.txt") "tokens.txt": os.path.join(self.model_dir, "tokens.txt"),
} }
# 下载并检查模型文件 # 下载并检查模型文件
@@ -58,15 +58,15 @@ class ASRProvider(ASRProviderBase):
model_file_download( model_file_download(
model_id="pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue", model_id="pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue",
file_path=file_name, file_path=file_name,
local_dir=self.model_dir local_dir=self.model_dir,
) )
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
raise FileNotFoundError(f"模型文件下载失败: {file_path}") raise FileNotFoundError(f"模型文件下载失败: {file_path}")
self.model_path = model_files["model.int8.onnx"] self.model_path = model_files["model.int8.onnx"]
self.tokens_path = model_files["tokens.txt"] self.tokens_path = model_files["tokens.txt"]
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"模型文件处理失败: {str(e)}") logger.bind(tag=TAG).error(f"模型文件处理失败: {str(e)}")
raise raise
@@ -83,11 +83,22 @@ class ASRProvider(ASRProviderBase):
use_itn=True, use_itn=True,
) )
def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str: def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str:
"""将Opus音频数据解码并保存为WAV文件""" """PCM数据保存为WAV文件"""
file_name = f"asr_{session_id}_{uuid.uuid4()}.wav" file_name = f"asr_{session_id}_{uuid.uuid4()}.wav"
file_path = os.path.join(self.output_dir, file_name) file_path = os.path.join(self.output_dir, file_name)
with wave.open(file_path, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2) # 2 bytes = 16-bit
wf.setframerate(16000)
wf.writeframes(b"".join(pcm_data))
return file_path
@staticmethod
def decode_opus(opus_data: List[bytes], session_id: str) -> List[bytes]:
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道 decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = [] pcm_data = []
@@ -98,13 +109,7 @@ class ASRProvider(ASRProviderBase):
except opuslib_next.OpusError as e: except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True) logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
with wave.open(file_path, "wb") as wf: return pcm_data
wf.setnchannels(1)
wf.setsampwidth(2) # 2 bytes = 16-bit
wf.setframerate(16000)
wf.writeframes(b"".join(pcm_data))
return file_path
def read_wave(self, wave_filename: str) -> Tuple[np.ndarray, int]: def read_wave(self, wave_filename: str) -> Tuple[np.ndarray, int]:
""" """
@@ -130,14 +135,19 @@ class ASRProvider(ASRProviderBase):
samples_float32 = samples_float32 / 32768 samples_float32 = samples_float32 / 32768
return samples_float32, f.getframerate() return samples_float32, f.getframerate()
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]: async def speech_to_text(
self, opus_data: List[bytes], session_id: str
) -> Tuple[Optional[str], Optional[str]]:
"""语音转文本主处理逻辑""" """语音转文本主处理逻辑"""
file_path = None file_path = None
try: try:
# 保存音频文件 # 保存音频文件
start_time = time.time() start_time = time.time()
file_path = self.save_audio_to_file(opus_data, session_id) pcm_data = self.decode_opus(opus_data, session_id)
logger.bind(tag=TAG).debug(f"音频文件保存耗时: {time.time() - start_time:.3f}s | 路径: {file_path}") file_path = self.save_audio_to_file(pcm_data, session_id)
logger.bind(tag=TAG).debug(
f"音频文件保存耗时: {time.time() - start_time:.3f}s | 路径: {file_path}"
)
# 语音识别 # 语音识别
start_time = time.time() start_time = time.time()
@@ -146,14 +156,15 @@ class ASRProvider(ASRProviderBase):
s.accept_waveform(sample_rate, samples) s.accept_waveform(sample_rate, samples)
self.model.decode_stream(s) self.model.decode_stream(s)
text = s.result.text text = s.result.text
logger.bind(tag=TAG).debug(f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}") logger.bind(tag=TAG).debug(
f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}"
)
return text, file_path return text, file_path
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"语音识别失败: {e}", exc_info=True) logger.bind(tag=TAG).error(f"语音识别失败: {e}", exc_info=True)
return "", None return "", None
finally: finally:
# 文件清理逻辑 # 文件清理逻辑
if self.delete_audio_file and file_path and os.path.exists(file_path): if self.delete_audio_file and file_path and os.path.exists(file_path):
@@ -31,21 +31,11 @@ class ASRProvider(ASRProviderBase):
os.makedirs(self.output_dir, exist_ok=True) os.makedirs(self.output_dir, exist_ok=True)
def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str: def save_audio_to_file(self, opus_data: List[bytes], session_id: str) -> str:
"""将Opus音频数据解码并保存为WAV文件""" """PCM数据保存为WAV文件"""
file_name = f"tencent_asr_{session_id}_{uuid.uuid4()}.wav" file_name = f"tencent_asr_{session_id}_{uuid.uuid4()}.wav"
file_path = os.path.join(self.output_dir, file_name) file_path = os.path.join(self.output_dir, file_name)
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
with wave.open(file_path, "wb") as wf: with wave.open(file_path, "wb") as wf:
wf.setnchannels(1) wf.setnchannels(1)
wf.setsampwidth(2) # 2 bytes = 16-bit wf.setsampwidth(2) # 2 bytes = 16-bit
@@ -85,6 +75,12 @@ class ASRProvider(ASRProviderBase):
# 将Opus音频数据解码为PCM # 将Opus音频数据解码为PCM
pcm_data = self.decode_opus(opus_data) pcm_data = self.decode_opus(opus_data)
# 判断是否保存为WAV文件
if self.delete_audio_file:
pass
else:
self.save_audio_to_file(pcm_data, session_id)
# 将音频数据转换为Base64编码 # 将音频数据转换为Base64编码
base64_audio = base64.b64encode(pcm_data).decode('utf-8') base64_audio = base64.b64encode(pcm_data).decode('utf-8')
@@ -29,16 +29,20 @@ class TTSProviderBase(ABC):
try: try:
asyncio.run(self.text_to_speak(text, tmp_file)) asyncio.run(self.text_to_speak(text, tmp_file))
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"语音生成失败: {text},错误: {e}") logger.bind(tag=TAG).warning(f"语音生成失败{5 - max_repeat_time + 1}: {text},错误: {e}")
if not os.path.exists(tmp_file): # 未执行成功,删除文件
if os.path.exists(tmp_file):
os.remove(tmp_file)
max_repeat_time -= 1 max_repeat_time -= 1
if max_repeat_time > 0:
logger.bind(tag=TAG).error(f"再试{max_repeat_time}")
if max_repeat_time > 0: if max_repeat_time > 0:
logger.bind(tag=TAG).info( logger.bind(tag=TAG).info(
f"语音生成成功: {text}:{tmp_file},重试{5 - max_repeat_time}" f"语音生成成功: {text}:{tmp_file},重试{5 - max_repeat_time}"
) )
else:
logger.bind(tag=TAG).error(
f"语音生成失败: {text},请检查网络或服务是否正常"
)
return tmp_file return tmp_file
except Exception as e: except Exception as e:
@@ -27,6 +27,15 @@ class TTSProvider(TTSProviderBase):
else: else:
self.voice = config.get("voice") self.voice = config.get("voice")
# 处理空字符串的情况
speed_ratio = config.get("speed_ratio", "1.0")
volume_ratio = config.get("volume_ratio", "1.0")
pitch_ratio = config.get("pitch_ratio", "1.0")
self.speed_ratio = float(speed_ratio) if speed_ratio else 1.0
self.volume_ratio = float(volume_ratio) if volume_ratio else 1.0
self.pitch_ratio = float(pitch_ratio) if pitch_ratio else 1.0
self.api_url = config.get("api_url") self.api_url = config.get("api_url")
self.authorization = config.get("authorization") self.authorization = config.get("authorization")
self.header = {"Authorization": f"{self.authorization}{self.access_token}"} self.header = {"Authorization": f"{self.authorization}{self.access_token}"}
@@ -49,9 +58,9 @@ class TTSProvider(TTSProviderBase):
"audio": { "audio": {
"voice_type": self.voice, "voice_type": self.voice,
"encoding": "wav", "encoding": "wav",
"speed_ratio": 1.0, "speed_ratio": self.speed_ratio,
"volume_ratio": 1.0, "volume_ratio": self.volume_ratio,
"pitch_ratio": 1.0, "pitch_ratio": self.pitch_ratio,
}, },
"request": { "request": {
"reqid": str(uuid.uuid4()), "reqid": str(uuid.uuid4()),

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