mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-23 15:43:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf08a91191 | ||
|
|
f0e353cc1a | ||
|
|
456b1eddcc | ||
|
|
5e08f05437 | ||
|
|
9f9227bae4 | ||
|
|
76f47bce37 | ||
|
|
e1f54b30dc | ||
|
|
fc4e7e6c05 | ||
|
|
7aaa79b644 | ||
|
|
9b6f894be2 | ||
|
|
48f7ef2023 | ||
|
|
54692859fd | ||
|
|
1719244015 | ||
|
|
48f8c6c5b7 | ||
|
|
f0815e595b | ||
|
|
4f829017ed | ||
|
|
d2f8f05acb | ||
|
|
e8b1156bfd | ||
|
|
bf748773c8 | ||
|
|
a05b25ae90 | ||
|
|
3ceb3feccf | ||
|
|
9e9af2a031 | ||
|
|
b50c7dbed4 | ||
|
|
233e1ee0a6 | ||
|
|
805b40ec76 | ||
|
|
86ca3e5be0 | ||
|
|
bb2143d9a0 | ||
|
|
ef662e0ccf | ||
|
|
30708a59f1 | ||
|
|
b0262cfe72 | ||
|
|
259886c1a8 | ||
|
|
f1b4b33727 | ||
|
|
38e8b690bd | ||
|
|
9a52b43e09 | ||
|
|
5e35cfc44a | ||
|
|
ac0ec6dfe6 | ||
|
|
361defc348 | ||
|
|
6ac8947168 | ||
|
|
5655930d12 | ||
|
|
18acc37324 | ||
|
|
e9155d719e | ||
|
|
16e77b02c2 | ||
|
|
adc1902649 | ||
|
|
1cc470bc5a | ||
|
|
b3f6efc7d0 | ||
|
|
d91fbf3ff4 | ||
|
|
e552593474 | ||
|
|
1f5a78f458 | ||
|
|
f490c15282 | ||
|
|
90a31debd1 | ||
|
|
416145a90e | ||
|
|
6285786282 | ||
|
|
f9f6a69c89 | ||
|
|
61da60bdf1 | ||
|
|
2d5ef3011a | ||
|
|
63dab6d224 | ||
|
|
38dd76dfb3 | ||
|
|
fe642de526 | ||
|
|
f4b7eefc38 | ||
|
|
5bbbad02b4 | ||
|
|
bd85dd5faf | ||
|
|
d91ba3d219 | ||
|
|
7992c40815 | ||
|
|
574b01db97 | ||
|
|
2d6049b7bd | ||
|
|
96d0f2e6f9 | ||
|
|
b46bd4d0dd | ||
|
|
244c76793c | ||
|
|
411fc46189 | ||
|
|
48427e330f | ||
|
|
5ddc2451c1 | ||
|
|
e357cad2bb | ||
|
|
9a5aaea8ca | ||
|
|
c9eb640510 | ||
|
|
766a706250 |
@@ -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]
|
|
||||||
|
|
||||||
## 📋 其他信息
|
## 📋 其他信息
|
||||||
<!-- 在此添加关于此问题的任何其他上下文信息 -->
|
<!-- 在此添加关于此问题的任何其他上下文信息 -->
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
[](https://github.com/xinnan-tech/xiaozhi-esp32-server)
|
[](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
@@ -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)了。
|
||||||
|
|
||||||
|
|
||||||
以下是一些常见问题,供参考:
|
以下是一些常见问题,供参考:
|
||||||
|
|||||||
@@ -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
@@ -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群:适用于硬件烧录问题
|
|
||||||
- 开源服务端交流群:适用于服务端问题
|
|
||||||
- 产品建议联系人:适用于产品功能、产品设计等建议
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
进入菜单配置后,再进入`Xiaozhi Assistant`,将`CONNECTION_TYPE`设置为`Websocket`
|
|
||||||
回退到主菜单,再进入`Xiaozhi Assistant`,将`BOARD_TYPE`设置你板子的具体型号
|
|
||||||
保存退出,回到终端命令行。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 第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
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||

|
进入菜单配置后,再进入`Xiaozhi Assistant`,将`BOARD_TYPE`设置你板子的具体型号
|
||||||
|
|
||||||
进入菜单配置后,再进入`Xiaozhi Assistant`,将`CONNECTION_TYPE`设置为`Websocket`
|
|
||||||
回退到主菜单,再进入`Xiaozhi Assistant`,将`BOARD_TYPE`设置你板子的具体型号
|
|
||||||
保存退出,回到终端命令行。
|
保存退出,回到终端命令行。
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 第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);
|
||||||
}
|
}
|
||||||
|
|||||||
+36
@@ -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> {
|
||||||
|
}
|
||||||
+31
@@ -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;
|
||||||
|
}
|
||||||
+87
@@ -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;
|
||||||
|
}
|
||||||
+22
@@ -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);
|
||||||
|
}
|
||||||
+14
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
+22
@@ -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);
|
||||||
|
}
|
||||||
+81
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -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 {
|
||||||
|
|
||||||
|
}
|
||||||
+9
-1
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-20
@@ -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地址")
|
||||||
|
|||||||
+1
-2
@@ -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) {
|
||||||
|
|
||||||
// 查询所有系统参数
|
// 查询所有系统参数
|
||||||
|
|||||||
+16
-2
@@ -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表示单播地址,合法
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+60
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+105
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+92
@@ -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);
|
||||||
}
|
}
|
||||||
+114
-10
@@ -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转成Map,Map<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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+76
-27
@@ -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转成Map,Map<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("字典类型编码重复");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
+1
@@ -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());
|
||||||
|
|||||||
+3
-1
@@ -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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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("服务器已关闭,程序退出。")
|
||||||
|
|||||||
@@ -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_id,access_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
|
||||||
# 是否不使用本地prompt:true|false (默不用请在百练应用中设置prompt)
|
# 是否不使用本地prompt:true|false (默不用请在百练应用中设置prompt)
|
||||||
is_no_prompt: true
|
is_no_prompt: true
|
||||||
# Ali_memory_id:false(不使用)|你的memory_id(请在百练应用中设置中获取)
|
# Ali_memory_id:false(不使用)|你的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/
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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
Reference in New Issue
Block a user