mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-23 23:53:55 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5ef3011a | ||
|
|
63dab6d224 | ||
|
|
38dd76dfb3 | ||
|
|
fe642de526 | ||
|
|
f4b7eefc38 | ||
|
|
5bbbad02b4 | ||
|
|
bd85dd5faf | ||
|
|
d91ba3d219 | ||
|
|
7992c40815 | ||
|
|
574b01db97 | ||
|
|
2d6049b7bd | ||
|
|
96d0f2e6f9 | ||
|
|
b46bd4d0dd | ||
|
|
411fc46189 | ||
|
|
48427e330f | ||
|
|
5ddc2451c1 | ||
|
|
e357cad2bb | ||
|
|
80be8b2eaa | ||
|
|
9a5aaea8ca | ||
|
|
7d585615a9 | ||
|
|
3f3375dcac | ||
|
|
f555eae653 | ||
|
|
e805a6e037 | ||
|
|
3ed82ed7bb | ||
|
|
d638e512c9 | ||
|
|
204b966935 | ||
|
|
8318ba5e55 | ||
|
|
d39c595828 | ||
|
|
04154162c6 | ||
|
|
47f8e0f04c | ||
|
|
7e4ada5f4b | ||
|
|
b0b4f87848 | ||
|
|
d9085b82bf | ||
|
|
8e5770ff59 | ||
|
|
beb420c69d | ||
|
|
1d8b898924 | ||
|
|
b5b4c5ced7 | ||
|
|
b4e9d77895 | ||
|
|
21a08b3c7a | ||
|
|
6977d6bf73 | ||
|
|
39f81dd0c0 | ||
|
|
b3191f5e0b | ||
|
|
78f2f21142 | ||
|
|
497fc1d1ae | ||
|
|
d83e1c6fb2 | ||
|
|
c5218d485a | ||
|
|
19cbd3b8da | ||
|
|
c0c5630bfb | ||
|
|
969b5fcfbb | ||
|
|
0739fa2195 | ||
|
|
3c62adab3c | ||
|
|
edc601c35e | ||
|
|
951370eee7 | ||
|
|
fa81f4a150 | ||
|
|
e6a90c40fb | ||
|
|
2e0a1c00fa | ||
|
|
109754fac9 | ||
|
|
c3fbff3d7a | ||
|
|
6dd967fe24 | ||
|
|
f0bfdba944 | ||
|
|
7f76b5f4ee | ||
|
|
35f4d8f15a | ||
|
|
d57482d966 | ||
|
|
3d7c2aff4b | ||
|
|
3757c5dde7 |
+1
-1
@@ -23,7 +23,7 @@ RUN apt-get update && \
|
|||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# 配置Nginx
|
# 配置Nginx
|
||||||
COPY docs/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY docs/docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
# 复制前端构建产物
|
# 复制前端构建产物
|
||||||
COPY --from=web-builder /app/dist /usr/share/nginx/html
|
COPY --from=web-builder /app/dist /usr/share/nginx/html
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
本项目为开源智能硬件项目
|
本项目为开源智能硬件项目
|
||||||
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a>提供后端服务<br/>
|
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a>提供后端服务<br/>
|
||||||
根据<a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">小智通信协议</a>使用Python实现<br/>
|
根据<a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">小智通信协议</a>使用Python、Java、Vue实现<br/>
|
||||||
帮助您快速搭建小智服务器
|
帮助您快速搭建小智服务器
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
· 简体中文
|
· 简体中文
|
||||||
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">更新日志</a>
|
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">更新日志</a>
|
||||||
· <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a>
|
· <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a>
|
||||||
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues ">反馈问题</a>
|
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">反馈问题</a>
|
||||||
|
· <a href="./docs/FAQ.md">常见问题</a>
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
|
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
|
||||||
@@ -147,6 +148,8 @@ server:
|
|||||||
|
|
||||||
## 部署文档
|
## 部署文档
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
本项目提供两种部署方式,请根据您的具体需求选择:
|
本项目提供两种部署方式,请根据您的具体需求选择:
|
||||||
|
|
||||||
#### 🚀 部署方式选择
|
#### 🚀 部署方式选择
|
||||||
|
|||||||
+11
-6
@@ -276,21 +276,26 @@ LLM:
|
|||||||
如果你能看到,类似以下日志,则是本项目服务启动成功的标志。
|
如果你能看到,类似以下日志,则是本项目服务启动成功的标志。
|
||||||
|
|
||||||
```
|
```
|
||||||
25-02-23 12:01:09[core.websocket_server] - INFO - Server is running at ws://xxx.xx.xx.xx:8000/xiaozhi/v1/
|
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-OTA接口是 http://192.168.4.123:8002/xiaozhi/ota/
|
||||||
25-02-23 12:01:09[core.websocket_server] - INFO - =======上面的地址是websocket协议地址,请勿用浏览器访问=======
|
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-Websocket地址是 ws://192.168.4.123:8000/xiaozhi/v1/
|
||||||
25-02-23 12:01:09[core.websocket_server] - INFO - 如想测试websocket请用谷歌浏览器打开test目录下的test_page.html
|
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问=======
|
||||||
25-02-23 12:01:09[core.websocket_server] - INFO - =======================================================
|
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-如想测试websocket请用谷歌浏览器打开test目录下的test_page.html
|
||||||
|
250427 13:04:20[0.3.11_SiFuChTTnofu][__main__]-INFO-=======================================================
|
||||||
```
|
```
|
||||||
|
|
||||||
正常来说,如果您是通过源码运行本项目,日志会有你的接口地址信息。
|
正常来说,如果您是通过源码运行本项目,日志会有你的接口地址信息。
|
||||||
但是如果你用docker部署,那么你的日志里给出的接口地址信息就不是真实的接口地址。
|
但是如果你用docker部署,那么你的日志里给出的接口地址信息就不是真实的接口地址。
|
||||||
|
|
||||||
最正确的方法,是根据电脑的局域网IP来确定你的接口地址。
|
最正确的方法,是根据电脑的局域网IP来确定你的接口地址。
|
||||||
如果你的电脑的局域网IP比如是`192.168.1.25`,那么你的接口地址就是:`ws://192.168.1.25:8000/xiaozhi/v1/`。
|
如果你的电脑的局域网IP比如是`192.168.1.25`,那么你的接口地址就是:`ws://192.168.1.25:8000/xiaozhi/v1/`,对应的OTA地址就是:`http://192.168.1.25:8002/xiaozhi/ota/`。
|
||||||
|
|
||||||
这个信息很有用的,后面`编译esp32固件`需要用到。
|
这个信息很有用的,后面`编译esp32固件`需要用到。
|
||||||
|
|
||||||
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。
|
接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
|
||||||
|
|
||||||
|
1、 [编译自己的esp32固件](firmware-build.md)了。
|
||||||
|
|
||||||
|
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
|
||||||
|
|
||||||
|
|
||||||
以下是一些常见问题,供参考:
|
以下是一些常见问题,供参考:
|
||||||
|
|||||||
+38
-12
@@ -122,11 +122,11 @@ http://localhost:8002/xiaozhi/doc.html
|
|||||||
|
|
||||||
这时,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8002 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。
|
这时,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8002 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。
|
||||||
|
|
||||||
接下来要做两件重要的事情:
|
接下来要做三件重要的事情:
|
||||||
|
|
||||||
### 第一件重要的事情
|
### 第一件重要的事情
|
||||||
|
|
||||||
使用超级管理员账号,登录系统,在顶部菜单找到`参数管理`,找到列表中第三条数据,参数编码是`server.secret`,复制它到`参数值`。
|
使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到列表中第一条数据,参数编码是`server.secret`,复制它到`参数值`。
|
||||||
|
|
||||||
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api`。`server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
|
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api`。`server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
|
||||||
|
|
||||||
@@ -138,7 +138,12 @@ manager-api:
|
|||||||
secret: 你的server.secret值
|
secret: 你的server.secret值
|
||||||
```
|
```
|
||||||
1、把你刚才从`智控台`复制过来的`server.secret`的`参数值`复制到`.config.yaml`文件里的`secret`里。
|
1、把你刚才从`智控台`复制过来的`server.secret`的`参数值`复制到`.config.yaml`文件里的`secret`里。
|
||||||
2、注意,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
|
|
||||||
|
2、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
|
||||||
|
|
||||||
|
3、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
|
||||||
|
|
||||||
|
4、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
|
||||||
|
|
||||||
类似这样的效果
|
类似这样的效果
|
||||||
```
|
```
|
||||||
@@ -151,7 +156,7 @@ manager-api:
|
|||||||
|
|
||||||
### 第二件重要的事情
|
### 第二件重要的事情
|
||||||
|
|
||||||
使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
|
使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
|
||||||
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
|
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
|
||||||
|
|
||||||
## 5.重启xiaozhi-esp32-server
|
## 5.重启xiaozhi-esp32-server
|
||||||
@@ -164,7 +169,7 @@ docker logs -f xiaozhi-esp32-server
|
|||||||
如果你能看到,类似以下日志,则是Server启动成功的标志。
|
如果你能看到,类似以下日志,则是Server启动成功的标志。
|
||||||
|
|
||||||
```
|
```
|
||||||
25-02-23 12:01:09[core.websocket_server] - INFO - Server is running at ws://xxx.xx.xx.xx:8000/xiaozhi/v1/
|
25-02-23 12:01:09[core.websocket_server] - INFO - Websocket地址是 ws://xxx.xx.xx.xx:8000/xiaozhi/v1/
|
||||||
25-02-23 12:01:09[core.websocket_server] - INFO - =======上面的地址是websocket协议地址,请勿用浏览器访问=======
|
25-02-23 12:01:09[core.websocket_server] - INFO - =======上面的地址是websocket协议地址,请勿用浏览器访问=======
|
||||||
25-02-23 12:01:09[core.websocket_server] - INFO - 如想测试websocket请用谷歌浏览器打开test目录下的test_page.html
|
25-02-23 12:01:09[core.websocket_server] - INFO - 如想测试websocket请用谷歌浏览器打开test目录下的test_page.html
|
||||||
25-02-23 12:01:09[core.websocket_server] - INFO - =======================================================
|
25-02-23 12:01:09[core.websocket_server] - INFO - =======================================================
|
||||||
@@ -182,7 +187,17 @@ Websocket接口:
|
|||||||
ws://你电脑局域网的ip:8000/xiaozhi/v1/
|
ws://你电脑局域网的ip:8000/xiaozhi/v1/
|
||||||
```
|
```
|
||||||
|
|
||||||
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。
|
### 第三件重要的事情
|
||||||
|
|
||||||
|
使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到参数编码是`server.websocket`,输入你的`Websocket接口`。
|
||||||
|
|
||||||
|
使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到数编码是`server.ota`,输入你的`OTA接口`。
|
||||||
|
|
||||||
|
接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
|
||||||
|
|
||||||
|
1、 [编译自己的esp32固件](firmware-build.md)了。
|
||||||
|
|
||||||
|
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
|
||||||
|
|
||||||
|
|
||||||
# 方式二:本地源码运行全模块
|
# 方式二:本地源码运行全模块
|
||||||
@@ -278,13 +293,13 @@ npm run serve
|
|||||||
运行成功后,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8001 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。
|
运行成功后,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8001 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。
|
||||||
|
|
||||||
|
|
||||||
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
|
重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
|
||||||
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
|
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
|
||||||
|
|
||||||
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
|
重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
|
||||||
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
|
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
|
||||||
|
|
||||||
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
|
重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
|
||||||
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
|
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
|
||||||
|
|
||||||
## 5.安装Python环境
|
## 5.安装Python环境
|
||||||
@@ -349,7 +364,7 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
## 8.配置项目文件
|
## 8.配置项目文件
|
||||||
|
|
||||||
使用超级管理员账号,登录智控台 http://127.0.0.1:8001 ,在顶部菜单找到`参数管理`,找到列表中第三条数据,参数编码是`server.secret`,复制它到`参数值`。
|
使用超级管理员账号,登录智控台 ,在顶部菜单找到`参数管理`,找到列表中第一条数据,参数编码是`server.secret`,复制它到`参数值`。
|
||||||
|
|
||||||
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api`。`server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
|
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api`。`server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
|
||||||
|
|
||||||
@@ -390,7 +405,7 @@ python app.py
|
|||||||
25-02-23 12:01:09[core.websocket_server] - INFO - =======================================================
|
25-02-23 12:01:09[core.websocket_server] - INFO - =======================================================
|
||||||
```
|
```
|
||||||
|
|
||||||
由于你是全模块部署,因此你有两个重要的接口需要写入到esp32中。
|
由于你是全模块部署,因此你有两个重要的接口。
|
||||||
|
|
||||||
OTA接口:
|
OTA接口:
|
||||||
```
|
```
|
||||||
@@ -402,7 +417,18 @@ Websocket接口:
|
|||||||
ws://你电脑局域网的ip:8000/xiaozhi/v1/
|
ws://你电脑局域网的ip:8000/xiaozhi/v1/
|
||||||
```
|
```
|
||||||
|
|
||||||
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。
|
请你务必把以上两个接口地址写入到智控台中:他们将会影响websocket地址发放和自动升级功能。
|
||||||
|
|
||||||
|
1、使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到参数编码是`server.websocket`,输入你的`Websocket接口`。
|
||||||
|
|
||||||
|
2、使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到数编码是`server.ota`,输入你的`OTA接口`。
|
||||||
|
|
||||||
|
|
||||||
|
接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
|
||||||
|
|
||||||
|
1、 [编译自己的esp32固件](firmware-build.md)了。
|
||||||
|
|
||||||
|
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
|
||||||
|
|
||||||
# 常见问题
|
# 常见问题
|
||||||
|
|
||||||
|
|||||||
+1
-35
@@ -102,41 +102,7 @@ VAD:
|
|||||||
|
|
||||||
### 7、我想通过小智控制电灯、空调、远程开关机等操作 💡
|
### 7、我想通过小智控制电灯、空调、远程开关机等操作 💡
|
||||||
|
|
||||||
本项目,支持以工具调用的方式控制HomeAssistant设备
|
参考教程[ESP32设备与HomeAssistant集成指南](./homeassistant-integration.md)
|
||||||
|
|
||||||
1、首先选择一款支持function call支持的LLM,例如`ChatGLMLLM`。
|
|
||||||
|
|
||||||
2、在配置文件中,将 `selected_module.Intent` 设置为 `function_call`。
|
|
||||||
|
|
||||||
3、登录`HomeAssistant`,点击`左下角个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key。
|
|
||||||
|
|
||||||
在配置文件中,配置好你的home assistant的`devices`(被控制的设备)和`api_key`和`base_url`等信息。例如:
|
|
||||||
|
|
||||||
``` yaml
|
|
||||||
plugins
|
|
||||||
home_assistant:
|
|
||||||
devices:
|
|
||||||
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
|
|
||||||
- 卧室,台灯,switch.iot_cn_831898993_socn1_on_p_2_1
|
|
||||||
base_url: http://你的homeassistant地址:8123
|
|
||||||
api_key: 你的home assistant api访问令牌
|
|
||||||
```
|
|
||||||
|
|
||||||
最后,允许function_call 插件在配置文件中启用`hass_get_state`(必须)、`hass_set_state`(必须)、`hass_play_music`(不想用ha听音乐可以不启动),例如:
|
|
||||||
|
|
||||||
``` yaml
|
|
||||||
Intent:
|
|
||||||
...
|
|
||||||
function_call:
|
|
||||||
type: nointent
|
|
||||||
functions:
|
|
||||||
- change_role
|
|
||||||
- get_weather
|
|
||||||
- get_news
|
|
||||||
- hass_get_state
|
|
||||||
- hass_set_state
|
|
||||||
- hass_play_music
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8、更多问题,可联系我们反馈 💬
|
### 8、更多问题,可联系我们反馈 💬
|
||||||
|
|
||||||
|
|||||||
+47
-20
@@ -1,26 +1,53 @@
|
|||||||
server {
|
user root;
|
||||||
listen 8002;
|
worker_processes 4;
|
||||||
server_name localhost;
|
|
||||||
|
|
||||||
# 静态资源服务(Vue项目)
|
events {
|
||||||
location / {
|
worker_connections 1024;
|
||||||
root /usr/share/nginx/html;
|
}
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# API反向代理(Java项目)
|
http {
|
||||||
location /xiaozhi/ {
|
include mime.types;
|
||||||
proxy_pass http://127.0.0.1:8003;
|
default_type application/octet-stream;
|
||||||
proxy_set_header Host $host;
|
sendfile on;
|
||||||
proxy_cookie_path /api/ /;
|
keepalive_timeout 300;
|
||||||
proxy_set_header Referer $http_referer;
|
client_header_timeout 180s;
|
||||||
proxy_set_header Cookie $http_cookie;
|
client_body_timeout 180s;
|
||||||
|
client_max_body_size 1024M;
|
||||||
|
|
||||||
proxy_connect_timeout 10;
|
gzip on;
|
||||||
proxy_send_timeout 10;
|
gzip_buffers 32 4K;
|
||||||
proxy_read_timeout 10;
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 100;
|
||||||
|
gzip_types application/javascript text/css text/xml image/jpeg image/gif image/png;
|
||||||
|
gzip_disable "MSIE [1-6]\.";
|
||||||
|
gzip_vary on;
|
||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
server {
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
# 无域名访问,就用localhost
|
||||||
|
server_name localhost;
|
||||||
|
# 80端口
|
||||||
|
listen 8002;
|
||||||
|
|
||||||
|
# 转发到编译后到web目录
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 转发到manager-api
|
||||||
|
location /xiaozhi/ {
|
||||||
|
proxy_pass http://127.0.0.1:8003;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cookie_path /manager/ /;
|
||||||
|
proxy_set_header Referer $http_referer;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
|
||||||
|
proxy_connect_timeout 10;
|
||||||
|
proxy_send_timeout 10;
|
||||||
|
proxy_read_timeout 10;
|
||||||
|
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+58
-61
@@ -1,76 +1,79 @@
|
|||||||
# 编译esp32固件
|
# esp32固件编译
|
||||||
|
|
||||||
## 第1步 配置环境
|
## 第1步 准备你的ota地址
|
||||||
|
|
||||||
|
如果你,使用的是本项目0.3.12版本,不管是简单Server部署还是全模块部署,都会有ota地址。
|
||||||
|
|
||||||
|
由于简单Server部署和全模块部署的OTA地址设置方式不一样,请你选择下面的具体方式:
|
||||||
|
|
||||||
|
### 如果你用的是简单Server部署
|
||||||
|
此刻,请你用浏览器打开你的ota地址,例如我的ota地址
|
||||||
|
```
|
||||||
|
http://192.168.1.25:8002/xiaozhi/ota/
|
||||||
|
```
|
||||||
|
如果显示“OTA接口运行正常,向设备发送的websocket地址是:ws://xxx:8000/xiaozhi/v1/
|
||||||
|
|
||||||
|
你可以使用项目自带的`test_page.html`测试一下,是否能连上ota页面输出的websocket地址。
|
||||||
|
|
||||||
|
如果访问不到,你需要到配置文件`.config.yaml`里修改`server.websocket`的地址,重启后再重新测试,直到`test_page.html`能正常访问。
|
||||||
|
|
||||||
|
成功后,请往下进行第2步
|
||||||
|
|
||||||
|
### 如果你用的是全模块部署
|
||||||
|
此刻,请你用浏览器打开你的ota地址,例如我的ota地址
|
||||||
|
```
|
||||||
|
http://192.168.1.25:8002/xiaozhi/ota/
|
||||||
|
```
|
||||||
|
|
||||||
|
如果显示“OTA接口运行正常,websocket集群数量:X”。那就往下进行2步。
|
||||||
|
|
||||||
|
如果显示“OTA接口运行不正常”,大概是你还没在`智控台`配置`Websocket`地址。那就:
|
||||||
|
|
||||||
|
- 1、使用超级管理员登录智控台
|
||||||
|
|
||||||
|
- 2、顶部菜单点击`参数管理`
|
||||||
|
|
||||||
|
- 3、在列表中找到`server.websocket`项目,输入你的`Websocket`地址。例如我的就是
|
||||||
|
|
||||||
|
```
|
||||||
|
ws://192.168.1.25:8000/xiaozhi/v1/
|
||||||
|
```
|
||||||
|
|
||||||
|
配置完后,再使用浏览器刷新你的ota接口地址,看看是不是正常了。如果还不正常就,就再次确认一下Websocket是否正常启动,是否配置了Websocket地址。
|
||||||
|
|
||||||
|
## 第2步 配置环境
|
||||||
先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
|
先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
|
||||||
|
|
||||||
## 第2步 打开配置文件
|
## 第3步 打开配置文件
|
||||||
配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
|
配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
|
||||||
|
|
||||||
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
|
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32)。
|
||||||
|
|
||||||
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
|
下载后,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
|
||||||
|
|
||||||
从这里下载虾哥[xiaozhi-esp32项目源码](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步 修改OTA地址
|
## 第4步 修改OTA地址
|
||||||
如果你是全模块部署本项目,就修改OTA接口,如果你只是部署了8000端口的xiaozhi-server,可以继续沿用虾哥团队的OTA接口。如果你不修改OTA接口,请直接忽略本第4步,直接看第5步
|
|
||||||
|
|
||||||
找到`OTA_VERSION_URL`的`default`的内容,把`https://api.tenclass.net/xiaozhi/ota/`
|
找到`OTA_URL`的`default`的内容,把`https://api.tenclass.net/xiaozhi/ota/`
|
||||||
改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8002/xiaozhi/ota/`,就把内容改成这个。
|
改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8002/xiaozhi/ota/`,就把内容改成这个。
|
||||||
|
|
||||||
修改前:
|
修改前:
|
||||||
```
|
```
|
||||||
config OTA_VERSION_URL
|
config OTA_URL
|
||||||
string "OTA Version URL"
|
string "Default OTA URL"
|
||||||
default "https://api.tenclass.net/xiaozhi/ota/"
|
default "https://api.tenclass.net/xiaozhi/ota/"
|
||||||
help
|
help
|
||||||
The application will access this URL to check for updates.
|
The application will access this URL to check for new firmwares and server address.
|
||||||
```
|
```
|
||||||
修改后:
|
修改后:
|
||||||
```
|
```
|
||||||
config OTA_VERSION_URL
|
config OTA_URL
|
||||||
string "OTA Version URL"
|
string "Default OTA URL"
|
||||||
default "http://192.168.1.25:8002/xiaozhi/ota/"
|
default "http://192.168.1.25:8002/xiaozhi/ota/"
|
||||||
help
|
help
|
||||||
The application will access this URL to check for updates.
|
The application will access this URL to check for new firmwares and server address.
|
||||||
```
|
```
|
||||||
|
|
||||||
## 第5步 设置编译参数
|
## 第4步 设置编译参数
|
||||||
|
|
||||||
设置编译参数
|
设置编译参数
|
||||||
|
|
||||||
@@ -91,32 +94,26 @@ idf.py menuconfig
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 第6步 编译固件
|
## 第5步 编译固件
|
||||||
|
|
||||||
```
|
```
|
||||||
idf.py build
|
idf.py build
|
||||||
```
|
```
|
||||||
|
|
||||||
如果是vscode安装的idf可以使用`F1`或者`ctrl+shift+p`,输入idf然后直接选择进行编译
|
## 第6步 打包bin固件
|
||||||
|
|
||||||
还可以直接进行烧录不用接下来的操作
|
|
||||||
|
|
||||||
<img src="./images/vscode_idf.png" width="500px"/>
|
|
||||||
|
|
||||||
## 第7步 打包bin固件
|
|
||||||
|
|
||||||
```
|
```
|
||||||
cd scripts
|
cd scripts
|
||||||
python release.py
|
python release.py
|
||||||
```
|
```
|
||||||
|
|
||||||
编译成功后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin`。
|
上面的打包命令执行完成后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin`。
|
||||||
这个`merged-binary.bin`就是要烧录到硬件上的固件文件。
|
这个`merged-binary.bin`就是要烧录到硬件上的固件文件。
|
||||||
|
|
||||||
注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin`
|
注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin`
|
||||||
,对你没有太大影响,请继续。
|
,对你没有太大影响,请继续。
|
||||||
|
|
||||||
## 第8步 烧录固件
|
## 第7步 烧录固件
|
||||||
将esp32设备连接电脑,使用chrome浏览器,打开以下网址
|
将esp32设备连接电脑,使用chrome浏览器,打开以下网址
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 基于虾哥编译好的固件配置自定义服务器
|
||||||
|
|
||||||
|
## 第1步 确认版本
|
||||||
|
烧录虾哥已经编译好的1.6.1版本固件
|
||||||
|
|
||||||
|
## 第2步 准备你的ota地址
|
||||||
|
如果你按照教程使用的是全模块部署,就应该会有ota地址。
|
||||||
|
|
||||||
|
此刻,请你用浏览器打开你的ota地址,例如我的ota地址
|
||||||
|
```
|
||||||
|
https://2662r3426b.vicp.fun/xiaozhi/ota/
|
||||||
|
```
|
||||||
|
|
||||||
|
如果显示“OTA接口运行正常,websocket集群数量:X”。那就往下。
|
||||||
|
|
||||||
|
如果显示“OTA接口运行不正常”,大概是你还没在`智控台`配置`Websocket`地址。那就:
|
||||||
|
|
||||||
|
- 1、使用超级管理员登录智控台
|
||||||
|
|
||||||
|
- 2、顶部菜单点击`参数管理`
|
||||||
|
|
||||||
|
- 3、在列表中找到`server.websocket`项目,输入你的`Websocket`地址。例如我的就是
|
||||||
|
|
||||||
|
```
|
||||||
|
wss://2662r3426b.vicp.fun/xiaozhi/v1/
|
||||||
|
```
|
||||||
|
|
||||||
|
配置完后,再使用浏览器刷新你的ota接口地址,看看是不是正常了。如果还不正常就,就再次确认一下Websocket是否正常启动,是否配置了Websocket地址。
|
||||||
|
|
||||||
|
## 第3步 进入配网模式
|
||||||
|
进入机器的配网模式,在页面顶部,点击“高级选项”,在里面输入你服务器的`ota`地址,点击保存。重启设备
|
||||||
|
|
||||||
|
## 第4步 唤醒小智,查看日志输出
|
||||||
|
|
||||||
|
唤醒小智,看看日志是不是正常输出。
|
||||||
|
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
以下是一些常见问题,供参考:
|
||||||
|
|
||||||
|
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
|
||||||
|
|
||||||
|
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
|
||||||
|
|
||||||
|
[3、TTS 经常失败,经常超时](./FAQ.md)
|
||||||
|
|
||||||
|
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
|
||||||
|
|
||||||
|
[5、如何提高小智对话响应速度?](./FAQ.md)
|
||||||
|
|
||||||
|
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
|
||||||
|
|
||||||
|
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# ESP32设备与HomeAssistant集成指南
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
|
||||||
|
本文档将指导您如何将ESP32设备与HomeAssistant进行集成。
|
||||||
|
|
||||||
|
## 前提条件
|
||||||
|
|
||||||
|
- 已安装并配置好`HomeAssistant`
|
||||||
|
- 已经在HomeAssistant中集成`Xiaomi Home`,并将米家的设备导入进`HomeAssistant`
|
||||||
|
- `xiaozhi-esp32-server智控台`能正常使用
|
||||||
|
- 本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用
|
||||||
|
|
||||||
|
## 网络环境
|
||||||
|
|
||||||
|
1、我的`HomeAssistant`部署在下面,版本是`2025.3.4`
|
||||||
|
```
|
||||||
|
http://192.168.4.7:8123
|
||||||
|
```
|
||||||
|
|
||||||
|
2、我的`xiaozhi-esp32-server智控台`和`HomeAssistant`部署在同一台机器的另一个端口,版本是`0.3.10`
|
||||||
|
```
|
||||||
|
http://192.168.4.7:8002
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 配置步骤
|
||||||
|
|
||||||
|
### 1. 登录`HomeAssistant`整理需要控制的设备清单
|
||||||
|
|
||||||
|
登录`HomeAssistant`,点击`左下角的设置`,然后进入`设备与服务`,再点击顶部的`实体`。
|
||||||
|
|
||||||
|
然后在实体中搜索你相关控制的开关,结果出来后,在列表中,点击其中一个结果,这是会出现一个开关的界面。
|
||||||
|
|
||||||
|
在开关的界面,我们尝试点击开关,看看是开发会随着我们的点击开/关。如果能操作,说明是正常联网的。
|
||||||
|
|
||||||
|
接着在开关面板找到设置按钮,点击后,可以查看这个开关的`实体标识符`。
|
||||||
|
|
||||||
|
我们打开一个记事本,按照这样格式整理一条数据:
|
||||||
|
|
||||||
|
位置+英文逗号+设备名称+英文逗号+`实体标识符`+英文分号
|
||||||
|
|
||||||
|
例如,我在公司,我有一个玩具灯,他的标识符是switch.cuco_cn_460494544_cp1_on_p_2_1,那么就这个写这一条数据
|
||||||
|
|
||||||
|
```
|
||||||
|
公司,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;
|
||||||
|
```
|
||||||
|
|
||||||
|
当然最后我可能要操作两个灯,我的最终的结果是:
|
||||||
|
|
||||||
|
```
|
||||||
|
公司,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;
|
||||||
|
公司,台灯,switch.iot_cn_831898993_socn1_on_p_2_1;
|
||||||
|
```
|
||||||
|
|
||||||
|
这段字符,我们成为“设备清单字符”需要保存好,等一下有用。
|
||||||
|
|
||||||
|
|
||||||
|
### 2. 登录`HomeAssistant`拿到开发密钥
|
||||||
|
|
||||||
|
登录`HomeAssistant`,点击`左下角个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key。
|
||||||
|
|
||||||
|
|
||||||
|
### 3. 登录`智控台`
|
||||||
|
|
||||||
|
使用管理员账号,登录`智控台`。点击顶部菜单`参数管理`,搜索`plugins.home_assistant.`,会有三条结果出来
|
||||||
|
|
||||||
|
编辑`plugins.home_assistant.devices`,把刚才整理的设备清单字符粘贴进去。
|
||||||
|
|
||||||
|
|
||||||
|
编辑`plugins.home_assistant.base_url`,把你部署的`HomeAssistant`接口地址粘贴进去,我粘贴进去的地址是这样的
|
||||||
|
|
||||||
|
```
|
||||||
|
http://192.168.4.7:8123
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑`plugins.home_assistant.api_key`,把你从`HomeAssistant`复制过来的密钥,粘贴进去
|
||||||
|
|
||||||
|
|
||||||
|
### 4. 设置`意图识别`函数
|
||||||
|
|
||||||
|
在智控台,点击顶部菜单“模型配置”,在左侧栏,找到“意图识别”,找到id为`Intent_function_call`的意图,点击编辑
|
||||||
|
|
||||||
|
然后在弹框中,在原来的基础上追加两个函数:“hass_get_state”和“hass_set_state”
|
||||||
|
|
||||||
|
修改前
|
||||||
|
```
|
||||||
|
change_role;get_weather;get_news;play_music
|
||||||
|
```
|
||||||
|
|
||||||
|
修改后
|
||||||
|
|
||||||
|
```
|
||||||
|
change_role;get_weather;get_news;play_music;hass_get_state;hass_set_state
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 手动重启xiaozhi-server
|
||||||
|
|
||||||
|
重启xiaozhi-server程序
|
||||||
|
|
||||||
|
|
||||||
|
### 5. 确认角色配置是否设置了函数意图识别
|
||||||
|
|
||||||
|
在智控台,点击顶部菜单“智能体管理”,找到设备所在的智能体,点击“配置角色”
|
||||||
|
|
||||||
|
确认意图识别(Intent),是否选择“函数调用意图识别”
|
||||||
|
|
||||||
|
|
||||||
|
### 6. 唤醒设别进行控制
|
||||||
|
|
||||||
|
尝试和esp32说,“打开XXX灯”
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package xiaozhi.common.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RestTemplate配置
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RestTemplateConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestTemplate restTemplate() {
|
||||||
|
return new RestTemplate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,11 @@ public interface Constant {
|
|||||||
*/
|
*/
|
||||||
String SERVER_WEBSOCKET = "server.websocket";
|
String SERVER_WEBSOCKET = "server.websocket";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ota地址
|
||||||
|
*/
|
||||||
|
String SERVER_OTA = "server.ota";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否允许用户注册
|
* 是否允许用户注册
|
||||||
*/
|
*/
|
||||||
@@ -172,5 +177,5 @@ public interface Constant {
|
|||||||
/**
|
/**
|
||||||
* 版本号
|
* 版本号
|
||||||
*/
|
*/
|
||||||
public static final String VERSION = "0.3.10";
|
public static final String VERSION = "0.3.12";
|
||||||
}
|
}
|
||||||
+13
@@ -7,6 +7,7 @@ import org.apache.shiro.authz.annotation.RequiresPermissions;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -79,4 +80,16 @@ public class DeviceController {
|
|||||||
return new Result<Void>();
|
return new Result<Void>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/enableOta/{id}/{status}")
|
||||||
|
@Operation(summary = "启用/关闭OTA自动升级")
|
||||||
|
@RequiresPermissions("sys:role:normal")
|
||||||
|
public Result<Void> enableOtaUpgrade(@PathVariable String id, @PathVariable Integer status) {
|
||||||
|
DeviceEntity entity = deviceService.selectById(id);
|
||||||
|
if (entity == null) {
|
||||||
|
return new Result<Void>().error("设备不存在");
|
||||||
|
}
|
||||||
|
entity.setAutoUpdate(status);
|
||||||
|
deviceService.updateById(entity);
|
||||||
|
return new Result<Void>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,11 @@ public class OTAController {
|
|||||||
public ResponseEntity<String> getOTA() {
|
public ResponseEntity<String> getOTA() {
|
||||||
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||||
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
||||||
return ResponseEntity.ok("OTA接口不正常,缺少websocket地址");
|
return ResponseEntity.ok("OTA接口不正常,缺少websocket地址,请登录智控台,在参数管理找到【server.websocket】配置");
|
||||||
|
}
|
||||||
|
String otaUrl = sysParamsService.getValue(Constant.SERVER_OTA, true);
|
||||||
|
if (StringUtils.isBlank(otaUrl) || otaUrl.equals("null")) {
|
||||||
|
return ResponseEntity.ok("OTA接口不正常,缺少ota地址,请登录智控台,在参数管理找到【server.ota】配置");
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
|
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,14 @@ package xiaozhi.modules.device.service;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import xiaozhi.common.page.PageData;
|
import xiaozhi.common.page.PageData;
|
||||||
|
import xiaozhi.common.service.BaseService;
|
||||||
import xiaozhi.modules.device.dto.DevicePageUserDTO;
|
import xiaozhi.modules.device.dto.DevicePageUserDTO;
|
||||||
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
|
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
|
||||||
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
|
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
|
||||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||||
import xiaozhi.modules.device.vo.UserShowDeviceListVO;
|
import xiaozhi.modules.device.vo.UserShowDeviceListVO;
|
||||||
|
|
||||||
public interface DeviceService {
|
public interface DeviceService extends BaseService<DeviceEntity> {
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据Mac地址获取设备信息
|
|
||||||
*/
|
|
||||||
DeviceEntity getDeviceById(String macAddress);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查设备是否激活
|
* 检查设备是否激活
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ public interface OtaService extends BaseService<OtaEntity> {
|
|||||||
void update(OtaEntity entity);
|
void update(OtaEntity entity);
|
||||||
|
|
||||||
void delete(String[] ids);
|
void delete(String[] ids);
|
||||||
|
|
||||||
|
OtaEntity getLatestOta(String type);
|
||||||
}
|
}
|
||||||
+106
-44
@@ -6,22 +6,26 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.TimeZone;
|
import java.util.TimeZone;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
|
||||||
import cn.hutool.core.util.RandomUtil;
|
import cn.hutool.core.util.RandomUtil;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import xiaozhi.common.constant.Constant;
|
import xiaozhi.common.constant.Constant;
|
||||||
import xiaozhi.common.exception.RenException;
|
import xiaozhi.common.exception.RenException;
|
||||||
import xiaozhi.common.page.PageData;
|
import xiaozhi.common.page.PageData;
|
||||||
|
import xiaozhi.common.redis.RedisKeys;
|
||||||
|
import xiaozhi.common.redis.RedisUtils;
|
||||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||||
import xiaozhi.common.user.UserDetail;
|
import xiaozhi.common.user.UserDetail;
|
||||||
import xiaozhi.common.utils.ConvertUtils;
|
import xiaozhi.common.utils.ConvertUtils;
|
||||||
@@ -31,7 +35,9 @@ import xiaozhi.modules.device.dto.DevicePageUserDTO;
|
|||||||
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
|
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
|
||||||
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
|
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
|
||||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||||
|
import xiaozhi.modules.device.entity.OtaEntity;
|
||||||
import xiaozhi.modules.device.service.DeviceService;
|
import xiaozhi.modules.device.service.DeviceService;
|
||||||
|
import xiaozhi.modules.device.service.OtaService;
|
||||||
import xiaozhi.modules.device.vo.UserShowDeviceListVO;
|
import xiaozhi.modules.device.vo.UserShowDeviceListVO;
|
||||||
import xiaozhi.modules.security.user.SecurityUser;
|
import xiaozhi.modules.security.user.SecurityUser;
|
||||||
import xiaozhi.modules.sys.service.SysParamsService;
|
import xiaozhi.modules.sys.service.SysParamsService;
|
||||||
@@ -39,32 +45,14 @@ import xiaozhi.modules.sys.service.SysUserUtilService;
|
|||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
|
@AllArgsConstructor
|
||||||
public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity> implements DeviceService {
|
public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity> implements DeviceService {
|
||||||
|
|
||||||
private final DeviceDao deviceDao;
|
private final DeviceDao deviceDao;
|
||||||
|
|
||||||
private final SysUserUtilService sysUserUtilService;
|
private final SysUserUtilService sysUserUtilService;
|
||||||
|
|
||||||
private final RedisTemplate<String, Object> redisTemplate;
|
|
||||||
|
|
||||||
private final SysParamsService sysParamsService;
|
private final SysParamsService sysParamsService;
|
||||||
|
private final RedisUtils redisUtils;
|
||||||
// 添加构造函数来初始化 deviceMapper
|
private final OtaService otaService;
|
||||||
public DeviceServiceImpl(DeviceDao deviceDao, SysUserUtilService sysUserUtilService,
|
|
||||||
SysParamsService sysParamsService,
|
|
||||||
RedisTemplate<String, Object> redisTemplate) {
|
|
||||||
this.deviceDao = deviceDao;
|
|
||||||
this.sysUserUtilService = sysUserUtilService;
|
|
||||||
this.redisTemplate = redisTemplate;
|
|
||||||
this.sysParamsService = sysParamsService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public DeviceEntity getDeviceById(String deviceId) {
|
|
||||||
LambdaQueryWrapper<DeviceEntity> queryWrapper = new LambdaQueryWrapper<>();
|
|
||||||
queryWrapper.eq(DeviceEntity::getId, deviceId);
|
|
||||||
return deviceDao.selectOne(queryWrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Boolean deviceActivation(String agentId, String activationCode) {
|
public Boolean deviceActivation(String agentId, String activationCode) {
|
||||||
@@ -72,14 +60,14 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
throw new RenException("激活码不能为空");
|
throw new RenException("激活码不能为空");
|
||||||
}
|
}
|
||||||
String deviceKey = "ota:activation:code:" + activationCode;
|
String deviceKey = "ota:activation:code:" + activationCode;
|
||||||
Object cacheDeviceId = redisTemplate.opsForValue().get(deviceKey);
|
Object cacheDeviceId = redisUtils.get(deviceKey);
|
||||||
if (cacheDeviceId == null) {
|
if (cacheDeviceId == null) {
|
||||||
throw new RenException("激活码错误");
|
throw new RenException("激活码错误");
|
||||||
}
|
}
|
||||||
String deviceId = (String) cacheDeviceId;
|
String deviceId = (String) cacheDeviceId;
|
||||||
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
|
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
|
||||||
String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId);
|
String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId);
|
||||||
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(cacheDeviceKey);
|
Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(cacheDeviceKey);
|
||||||
if (cacheMap == null) {
|
if (cacheMap == null) {
|
||||||
throw new RenException("激活码错误");
|
throw new RenException("激活码错误");
|
||||||
}
|
}
|
||||||
@@ -109,6 +97,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
deviceEntity.setMacAddress(macAddress);
|
deviceEntity.setMacAddress(macAddress);
|
||||||
deviceEntity.setUserId(user.getId());
|
deviceEntity.setUserId(user.getId());
|
||||||
deviceEntity.setCreator(user.getId());
|
deviceEntity.setCreator(user.getId());
|
||||||
|
deviceEntity.setAutoUpdate(1);
|
||||||
deviceEntity.setCreateDate(currentTime);
|
deviceEntity.setCreateDate(currentTime);
|
||||||
deviceEntity.setUpdater(user.getId());
|
deviceEntity.setUpdater(user.getId());
|
||||||
deviceEntity.setUpdateDate(currentTime);
|
deviceEntity.setUpdateDate(currentTime);
|
||||||
@@ -116,8 +105,8 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
deviceDao.insert(deviceEntity);
|
deviceDao.insert(deviceEntity);
|
||||||
|
|
||||||
// 清理redis缓存
|
// 清理redis缓存
|
||||||
redisTemplate.delete(cacheDeviceKey);
|
redisUtils.delete(cacheDeviceKey);
|
||||||
redisTemplate.delete(deviceKey);
|
redisUtils.delete(deviceKey);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,20 +115,24 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
DeviceReportReqDTO deviceReport) {
|
DeviceReportReqDTO deviceReport) {
|
||||||
DeviceReportRespDTO response = new DeviceReportRespDTO();
|
DeviceReportRespDTO response = new DeviceReportRespDTO();
|
||||||
response.setServer_time(buildServerTime());
|
response.setServer_time(buildServerTime());
|
||||||
// todo: 此处是固件信息,目前是针对固件上传上来的版本号再返回回去
|
|
||||||
// 在未来开发了固件更新功能,需要更换此处代码,
|
DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
|
||||||
// 或写定时任务定期请求虾哥的OTA,获取最新的版本讯息保存到服务内
|
|
||||||
DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
|
if (deviceById == null || deviceById.getAutoUpdate() != 0) {
|
||||||
firmware.setVersion(deviceReport.getApplication().getVersion());
|
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
|
||||||
firmware.setUrl("http://localhost:8002/xiaozhi/ota/download");
|
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
|
||||||
response.setFirmware(firmware);
|
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
|
||||||
|
response.setFirmware(firmware);
|
||||||
|
} else {
|
||||||
|
response.setFirmware(null);
|
||||||
|
}
|
||||||
|
|
||||||
// 添加WebSocket配置
|
// 添加WebSocket配置
|
||||||
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
|
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
|
||||||
// 从系统参数获取WebSocket URL,如果未配置则使用默认值
|
// 从系统参数获取WebSocket URL,如果未配置则使用默认值
|
||||||
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||||
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
||||||
log.error("WebSocket URL is not configured");
|
log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
|
||||||
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
|
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
|
||||||
websocket.setUrl(wsUrl);
|
websocket.setUrl(wsUrl);
|
||||||
} else {
|
} else {
|
||||||
@@ -148,18 +141,23 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
// 随机选择一个WebSocket URL
|
// 随机选择一个WebSocket URL
|
||||||
websocket.setUrl(wsUrls[RandomUtil.randomInt(0, wsUrls.length)]);
|
websocket.setUrl(wsUrls[RandomUtil.randomInt(0, wsUrls.length)]);
|
||||||
} else {
|
} else {
|
||||||
log.error("WebSocket URL list is empty");
|
log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
|
||||||
websocket.setUrl("ws://xiaozhi.server.com:8000/xiaozhi/v1/");
|
websocket.setUrl("ws://xiaozhi.server.com:8000/xiaozhi/v1/");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response.setWebsocket(websocket);
|
response.setWebsocket(websocket);
|
||||||
|
|
||||||
DeviceEntity deviceById = getDeviceById(macAddress);
|
if (deviceById != null) {
|
||||||
if (deviceById != null) { // 如果设备存在,则更新上次连接时间
|
// 如果设备存在,则更新上次连接时间
|
||||||
deviceById.setLastConnectedAt(new Date());
|
deviceById.setLastConnectedAt(new Date());
|
||||||
|
if (deviceReport.getApplication() != null
|
||||||
|
&& StringUtils.isNotBlank(deviceReport.getApplication().getVersion())) {
|
||||||
|
deviceById.setAppVersion(deviceReport.getApplication().getVersion());
|
||||||
|
}
|
||||||
deviceDao.updateById(deviceById);
|
deviceDao.updateById(deviceById);
|
||||||
} else { // 如果设备不存在,则生成激活码
|
} else {
|
||||||
|
// 如果设备不存在,则生成激活码
|
||||||
DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
|
DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
|
||||||
response.setActivation(code);
|
response.setActivation(code);
|
||||||
}
|
}
|
||||||
@@ -252,7 +250,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
public String geCodeByDeviceId(String deviceId) {
|
public String geCodeByDeviceId(String deviceId) {
|
||||||
String dataKey = getDeviceCacheKey(deviceId);
|
String dataKey = getDeviceCacheKey(deviceId);
|
||||||
|
|
||||||
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(dataKey);
|
Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(dataKey);
|
||||||
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
|
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
|
||||||
String cachedCode = (String) cacheMap.get("activation_code");
|
String cachedCode = (String) cacheMap.get("activation_code");
|
||||||
return cachedCode;
|
return cachedCode;
|
||||||
@@ -299,13 +297,77 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
|
|
||||||
// 写入主数据 key
|
// 写入主数据 key
|
||||||
String dataKey = getDeviceCacheKey(deviceId);
|
String dataKey = getDeviceCacheKey(deviceId);
|
||||||
redisTemplate.opsForHash().putAll(dataKey, dataMap);
|
redisUtils.set(dataKey, dataMap);
|
||||||
redisTemplate.expire(dataKey, 24, TimeUnit.HOURS);
|
|
||||||
|
|
||||||
// 写入反查激活码 key
|
// 写入反查激活码 key
|
||||||
String codeKey = "ota:activation:code:" + newCode;
|
String codeKey = "ota:activation:code:" + newCode;
|
||||||
redisTemplate.opsForValue().set(codeKey, deviceId, 24, TimeUnit.HOURS);
|
redisUtils.set(codeKey, deviceId);
|
||||||
}
|
}
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private DeviceReportRespDTO.Firmware buildFirmwareInfo(String type, String currentVersion) {
|
||||||
|
if (StringUtils.isBlank(type)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(currentVersion)) {
|
||||||
|
currentVersion = "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
OtaEntity ota = otaService.getLatestOta(type);
|
||||||
|
DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
|
||||||
|
String downloadUrl = null;
|
||||||
|
|
||||||
|
if (ota != null) {
|
||||||
|
// 如果设备没有版本信息,或者OTA版本比设备版本新,则返回下载地址
|
||||||
|
if (compareVersions(ota.getVersion(), currentVersion) > 0) {
|
||||||
|
String otaUrl = sysParamsService.getValue(Constant.SERVER_OTA, true);
|
||||||
|
if (StringUtils.isBlank(otaUrl) || otaUrl.equals("null")) {
|
||||||
|
log.error("OTA地址未配置,请登录智控台,在参数管理找到【server.ota】配置");
|
||||||
|
// 尝试从请求中获取
|
||||||
|
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
|
||||||
|
.getRequestAttributes())
|
||||||
|
.getRequest();
|
||||||
|
otaUrl = request.getRequestURL().toString();
|
||||||
|
}
|
||||||
|
// 将URL中的/ota/替换为/otaMag/download/
|
||||||
|
String uuid = UUID.randomUUID().toString();
|
||||||
|
redisUtils.set(RedisKeys.getOtaIdKey(uuid), ota.getId());
|
||||||
|
downloadUrl = otaUrl.replace("/ota/", "/otaMag/download/") + uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
firmware.setVersion(ota == null ? currentVersion : ota.getVersion());
|
||||||
|
firmware.setUrl(downloadUrl == null ? "" : downloadUrl);
|
||||||
|
return firmware;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较两个版本号
|
||||||
|
*
|
||||||
|
* @param version1 版本1
|
||||||
|
* @param version2 版本2
|
||||||
|
* @return 如果version1 > version2返回1,version1 < version2返回-1,相等返回0
|
||||||
|
*/
|
||||||
|
private static int compareVersions(String version1, String version2) {
|
||||||
|
if (version1 == null || version2 == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] v1Parts = version1.split("\\.");
|
||||||
|
String[] v2Parts = version2.split("\\.");
|
||||||
|
|
||||||
|
int length = Math.max(v1Parts.length, v2Parts.length);
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
int v1 = i < v1Parts.length ? Integer.parseInt(v1Parts[i]) : 0;
|
||||||
|
int v2 = i < v2Parts.length ? Integer.parseInt(v2Parts[i]) : 0;
|
||||||
|
|
||||||
|
if (v1 > v2) {
|
||||||
|
return 1;
|
||||||
|
} else if (v1 < v2) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+22
-11
@@ -1,6 +1,8 @@
|
|||||||
package xiaozhi.modules.device.service.impl;
|
package xiaozhi.modules.device.service.impl;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -38,17 +40,17 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void update(OtaEntity entity) {
|
public void update(OtaEntity entity) {
|
||||||
// 检查是否存在相同名称、类型和版本的固件(排除当前记录)
|
// 检查是否存在相同类型和版本的固件(排除当前记录)
|
||||||
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
|
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
|
||||||
.eq("firmware_name", entity.getFirmwareName())
|
|
||||||
.eq("type", entity.getType())
|
.eq("type", entity.getType())
|
||||||
.eq("version", entity.getVersion())
|
.eq("version", entity.getVersion())
|
||||||
.ne("id", entity.getId()); // 排除当前记录
|
.ne("id", entity.getId()); // 排除当前记录
|
||||||
|
|
||||||
if (baseDao.selectCount(queryWrapper) > 0) {
|
if (baseDao.selectCount(queryWrapper) > 0) {
|
||||||
throw new RuntimeException("已存在相同名称、类型和版本的固件,请修改后重试");
|
throw new RuntimeException("已存在相同类型和版本的固件,请修改后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entity.setUpdateDate(new Date());
|
||||||
baseDao.updateById(entity);
|
baseDao.updateById(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,16 +61,25 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean save(OtaEntity entity) {
|
public boolean save(OtaEntity entity) {
|
||||||
// 检查是否存在相同名称、类型和版本的固件
|
|
||||||
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
|
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
|
||||||
.eq("firmware_name", entity.getFirmwareName())
|
.eq("type", entity.getType());
|
||||||
.eq("type", entity.getType())
|
// 同类固件只保留最新的一条
|
||||||
.eq("version", entity.getVersion());
|
List<OtaEntity> otaList = baseDao.selectList(queryWrapper);
|
||||||
|
if (otaList != null && otaList.size() > 0) {
|
||||||
if (baseDao.selectCount(queryWrapper) > 0) {
|
OtaEntity otaBefore = otaList.getFirst();
|
||||||
throw new RuntimeException("已存在相同名称、类型和版本的固件,请勿重复添加");
|
entity.setId(otaBefore.getId());
|
||||||
|
baseDao.updateById(entity);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseDao.insert(entity) > 0;
|
return baseDao.insert(entity) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OtaEntity getLatestOta(String type) {
|
||||||
|
QueryWrapper<OtaEntity> wrapper = new QueryWrapper<>();
|
||||||
|
wrapper.eq("type", type)
|
||||||
|
.orderByDesc("update_date")
|
||||||
|
.last("LIMIT 1");
|
||||||
|
return baseDao.selectOne(wrapper);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+64
@@ -3,6 +3,7 @@ package xiaozhi.modules.model.service.impl;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -19,6 +20,10 @@ import xiaozhi.common.redis.RedisKeys;
|
|||||||
import xiaozhi.common.redis.RedisUtils;
|
import xiaozhi.common.redis.RedisUtils;
|
||||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||||
import xiaozhi.common.utils.ConvertUtils;
|
import xiaozhi.common.utils.ConvertUtils;
|
||||||
|
import xiaozhi.modules.agent.dao.AgentDao;
|
||||||
|
import xiaozhi.modules.agent.dao.AgentTemplateDao;
|
||||||
|
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||||
|
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||||
import xiaozhi.modules.model.dao.ModelConfigDao;
|
import xiaozhi.modules.model.dao.ModelConfigDao;
|
||||||
import xiaozhi.modules.model.dto.ModelBasicInfoDTO;
|
import xiaozhi.modules.model.dto.ModelBasicInfoDTO;
|
||||||
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
|
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
|
||||||
@@ -36,6 +41,9 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
|||||||
private final ModelConfigDao modelConfigDao;
|
private final ModelConfigDao modelConfigDao;
|
||||||
private final ModelProviderService modelProviderService;
|
private final ModelProviderService modelProviderService;
|
||||||
private final RedisUtils redisUtils;
|
private final RedisUtils redisUtils;
|
||||||
|
private final AgentTemplateDao agentTemplateDao;
|
||||||
|
private final AgentTemplateService agentTemplateService;
|
||||||
|
private final AgentDao agentDao;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ModelBasicInfoDTO> getModelCodeList(String modelType, String modelName) {
|
public List<ModelBasicInfoDTO> getModelCodeList(String modelType, String modelName) {
|
||||||
@@ -75,6 +83,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
|||||||
// 再保存供应器提供的模型
|
// 再保存供应器提供的模型
|
||||||
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class);
|
ModelConfigEntity modelConfigEntity = ConvertUtils.sourceToTarget(modelConfigBodyDTO, ModelConfigEntity.class);
|
||||||
modelConfigEntity.setModelType(modelType);
|
modelConfigEntity.setModelType(modelType);
|
||||||
|
modelConfigEntity.setIsDefault(0);
|
||||||
modelConfigDao.insert(modelConfigEntity);
|
modelConfigDao.insert(modelConfigEntity);
|
||||||
return ConvertUtils.sourceToTarget(modelConfigEntity, ModelConfigDTO.class);
|
return ConvertUtils.sourceToTarget(modelConfigEntity, ModelConfigDTO.class);
|
||||||
}
|
}
|
||||||
@@ -102,9 +111,64 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(String id) {
|
public void delete(String id) {
|
||||||
|
// 查看是否是默认
|
||||||
|
ModelConfigEntity modelConfig = modelConfigDao.selectById(id);
|
||||||
|
if (modelConfig != null && modelConfig.getIsDefault() == 1) {
|
||||||
|
throw new RenException("该模型为默认模型,请先设置其他模型为默认模型");
|
||||||
|
}
|
||||||
|
// 验证是否有引用
|
||||||
|
checkAgentReference(id);
|
||||||
|
checkIntentConfigReference(id);
|
||||||
|
|
||||||
modelConfigDao.deleteById(id);
|
modelConfigDao.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查智能体配置是否有引用
|
||||||
|
*
|
||||||
|
* @param modelId 模型ID
|
||||||
|
*/
|
||||||
|
private void checkAgentReference(String modelId) {
|
||||||
|
List<AgentEntity> agents = agentDao.selectList(
|
||||||
|
new QueryWrapper<AgentEntity>()
|
||||||
|
.eq("vad_model_id", modelId)
|
||||||
|
.or()
|
||||||
|
.eq("asr_model_id", modelId)
|
||||||
|
.or()
|
||||||
|
.eq("llm_model_id", modelId)
|
||||||
|
.or()
|
||||||
|
.eq("tts_model_id", modelId)
|
||||||
|
.or()
|
||||||
|
.eq("mem_model_id", modelId)
|
||||||
|
.or()
|
||||||
|
.eq("intent_model_id", modelId));
|
||||||
|
if (!agents.isEmpty()) {
|
||||||
|
String agentNames = agents.stream()
|
||||||
|
.map(AgentEntity::getAgentName)
|
||||||
|
.collect(Collectors.joining("、"));
|
||||||
|
throw new RenException(String.format("该模型配置已被智能体[%s]引用,无法删除", agentNames));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查意图识别配置是否有引用
|
||||||
|
*
|
||||||
|
* @param modelId 模型ID
|
||||||
|
*/
|
||||||
|
private void checkIntentConfigReference(String modelId) {
|
||||||
|
ModelConfigEntity modelConfig = modelConfigDao.selectById(modelId);
|
||||||
|
if (modelConfig != null
|
||||||
|
&& "LLM".equals(modelConfig.getModelType() == null ? null : modelConfig.getModelType().toUpperCase())) {
|
||||||
|
List<ModelConfigEntity> intentConfigs = modelConfigDao.selectList(
|
||||||
|
new QueryWrapper<ModelConfigEntity>()
|
||||||
|
.eq("model_type", "Intent")
|
||||||
|
.like("config_json", "%" + modelId + "%"));
|
||||||
|
if (!intentConfigs.isEmpty()) {
|
||||||
|
throw new RenException("该LLM模型已被意图识别配置引用,无法删除");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getModelNameById(String id) {
|
public String getModelNameById(String id) {
|
||||||
if (StringUtils.isBlank(id)) {
|
if (StringUtils.isBlank(id)) {
|
||||||
|
|||||||
@@ -33,11 +33,15 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
|
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
|
||||||
|
// 特殊用途的转换器
|
||||||
converters.add(new ByteArrayHttpMessageConverter());
|
converters.add(new ByteArrayHttpMessageConverter());
|
||||||
converters.add(new StringHttpMessageConverter());
|
|
||||||
converters.add(new ResourceHttpMessageConverter());
|
converters.add(new ResourceHttpMessageConverter());
|
||||||
converters.add(new AllEncompassingFormHttpMessageConverter());
|
|
||||||
|
// 通用转换器
|
||||||
converters.add(new StringHttpMessageConverter());
|
converters.add(new StringHttpMessageConverter());
|
||||||
|
converters.add(new AllEncompassingFormHttpMessageConverter());
|
||||||
|
|
||||||
|
// JSON 转换器
|
||||||
converters.add(jackson2HttpMessageConverter());
|
converters.add(jackson2HttpMessageConverter());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
@@ -4,6 +4,8 @@ import java.util.Map;
|
|||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
@@ -47,6 +50,7 @@ import xiaozhi.modules.sys.utils.WebSocketValidator;
|
|||||||
public class SysParamsController {
|
public class SysParamsController {
|
||||||
private final SysParamsService sysParamsService;
|
private final SysParamsService sysParamsService;
|
||||||
private final ConfigService configService;
|
private final ConfigService configService;
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
|
||||||
@GetMapping("page")
|
@GetMapping("page")
|
||||||
@Operation(summary = "分页")
|
@Operation(summary = "分页")
|
||||||
@@ -97,6 +101,9 @@ public class SysParamsController {
|
|||||||
// 验证WebSocket地址列表
|
// 验证WebSocket地址列表
|
||||||
validateWebSocketUrls(dto.getParamCode(), dto.getParamValue());
|
validateWebSocketUrls(dto.getParamCode(), dto.getParamValue());
|
||||||
|
|
||||||
|
// 验证OTA地址
|
||||||
|
validateOtaUrl(dto.getParamCode(), dto.getParamValue());
|
||||||
|
|
||||||
sysParamsService.update(dto);
|
sysParamsService.update(dto);
|
||||||
configService.getConfig(false);
|
configService.getConfig(false);
|
||||||
return new Result<Void>();
|
return new Result<Void>();
|
||||||
@@ -118,6 +125,11 @@ public class SysParamsController {
|
|||||||
}
|
}
|
||||||
for (String url : wsUrls) {
|
for (String url : wsUrls) {
|
||||||
if (StringUtils.isNotBlank(url)) {
|
if (StringUtils.isNotBlank(url)) {
|
||||||
|
// 检查是否包含localhost或127.0.0.1
|
||||||
|
if (url.contains("localhost") || url.contains("127.0.0.1")) {
|
||||||
|
throw new RenException("WebSocket地址不能使用localhost或127.0.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
// 验证WebSocket地址格式
|
// 验证WebSocket地址格式
|
||||||
if (!WebSocketValidator.validateUrlFormat(url)) {
|
if (!WebSocketValidator.validateUrlFormat(url)) {
|
||||||
throw new RenException("WebSocket地址格式不正确: " + url);
|
throw new RenException("WebSocket地址格式不正确: " + url);
|
||||||
@@ -131,6 +143,46 @@ public class SysParamsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证OTA地址
|
||||||
|
*/
|
||||||
|
private void validateOtaUrl(String paramCode, String url) {
|
||||||
|
if (!paramCode.equals(Constant.SERVER_OTA)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(url) || url.equals("null")) {
|
||||||
|
throw new RenException("OTA地址不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含localhost或127.0.0.1
|
||||||
|
if (url.contains("localhost") || url.contains("127.0.0.1")) {
|
||||||
|
throw new RenException("OTA地址不能使用localhost或127.0.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证URL格式
|
||||||
|
if (!url.toLowerCase().startsWith("http")) {
|
||||||
|
throw new RenException("OTA地址必须以http或https开头");
|
||||||
|
}
|
||||||
|
if (!url.endsWith("/ota/")) {
|
||||||
|
throw new RenException("OTA地址必须以/ota/结尾");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 发送GET请求
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
if (response.getStatusCode() != HttpStatus.OK) {
|
||||||
|
throw new RenException("OTA接口访问失败,状态码:" + response.getStatusCode());
|
||||||
|
}
|
||||||
|
// 检查响应内容是否包含OTA相关信息
|
||||||
|
String body = response.getBody();
|
||||||
|
if (body == null || !body.contains("OTA")) {
|
||||||
|
throw new RenException("OTA接口返回内容格式不正确,可能不是一个真实的OTA接口");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RenException("OTA接口验证失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/delete")
|
@PostMapping("/delete")
|
||||||
@Operation(summary = "删除")
|
@Operation(summary = "删除")
|
||||||
@LogOperation("删除")
|
@LogOperation("删除")
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- 添加火山引擎边缘大模型网关(LLM + TTS)
|
||||||
|
INSERT INTO `ai_model_config` VALUES ('LLM_VolcesAiGatewayLLM', 'LLM', 'VolcesAiGatewayTTS', '火山引擎边缘大模型网关', 0, 1, '{\"type\": \"openai\", \"base_url\": \"https://ai-gateway.vei.volces.com/v1\", \"model_name\": \"doubao-pro-32k-functioncall\", \"api_key\": \"你的网关访问密钥\"}', NULL, NULL, 14, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_model_config` VALUES ('TTS_VolcesAiGatewayTTS', 'TTS', 'VolcesAiGatewayTTS', '火山引擎边缘大模型网关', 0, 1, '{\"type\": \"openai\", \"api_key\": \"你的网关访问密钥\", \"api_url\": \"https://ai-gateway.vei.volces.com/v1/audio/speech\", \"model\": \"doubao-tts\", \"voice\": \"zh_male_shaonianzixin_moon_bigtts\", \"speed\": 1, \"output_dir\": \"tmp/\"}', NULL, NULL, 15, NULL, NULL, NULL, NULL);
|
||||||
|
-- 添加火山引擎边缘大模型网关语音合成音色
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0001', 'TTS_VolcesAiGatewayTTS', '灿灿/Shiny', 'zh_female_cancan_mars_bigtts', '中文、美式英语', NULL, NULL, 1, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0002', 'TTS_VolcesAiGatewayTTS', '清新女声', 'zh_female_qingxinnvsheng_mars_bigtts', '中文', NULL, NULL, 2, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0003', 'TTS_VolcesAiGatewayTTS', '爽快思思/Skye', 'zh_female_shuangkuaisisi_moon_bigtts', '中文、美式英语', NULL, NULL, 3, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0004', 'TTS_VolcesAiGatewayTTS', '温暖阿虎/Alvin', 'zh_male_wennuanahu_moon_bigtts', '中文、美式英语', NULL, NULL, 4, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0005', 'TTS_VolcesAiGatewayTTS', '少年梓辛/Brayan', 'zh_male_shaonianzixin_moon_bigtts', '中文、美式英语', NULL, NULL, 5, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0006', 'TTS_VolcesAiGatewayTTS', '知性女声', 'zh_female_zhixingnvsheng_mars_bigtts', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0007', 'TTS_VolcesAiGatewayTTS', '清爽男大', 'zh_male_qingshuangnanda_mars_bigtts', '中文', NULL, NULL, 7, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0008', 'TTS_VolcesAiGatewayTTS', '邻家女孩', 'zh_female_linjianvhai_moon_bigtts', '中文', NULL, NULL, 8, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0009', 'TTS_VolcesAiGatewayTTS', '渊博小叔', 'zh_male_yuanboxiaoshu_moon_bigtts', '中文', NULL, NULL, 9, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0010', 'TTS_VolcesAiGatewayTTS', '阳光青年', 'zh_male_yangguangqingnian_moon_bigtts', '中文', NULL, NULL, 10, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0011', 'TTS_VolcesAiGatewayTTS', '甜美小源', 'zh_female_tianmeixiaoyuan_moon_bigtts', '中文', NULL, NULL, 11, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0012', 'TTS_VolcesAiGatewayTTS', '清澈梓梓', 'zh_female_qingchezizi_moon_bigtts', '中文', NULL, NULL, 12, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0013', 'TTS_VolcesAiGatewayTTS', '解说小明', 'zh_male_jieshuoxiaoming_moon_bigtts', '中文', NULL, NULL, 13, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0014', 'TTS_VolcesAiGatewayTTS', '开朗姐姐', 'zh_female_kailangjiejie_moon_bigtts', '中文', NULL, NULL, 14, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0015', 'TTS_VolcesAiGatewayTTS', '邻家男孩', 'zh_male_linjiananhai_moon_bigtts', '中文', NULL, NULL, 15, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0016', 'TTS_VolcesAiGatewayTTS', '甜美悦悦', 'zh_female_tianmeiyueyue_moon_bigtts', '中文', NULL, NULL, 16, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0017', 'TTS_VolcesAiGatewayTTS', '心灵鸡汤', 'zh_female_xinlingjitang_moon_bigtts', '中文', NULL, NULL, 17, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0018', 'TTS_VolcesAiGatewayTTS', '知性温婉', 'ICL_zh_female_zhixingwenwan_tob', '中文', NULL, NULL, 18, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0019', 'TTS_VolcesAiGatewayTTS', '暖心体贴', 'ICL_zh_male_nuanxintitie_tob', '中文', NULL, NULL, 19, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0020', 'TTS_VolcesAiGatewayTTS', '温柔文雅', 'ICL_zh_female_wenrouwenya_tob', '中文', NULL, NULL, 20, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0021', 'TTS_VolcesAiGatewayTTS', '开朗轻快', 'ICL_zh_male_kailangqingkuai_tob', '中文', NULL, NULL, 21, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0022', 'TTS_VolcesAiGatewayTTS', '活泼爽朗', 'ICL_zh_male_huoposhuanglang_tob', '中文', NULL, NULL, 22, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0023', 'TTS_VolcesAiGatewayTTS', '率真小伙', 'ICL_zh_male_shuaizhenxiaohuo_tob', '中文', NULL, NULL, 23, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0024', 'TTS_VolcesAiGatewayTTS', '温柔小哥', 'zh_male_wenrouxiaoge_mars_bigtts', '中文', NULL, NULL, 24, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0025', 'TTS_VolcesAiGatewayTTS', 'Smith', 'en_male_smith_mars_bigtts', '英式英语', NULL, NULL, 25, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0026', 'TTS_VolcesAiGatewayTTS', 'Anna', 'en_female_anna_mars_bigtts', '英式英语', NULL, NULL, 26, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0027', 'TTS_VolcesAiGatewayTTS', 'Adam', 'en_male_adam_mars_bigtts', '美式英语', NULL, NULL, 27, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0028', 'TTS_VolcesAiGatewayTTS', 'Sarah', 'en_female_sarah_mars_bigtts', '澳洲英语', NULL, NULL, 28, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0029', 'TTS_VolcesAiGatewayTTS', 'Dryw', 'en_male_dryw_mars_bigtts', '澳洲英语', NULL, NULL, 29, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0030', 'TTS_VolcesAiGatewayTTS', 'かずね(和音)', 'multi_male_jingqiangkanye_moon_bigtts', '日语、西语', NULL, NULL, 30, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0031', 'TTS_VolcesAiGatewayTTS', 'はるこ(晴子)', 'multi_female_shuangkuaisisi_moon_bigtts', '日语、西语', NULL, NULL, 31, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0032', 'TTS_VolcesAiGatewayTTS', 'ひろし(広志)', 'multi_male_wanqudashu_moon_bigtts', '日语、西语', NULL, NULL, 32, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0033', 'TTS_VolcesAiGatewayTTS', 'あけみ(朱美)', 'multi_female_gaolengyujie_moon_bigtts', '日语', NULL, NULL, 33, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0034', 'TTS_VolcesAiGatewayTTS', 'Amanda', 'en_female_amanda_mars_bigtts', '美式英语', NULL, NULL, 34, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0035', 'TTS_VolcesAiGatewayTTS', 'Jackson', 'en_male_jackson_mars_bigtts', '美式英语', NULL, NULL, 35, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0036', 'TTS_VolcesAiGatewayTTS', '京腔侃爷/Harmony', 'zh_male_jingqiangkanye_moon_bigtts', '中文-北京口音、英文', NULL, NULL, 36, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0037', 'TTS_VolcesAiGatewayTTS', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文-台湾口音', NULL, NULL, 37, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0038', 'TTS_VolcesAiGatewayTTS', '湾区大叔', 'zh_female_wanqudashu_moon_bigtts', '中文-广东口音', NULL, NULL, 38, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0039', 'TTS_VolcesAiGatewayTTS', '呆萌川妹', 'zh_female_daimengchuanmei_moon_bigtts', '中文-四川口音', NULL, NULL, 39, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0040', 'TTS_VolcesAiGatewayTTS', '广州德哥', 'zh_male_guozhoudege_moon_bigtts', '中文-广东口音', NULL, NULL, 40, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0041', 'TTS_VolcesAiGatewayTTS', '北京小爷', 'zh_male_beijingxiaoye_moon_bigtts', '中文-北京口音', NULL, NULL, 41, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0042', 'TTS_VolcesAiGatewayTTS', '浩宇小哥', 'zh_male_haoyuxiaoge_moon_bigtts', '中文-青岛口音', NULL, NULL, 42, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0043', 'TTS_VolcesAiGatewayTTS', '广西远舟', 'zh_male_guangxiyuanzhou_moon_bigtts', '中文-广西口音', NULL, NULL, 43, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0044', 'TTS_VolcesAiGatewayTTS', '妹坨洁儿', 'zh_female_meituojieer_moon_bigtts', '中文-长沙口音', NULL, NULL, 44, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0045', 'TTS_VolcesAiGatewayTTS', '豫州子轩', 'zh_male_yuzhouzixuan_moon_bigtts', '中文-河南口音', NULL, NULL, 45, NULL, NULL, NULL, NULL);
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-- 删除server.ip和server.port,改成在xiaozhi-server中配置,方便运行多个websocket
|
|
||||||
delete from `sys_params` where id = 100;
|
|
||||||
delete from `sys_params` where id = 101;
|
|
||||||
|
|
||||||
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (106, 'server.websocket', 'null', 'string', 1, 'websocket地址,多个用;分隔');
|
|
||||||
|
|
||||||
+17
-6
@@ -1,8 +1,17 @@
|
|||||||
-- 本文件用于初始化固件信息表,无需手动执行,在项目启动时会自动执行
|
-- 增加server.ota,用于配置ota地址
|
||||||
-- -------------------------------------------------------
|
|
||||||
-- 初始化固件信息表
|
delete from `sys_params` where id = 100;
|
||||||
drop table if exists `ai_ota`;
|
delete from `sys_params` where id = 101;
|
||||||
CREATE TABLE `ai_ota` (
|
|
||||||
|
delete from `sys_params` where id = 106;
|
||||||
|
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (106, 'server.websocket', 'null', 'string', 1, 'websocket地址,多个用;分隔');
|
||||||
|
|
||||||
|
delete from `sys_params` where id = 107;
|
||||||
|
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (107, 'server.ota', 'null', 'string', 1, 'ota地址');
|
||||||
|
|
||||||
|
|
||||||
|
-- 增加固件信息表
|
||||||
|
CREATE TABLE IF NOT EXISTS `ai_ota` (
|
||||||
`id` varchar(32) NOT NULL COMMENT 'ID',
|
`id` varchar(32) NOT NULL COMMENT 'ID',
|
||||||
`firmware_name` varchar(100) DEFAULT NULL COMMENT '固件名称',
|
`firmware_name` varchar(100) DEFAULT NULL COMMENT '固件名称',
|
||||||
`type` varchar(50) DEFAULT NULL COMMENT '固件类型',
|
`type` varchar(50) DEFAULT NULL COMMENT '固件类型',
|
||||||
@@ -16,4 +25,6 @@ CREATE TABLE `ai_ota` (
|
|||||||
`creator` bigint DEFAULT NULL COMMENT '创建者',
|
`creator` bigint DEFAULT NULL COMMENT '创建者',
|
||||||
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
|
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='固件信息表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='固件信息表';
|
||||||
|
|
||||||
|
update ai_device set auto_update = 1;
|
||||||
@@ -58,13 +58,6 @@ databaseChangeLog:
|
|||||||
- sqlFile:
|
- sqlFile:
|
||||||
encoding: utf8
|
encoding: utf8
|
||||||
path: classpath:db/changelog/202504181536.sql
|
path: classpath:db/changelog/202504181536.sql
|
||||||
- changeSet:
|
|
||||||
id: 202504180758
|
|
||||||
author: jiangkunyin
|
|
||||||
changes:
|
|
||||||
- sqlFile:
|
|
||||||
encoding: utf8
|
|
||||||
path: classpath:db/changelog/202504180758.sql
|
|
||||||
- changeSet:
|
- changeSet:
|
||||||
id: 202504221135
|
id: 202504221135
|
||||||
author: John
|
author: John
|
||||||
@@ -73,9 +66,16 @@ databaseChangeLog:
|
|||||||
encoding: utf8
|
encoding: utf8
|
||||||
path: classpath:db/changelog/202504221135.sql
|
path: classpath:db/changelog/202504221135.sql
|
||||||
- changeSet:
|
- changeSet:
|
||||||
id: 202504221646
|
id: 202504221555
|
||||||
author: John
|
author: John
|
||||||
changes:
|
changes:
|
||||||
- sqlFile:
|
- sqlFile:
|
||||||
encoding: utf8
|
encoding: utf8
|
||||||
path: classpath:db/changelog/202504221646.sql
|
path: classpath:db/changelog/202504221555.sql
|
||||||
|
- changeSet:
|
||||||
|
id: 202504251422
|
||||||
|
author: jiangkunyin
|
||||||
|
changes:
|
||||||
|
- sqlFile:
|
||||||
|
encoding: utf8
|
||||||
|
path: classpath:db/changelog/202504251422.sql
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
VUE_APP_API_BASE_URL=/xiaozhi
|
VUE_APP_API_BASE_URL=/xiaozhi
|
||||||
|
VUE_APP_PUBLIC_PATH=/
|
||||||
# 是否开启CDN
|
# 是否开启CDN
|
||||||
VUE_APP_USE_CDN=false
|
VUE_APP_USE_CDN=false
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.41.0",
|
"core-js": "^3.41.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
"element-ui": "^2.15.14",
|
"element-ui": "^2.15.14",
|
||||||
"flyio": "^0.6.14",
|
"flyio": "^0.6.14",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
|
|||||||
@@ -51,4 +51,20 @@ export default {
|
|||||||
});
|
});
|
||||||
}).send();
|
}).send();
|
||||||
},
|
},
|
||||||
|
enableOtaUpgrade(id, status, callback) {
|
||||||
|
RequestService.sendRequest()
|
||||||
|
.url(`${getServiceUrl()}/device/enableOta/${id}/${status}`)
|
||||||
|
.method('PUT')
|
||||||
|
.success((res) => {
|
||||||
|
RequestService.clearRequestTime()
|
||||||
|
callback(res)
|
||||||
|
})
|
||||||
|
.fail((err) => {
|
||||||
|
console.error('更新OTA状态失败:', err)
|
||||||
|
this.$message.error(err.msg || '更新OTA状态失败')
|
||||||
|
RequestService.reAjaxFun(() => {
|
||||||
|
this.enableOtaUpgrade(id, status, callback)
|
||||||
|
})
|
||||||
|
}).send()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose">
|
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose" @open="handleOpen">
|
||||||
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
|
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
|
||||||
<el-form-item label="固件名称" prop="firmwareName">
|
<el-form-item label="固件名称" prop="firmwareName">
|
||||||
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
|
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="固件类型" prop="type">
|
<el-form-item label="固件类型" prop="type">
|
||||||
<el-select v-model="form.type" placeholder="请选择固件类型" style="width: 100%;" filterable>
|
<el-select v-model="form.type" placeholder="请选择固件类型" style="width: 100%;" filterable :disabled="isTypeDisabled">
|
||||||
<el-option v-for="item in firmwareTypes" :key="item.key" :label="item.name" :value="item.key"></el-option>
|
<el-option v-for="item in firmwareTypes" :key="item.key" :label="item.name" :value="item.key"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -13,11 +13,17 @@
|
|||||||
<el-input v-model="form.version" placeholder="请输入版本号(x.x.x格式)"></el-input>
|
<el-input v-model="form.version" placeholder="请输入版本号(x.x.x格式)"></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="固件文件" prop="firmwarePath">
|
<el-form-item label="固件文件" prop="firmwarePath">
|
||||||
<el-upload class="upload-demo" action="#" :http-request="handleUpload" :before-upload="beforeUpload"
|
<el-upload ref="upload" class="upload-demo" action="#" :http-request="handleUpload"
|
||||||
:accept="'.bin,.apk'" :limit="1" :multiple="false" :auto-upload="true">
|
:before-upload="beforeUpload" :accept="'.bin,.apk'" :limit="1" :multiple="false" :auto-upload="true"
|
||||||
|
:on-remove="handleRemove">
|
||||||
<el-button size="small" type="primary">点击上传</el-button>
|
<el-button size="small" type="primary">点击上传</el-button>
|
||||||
<div slot="tip" class="el-upload__tip">只能上传固件文件(.bin/.apk),且不超过100MB</div>
|
<div slot="tip" class="el-upload__tip">只能上传固件文件(.bin/.apk),且不超过100MB</div>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
|
<el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress"
|
||||||
|
:status="uploadStatus"></el-progress>
|
||||||
|
<div class="hint-text">
|
||||||
|
<span>温馨提示:请上传xiaozhi.bin文件,而不是merged-binary.bin文件</span>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注" prop="remark">
|
<el-form-item label="备注" prop="remark">
|
||||||
<el-input type="textarea" v-model="form.remark" placeholder="请输入备注信息"></el-input>
|
<el-input type="textarea" v-model="form.remark" placeholder="请输入备注信息"></el-input>
|
||||||
@@ -53,6 +59,9 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
firmwareTypes: FIRMWARE_TYPES,
|
firmwareTypes: FIRMWARE_TYPES,
|
||||||
|
uploadProgress: 0,
|
||||||
|
uploadStatus: '',
|
||||||
|
isUploading: false,
|
||||||
rules: {
|
rules: {
|
||||||
firmwareName: [
|
firmwareName: [
|
||||||
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
|
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
|
||||||
@@ -70,6 +79,12 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
isTypeDisabled() {
|
||||||
|
// 如果有id,说明是编辑模式,禁用类型选择
|
||||||
|
return !!this.form.id
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleClose() {
|
handleClose() {
|
||||||
this.$refs.form.clearValidate();
|
this.$refs.form.clearValidate();
|
||||||
@@ -108,16 +123,72 @@ export default {
|
|||||||
},
|
},
|
||||||
handleUpload(options) {
|
handleUpload(options) {
|
||||||
const { file } = options
|
const { file } = options
|
||||||
|
this.uploadProgress = 0
|
||||||
|
this.uploadStatus = ''
|
||||||
|
this.isUploading = true
|
||||||
|
|
||||||
|
// 使用setTimeout实现简单的0-50%过渡
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (this.uploadProgress < 50) { // 只有当进度小于50%时才设置
|
||||||
|
this.uploadProgress = 50
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
Api.ota.uploadFirmware(file, (res) => {
|
Api.ota.uploadFirmware(file, (res) => {
|
||||||
|
clearTimeout(timer) // 清除定时器
|
||||||
res = res.data
|
res = res.data
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
this.form.firmwarePath = res.data
|
this.form.firmwarePath = res.data
|
||||||
this.form.size = file.size
|
this.form.size = file.size
|
||||||
|
this.uploadProgress = 100
|
||||||
|
this.uploadStatus = 'success'
|
||||||
this.$message.success('固件文件上传成功')
|
this.$message.success('固件文件上传成功')
|
||||||
|
// 延迟2秒后隐藏进度条
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isUploading = false
|
||||||
|
}, 2000)
|
||||||
} else {
|
} else {
|
||||||
|
this.uploadStatus = 'exception'
|
||||||
this.$message.error(res.msg || '文件上传失败')
|
this.$message.error(res.msg || '文件上传失败')
|
||||||
|
this.isUploading = false
|
||||||
|
}
|
||||||
|
}, (progressEvent) => {
|
||||||
|
if (progressEvent.total) {
|
||||||
|
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||||
|
// 只有当进度大于50%时才更新
|
||||||
|
if (progress > 50) {
|
||||||
|
this.uploadProgress = progress
|
||||||
|
}
|
||||||
|
// 如果上传完成但还没收到成功响应,保持进度条显示
|
||||||
|
if (progress === 100) {
|
||||||
|
this.uploadStatus = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
handleRemove() {
|
||||||
|
this.form.firmwarePath = ''
|
||||||
|
this.form.size = 0
|
||||||
|
this.uploadProgress = 0
|
||||||
|
this.uploadStatus = ''
|
||||||
|
this.isUploading = false
|
||||||
|
},
|
||||||
|
handleOpen() {
|
||||||
|
// 重置上传相关状态
|
||||||
|
this.uploadProgress = 0
|
||||||
|
this.uploadStatus = ''
|
||||||
|
this.isUploading = false
|
||||||
|
// 重置表单中的文件相关字段
|
||||||
|
if (!this.form.id) { // 只在新增时重置
|
||||||
|
this.form.firmwarePath = ''
|
||||||
|
this.form.size = 0
|
||||||
|
// 重置上传组件
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.upload) {
|
||||||
|
this.$refs.upload.clearFiles()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,4 +204,12 @@ export default {
|
|||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #979db1;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -87,6 +87,7 @@ const routes = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const router = new VueRouter({
|
const router = new VueRouter({
|
||||||
|
base: process.env.VUE_APP_PUBLIC_PATH || '/',
|
||||||
routes
|
routes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,9 @@
|
|||||||
<div class="content-panel">
|
<div class="content-panel">
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<el-card class="device-card" shadow="never">
|
<el-card class="device-card" shadow="never">
|
||||||
<el-table
|
<el-table ref="deviceTable" :data="paginatedDeviceList" class="transparent-table"
|
||||||
ref="deviceTable"
|
:header-cell-class-name="headerCellClassName" v-loading="loading" element-loading-text="拼命加载中"
|
||||||
:data="paginatedDeviceList"
|
element-loading-spinner="el-icon-loading" element-loading-background="rgba(255, 255, 255, 0.7)">
|
||||||
class="transparent-table"
|
|
||||||
:header-cell-class-name="headerCellClassName"
|
|
||||||
v-loading="loading"
|
|
||||||
element-loading-text="拼命加载中"
|
|
||||||
element-loading-spinner="el-icon-loading"
|
|
||||||
element-loading-background="rgba(255, 255, 255, 0.7)">
|
|
||||||
<el-table-column label="选择" align="center" width="120">
|
<el-table-column label="选择" align="center" width="120">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
||||||
@@ -34,7 +28,7 @@
|
|||||||
{{ getFirmwareTypeName(scope.row.model) }}
|
{{ getFirmwareTypeName(scope.row.model) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="固件版本" prop="firmwareVersion" align="center" ></el-table-column>
|
<el-table-column label="固件版本" prop="firmwareVersion" align="center"></el-table-column>
|
||||||
<el-table-column label="Mac地址" prop="macAddress" align="center"></el-table-column>
|
<el-table-column label="Mac地址" prop="macAddress" align="center"></el-table-column>
|
||||||
<el-table-column label="绑定时间" prop="bindTime" align="center"></el-table-column>
|
<el-table-column label="绑定时间" prop="bindTime" align="center"></el-table-column>
|
||||||
<el-table-column label="最近对话" prop="lastConversation" align="center"></el-table-column>
|
<el-table-column label="最近对话" prop="lastConversation" align="center"></el-table-column>
|
||||||
@@ -53,8 +47,8 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="OTA升级" align="center">
|
<el-table-column label="OTA升级" align="center">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-switch v-model="scope.row.otaSwitch" size="mini" active-color="#13ce66"
|
<el-switch v-model="scope.row.otaSwitch" size="mini" active-color="#13ce66" inactive-color="#ff4949"
|
||||||
inactive-color="#ff4949"></el-switch>
|
@change="handleOtaSwitchChange(scope.row)"></el-switch>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" align="center">
|
<el-table-column label="操作" align="center">
|
||||||
@@ -326,6 +320,16 @@ export default {
|
|||||||
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
|
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
|
||||||
return firmwareType ? firmwareType.name : type;
|
return firmwareType ? firmwareType.name : type;
|
||||||
},
|
},
|
||||||
|
handleOtaSwitchChange(row) {
|
||||||
|
Api.device.enableOtaUpgrade(row.device_id, row.otaSwitch ? 1 : 0, ({ data }) => {
|
||||||
|
if (data.code === 0) {
|
||||||
|
this.$message.success(row.otaSwitch ? '已设置成自动升级' : '已关闭自动升级')
|
||||||
|
} else {
|
||||||
|
row.otaSwitch = !row.otaSwitch
|
||||||
|
this.$message.error(data.msg || '操作失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -472,12 +476,13 @@ export default {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
::v-deep .el-card__body {
|
|
||||||
padding: 15px;
|
::v-deep .el-card__body {
|
||||||
display: flex;
|
padding: 15px;
|
||||||
flex-direction: column;
|
display: flex;
|
||||||
flex: 1;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table_bottom {
|
.table_bottom {
|
||||||
@@ -691,5 +696,4 @@ export default {
|
|||||||
::v-deep .el-table::before {
|
::v-deep .el-table::before {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,19 +44,11 @@
|
|||||||
<!-- 右侧内容 -->
|
<!-- 右侧内容 -->
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<el-card class="model-card" shadow="never">
|
<el-card class="model-card" shadow="never">
|
||||||
<el-table
|
<el-table ref="modelTable" style="width: 100%" v-loading="loading" element-loading-text="拼命加载中"
|
||||||
ref="modelTable"
|
element-loading-spinner="el-icon-loading" element-loading-background="rgba(255, 255, 255, 0.7)"
|
||||||
style="width: 100%"
|
:header-cell-style="{ background: 'transparent' }" :data="modelList" class="data-table"
|
||||||
v-loading="loading"
|
header-row-class-name="table-header" :header-cell-class-name="headerCellClassName"
|
||||||
element-loading-text="拼命加载中"
|
@selection-change="handleSelectionChange">
|
||||||
element-loading-spinner="el-icon-loading"
|
|
||||||
element-loading-background="rgba(255, 255, 255, 0.7)"
|
|
||||||
:header-cell-style="{ background: 'transparent' }"
|
|
||||||
:data="modelList"
|
|
||||||
class="data-table"
|
|
||||||
header-row-class-name="table-header"
|
|
||||||
:header-cell-class-name="headerCellClassName"
|
|
||||||
@selection-change="handleSelectionChange">
|
|
||||||
<el-table-column type="selection" width="55" align="center"></el-table-column>
|
<el-table-column type="selection" width="55" align="center"></el-table-column>
|
||||||
<el-table-column label="模型ID" prop="id" align="center"></el-table-column>
|
<el-table-column label="模型ID" prop="id" align="center"></el-table-column>
|
||||||
<el-table-column label="模型名称" prop="modelName" align="center"></el-table-column>
|
<el-table-column label="模型名称" prop="modelName" align="center"></el-table-column>
|
||||||
@@ -136,6 +128,9 @@
|
|||||||
<TtsModel :visible.sync="ttsDialogVisible" :ttsModelId="selectedTtsModelId" />
|
<TtsModel :visible.sync="ttsDialogVisible" :ttsModelId="selectedTtsModelId" />
|
||||||
<AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" />
|
<AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" />
|
||||||
</div>
|
</div>
|
||||||
|
<el-footer>
|
||||||
|
<version-footer />
|
||||||
|
</el-footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -145,9 +140,9 @@ import AddModelDialog from "@/components/AddModelDialog.vue";
|
|||||||
import HeaderBar from "@/components/HeaderBar.vue";
|
import HeaderBar from "@/components/HeaderBar.vue";
|
||||||
import ModelEditDialog from "@/components/ModelEditDialog.vue";
|
import ModelEditDialog from "@/components/ModelEditDialog.vue";
|
||||||
import TtsModel from "@/components/TtsModel.vue";
|
import TtsModel from "@/components/TtsModel.vue";
|
||||||
|
import VersionFooter from "@/components/VersionFooter.vue";
|
||||||
export default {
|
export default {
|
||||||
components: { HeaderBar, ModelEditDialog, TtsModel, AddModelDialog },
|
components: { HeaderBar, ModelEditDialog, TtsModel, AddModelDialog, VersionFooter },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
addDialogVisible: false,
|
addDialogVisible: false,
|
||||||
@@ -520,8 +515,9 @@ export default {
|
|||||||
|
|
||||||
.nav-panel .el-menu-item {
|
.nav-panel .el-menu-item {
|
||||||
height: 50px;
|
height: 50px;
|
||||||
|
background: #e9f0ff;
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
border-radius: 4px;
|
border-radius: 4px 0 0 4px !important;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -532,8 +528,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-panel .el-menu-item.is-active {
|
.nav-panel .el-menu-item.is-active {
|
||||||
background: #e9f0ff;
|
background: #5778ff;
|
||||||
color: #0ba6f4 !important;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 40px !important;
|
padding-left: 40px !important;
|
||||||
}
|
}
|
||||||
@@ -546,7 +541,7 @@ export default {
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 13px;
|
width: 13px;
|
||||||
height: 13px;
|
height: 13px;
|
||||||
background: #409EFF;
|
background: #fff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 4px rgba(64, 158, 255, 0.5);
|
box-shadow: 0 0 4px rgba(64, 158, 255, 0.5);
|
||||||
}
|
}
|
||||||
@@ -758,7 +753,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::v-deep .nav-panel .el-menu-item.is-active .menu-text {
|
::v-deep .nav-panel .el-menu-item.is-active .menu-text {
|
||||||
color: #409EFF !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep .data-table {
|
::v-deep .data-table {
|
||||||
@@ -929,17 +924,19 @@ export default {
|
|||||||
background-color: rgba(255, 255, 255, 0.6) !important;
|
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep .el-loading-spinner .circular {
|
::v-deep .el-loading-spinner .circular {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep .el-loading-spinner .path {
|
::v-deep .el-loading-spinner .path {
|
||||||
stroke: #6b8cff;
|
stroke: #6b8cff;
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep .el-loading-text {
|
::v-deep .el-loading-text {
|
||||||
color: #6b8cff !important;
|
color: #6b8cff !important;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
<div class="content-panel">
|
<div class="content-panel">
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<el-card class="params-card" shadow="never">
|
<el-card class="params-card" shadow="never">
|
||||||
<el-table ref="paramsTable" :data="paramsList" class="transparent-table"
|
<el-table ref="paramsTable" :data="paramsList" class="transparent-table" v-loading="loading"
|
||||||
|
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
|
||||||
|
element-loading-background="rgba(255, 255, 255, 0.7)"
|
||||||
:header-cell-class-name="headerCellClassName">
|
:header-cell-class-name="headerCellClassName">
|
||||||
<el-table-column label="选择" align="center" width="120">
|
<el-table-column label="选择" align="center" width="120">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
@@ -41,6 +43,11 @@
|
|||||||
{{ formatDate(scope.row.createDate) }}
|
{{ formatDate(scope.row.createDate) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="更新时间" prop="updateDate" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
{{ formatDate(scope.row.updateDate) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" align="center">
|
<el-table-column label="操作" align="center">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-button size="mini" type="text"
|
<el-button size="mini" type="text"
|
||||||
@@ -91,7 +98,9 @@
|
|||||||
<!-- 新增/编辑固件对话框 -->
|
<!-- 新增/编辑固件对话框 -->
|
||||||
<firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm" @submit="handleSubmit"
|
<firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm" @submit="handleSubmit"
|
||||||
@cancel="dialogVisible = false" />
|
@cancel="dialogVisible = false" />
|
||||||
|
<el-footer>
|
||||||
|
<version-footer />
|
||||||
|
</el-footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -99,14 +108,16 @@
|
|||||||
import Api from "@/apis/api";
|
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 { FIRMWARE_TYPES } from "@/utils";
|
import { FIRMWARE_TYPES } from "@/utils";
|
||||||
import { formatDate, formatFileSize } from "@/utils/format";
|
import { formatDate, formatFileSize } from "@/utils/format";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { HeaderBar, FirmwareDialog },
|
components: { HeaderBar, FirmwareDialog, VersionFooter },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
searchName: "",
|
searchName: "",
|
||||||
|
loading: false,
|
||||||
paramsList: [],
|
paramsList: [],
|
||||||
firmwareList: [],
|
firmwareList: [],
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
@@ -158,6 +169,7 @@ export default {
|
|||||||
this.fetchFirmwareList();
|
this.fetchFirmwareList();
|
||||||
},
|
},
|
||||||
fetchFirmwareList() {
|
fetchFirmwareList() {
|
||||||
|
this.loading = true;
|
||||||
const params = {
|
const params = {
|
||||||
pageNum: this.currentPage,
|
pageNum: this.currentPage,
|
||||||
pageSize: this.pageSize,
|
pageSize: this.pageSize,
|
||||||
@@ -166,6 +178,7 @@ export default {
|
|||||||
order: "desc"
|
order: "desc"
|
||||||
};
|
};
|
||||||
Api.ota.getOtaList(params, (res) => {
|
Api.ota.getOtaList(params, (res) => {
|
||||||
|
this.loading = false;
|
||||||
res = res.data
|
res = res.data
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
this.firmwareList = res.data.list.map(item => ({
|
this.firmwareList = res.data.list.map(item => ({
|
||||||
@@ -460,12 +473,20 @@ export default {
|
|||||||
|
|
||||||
.params-card {
|
.params-card {
|
||||||
background: white;
|
background: white;
|
||||||
border: none;
|
flex: 1;
|
||||||
box-shadow: none;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
::v-deep .el-card__body {
|
||||||
|
padding: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table_bottom {
|
.table_bottom {
|
||||||
@@ -513,7 +534,7 @@ export default {
|
|||||||
.custom-pagination {
|
.custom-pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 5px;
|
||||||
|
|
||||||
.el-select {
|
.el-select {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
@@ -580,21 +601,67 @@ export default {
|
|||||||
|
|
||||||
.page-size-select {
|
.page-size-select {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
}
|
margin-right: 10px;
|
||||||
|
|
||||||
.custom-selection-header {
|
:deep(.el-input__inner) {
|
||||||
text-align: center !important;
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
background: #dee7ff;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__suffix) {
|
||||||
|
right: 6px;
|
||||||
|
width: 15px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
top: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__suffix-inner) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-icon-arrow-up:before) {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-top: 9px solid #606266;
|
||||||
|
position: relative;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.transparent-table) {
|
:deep(.transparent-table) {
|
||||||
background-color: transparent;
|
background: white;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.el-table__body-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.el-table__header-wrapper {
|
.el-table__header-wrapper {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-table__header th {
|
.el-table__header th {
|
||||||
background-color: white !important;
|
background: white !important;
|
||||||
color: black;
|
color: black;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -603,10 +670,12 @@ export default {
|
|||||||
border-bottom: 1px solid #e4e7ed;
|
border-bottom: 1px solid #e4e7ed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-table__body-wrapper {
|
.el-table__body tr {
|
||||||
|
background-color: white;
|
||||||
|
|
||||||
td {
|
td {
|
||||||
background-color: transparent;
|
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
border-bottom: 1px solid #e4e7ed;
|
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
color: #606266;
|
color: #606266;
|
||||||
@@ -617,6 +686,10 @@ export default {
|
|||||||
.el-table__row:hover>td {
|
.el-table__row:hover>td {
|
||||||
background-color: #f5f7fa !important;
|
background-color: #f5f7fa !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-table .el-button--text) {
|
:deep(.el-table .el-button--text) {
|
||||||
@@ -640,4 +713,44 @@ export default {
|
|||||||
background-color: #5f70f3 !important;
|
background-color: #5f70f3 !important;
|
||||||
border-color: #5f70f3 !important;
|
border-color: #5f70f3 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.el-loading-mask) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-loading-spinner .path) {
|
||||||
|
stroke: #6b8cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
--table-max-height: calc(100vh - 40vh);
|
||||||
|
max-height: var(--table-max-height);
|
||||||
|
|
||||||
|
.el-table__body-wrapper {
|
||||||
|
max-height: calc(var(--table-max-height) - 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1144px) {
|
||||||
|
.table_bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.transparent-table) {
|
||||||
|
.el-table__body tr {
|
||||||
|
td {
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&+tr {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,15 +15,10 @@
|
|||||||
<div class="content-panel">
|
<div class="content-panel">
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<el-card class="params-card" shadow="never">
|
<el-card class="params-card" shadow="never">
|
||||||
<el-table
|
<el-table ref="paramsTable" :data="paramsList" class="transparent-table" v-loading="loading"
|
||||||
ref="paramsTable"
|
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
|
||||||
:data="paramsList"
|
element-loading-background="rgba(255, 255, 255, 0.7)"
|
||||||
class="transparent-table"
|
:header-cell-class-name="headerCellClassName">
|
||||||
v-loading="loading"
|
|
||||||
element-loading-text="拼命加载中"
|
|
||||||
element-loading-spinner="el-icon-loading"
|
|
||||||
element-loading-background="rgba(255, 255, 255, 0.7)"
|
|
||||||
:header-cell-class-name="headerCellClassName">
|
|
||||||
<el-table-column label="选择" align="center" width="120">
|
<el-table-column label="选择" align="center" width="120">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
||||||
@@ -48,14 +43,11 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
|
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
|
||||||
<el-button size="mini" type="danger" icon="el-icon-delete"
|
<el-button size="mini" type="danger" icon="el-icon-delete"
|
||||||
@click="deleteSelectedParams">删除</el-button>
|
@click="deleteSelectedParams">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="custom-pagination">
|
<div class="custom-pagination">
|
||||||
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
||||||
<el-option
|
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
|
||||||
v-for="item in pageSizeOptions"
|
|
||||||
:key="item"
|
|
||||||
:label="`${item}条/页`"
|
|
||||||
:value="item">
|
:value="item">
|
||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -83,7 +75,9 @@
|
|||||||
<!-- 新增/编辑参数对话框 -->
|
<!-- 新增/编辑参数对话框 -->
|
||||||
<param-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="paramForm" @submit="handleSubmit"
|
<param-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="paramForm" @submit="handleSubmit"
|
||||||
@cancel="dialogVisible = false" />
|
@cancel="dialogVisible = false" />
|
||||||
|
<el-footer>
|
||||||
|
<version-footer />
|
||||||
|
</el-footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -91,9 +85,9 @@
|
|||||||
import Api from "@/apis/api";
|
import Api from "@/apis/api";
|
||||||
import HeaderBar from "@/components/HeaderBar.vue";
|
import HeaderBar from "@/components/HeaderBar.vue";
|
||||||
import ParamDialog from "@/components/ParamDialog.vue";
|
import ParamDialog from "@/components/ParamDialog.vue";
|
||||||
|
import VersionFooter from "@/components/VersionFooter.vue";
|
||||||
export default {
|
export default {
|
||||||
components: { HeaderBar, ParamDialog },
|
components: { HeaderBar, ParamDialog, VersionFooter },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
searchCode: "",
|
searchCode: "",
|
||||||
@@ -146,29 +140,29 @@ export default {
|
|||||||
this.fetchParams();
|
this.fetchParams();
|
||||||
},
|
},
|
||||||
fetchParams() {
|
fetchParams() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
Api.admin.getParamsList(
|
Api.admin.getParamsList(
|
||||||
{
|
{
|
||||||
page: this.currentPage,
|
page: this.currentPage,
|
||||||
limit: this.pageSize,
|
limit: this.pageSize,
|
||||||
paramCode: this.searchCode,
|
paramCode: this.searchCode,
|
||||||
},
|
},
|
||||||
({ data }) => {
|
({ data }) => {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
this.paramsList = data.data.list.map(item => ({
|
this.paramsList = data.data.list.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
selected: false
|
selected: false
|
||||||
}));
|
}));
|
||||||
this.total = data.data.total;
|
this.total = data.data.total;
|
||||||
} else {
|
} else {
|
||||||
this.$message.error({
|
this.$message.error({
|
||||||
message: data.msg || '获取参数列表失败',
|
message: data.msg || '获取参数列表失败',
|
||||||
showClose: true
|
showClose: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
handleSearch() {
|
handleSearch() {
|
||||||
this.currentPage = 1;
|
this.currentPage = 1;
|
||||||
@@ -197,33 +191,33 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleSubmit({ form, done }) {
|
handleSubmit({ form, done }) {
|
||||||
if (form.id) {
|
if (form.id) {
|
||||||
// 编辑
|
// 编辑
|
||||||
Api.admin.updateParam(form, ({ data }) => {
|
Api.admin.updateParam(form, ({ data }) => {
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
this.$message.success({
|
this.$message.success({
|
||||||
message:"修改成功",
|
message: "修改成功",
|
||||||
showClose:true
|
showClose: true
|
||||||
|
});
|
||||||
|
this.dialogVisible = false;
|
||||||
|
this.fetchParams();
|
||||||
|
}
|
||||||
|
done && done();
|
||||||
});
|
});
|
||||||
this.dialogVisible = false;
|
} else {
|
||||||
this.fetchParams();
|
// 新增
|
||||||
}
|
Api.admin.addParam(form, ({ data }) => {
|
||||||
done && done();
|
if (data.code === 0) {
|
||||||
});
|
this.$message.success({
|
||||||
} else {
|
message: "新增成功",
|
||||||
// 新增
|
showClose: true
|
||||||
Api.admin.addParam(form, ({ data }) => {
|
});
|
||||||
if (data.code === 0) {
|
this.dialogVisible = false;
|
||||||
this.$message.success({
|
this.fetchParams();
|
||||||
message:"新增成功",
|
}
|
||||||
showClose:true
|
done && done();
|
||||||
});
|
});
|
||||||
this.dialogVisible = false;
|
}
|
||||||
this.fetchParams();
|
|
||||||
}
|
|
||||||
done && done();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteSelectedParams() {
|
deleteSelectedParams() {
|
||||||
@@ -243,8 +237,8 @@ export default {
|
|||||||
|
|
||||||
if (Array.isArray(row) && row.length === 0) {
|
if (Array.isArray(row) && row.length === 0) {
|
||||||
this.$message.warning({
|
this.$message.warning({
|
||||||
message:"请先选择需要删除的参数",
|
message: "请先选择需要删除的参数",
|
||||||
showClose:true
|
showClose: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -259,8 +253,8 @@ export default {
|
|||||||
const ids = params.map(param => param.id);
|
const ids = params.map(param => param.id);
|
||||||
if (ids.some(id => isNaN(id))) {
|
if (ids.some(id => isNaN(id))) {
|
||||||
this.$message.error({
|
this.$message.error({
|
||||||
message:'存在无效的参数ID',
|
message: '存在无效的参数ID',
|
||||||
showClose: true
|
showClose: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -341,16 +335,16 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-wrapper {
|
.main-wrapper {
|
||||||
margin: 5px 22px;
|
margin: 5px 22px;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
min-height: calc(100vh - 24vh);
|
min-height: calc(100vh - 24vh);
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
background: rgba(237, 242, 255, 0.5);
|
background: rgba(237, 242, 255, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.operation-bar {
|
.operation-bar {
|
||||||
@@ -402,21 +396,21 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.params-card {
|
.params-card {
|
||||||
background: white;
|
background: white;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
::v-deep .el-card__body {
|
|
||||||
padding: 15px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
|
||||||
|
::v-deep .el-card__body {
|
||||||
|
padding: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table_bottom {
|
.table_bottom {
|
||||||
@@ -467,7 +461,7 @@ export default {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
.el-select {
|
.el-select {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-btn:first-child,
|
.pagination-btn:first-child,
|
||||||
@@ -535,15 +529,17 @@ export default {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.el-table__body-wrapper {
|
.el-table__body-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: none !important;
|
max-height: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-table__header-wrapper {
|
.el-table__header-wrapper {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-table__header th {
|
.el-table__header th {
|
||||||
background: white !important;
|
background: white !important;
|
||||||
color: black;
|
color: black;
|
||||||
@@ -670,32 +666,31 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.el-table {
|
.el-table {
|
||||||
--table-max-height: calc(100vh - 40vh);
|
--table-max-height: calc(100vh - 40vh);
|
||||||
max-height: var(--table-max-height);
|
max-height: var(--table-max-height);
|
||||||
|
|
||||||
.el-table__body-wrapper {
|
.el-table__body-wrapper {
|
||||||
max-height: calc(var(--table-max-height) - 40px);
|
max-height: calc(var(--table-max-height) - 40px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-loading-mask) {
|
:deep(.el-loading-mask) {
|
||||||
background-color: rgba(255, 255, 255, 0.6) !important;
|
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-loading-spinner .circular) {
|
:deep(.el-loading-spinner .circular) {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-loading-spinner .path) {
|
:deep(.el-loading-spinner .path) {
|
||||||
stroke: #6b8cff;
|
stroke: #6b8cff;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-loading-text) {
|
:deep(.el-loading-text) {
|
||||||
color: #6b8cff !important;
|
color: #6b8cff !important;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,18 +15,13 @@
|
|||||||
<div class="content-panel">
|
<div class="content-panel">
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<el-card class="user-card" shadow="never">
|
<el-card class="user-card" shadow="never">
|
||||||
<el-table
|
<el-table ref="userTable" :data="userList" class="transparent-table" v-loading="loading"
|
||||||
ref="userTable"
|
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
|
||||||
:data="userList"
|
element-loading-background="rgba(255, 255, 255, 0.7)">
|
||||||
class="transparent-table"
|
|
||||||
v-loading="loading"
|
|
||||||
element-loading-text="拼命加载中"
|
|
||||||
element-loading-spinner="el-icon-loading"
|
|
||||||
element-loading-background="rgba(255, 255, 255, 0.7)">
|
|
||||||
<el-table-column label="选择" align="center" width="120">
|
<el-table-column label="选择" align="center" width="120">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
<el-checkbox v-model="scope.row.selected"></el-checkbox>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
|
<el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
|
||||||
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
|
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
|
||||||
@@ -61,11 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="custom-pagination">
|
<div class="custom-pagination">
|
||||||
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
|
||||||
<el-option
|
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
|
||||||
v-for="item in pageSizeOptions"
|
|
||||||
:key="item"
|
|
||||||
:label="`${item}条/页`"
|
|
||||||
:value="item">
|
|
||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|
||||||
@@ -90,16 +81,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<view-password-dialog :visible.sync="showViewPassword" :password="currentPassword" />
|
<view-password-dialog :visible.sync="showViewPassword" :password="currentPassword" />
|
||||||
|
<el-footer>
|
||||||
|
<version-footer />
|
||||||
|
</el-footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Api from "@/apis/api";
|
import Api from "@/apis/api";
|
||||||
import HeaderBar from "@/components/HeaderBar.vue";
|
import HeaderBar from "@/components/HeaderBar.vue";
|
||||||
|
import VersionFooter from "@/components/VersionFooter.vue";
|
||||||
import ViewPasswordDialog from "@/components/ViewPasswordDialog.vue";
|
import ViewPasswordDialog from "@/components/ViewPasswordDialog.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { HeaderBar, ViewPasswordDialog },
|
components: { HeaderBar, ViewPasswordDialog, VersionFooter },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showViewPassword: false,
|
showViewPassword: false,
|
||||||
@@ -138,7 +132,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handlePageSizeChange(val) {
|
handlePageSizeChange(val) {
|
||||||
this.pageSize = val;
|
this.pageSize = val;
|
||||||
this.currentPage = 1;
|
this.currentPage = 1;
|
||||||
this.fetchUsers();
|
this.fetchUsers();
|
||||||
@@ -169,10 +163,10 @@ export default {
|
|||||||
this.fetchUsers();
|
this.fetchUsers();
|
||||||
},
|
},
|
||||||
handleSelectAll() {
|
handleSelectAll() {
|
||||||
this.isAllSelected = !this.isAllSelected;
|
this.isAllSelected = !this.isAllSelected;
|
||||||
this.userList.forEach(row => {
|
this.userList.forEach(row => {
|
||||||
row.selected = this.isAllSelected;
|
row.selected = this.isAllSelected;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
batchDelete() {
|
batchDelete() {
|
||||||
const selectedUsers = this.userList.filter(user => user.selected);
|
const selectedUsers = this.userList.filter(user => user.selected);
|
||||||
@@ -240,12 +234,12 @@ export default {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
batchEnable() {
|
batchEnable() {
|
||||||
const selectedUsers = this.userList.filter(user => user.selected);
|
const selectedUsers = this.userList.filter(user => user.selected);
|
||||||
this.handleChangeStatus(selectedUsers, 1);
|
this.handleChangeStatus(selectedUsers, 1);
|
||||||
},
|
},
|
||||||
batchDisable() {
|
batchDisable() {
|
||||||
const selectedUsers = this.userList.filter(user => user.selected);
|
const selectedUsers = this.userList.filter(user => user.selected);
|
||||||
this.handleChangeStatus(selectedUsers, 0);
|
this.handleChangeStatus(selectedUsers, 0);
|
||||||
},
|
},
|
||||||
resetPassword(row) {
|
resetPassword(row) {
|
||||||
this.$confirm("重置后将会生成新密码,是否继续?", "提示", {
|
this.$confirm("重置后将会生成新密码,是否继续?", "提示", {
|
||||||
@@ -573,6 +567,7 @@ export default {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.el-table__body-wrapper {
|
.el-table__body-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -648,47 +643,47 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-size-select {
|
.page-size-select {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
|
|
||||||
:deep(.el-input__inner) {
|
:deep(.el-input__inner) {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
line-height: 32px;
|
line-height: 32px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #e4e7ed;
|
border: 1px solid #e4e7ed;
|
||||||
background: #dee7ff;
|
background: #dee7ff;
|
||||||
color: #606266;
|
color: #606266;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-input__suffix) {
|
:deep(.el-input__suffix) {
|
||||||
right: 6px;
|
right: 6px;
|
||||||
width: 15px;
|
width: 15px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-input__suffix-inner) {
|
:deep(.el-input__suffix-inner) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-icon-arrow-up:before) {
|
:deep(.el-icon-arrow-up:before) {
|
||||||
content: "";
|
content: "";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border-left: 6px solid transparent;
|
border-left: 6px solid transparent;
|
||||||
border-right: 6px solid transparent;
|
border-right: 6px solid transparent;
|
||||||
border-top: 9px solid #606266;
|
border-top: 9px solid #606266;
|
||||||
position: relative;
|
position: relative;
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
transition: transform 0.3s;
|
transition: transform 0.3s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-table {
|
.el-table {
|
||||||
@@ -704,17 +699,19 @@ export default {
|
|||||||
background-color: rgba(255, 255, 255, 0.6) !important;
|
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-loading-spinner .circular) {
|
:deep(.el-loading-spinner .circular) {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-loading-spinner .path) {
|
:deep(.el-loading-spinner .path) {
|
||||||
stroke: #6b8cff;
|
stroke: #6b8cff;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-loading-text) {
|
:deep(.el-loading-text) {
|
||||||
color: #6b8cff !important;
|
color: #6b8cff !important;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const cdnResources = {
|
|||||||
const useCDN = process.env.VUE_APP_USE_CDN === 'true';
|
const useCDN = process.env.VUE_APP_USE_CDN === 'true';
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
productionSourceMap: process.env.NODE_ENV === 'production' ? false : true, // 生产环境不生成 source map
|
productionSourceMap: process.env.NODE_ENV !=='production', // 生产环境不生成 source map
|
||||||
devServer: {
|
devServer: {
|
||||||
port: 8001, // 指定端口为 8001
|
port: 8001, // 指定端口为 8001
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -52,6 +52,7 @@ module.exports = defineConfig({
|
|||||||
overlay: false, // 不显示 webpack 错误覆盖层
|
overlay: false, // 不显示 webpack 错误覆盖层
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
publicPath: process.env.VUE_APP_PUBLIC_PATH || "/",
|
||||||
chainWebpack: config => {
|
chainWebpack: config => {
|
||||||
|
|
||||||
// 修改 HTML 插件配置,动态插入 CDN 链接
|
// 修改 HTML 插件配置,动态插入 CDN 链接
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import sys
|
|||||||
import signal
|
import signal
|
||||||
from config.settings import load_config, check_config_file
|
from config.settings import load_config, check_config_file
|
||||||
from core.websocket_server import WebSocketServer
|
from core.websocket_server import WebSocketServer
|
||||||
|
from core.ota_server import SimpleOtaServer
|
||||||
from core.utils.util import check_ffmpeg_installed
|
from core.utils.util import check_ffmpeg_installed
|
||||||
|
from config.logger import setup_logging
|
||||||
|
from core.utils.util import get_local_ip
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
|
logger = setup_logging()
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_exit():
|
async def wait_for_exit():
|
||||||
@@ -34,6 +38,41 @@ async def main():
|
|||||||
# 启动 WebSocket 服务器
|
# 启动 WebSocket 服务器
|
||||||
ws_server = WebSocketServer(config)
|
ws_server = WebSocketServer(config)
|
||||||
ws_task = asyncio.create_task(ws_server.start())
|
ws_task = asyncio.create_task(ws_server.start())
|
||||||
|
ota_task = None
|
||||||
|
|
||||||
|
read_config_from_api = config.get("read_config_from_api", False)
|
||||||
|
if not read_config_from_api:
|
||||||
|
# 启动 Simple OTA 服务器
|
||||||
|
ota_server = SimpleOtaServer(config)
|
||||||
|
ota_task = asyncio.create_task(ota_server.start())
|
||||||
|
|
||||||
|
logger.bind(tag=TAG).info(
|
||||||
|
"OTA接口是\t\thttp://{}:{}/xiaozhi/ota/",
|
||||||
|
get_local_ip(),
|
||||||
|
config["server"]["ota_port"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取WebSocket配置,使用安全的默认值
|
||||||
|
websocket_port = 8000
|
||||||
|
server_config = config.get("server", {})
|
||||||
|
if isinstance(server_config, dict):
|
||||||
|
websocket_port = int(server_config.get("port", 8000))
|
||||||
|
|
||||||
|
logger.bind(tag=TAG).info(
|
||||||
|
"Websocket地址是\tws://{}:{}/xiaozhi/v1/",
|
||||||
|
get_local_ip(),
|
||||||
|
websocket_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.bind(tag=TAG).info(
|
||||||
|
"=======上面的地址是websocket协议地址,请勿用浏览器访问======="
|
||||||
|
)
|
||||||
|
logger.bind(tag=TAG).info(
|
||||||
|
"如想测试websocket请用谷歌浏览器打开test目录下的test_page.html"
|
||||||
|
)
|
||||||
|
logger.bind(tag=TAG).info(
|
||||||
|
"=============================================================\n"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await wait_for_exit() # 监听退出信号
|
await wait_for_exit() # 监听退出信号
|
||||||
@@ -41,8 +80,12 @@ async def main():
|
|||||||
print("任务被取消,清理资源中...")
|
print("任务被取消,清理资源中...")
|
||||||
finally:
|
finally:
|
||||||
ws_task.cancel()
|
ws_task.cancel()
|
||||||
|
if ota_task:
|
||||||
|
ota_task.cancel()
|
||||||
try:
|
try:
|
||||||
await ws_task
|
await ws_task
|
||||||
|
if ota_task:
|
||||||
|
await ota_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
print("服务器已关闭,程序退出。")
|
print("服务器已关闭,程序退出。")
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ server:
|
|||||||
# 服务器监听地址和端口(Server listening address and port)
|
# 服务器监听地址和端口(Server listening address and port)
|
||||||
ip: 0.0.0.0
|
ip: 0.0.0.0
|
||||||
port: 8000
|
port: 8000
|
||||||
|
# OTA接口的端口号
|
||||||
|
ota_port: 8002
|
||||||
|
# 这个websocket配置是指ota接口向设备发送的websocket地址
|
||||||
|
# 如果按默认的写法,ota接口会自动生成websocket地址。这个地址你可以直接用浏览器访问ota接口确认一下
|
||||||
|
# 当你使用docker部署或使用公网部署(使用ssl、域名)时,不一定准确
|
||||||
|
# 所以如果你使用docker部署或使用公网部署时,请设置正确的websocket地址
|
||||||
|
websocket: ws://你的ip或者域名:端口号/xiaozhi/v1/
|
||||||
|
# OTA返回信息时区偏移量
|
||||||
|
timezone_offset: +8
|
||||||
# 认证配置
|
# 认证配置
|
||||||
auth:
|
auth:
|
||||||
# 是否启用认证
|
# 是否启用认证
|
||||||
@@ -314,6 +323,17 @@ LLM:
|
|||||||
bot_id: "你的bot_id"
|
bot_id: "你的bot_id"
|
||||||
user_id: "你的user_id"
|
user_id: "你的user_id"
|
||||||
personal_access_token: 你的coze个人令牌
|
personal_access_token: 你的coze个人令牌
|
||||||
|
VolcesAiGatewayLLM:
|
||||||
|
# 火山引擎 - 边缘大模型网关
|
||||||
|
# 定义LLM API类型
|
||||||
|
type: openai
|
||||||
|
# 先开通服务,打开以下网址,创建网关访问密钥,搜索并勾选 Doubao-pro-32k-functioncall ,开通
|
||||||
|
# 如果需要使用边缘大模型网关提供的语音合成,一并勾选 Doubao-语音合成 ,另见 TTS.VolcesAiGatewayTTS 配置
|
||||||
|
# https://console.volcengine.com/vei/aigateway/
|
||||||
|
# 开通后,进入这里获取密钥:https://console.volcengine.com/vei/aigateway/tokens-list
|
||||||
|
base_url: https://ai-gateway.vei.volces.com/v1
|
||||||
|
model_name: doubao-pro-32k-functioncall
|
||||||
|
api_key: 你的网关访问密钥
|
||||||
LMStudioLLM:
|
LMStudioLLM:
|
||||||
# 定义LLM API类型
|
# 定义LLM API类型
|
||||||
type: openai
|
type: openai
|
||||||
@@ -382,6 +402,20 @@ TTS:
|
|||||||
output_dir: tmp/
|
output_dir: tmp/
|
||||||
access_token: 你的coze web key
|
access_token: 你的coze web key
|
||||||
response_format: wav
|
response_format: wav
|
||||||
|
VolcesAiGatewayTTS:
|
||||||
|
type: openai
|
||||||
|
# 火山引擎 - 边缘大模型网关
|
||||||
|
# 先开通服务,打开以下网址,创建网关访问密钥,搜索并勾选 Doubao-语音合成 ,开通
|
||||||
|
# 如果需要使用边缘大模型网关提供的 LLM,一并勾选 Doubao-pro-32k-functioncall ,另见 LLM.VolcesAiGatewayLLM 配置
|
||||||
|
# https://console.volcengine.com/vei/aigateway/
|
||||||
|
# 开通后,进入这里获取密钥:https://console.volcengine.com/vei/aigateway/tokens-list
|
||||||
|
api_key: 你的网关访问密钥
|
||||||
|
api_url: https://ai-gateway.vei.volces.com/v1/audio/speech
|
||||||
|
model: doubao-tts
|
||||||
|
# 音色列表见 https://www.volcengine.com/docs/6561/1257544
|
||||||
|
voice: zh_male_shaonianzixin_moon_bigtts
|
||||||
|
speed: 1
|
||||||
|
output_dir: tmp/
|
||||||
FishSpeech:
|
FishSpeech:
|
||||||
# 定义TTS API类型
|
# 定义TTS API类型
|
||||||
#启动tts方法:
|
#启动tts方法:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import sys
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from config.config_loader import load_config
|
from config.config_loader import load_config
|
||||||
|
|
||||||
SERVER_VERSION = "0.3.10"
|
SERVER_VERSION = "0.3.12"
|
||||||
|
|
||||||
|
|
||||||
def get_module_abbreviation(module_name, module_dict):
|
def get_module_abbreviation(module_name, module_dict):
|
||||||
|
|||||||
@@ -225,13 +225,6 @@ class ConnectionHandler:
|
|||||||
self._initialize_memory()
|
self._initialize_memory()
|
||||||
"""加载意图识别"""
|
"""加载意图识别"""
|
||||||
self._initialize_intent()
|
self._initialize_intent()
|
||||||
"""加载位置信息"""
|
|
||||||
self.client_ip_info = get_ip_info(self.client_ip, self.logger)
|
|
||||||
if self.client_ip_info is not None and "city" in self.client_ip_info:
|
|
||||||
self.logger.bind(tag=TAG).info(f"Client ip info: {self.client_ip_info}")
|
|
||||||
self.prompt = self.prompt + f"\nuser location:{self.client_ip_info}"
|
|
||||||
|
|
||||||
self.dialogue.update_system_message(self.prompt)
|
|
||||||
|
|
||||||
def _initialize_private_config(self):
|
def _initialize_private_config(self):
|
||||||
read_config_from_api = self.config.get("read_config_from_api", False)
|
read_config_from_api = self.config.get("read_config_from_api", False)
|
||||||
@@ -448,11 +441,16 @@ class ConnectionHandler:
|
|||||||
current_text = full_text[processed_chars:] # 从未处理的位置开始
|
current_text = full_text[processed_chars:] # 从未处理的位置开始
|
||||||
|
|
||||||
# 查找最后一个有效标点
|
# 查找最后一个有效标点
|
||||||
punctuations = ("。", "?", "!", ";", ":")
|
punctuations = ("。", ".", "?", "?", "!", "!", ";", ";", ":")
|
||||||
last_punct_pos = -1
|
last_punct_pos = -1
|
||||||
|
number_flag = True
|
||||||
for punct in punctuations:
|
for punct in punctuations:
|
||||||
pos = current_text.rfind(punct)
|
pos = current_text.rfind(punct)
|
||||||
if pos > last_punct_pos:
|
prev_char = current_text[pos - 1] if pos - 1 >= 0 else ""
|
||||||
|
# 如果.前面是数字统一判断为小数
|
||||||
|
if prev_char.isdigit() and punct == '.':
|
||||||
|
number_flag = False
|
||||||
|
if pos > last_punct_pos and number_flag:
|
||||||
last_punct_pos = pos
|
last_punct_pos = pos
|
||||||
|
|
||||||
# 找到分割点则处理
|
# 找到分割点则处理
|
||||||
@@ -574,11 +572,16 @@ class ConnectionHandler:
|
|||||||
current_text = full_text[processed_chars:] # 从未处理的位置开始
|
current_text = full_text[processed_chars:] # 从未处理的位置开始
|
||||||
|
|
||||||
# 查找最后一个有效标点
|
# 查找最后一个有效标点
|
||||||
punctuations = ("。", "?", "!", ";", ":")
|
punctuations = ("。", ".", "?", "?", "!", "!", ";", ";", ":")
|
||||||
last_punct_pos = -1
|
last_punct_pos = -1
|
||||||
|
number_flag = True
|
||||||
for punct in punctuations:
|
for punct in punctuations:
|
||||||
pos = current_text.rfind(punct)
|
pos = current_text.rfind(punct)
|
||||||
if pos > last_punct_pos:
|
prev_char = current_text[pos - 1] if pos - 1 >= 0 else ""
|
||||||
|
# 如果.前面是数字统一判断为小数
|
||||||
|
if prev_char.isdigit() and punct == '.':
|
||||||
|
number_flag = False
|
||||||
|
if pos > last_punct_pos and number_flag:
|
||||||
last_punct_pos = pos
|
last_punct_pos = pos
|
||||||
|
|
||||||
# 找到分割点则处理
|
# 找到分割点则处理
|
||||||
@@ -885,7 +888,7 @@ class ConnectionHandler:
|
|||||||
self.executor = None
|
self.executor = None
|
||||||
|
|
||||||
# 清空任务队列
|
# 清空任务队列
|
||||||
self._clear_queues()
|
self.clear_queues()
|
||||||
|
|
||||||
if ws:
|
if ws:
|
||||||
await ws.close()
|
await ws.close()
|
||||||
@@ -893,8 +896,11 @@ class ConnectionHandler:
|
|||||||
await self.websocket.close()
|
await self.websocket.close()
|
||||||
self.logger.bind(tag=TAG).info("连接资源已释放")
|
self.logger.bind(tag=TAG).info("连接资源已释放")
|
||||||
|
|
||||||
def _clear_queues(self):
|
def clear_queues(self):
|
||||||
# 清空所有任务队列
|
# 清空所有任务队列
|
||||||
|
self.logger.bind(tag=TAG).debug(
|
||||||
|
f"开始清理: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
|
||||||
|
)
|
||||||
for q in [self.tts_queue, self.audio_play_queue]:
|
for q in [self.tts_queue, self.audio_play_queue]:
|
||||||
if not q:
|
if not q:
|
||||||
continue
|
continue
|
||||||
@@ -906,6 +912,9 @@ class ConnectionHandler:
|
|||||||
q.queue.clear()
|
q.queue.clear()
|
||||||
# 添加毒丸信号到队列,确保线程退出
|
# 添加毒丸信号到队列,确保线程退出
|
||||||
# q.queue.put(None)
|
# q.queue.put(None)
|
||||||
|
self.logger.bind(tag=TAG).debug(
|
||||||
|
f"清理结束: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
|
||||||
|
)
|
||||||
|
|
||||||
def reset_vad_states(self):
|
def reset_vad_states(self):
|
||||||
self.client_audio_buffer = bytearray()
|
self.client_audio_buffer = bytearray()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ async def handleAbortMessage(conn):
|
|||||||
logger.bind(tag=TAG).info("Abort message received")
|
logger.bind(tag=TAG).info("Abort message received")
|
||||||
# 设置成打断状态,会自动打断llm、tts任务
|
# 设置成打断状态,会自动打断llm、tts任务
|
||||||
conn.client_abort = True
|
conn.client_abort = True
|
||||||
|
conn.clear_queues()
|
||||||
# 打断客户端说话状态
|
# 打断客户端说话状态
|
||||||
await conn.websocket.send(json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id}))
|
await conn.websocket.send(json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id}))
|
||||||
conn.clearSpeakStatus()
|
conn.clearSpeakStatus()
|
||||||
|
|||||||
@@ -4,14 +4,49 @@ import asyncio
|
|||||||
import time
|
import time
|
||||||
from core.utils.util import (
|
from core.utils.util import (
|
||||||
get_string_no_punctuation_or_emoji,
|
get_string_no_punctuation_or_emoji,
|
||||||
|
analyze_emotion
|
||||||
)
|
)
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
|
|
||||||
|
emoji_map = {
|
||||||
|
'neutral': '😶',
|
||||||
|
'happy': '🙂',
|
||||||
|
'laughing': '😆',
|
||||||
|
'funny': '😂',
|
||||||
|
'sad': '😔',
|
||||||
|
'angry': '😠',
|
||||||
|
'crying': '😭',
|
||||||
|
'loving': '😍',
|
||||||
|
'embarrassed': '😳',
|
||||||
|
'surprised': '😲',
|
||||||
|
'shocked': '😱',
|
||||||
|
'thinking': '🤔',
|
||||||
|
'winking': '😉',
|
||||||
|
'cool': '😎',
|
||||||
|
'relaxed': '😌',
|
||||||
|
'delicious': '🤤',
|
||||||
|
'kissy': '😘',
|
||||||
|
'confident': '😏',
|
||||||
|
'sleepy': '😴',
|
||||||
|
'silly': '😜',
|
||||||
|
'confused': '🙄'
|
||||||
|
}
|
||||||
|
|
||||||
async def sendAudioMessage(conn, audios, text, text_index=0):
|
async def sendAudioMessage(conn, audios, text, text_index=0):
|
||||||
# 发送句子开始消息
|
# 发送句子开始消息
|
||||||
|
emotion = analyze_emotion(text)
|
||||||
|
emoji = emoji_map.get(emotion, '🙂') # 默认使用笑脸
|
||||||
|
await conn.websocket.send(json.dumps(
|
||||||
|
{
|
||||||
|
"type": "llm",
|
||||||
|
"text": emoji,
|
||||||
|
"emotion": emotion,
|
||||||
|
"session_id": conn.session_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
if text_index == conn.tts_first_text_index:
|
if text_index == conn.tts_first_text_index:
|
||||||
logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
|
logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
|
||||||
await send_tts_message(conn, "sentence_start", text)
|
await send_tts_message(conn, "sentence_start", text)
|
||||||
@@ -86,14 +121,4 @@ async def send_stt_message(conn, text):
|
|||||||
await conn.websocket.send(
|
await conn.websocket.send(
|
||||||
json.dumps({"type": "stt", "text": stt_text, "session_id": conn.session_id})
|
json.dumps({"type": "stt", "text": stt_text, "session_id": conn.session_id})
|
||||||
)
|
)
|
||||||
await conn.websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"type": "llm",
|
|
||||||
"text": "😊",
|
|
||||||
"emotion": "happy",
|
|
||||||
"session_id": conn.session_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await send_tts_message(conn, "start")
|
await send_tts_message(conn, "start")
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
from aiohttp import web
|
||||||
|
from config.logger import setup_logging
|
||||||
|
from core.connection import ConnectionHandler
|
||||||
|
from core.utils.util import get_local_ip, initialize_modules
|
||||||
|
|
||||||
|
TAG = __name__
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleOtaServer:
|
||||||
|
def __init__(self, config: dict):
|
||||||
|
self.config = config
|
||||||
|
self.logger = setup_logging()
|
||||||
|
|
||||||
|
def _get_websocket_url(self, local_ip: str, port: int) -> str:
|
||||||
|
"""获取websocket地址
|
||||||
|
|
||||||
|
Args:
|
||||||
|
local_ip: 本地IP地址
|
||||||
|
port: 端口号
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: websocket地址
|
||||||
|
"""
|
||||||
|
server_config = self.config["server"]
|
||||||
|
websocket_config = server_config.get("websocket")
|
||||||
|
|
||||||
|
if websocket_config and "你" not in websocket_config:
|
||||||
|
return websocket_config
|
||||||
|
else:
|
||||||
|
return f"ws://{local_ip}:{port}/xiaozhi/v1/"
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
server_config = self.config["server"]
|
||||||
|
host = server_config.get("ip", "0.0.0.0")
|
||||||
|
port = int(server_config.get("ota_port"))
|
||||||
|
|
||||||
|
if port:
|
||||||
|
app = web.Application()
|
||||||
|
# 添加路由
|
||||||
|
app.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/xiaozhi/ota/", self._handle_ota_get_request),
|
||||||
|
web.post("/xiaozhi/ota/", self._handle_ota_request),
|
||||||
|
web.options("/xiaozhi/ota/", self._handle_ota_request),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 运行服务
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, host, port)
|
||||||
|
await site.start()
|
||||||
|
|
||||||
|
# 保持服务运行
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600) # 每隔 1 小时检查一次
|
||||||
|
|
||||||
|
async def _handle_ota_request(self, request):
|
||||||
|
"""处理 /xiaozhi/ota/ 的 POST 请求"""
|
||||||
|
try:
|
||||||
|
data = await request.text()
|
||||||
|
self.logger.bind(tag=TAG).debug(f"OTA请求方法: {request.method}")
|
||||||
|
self.logger.bind(tag=TAG).debug(f"OTA请求头: {request.headers}")
|
||||||
|
self.logger.bind(tag=TAG).debug(f"OTA请求数据: {data}")
|
||||||
|
|
||||||
|
device_id = request.headers.get("device-id", "")
|
||||||
|
if device_id:
|
||||||
|
self.logger.bind(tag=TAG).info(f"OTA请求设备ID: {device_id}")
|
||||||
|
else:
|
||||||
|
raise Exception("OTA请求设备ID为空")
|
||||||
|
|
||||||
|
data_json = json.loads(data)
|
||||||
|
|
||||||
|
server_config = self.config["server"]
|
||||||
|
host = server_config.get("ip", "0.0.0.0")
|
||||||
|
port = int(server_config.get("port", 8000))
|
||||||
|
local_ip = get_local_ip()
|
||||||
|
|
||||||
|
# OTA基础信息
|
||||||
|
return_json = {
|
||||||
|
"server_time": {
|
||||||
|
"timestamp": int(round(time.time() * 1000)),
|
||||||
|
"timezone_offset": server_config.get("timezone_offset", 8) * 60,
|
||||||
|
},
|
||||||
|
"firmware": {
|
||||||
|
"version": data_json["application"].get("version", "1.0.0"),
|
||||||
|
"url": "",
|
||||||
|
},
|
||||||
|
"websocket": {
|
||||||
|
"url": self._get_websocket_url(local_ip, port),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response = web.Response(
|
||||||
|
text=json.dumps(return_json, separators=(",", ":")),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.bind(tag=TAG).error(f"OTA请求异常: {e}")
|
||||||
|
return_json = {"success": False, "message": "request error."}
|
||||||
|
response = web.Response(
|
||||||
|
text=json.dumps(return_json, separators=(",", ":")),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# 添加header,允许跨域访问
|
||||||
|
response.headers["Access-Control-Allow-Headers"] = (
|
||||||
|
"client-id, content-type, device-id"
|
||||||
|
)
|
||||||
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def _handle_ota_get_request(self, request):
|
||||||
|
"""处理 /xiaozhi/ota/ 的 GET 请求"""
|
||||||
|
try:
|
||||||
|
server_config = self.config["server"]
|
||||||
|
local_ip = get_local_ip()
|
||||||
|
port = int(server_config.get("port", 8000))
|
||||||
|
websocket_url = self._get_websocket_url(local_ip, port)
|
||||||
|
message = f"OTA接口运行正常,向设备发送的websocket地址是:{websocket_url}"
|
||||||
|
response = web.Response(text=message, content_type="text/plain")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.bind(tag=TAG).error(f"OTA GET请求异常: {e}")
|
||||||
|
response = web.Response(text="OTA接口异常", content_type="text/plain")
|
||||||
|
finally:
|
||||||
|
# 添加header,允许跨域访问
|
||||||
|
response.headers["Access-Control-Allow-Headers"] = (
|
||||||
|
"client-id, content-type, device-id"
|
||||||
|
)
|
||||||
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
return response
|
||||||
@@ -45,14 +45,14 @@ def parse_response(res):
|
|||||||
payload 类似与http 请求体
|
payload 类似与http 请求体
|
||||||
"""
|
"""
|
||||||
protocol_version = res[0] >> 4
|
protocol_version = res[0] >> 4
|
||||||
header_size = res[0] & 0x0f
|
header_size = res[0] & 0x0F
|
||||||
message_type = res[1] >> 4
|
message_type = res[1] >> 4
|
||||||
message_type_specific_flags = res[1] & 0x0f
|
message_type_specific_flags = res[1] & 0x0F
|
||||||
serialization_method = res[2] >> 4
|
serialization_method = res[2] >> 4
|
||||||
message_compression = res[2] & 0x0f
|
message_compression = res[2] & 0x0F
|
||||||
reserved = res[3]
|
reserved = res[3]
|
||||||
header_extensions = res[4:header_size * 4]
|
header_extensions = res[4 : header_size * 4]
|
||||||
payload = res[header_size * 4:]
|
payload = res[header_size * 4 :]
|
||||||
result = {}
|
result = {}
|
||||||
payload_msg = None
|
payload_msg = None
|
||||||
payload_size = 0
|
payload_size = 0
|
||||||
@@ -61,13 +61,13 @@ def parse_response(res):
|
|||||||
payload_msg = payload[4:]
|
payload_msg = payload[4:]
|
||||||
elif message_type == SERVER_ACK:
|
elif message_type == SERVER_ACK:
|
||||||
seq = int.from_bytes(payload[:4], "big", signed=True)
|
seq = int.from_bytes(payload[:4], "big", signed=True)
|
||||||
result['seq'] = seq
|
result["seq"] = seq
|
||||||
if len(payload) >= 8:
|
if len(payload) >= 8:
|
||||||
payload_size = int.from_bytes(payload[4:8], "big", signed=False)
|
payload_size = int.from_bytes(payload[4:8], "big", signed=False)
|
||||||
payload_msg = payload[8:]
|
payload_msg = payload[8:]
|
||||||
elif message_type == SERVER_ERROR_RESPONSE:
|
elif message_type == SERVER_ERROR_RESPONSE:
|
||||||
code = int.from_bytes(payload[:4], "big", signed=False)
|
code = int.from_bytes(payload[:4], "big", signed=False)
|
||||||
result['code'] = code
|
result["code"] = code
|
||||||
payload_size = int.from_bytes(payload[4:8], "big", signed=False)
|
payload_size = int.from_bytes(payload[4:8], "big", signed=False)
|
||||||
payload_msg = payload[8:]
|
payload_msg = payload[8:]
|
||||||
if payload_msg is None:
|
if payload_msg is None:
|
||||||
@@ -78,8 +78,8 @@ def parse_response(res):
|
|||||||
payload_msg = json.loads(str(payload_msg, "utf-8"))
|
payload_msg = json.loads(str(payload_msg, "utf-8"))
|
||||||
elif serialization_method != NO_SERIALIZATION:
|
elif serialization_method != NO_SERIALIZATION:
|
||||||
payload_msg = str(payload_msg, "utf-8")
|
payload_msg = str(payload_msg, "utf-8")
|
||||||
result['payload_msg'] = payload_msg
|
result["payload_msg"] = payload_msg
|
||||||
result['payload_size'] = payload_size
|
result["payload_size"] = payload_size
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -122,7 +122,9 @@ class ASRProvider(ASRProviderBase):
|
|||||||
return file_path
|
return file_path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _generate_header(message_type=CLIENT_FULL_REQUEST, message_type_specific_flags=NO_SEQUENCE) -> bytearray:
|
def _generate_header(
|
||||||
|
message_type=CLIENT_FULL_REQUEST, message_type_specific_flags=NO_SEQUENCE
|
||||||
|
) -> bytearray:
|
||||||
"""Generate protocol header."""
|
"""Generate protocol header."""
|
||||||
header = bytearray()
|
header = bytearray()
|
||||||
header_size = 1
|
header_size = 1
|
||||||
@@ -143,13 +145,9 @@ class ASRProvider(ASRProviderBase):
|
|||||||
"user": {
|
"user": {
|
||||||
"uid": str(uuid.uuid4()),
|
"uid": str(uuid.uuid4()),
|
||||||
},
|
},
|
||||||
"request": {
|
"request": {"reqid": reqid, "show_utterances": False, "sequence": 1},
|
||||||
"reqid": reqid,
|
|
||||||
"show_utterances": False,
|
|
||||||
"sequence": 1
|
|
||||||
},
|
|
||||||
"audio": {
|
"audio": {
|
||||||
"format": "wav",
|
"format": "raw",
|
||||||
"rate": 16000,
|
"rate": 16000,
|
||||||
"language": "zh-CN",
|
"language": "zh-CN",
|
||||||
"bits": 16,
|
"bits": 16,
|
||||||
@@ -158,18 +156,23 @@ class ASRProvider(ASRProviderBase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _send_request(self, audio_data: List[bytes], segment_size: int) -> Optional[str]:
|
async def _send_request(
|
||||||
|
self, audio_data: List[bytes], segment_size: int
|
||||||
|
) -> Optional[str]:
|
||||||
"""Send request to Volcano ASR service."""
|
"""Send request to Volcano ASR service."""
|
||||||
try:
|
try:
|
||||||
auth_header = {'Authorization': 'Bearer; {}'.format(self.access_token)}
|
auth_header = {"Authorization": "Bearer; {}".format(self.access_token)}
|
||||||
async with websockets.connect(self.ws_url, additional_headers=auth_header) as websocket:
|
async with websockets.connect(
|
||||||
|
self.ws_url, additional_headers=auth_header
|
||||||
|
) as websocket:
|
||||||
# Prepare request data
|
# Prepare request data
|
||||||
request_params = self._construct_request(str(uuid.uuid4()))
|
request_params = self._construct_request(str(uuid.uuid4()))
|
||||||
print(request_params)
|
|
||||||
payload_bytes = str.encode(json.dumps(request_params))
|
payload_bytes = str.encode(json.dumps(request_params))
|
||||||
payload_bytes = gzip.compress(payload_bytes)
|
payload_bytes = gzip.compress(payload_bytes)
|
||||||
full_client_request = self._generate_header()
|
full_client_request = self._generate_header()
|
||||||
full_client_request.extend((len(payload_bytes)).to_bytes(4, 'big')) # payload size(4 bytes)
|
full_client_request.extend(
|
||||||
|
(len(payload_bytes)).to_bytes(4, "big")
|
||||||
|
) # payload size(4 bytes)
|
||||||
full_client_request.extend(payload_bytes) # payload
|
full_client_request.extend(payload_bytes) # payload
|
||||||
|
|
||||||
# Send header and metadata
|
# Send header and metadata
|
||||||
@@ -177,22 +180,29 @@ class ASRProvider(ASRProviderBase):
|
|||||||
await websocket.send(full_client_request)
|
await websocket.send(full_client_request)
|
||||||
res = await websocket.recv()
|
res = await websocket.recv()
|
||||||
result = parse_response(res)
|
result = parse_response(res)
|
||||||
if 'payload_msg' in result and result['payload_msg']['code'] != self.success_code:
|
if (
|
||||||
|
"payload_msg" in result
|
||||||
|
and result["payload_msg"]["code"] != self.success_code
|
||||||
|
):
|
||||||
logger.bind(tag=TAG).error(f"ASR error: {result}")
|
logger.bind(tag=TAG).error(f"ASR error: {result}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for seq, (chunk, last) in enumerate(self.slice_data(audio_data, segment_size), 1):
|
for seq, (chunk, last) in enumerate(
|
||||||
|
self.slice_data(audio_data, segment_size), 1
|
||||||
|
):
|
||||||
if last:
|
if last:
|
||||||
audio_only_request = self._generate_header(
|
audio_only_request = self._generate_header(
|
||||||
message_type=CLIENT_AUDIO_ONLY_REQUEST,
|
message_type=CLIENT_AUDIO_ONLY_REQUEST,
|
||||||
message_type_specific_flags=NEG_SEQUENCE
|
message_type_specific_flags=NEG_SEQUENCE,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
audio_only_request = self._generate_header(
|
audio_only_request = self._generate_header(
|
||||||
message_type=CLIENT_AUDIO_ONLY_REQUEST
|
message_type=CLIENT_AUDIO_ONLY_REQUEST
|
||||||
)
|
)
|
||||||
payload_bytes = gzip.compress(chunk)
|
payload_bytes = gzip.compress(chunk)
|
||||||
audio_only_request.extend((len(payload_bytes)).to_bytes(4, 'big')) # payload size(4 bytes)
|
audio_only_request.extend(
|
||||||
|
(len(payload_bytes)).to_bytes(4, "big")
|
||||||
|
) # payload size(4 bytes)
|
||||||
audio_only_request.extend(payload_bytes) # payload
|
audio_only_request.extend(payload_bytes) # payload
|
||||||
# Send audio data
|
# Send audio data
|
||||||
await websocket.send(audio_only_request)
|
await websocket.send(audio_only_request)
|
||||||
@@ -201,9 +211,12 @@ class ASRProvider(ASRProviderBase):
|
|||||||
response = await websocket.recv()
|
response = await websocket.recv()
|
||||||
result = parse_response(response)
|
result = parse_response(response)
|
||||||
|
|
||||||
if 'payload_msg' in result and result['payload_msg']['code'] == self.success_code:
|
if (
|
||||||
if len(result['payload_msg']['result']) > 0:
|
"payload_msg" in result
|
||||||
return result['payload_msg']['result'][0]["text"]
|
and result["payload_msg"]["code"] == self.success_code
|
||||||
|
):
|
||||||
|
if len(result["payload_msg"]["result"]) > 0:
|
||||||
|
return result["payload_msg"]["result"][0]["text"]
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
logger.bind(tag=TAG).error(f"ASR error: {result}")
|
logger.bind(tag=TAG).error(f"ASR error: {result}")
|
||||||
@@ -231,7 +244,7 @@ class ASRProvider(ASRProviderBase):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def read_wav_info(data: io.BytesIO = None) -> (int, int, int, int, int):
|
def read_wav_info(data: io.BytesIO = None) -> (int, int, int, int, int):
|
||||||
with io.BytesIO(data) as _f:
|
with io.BytesIO(data) as _f:
|
||||||
wave_fp = wave.open(_f, 'rb')
|
wave_fp = wave.open(_f, "rb")
|
||||||
nchannels, sampwidth, framerate, nframes = wave_fp.getparams()[:4]
|
nchannels, sampwidth, framerate, nframes = wave_fp.getparams()[:4]
|
||||||
wave_bytes = wave_fp.readframes(nframes)
|
wave_bytes = wave_fp.readframes(nframes)
|
||||||
return nchannels, sampwidth, framerate, nframes, len(wave_bytes)
|
return nchannels, sampwidth, framerate, nframes, len(wave_bytes)
|
||||||
@@ -247,37 +260,32 @@ class ASRProvider(ASRProviderBase):
|
|||||||
data_len = len(data)
|
data_len = len(data)
|
||||||
offset = 0
|
offset = 0
|
||||||
while offset + chunk_size < data_len:
|
while offset + chunk_size < data_len:
|
||||||
yield data[offset: offset + chunk_size], False
|
yield data[offset : offset + chunk_size], False
|
||||||
offset += chunk_size
|
offset += chunk_size
|
||||||
else:
|
else:
|
||||||
yield data[offset: data_len], True
|
yield data[offset:data_len], True
|
||||||
|
|
||||||
async def speech_to_text(self, opus_data: List[bytes], session_id: str) -> Tuple[Optional[str], Optional[str]]:
|
async def speech_to_text(
|
||||||
|
self, opus_data: List[bytes], session_id: str
|
||||||
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
"""将语音数据转换为文本"""
|
"""将语音数据转换为文本"""
|
||||||
try:
|
try:
|
||||||
# 合并所有opus数据包
|
# 合并所有opus数据包
|
||||||
pcm_data = self.decode_opus(opus_data, session_id)
|
pcm_data = self.decode_opus(opus_data, session_id)
|
||||||
combined_pcm_data = b''.join(pcm_data)
|
combined_pcm_data = b"".join(pcm_data)
|
||||||
|
|
||||||
wav_buffer = io.BytesIO()
|
# 直接使用PCM数据
|
||||||
|
# 计算分段大小 (单声道, 16bit, 16kHz采样率)
|
||||||
with wave.open(wav_buffer, "wb") as wav_file:
|
size_per_sec = 1 * 2 * 16000 # nchannels * sampwidth * framerate
|
||||||
wav_file.setnchannels(1) # 设置声道数
|
|
||||||
wav_file.setsampwidth(2) # 设置采样宽度
|
|
||||||
wav_file.setframerate(16000) # 设置采样率
|
|
||||||
wav_file.writeframes(combined_pcm_data) # 写入 PCM 数据
|
|
||||||
|
|
||||||
# 获取封装后的 WAV 数据
|
|
||||||
wav_data = wav_buffer.getvalue()
|
|
||||||
nchannels, sampwidth, framerate, nframes, wav_len = self.read_wav_info(wav_data)
|
|
||||||
size_per_sec = nchannels * sampwidth * framerate
|
|
||||||
segment_size = int(size_per_sec * self.seg_duration / 1000)
|
segment_size = int(size_per_sec * self.seg_duration / 1000)
|
||||||
|
|
||||||
# 语音识别
|
# 语音识别
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
text = await self._send_request(wav_data, segment_size)
|
text = await self._send_request(combined_pcm_data, segment_size)
|
||||||
if text:
|
if text:
|
||||||
logger.bind(tag=TAG).debug(f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}")
|
logger.bind(tag=TAG).debug(
|
||||||
|
f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}"
|
||||||
|
)
|
||||||
return text, None
|
return text, None
|
||||||
return "", None
|
return "", None
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ from config.logger import setup_logging
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from dashscope import Application
|
from dashscope import Application
|
||||||
from core.providers.llm.base import LLMProviderBase
|
from core.providers.llm.base import LLMProviderBase
|
||||||
|
from core.utils.util import check_model_key
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
|
|
||||||
|
|
||||||
class LLMProvider(LLMProviderBase):
|
class LLMProvider(LLMProviderBase):
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.api_key = config["api_key"]
|
self.api_key = config["api_key"]
|
||||||
@@ -13,27 +15,32 @@ class LLMProvider(LLMProviderBase):
|
|||||||
self.base_url = config.get("base_url")
|
self.base_url = config.get("base_url")
|
||||||
self.is_No_prompt = config.get("is_no_prompt")
|
self.is_No_prompt = config.get("is_no_prompt")
|
||||||
self.memory_id = config.get("ali_memory_id")
|
self.memory_id = config.get("ali_memory_id")
|
||||||
|
check_model_key("AliBLLLM", self.api_key)
|
||||||
|
|
||||||
def response(self, session_id, dialogue):
|
def response(self, session_id, dialogue):
|
||||||
try:
|
try:
|
||||||
# 处理dialogue
|
# 处理dialogue
|
||||||
if self.is_No_prompt:
|
if self.is_No_prompt:
|
||||||
dialogue.pop(0)
|
dialogue.pop(0)
|
||||||
logger.bind(tag=TAG).debug(f"【阿里百练API服务】处理后的dialogue: {dialogue}")
|
logger.bind(tag=TAG).debug(
|
||||||
|
f"【阿里百练API服务】处理后的dialogue: {dialogue}"
|
||||||
|
)
|
||||||
|
|
||||||
# 构造调用参数
|
# 构造调用参数
|
||||||
call_params = {
|
call_params = {
|
||||||
"api_key": self.api_key,
|
"api_key": self.api_key,
|
||||||
"app_id": self.app_id,
|
"app_id": self.app_id,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"messages": dialogue
|
"messages": dialogue,
|
||||||
}
|
}
|
||||||
if self.memory_id != False:
|
if self.memory_id != False:
|
||||||
# 百练memory需要prompt参数
|
# 百练memory需要prompt参数
|
||||||
prompt = dialogue[-1].get("content")
|
prompt = dialogue[-1].get("content")
|
||||||
call_params["memory_id"] = self.memory_id
|
call_params["memory_id"] = self.memory_id
|
||||||
call_params["prompt"] = prompt
|
call_params["prompt"] = prompt
|
||||||
logger.bind(tag=TAG).debug(f"【阿里百练API服务】处理后的prompt: {prompt}")
|
logger.bind(tag=TAG).debug(
|
||||||
|
f"【阿里百练API服务】处理后的prompt: {prompt}"
|
||||||
|
)
|
||||||
|
|
||||||
responses = Application.call(**call_params)
|
responses = Application.call(**call_params)
|
||||||
if responses.status_code != HTTPStatus.OK:
|
if responses.status_code != HTTPStatus.OK:
|
||||||
@@ -44,9 +51,15 @@ class LLMProvider(LLMProviderBase):
|
|||||||
)
|
)
|
||||||
yield "【阿里百练API服务响应异常】"
|
yield "【阿里百练API服务响应异常】"
|
||||||
else:
|
else:
|
||||||
logger.bind(tag=TAG).debug(f"【阿里百练API服务】构造参数: {call_params}")
|
logger.bind(tag=TAG).debug(
|
||||||
|
f"【阿里百练API服务】构造参数: {call_params}"
|
||||||
|
)
|
||||||
yield responses.output.text
|
yield responses.output.text
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
|
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
|
||||||
yield "【LLM服务响应异常】"
|
yield "【LLM服务响应异常】"
|
||||||
|
|
||||||
|
def response_with_functions(self, session_id, dialogue, functions=None):
|
||||||
|
logger.bind(tag=TAG).info(f"阿里百练暂未实现完整的工具调用(function call)")
|
||||||
|
return self.response(session_id, dialogue)
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
import requests
|
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from core.providers.llm.base import LLMProviderBase
|
from core.providers.llm.base import LLMProviderBase
|
||||||
import os
|
|
||||||
|
|
||||||
# official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py)
|
# official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py)
|
||||||
from cozepy import COZE_CN_BASE_URL
|
from cozepy import COZE_CN_BASE_URL
|
||||||
from cozepy import Coze, TokenAuth, Message, ChatStatus, MessageContentType, ChatEventType # noqa
|
from cozepy import (
|
||||||
|
Coze,
|
||||||
|
TokenAuth,
|
||||||
|
Message,
|
||||||
|
ChatEventType,
|
||||||
|
) # noqa
|
||||||
from core.providers.llm.system_prompt import get_system_prompt_for_function
|
from core.providers.llm.system_prompt import get_system_prompt_for_function
|
||||||
|
from core.utils.util import check_model_key
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -20,6 +23,7 @@ class LLMProvider(LLMProviderBase):
|
|||||||
self.bot_id = str(config.get("bot_id"))
|
self.bot_id = str(config.get("bot_id"))
|
||||||
self.user_id = str(config.get("user_id"))
|
self.user_id = str(config.get("user_id"))
|
||||||
self.session_conversation_map = {} # 存储session_id和conversation_id的映射
|
self.session_conversation_map = {} # 存储session_id和conversation_id的映射
|
||||||
|
check_model_key("CozeLLM", self.personal_access_token)
|
||||||
|
|
||||||
def response(self, session_id, dialogue):
|
def response(self, session_id, dialogue):
|
||||||
coze_api_token = self.personal_access_token
|
coze_api_token = self.personal_access_token
|
||||||
@@ -59,7 +63,7 @@ class LLMProvider(LLMProviderBase):
|
|||||||
# 如果最后一个是 role="tool",附加到user上
|
# 如果最后一个是 role="tool",附加到user上
|
||||||
if len(dialogue) > 1 and dialogue[-1]["role"] == "tool":
|
if len(dialogue) > 1 and dialogue[-1]["role"] == "tool":
|
||||||
assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n"
|
assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n"
|
||||||
while len(dialogue) > 1 :
|
while len(dialogue) > 1:
|
||||||
if dialogue[-1]["role"] == "user":
|
if dialogue[-1]["role"] == "user":
|
||||||
dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"]
|
dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"]
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from config.logger import setup_logging
|
|||||||
import requests
|
import requests
|
||||||
from core.providers.llm.base import LLMProviderBase
|
from core.providers.llm.base import LLMProviderBase
|
||||||
from core.providers.llm.system_prompt import get_system_prompt_for_function
|
from core.providers.llm.system_prompt import get_system_prompt_for_function
|
||||||
|
from core.utils.util import check_model_key
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -14,6 +15,7 @@ class LLMProvider(LLMProviderBase):
|
|||||||
self.mode = config.get("mode", "chat-messages")
|
self.mode = config.get("mode", "chat-messages")
|
||||||
self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip("/")
|
self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip("/")
|
||||||
self.session_conversation_map = {} # 存储session_id和conversation_id的映射
|
self.session_conversation_map = {} # 存储session_id和conversation_id的映射
|
||||||
|
check_model_key("DifyLLM", self.api_key)
|
||||||
|
|
||||||
def response(self, session_id, dialogue):
|
def response(self, session_id, dialogue):
|
||||||
try:
|
try:
|
||||||
@@ -60,7 +62,9 @@ class LLMProvider(LLMProviderBase):
|
|||||||
conversation_id # 更新映射
|
conversation_id # 更新映射
|
||||||
)
|
)
|
||||||
# 过滤 message_replace 事件,此事件会全量推一次
|
# 过滤 message_replace 事件,此事件会全量推一次
|
||||||
if event.get("event") != "message_replace" and event.get("answer"):
|
if event.get("event") != "message_replace" and event.get(
|
||||||
|
"answer"
|
||||||
|
):
|
||||||
yield event["answer"]
|
yield event["answer"]
|
||||||
elif self.mode == "workflows/run":
|
elif self.mode == "workflows/run":
|
||||||
for line in r.iter_lines():
|
for line in r.iter_lines():
|
||||||
@@ -76,7 +80,9 @@ class LLMProvider(LLMProviderBase):
|
|||||||
if line.startswith(b"data: "):
|
if line.startswith(b"data: "):
|
||||||
event = json.loads(line[6:])
|
event = json.loads(line[6:])
|
||||||
# 过滤 message_replace 事件,此事件会全量推一次
|
# 过滤 message_replace 事件,此事件会全量推一次
|
||||||
if event.get("event") != "message_replace" and event.get("answer"):
|
if event.get("event") != "message_replace" and event.get(
|
||||||
|
"answer"
|
||||||
|
):
|
||||||
yield event["answer"]
|
yield event["answer"]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -94,7 +100,7 @@ class LLMProvider(LLMProviderBase):
|
|||||||
# 如果最后一个是 role="tool",附加到user上
|
# 如果最后一个是 role="tool",附加到user上
|
||||||
if len(dialogue) > 1 and dialogue[-1]["role"] == "tool":
|
if len(dialogue) > 1 and dialogue[-1]["role"] == "tool":
|
||||||
assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n"
|
assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n"
|
||||||
while len(dialogue) > 1 :
|
while len(dialogue) > 1:
|
||||||
if dialogue[-1]["role"] == "user":
|
if dialogue[-1]["role"] == "user":
|
||||||
dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"]
|
dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"]
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
import requests
|
import requests
|
||||||
from core.providers.llm.base import LLMProviderBase
|
from core.providers.llm.base import LLMProviderBase
|
||||||
|
from core.utils.util import check_model_key
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -13,6 +14,7 @@ class LLMProvider(LLMProviderBase):
|
|||||||
self.base_url = config.get("base_url")
|
self.base_url = config.get("base_url")
|
||||||
self.detail = config.get("detail", False)
|
self.detail = config.get("detail", False)
|
||||||
self.variables = config.get("variables", {})
|
self.variables = config.get("variables", {})
|
||||||
|
check_model_key("FastGPTLLM", self.api_key)
|
||||||
|
|
||||||
def response(self, session_id, dialogue):
|
def response(self, session_id, dialogue):
|
||||||
try:
|
try:
|
||||||
@@ -21,37 +23,36 @@ class LLMProvider(LLMProviderBase):
|
|||||||
|
|
||||||
# 发起流式请求
|
# 发起流式请求
|
||||||
with requests.post(
|
with requests.post(
|
||||||
f"{self.base_url}/chat/completions",
|
f"{self.base_url}/chat/completions",
|
||||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||||
json={
|
json={
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"chatId": session_id,
|
"chatId": session_id,
|
||||||
"detail": self.detail,
|
"detail": self.detail,
|
||||||
"variables": self.variables,
|
"variables": self.variables,
|
||||||
"messages": [
|
"messages": [{"role": "user", "content": last_msg["content"]}],
|
||||||
{
|
},
|
||||||
"role": "user",
|
stream=True,
|
||||||
"content": last_msg["content"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
stream=True
|
|
||||||
) as r:
|
) as r:
|
||||||
for line in r.iter_lines():
|
for line in r.iter_lines():
|
||||||
if line:
|
if line:
|
||||||
try:
|
try:
|
||||||
if line.startswith(b'data: '):
|
if line.startswith(b"data: "):
|
||||||
if line[6:].decode('utf-8') == '[DONE]':
|
if line[6:].decode("utf-8") == "[DONE]":
|
||||||
break
|
break
|
||||||
|
|
||||||
data = json.loads(line[6:])
|
data = json.loads(line[6:])
|
||||||
if 'choices' in data and len(data['choices']) > 0:
|
if "choices" in data and len(data["choices"]) > 0:
|
||||||
delta = data['choices'][0].get('delta', {})
|
delta = data["choices"][0].get("delta", {})
|
||||||
if delta and 'content' in delta and delta['content'] is not None:
|
if (
|
||||||
content = delta['content']
|
delta
|
||||||
if '<think>' in content:
|
and "content" in delta
|
||||||
|
and delta["content"] is not None
|
||||||
|
):
|
||||||
|
content = delta["content"]
|
||||||
|
if "<think>" in content:
|
||||||
continue
|
continue
|
||||||
if '</think>' in content:
|
if "</think>" in content:
|
||||||
continue
|
continue
|
||||||
yield content
|
yield content
|
||||||
|
|
||||||
@@ -63,3 +64,7 @@ class LLMProvider(LLMProviderBase):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"Error in response generation: {e}")
|
logger.bind(tag=TAG).error(f"Error in response generation: {e}")
|
||||||
yield "【服务响应异常】"
|
yield "【服务响应异常】"
|
||||||
|
|
||||||
|
def response_with_functions(self, session_id, dialogue, functions=None):
|
||||||
|
logger.bind(tag=TAG).info(f"fastgpt暂未实现完整的工具调用(function call)")
|
||||||
|
return self.response(session_id, dialogue)
|
||||||
|
|||||||
@@ -134,3 +134,7 @@ class LLMProvider(LLMProviderBase):
|
|||||||
yield f"JSON解码错误:{e}"
|
yield f"JSON解码错误:{e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield f"发生错误:{e}"
|
yield f"发生错误:{e}"
|
||||||
|
|
||||||
|
def response_with_functions(self, session_id, dialogue, functions=None):
|
||||||
|
logger.bind(tag=TAG).info(f"gemini暂未实现完整的工具调用(function call)")
|
||||||
|
return self.response(session_id, dialogue)
|
||||||
|
|||||||
@@ -26,13 +26,23 @@ class TTSProviderBase(ABC):
|
|||||||
max_repeat_time = 5
|
max_repeat_time = 5
|
||||||
text = MarkdownCleaner.clean_markdown(text)
|
text = MarkdownCleaner.clean_markdown(text)
|
||||||
while not os.path.exists(tmp_file) and max_repeat_time > 0:
|
while not os.path.exists(tmp_file) and max_repeat_time > 0:
|
||||||
asyncio.run(self.text_to_speak(text, tmp_file))
|
try:
|
||||||
if not os.path.exists(tmp_file):
|
asyncio.run(self.text_to_speak(text, tmp_file))
|
||||||
max_repeat_time = max_repeat_time - 1
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"语音生成失败: {text}:{tmp_file},再试{max_repeat_time}次")
|
logger.bind(tag=TAG).warning(f"语音生成失败{5 - max_repeat_time + 1}次: {text},错误: {e}")
|
||||||
|
# 未执行成功,删除文件
|
||||||
|
if os.path.exists(tmp_file):
|
||||||
|
os.remove(tmp_file)
|
||||||
|
max_repeat_time -= 1
|
||||||
|
|
||||||
if max_repeat_time > 0:
|
if max_repeat_time > 0:
|
||||||
logger.bind(tag=TAG).info(f"语音生成成功: {text}:{tmp_file},重试{5 - max_repeat_time}次")
|
logger.bind(tag=TAG).info(
|
||||||
|
f"语音生成成功: {text}:{tmp_file},重试{5 - max_repeat_time}次"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.bind(tag=TAG).error(
|
||||||
|
f"语音生成失败: {text},请检查网络或服务是否正常"
|
||||||
|
)
|
||||||
|
|
||||||
return tmp_file
|
return tmp_file
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -48,9 +58,11 @@ class TTSProviderBase(ABC):
|
|||||||
# 获取文件后缀名
|
# 获取文件后缀名
|
||||||
file_type = os.path.splitext(audio_file_path)[1]
|
file_type = os.path.splitext(audio_file_path)[1]
|
||||||
if file_type:
|
if file_type:
|
||||||
file_type = file_type.lstrip('.')
|
file_type = file_type.lstrip(".")
|
||||||
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
|
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
|
||||||
audio = AudioSegment.from_file(audio_file_path, format=file_type, parameters=["-nostdin"])
|
audio = AudioSegment.from_file(
|
||||||
|
audio_file_path, format=file_type, parameters=["-nostdin"]
|
||||||
|
)
|
||||||
|
|
||||||
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
|
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
|
||||||
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
|
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
|
||||||
@@ -72,11 +84,11 @@ class TTSProviderBase(ABC):
|
|||||||
# 按帧处理所有音频数据(包括最后一帧可能补零)
|
# 按帧处理所有音频数据(包括最后一帧可能补零)
|
||||||
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
|
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
|
||||||
# 获取当前帧的二进制数据
|
# 获取当前帧的二进制数据
|
||||||
chunk = raw_data[i:i + frame_size * 2]
|
chunk = raw_data[i : i + frame_size * 2]
|
||||||
|
|
||||||
# 如果最后一帧不足,补零
|
# 如果最后一帧不足,补零
|
||||||
if len(chunk) < frame_size * 2:
|
if len(chunk) < frame_size * 2:
|
||||||
chunk += b'\x00' * (frame_size * 2 - len(chunk))
|
chunk += b"\x00" * (frame_size * 2 - len(chunk))
|
||||||
|
|
||||||
# 转换为numpy数组处理
|
# 转换为numpy数组处理
|
||||||
np_frame = np.frombuffer(chunk, dtype=np.int16)
|
np_frame = np.frombuffer(chunk, dtype=np.int16)
|
||||||
|
|||||||
@@ -7,7 +7,29 @@ from typing import Dict, Any
|
|||||||
from core.utils import tts, llm, intent, memory, vad, asr
|
from core.utils import tts, llm, intent, memory, vad, asr
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
|
emoji_map = {
|
||||||
|
'neutral': '😶',
|
||||||
|
'happy': '🙂',
|
||||||
|
'laughing': '😆',
|
||||||
|
'funny': '😂',
|
||||||
|
'sad': '😔',
|
||||||
|
'angry': '😠',
|
||||||
|
'crying': '😭',
|
||||||
|
'loving': '😍',
|
||||||
|
'embarrassed': '😳',
|
||||||
|
'surprised': '😲',
|
||||||
|
'shocked': '😱',
|
||||||
|
'thinking': '🤔',
|
||||||
|
'winking': '😉',
|
||||||
|
'cool': '😎',
|
||||||
|
'relaxed': '😌',
|
||||||
|
'delicious': '🤤',
|
||||||
|
'kissy': '😘',
|
||||||
|
'confident': '😏',
|
||||||
|
'sleepy': '😴',
|
||||||
|
'silly': '😜',
|
||||||
|
'confused': '🙄'
|
||||||
|
}
|
||||||
|
|
||||||
def get_local_ip():
|
def get_local_ip():
|
||||||
try:
|
try:
|
||||||
@@ -90,13 +112,14 @@ def is_punctuation_or_emoji(char):
|
|||||||
punctuation_set = {
|
punctuation_set = {
|
||||||
",",
|
",",
|
||||||
",", # 中文逗号 + 英文逗号
|
",", # 中文逗号 + 英文逗号
|
||||||
"。",
|
|
||||||
".", # 中文句号 + 英文句号
|
|
||||||
"!",
|
|
||||||
"!", # 中文感叹号 + 英文感叹号
|
|
||||||
"-",
|
"-",
|
||||||
"-", # 英文连字符 + 中文全角横线
|
"-", # 英文连字符 + 中文全角横线
|
||||||
"、", # 中文顿号
|
"、", # 中文顿号
|
||||||
|
"“",
|
||||||
|
"”",
|
||||||
|
"\"", # 中文双引号 + 英文引号
|
||||||
|
":",
|
||||||
|
":", # 中文冒号 + 英文冒号
|
||||||
}
|
}
|
||||||
if char.isspace() or char in punctuation_set:
|
if char.isspace() or char in punctuation_set:
|
||||||
return True
|
return True
|
||||||
@@ -159,7 +182,6 @@ def check_model_key(modelType, modelKey):
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
"你还没配置" + modelType + "的密钥,请检查一下所使用的LLM是否配置了密钥"
|
"你还没配置" + modelType + "的密钥,请检查一下所使用的LLM是否配置了密钥"
|
||||||
)
|
)
|
||||||
return False
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -211,7 +233,7 @@ def check_ffmpeg_installed():
|
|||||||
def extract_json_from_string(input_string):
|
def extract_json_from_string(input_string):
|
||||||
"""提取字符串中的 JSON 部分"""
|
"""提取字符串中的 JSON 部分"""
|
||||||
pattern = r"(\{.*\})"
|
pattern = r"(\{.*\})"
|
||||||
match = re.search(pattern, input_string, re.DOTALL) #添加 re.DOTALL
|
match = re.search(pattern, input_string, re.DOTALL) # 添加 re.DOTALL
|
||||||
if match:
|
if match:
|
||||||
return match.group(1) # 返回提取的 JSON 字符串
|
return match.group(1) # 返回提取的 JSON 字符串
|
||||||
return None
|
return None
|
||||||
@@ -330,3 +352,136 @@ def initialize_modules(
|
|||||||
logger.bind(tag=TAG).info(f"初始化组件: prompt成功 {modules['prompt'][:50]}...")
|
logger.bind(tag=TAG).info(f"初始化组件: prompt成功 {modules['prompt'][:50]}...")
|
||||||
|
|
||||||
return modules
|
return modules
|
||||||
|
|
||||||
|
def analyze_emotion(text):
|
||||||
|
"""
|
||||||
|
分析文本情感并返回对应的emoji名称(支持中英文)
|
||||||
|
"""
|
||||||
|
if not text or not isinstance(text, str):
|
||||||
|
return 'neutral'
|
||||||
|
|
||||||
|
original_text = text
|
||||||
|
text = text.lower().strip()
|
||||||
|
|
||||||
|
# 检查是否包含现有emoji
|
||||||
|
for emotion, emoji in emoji_map.items():
|
||||||
|
if emoji in original_text:
|
||||||
|
return emotion
|
||||||
|
|
||||||
|
# 标点符号分析
|
||||||
|
has_exclamation = '!' in original_text or '!' in original_text
|
||||||
|
has_question = '?' in original_text or '?' in original_text
|
||||||
|
has_ellipsis = '...' in original_text or '…' in original_text
|
||||||
|
|
||||||
|
# 定义情感关键词映射(中英文扩展版)
|
||||||
|
emotion_keywords = {
|
||||||
|
'happy': ['开心', '高兴', '快乐', '愉快', '幸福', '满意', '棒', '好', '不错', '完美', '棒极了', '太好了',
|
||||||
|
'好呀', '好的', 'happy', 'joy', 'great', 'good', 'nice', 'awesome', 'fantastic', 'wonderful'],
|
||||||
|
'laughing': ['哈哈', '哈哈哈', '呵呵', '嘿嘿', '嘻嘻', '笑死', '太好笑了', '笑死我了', 'lol', 'lmao', 'haha',
|
||||||
|
'hahaha', 'hehe', 'rofl', 'funny', 'laugh'],
|
||||||
|
'funny': ['搞笑', '滑稽', '逗', '幽默', '笑点', '段子', '笑话', '太逗了', 'hilarious', 'joke', 'comedy'],
|
||||||
|
'sad': ['伤心', '难过', '悲哀', '悲伤', '忧郁', '郁闷', '沮丧', '失望', '想哭', '难受', '不开心', '唉', '呜呜',
|
||||||
|
'sad', 'upset', 'unhappy', 'depressed', 'sorrow', 'gloomy'],
|
||||||
|
'angry': ['生气', '愤怒', '气死', '讨厌', '烦人', '可恶', '烦死了', '恼火', '暴躁', '火大', '愤怒', '气炸了',
|
||||||
|
'angry', 'mad', 'annoyed', 'furious', 'pissed', 'hate'],
|
||||||
|
'crying': ['哭泣', '泪流', '大哭', '伤心欲绝', '泪目', '流泪', '哭死', '哭晕', '想哭', '泪崩',
|
||||||
|
'cry', 'crying', 'tears', 'sob', 'weep'],
|
||||||
|
'loving': ['爱你', '喜欢', '爱', '亲爱的', '宝贝', '么么哒', '抱抱', '想你', '思念', '最爱', '亲亲', '喜欢你',
|
||||||
|
'love', 'like', 'adore', 'darling', 'sweetie', 'honey', 'miss you', 'heart'],
|
||||||
|
'embarrassed': ['尴尬', '不好意思', '害羞', '脸红', '难为情', '社死', '丢脸', '出丑',
|
||||||
|
'embarrassed', 'awkward', 'shy', 'blush'],
|
||||||
|
'surprised': ['惊讶', '吃惊', '天啊', '哇塞', '哇', '居然', '竟然', '没想到', '出乎意料',
|
||||||
|
'surprise', 'wow', 'omg', 'oh my god', 'amazing', 'unbelievable'],
|
||||||
|
'shocked': ['震惊', '吓到', '惊呆了', '不敢相信', '震撼', '吓死', '恐怖', '害怕', '吓人',
|
||||||
|
'shocked', 'shocking', 'scared', 'frightened', 'terrified', 'horror'],
|
||||||
|
'thinking': ['思考', '考虑', '想一下', '琢磨', '沉思', '冥想', '想', '思考中', '在想',
|
||||||
|
'think', 'thinking', 'consider', 'ponder', 'meditate'],
|
||||||
|
'winking': ['调皮', '眨眼', '你懂的', '坏笑', '邪恶', '奸笑', '使眼色',
|
||||||
|
'wink', 'teasing', 'naughty', 'mischievous'],
|
||||||
|
'cool': ['酷', '帅', '厉害', '棒极了', '真棒', '牛逼', '强', '优秀', '杰出', '出色', '完美',
|
||||||
|
'cool', 'awesome', 'amazing', 'great', 'impressive', 'perfect'],
|
||||||
|
'relaxed': ['放松', '舒服', '惬意', '悠闲', '轻松', '舒适', '安逸', '自在',
|
||||||
|
'relax', 'relaxed', 'comfortable', 'cozy', 'chill', 'peaceful'],
|
||||||
|
'delicious': ['好吃', '美味', '香', '馋', '可口', '香甜', '大餐', '大快朵颐', '流口水', '垂涎',
|
||||||
|
'delicious', 'yummy', 'tasty', 'yum', 'appetizing', 'mouthwatering'],
|
||||||
|
'kissy': ['亲亲', '么么', '吻', 'mua', 'muah', '亲一下', '飞吻',
|
||||||
|
'kiss', 'xoxo', 'hug', 'muah', 'smooch'],
|
||||||
|
'confident': ['自信', '肯定', '确定', '毫无疑问', '当然', '必须的', '毫无疑问', '确信', '坚信',
|
||||||
|
'confident', 'sure', 'certain', 'definitely', 'positive'],
|
||||||
|
'sleepy': ['困', '睡觉', '晚安', '想睡', '好累', '疲惫', '疲倦', '困了', '想休息', '睡意',
|
||||||
|
'sleep', 'sleepy', 'tired', 'exhausted', 'bedtime', 'good night'],
|
||||||
|
'silly': ['傻', '笨', '呆', '憨', '蠢', '二', '憨憨', '傻乎乎', '呆萌',
|
||||||
|
'silly', 'stupid', 'dumb', 'foolish', 'goofy', 'ridiculous'],
|
||||||
|
'confused': ['疑惑', '不明白', '不懂', '困惑', '疑问', '为什么', '怎么回事', '啥意思', '不清楚',
|
||||||
|
'confused', 'puzzled', 'doubt', 'question', 'what', 'why', 'how']
|
||||||
|
}
|
||||||
|
|
||||||
|
# 特殊句型判断(中英文)
|
||||||
|
# 赞美他人
|
||||||
|
if any(phrase in text for phrase in
|
||||||
|
['你真', '你好', '您真', '你真棒', '你好厉害', '你太强了', '你真好', '你真聪明',
|
||||||
|
'you are', 'you\'re', 'you look', 'you seem', 'so smart', 'so kind']):
|
||||||
|
return 'loving'
|
||||||
|
# 自我赞美
|
||||||
|
if any(phrase in text for phrase in ['我真', '我最', '我太棒了', '我厉害', '我聪明', '我优秀',
|
||||||
|
'i am', 'i\'m', 'i feel', 'so good', 'so happy']):
|
||||||
|
return 'cool'
|
||||||
|
# 晚安/睡觉相关
|
||||||
|
if any(phrase in text for phrase in ['睡觉', '晚安', '睡了', '好梦', '休息了', '去睡了',
|
||||||
|
'sleep', 'good night', 'bedtime', 'go to bed']):
|
||||||
|
return 'sleepy'
|
||||||
|
# 疑问句
|
||||||
|
if has_question and not has_exclamation:
|
||||||
|
return 'thinking'
|
||||||
|
# 强烈情感(感叹号)
|
||||||
|
if has_exclamation and not has_question:
|
||||||
|
# 检查是否是积极内容
|
||||||
|
positive_words = emotion_keywords['happy'] + emotion_keywords['laughing'] + emotion_keywords['cool']
|
||||||
|
if any(word in text for word in positive_words):
|
||||||
|
return 'laughing'
|
||||||
|
# 检查是否是消极内容
|
||||||
|
negative_words = emotion_keywords['angry'] + emotion_keywords['sad'] + emotion_keywords['crying']
|
||||||
|
if any(word in text for word in negative_words):
|
||||||
|
return 'angry'
|
||||||
|
return 'surprised'
|
||||||
|
# 省略号(表示犹豫或思考)
|
||||||
|
if has_ellipsis:
|
||||||
|
return 'thinking'
|
||||||
|
|
||||||
|
# 关键词匹配(带权重)
|
||||||
|
emotion_scores = {emotion: 0 for emotion in emoji_map.keys()}
|
||||||
|
|
||||||
|
# 给匹配到的关键词加分
|
||||||
|
for emotion, keywords in emotion_keywords.items():
|
||||||
|
for keyword in keywords:
|
||||||
|
if keyword in text:
|
||||||
|
emotion_scores[emotion] += 1
|
||||||
|
|
||||||
|
# 给长文本中的重复关键词额外加分
|
||||||
|
if len(text) > 20: # 长文本
|
||||||
|
for emotion, keywords in emotion_keywords.items():
|
||||||
|
for keyword in keywords:
|
||||||
|
emotion_scores[emotion] += text.count(keyword) * 0.5
|
||||||
|
|
||||||
|
# 根据分数选择最可能的情感
|
||||||
|
max_score = max(emotion_scores.values())
|
||||||
|
if max_score == 0:
|
||||||
|
return 'happy' # 默认
|
||||||
|
|
||||||
|
# 可能有多个情感同分,根据上下文选择最合适的
|
||||||
|
top_emotions = [e for e, s in emotion_scores.items() if s == max_score]
|
||||||
|
|
||||||
|
# 如果多个情感同分,使用以下优先级
|
||||||
|
priority_order = [
|
||||||
|
'laughing', 'crying', 'angry', 'surprised', 'shocked', # 强烈情感优先
|
||||||
|
'loving', 'happy', 'funny', 'cool', # 积极情感
|
||||||
|
'sad', 'embarrassed', 'confused', # 消极情感
|
||||||
|
'thinking', 'winking', 'relaxed', # 中性情感
|
||||||
|
'delicious', 'kissy', 'confident', 'sleepy', 'silly' # 特殊场景
|
||||||
|
]
|
||||||
|
|
||||||
|
for emotion in priority_order:
|
||||||
|
if emotion in top_emotions:
|
||||||
|
return emotion
|
||||||
|
|
||||||
|
return top_emotions[0] # 如果都不在优先级列表里,返回第一个
|
||||||
@@ -27,18 +27,6 @@ class WebSocketServer:
|
|||||||
host = server_config.get("ip", "0.0.0.0")
|
host = server_config.get("ip", "0.0.0.0")
|
||||||
port = int(server_config.get("port", 8000))
|
port = int(server_config.get("port", 8000))
|
||||||
|
|
||||||
self.logger.bind(tag=TAG).info(
|
|
||||||
"Server is running at ws://{}:{}/xiaozhi/v1/", get_local_ip(), port
|
|
||||||
)
|
|
||||||
self.logger.bind(tag=TAG).info(
|
|
||||||
"=======上面的地址是websocket协议地址,请勿用浏览器访问======="
|
|
||||||
)
|
|
||||||
self.logger.bind(tag=TAG).info(
|
|
||||||
"如想测试websocket请用谷歌浏览器打开test目录下的test_page.html"
|
|
||||||
)
|
|
||||||
self.logger.bind(tag=TAG).info(
|
|
||||||
"=============================================================\n"
|
|
||||||
)
|
|
||||||
async with websockets.serve(self._handle_connection, host, port):
|
async with websockets.serve(self._handle_connection, host, port):
|
||||||
await asyncio.Future()
|
await asyncio.Future()
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
# ws服务端
|
# ws服务端
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
# ota服务端
|
||||||
|
- "8002:8002"
|
||||||
volumes:
|
volumes:
|
||||||
# 配置文件目录
|
# 配置文件目录
|
||||||
- ./data:/opt/xiaozhi-esp32-server/data
|
- ./data:/opt/xiaozhi-esp32-server/data
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import requests
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
from plugins_func.register import register_function, ToolType, ActionResponse, Action
|
from plugins_func.register import register_function, ToolType, ActionResponse, Action
|
||||||
|
from core.utils.util import get_ip_info
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -12,55 +13,104 @@ GET_WEATHER_FUNCTION_DESC = {
|
|||||||
"name": "get_weather",
|
"name": "get_weather",
|
||||||
"description": (
|
"description": (
|
||||||
"获取某个地点的天气,用户应提供一个位置,比如用户说杭州天气,参数为:杭州。"
|
"获取某个地点的天气,用户应提供一个位置,比如用户说杭州天气,参数为:杭州。"
|
||||||
"如果用户说的是省份,默认用省会城市。如果用户说的不是省份或城市而是一个地名,"
|
"如果用户说的是省份,默认用省会城市。如果用户说的不是省份或城市而是一个地名,默认用该地所在省份的省会城市。"
|
||||||
"默认用该地所在省份的省会城市。"
|
"如果用户没有指明地点,说“天气怎么样”,”今天天气如何“,location参数为空"
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"location": {
|
"location": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "地点名,例如杭州。可选参数,如果不提供则不传"
|
"description": "地点名,例如杭州。可选参数,如果不提供则不传",
|
||||||
},
|
},
|
||||||
"lang": {
|
"lang": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN"
|
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"required": ["lang"]
|
"required": ["lang"],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
'User-Agent': (
|
"User-Agent": (
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
'(KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36'
|
"(KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
# 天气代码 https://dev.qweather.com/docs/resource/icons/#weather-icons
|
# 天气代码 https://dev.qweather.com/docs/resource/icons/#weather-icons
|
||||||
WEATHER_CODE_MAP = {
|
WEATHER_CODE_MAP = {
|
||||||
"100": "晴", "101": "多云", "102": "少云", "103": "晴间多云", "104": "阴",
|
"100": "晴",
|
||||||
"150": "晴", "151": "多云", "152": "少云", "153": "晴间多云",
|
"101": "多云",
|
||||||
"300": "阵雨", "301": "强阵雨", "302": "雷阵雨", "303": "强雷阵雨", "304": "雷阵雨伴有冰雹",
|
"102": "少云",
|
||||||
"305": "小雨", "306": "中雨", "307": "大雨", "308": "极端降雨", "309": "毛毛雨/细雨",
|
"103": "晴间多云",
|
||||||
"310": "暴雨", "311": "大暴雨", "312": "特大暴雨", "313": "冻雨", "314": "小到中雨",
|
"104": "阴",
|
||||||
"315": "中到大雨", "316": "大到暴雨", "317": "暴雨到大暴雨", "318": "大暴雨到特大暴雨",
|
"150": "晴",
|
||||||
"350": "阵雨", "351": "强阵雨", "399": "雨",
|
"151": "多云",
|
||||||
"400": "小雪", "401": "中雪", "402": "大雪", "403": "暴雪", "404": "雨夹雪",
|
"152": "少云",
|
||||||
"405": "雨雪天气", "406": "阵雨夹雪", "407": "阵雪", "408": "小到中雪", "409": "中到大雪", "410": "大到暴雪",
|
"153": "晴间多云",
|
||||||
"456": "阵雨夹雪", "457": "阵雪", "499": "雪",
|
"300": "阵雨",
|
||||||
"500": "薄雾", "501": "雾", "502": "霾", "503": "扬沙", "504": "浮尘",
|
"301": "强阵雨",
|
||||||
"507": "沙尘暴", "508": "强沙尘暴",
|
"302": "雷阵雨",
|
||||||
"509": "浓雾", "510": "强浓雾", "511": "中度霾", "512": "重度霾", "513": "严重霾", "514": "大雾", "515": "特强浓雾",
|
"303": "强雷阵雨",
|
||||||
"900": "热", "901": "冷", "999": "未知"
|
"304": "雷阵雨伴有冰雹",
|
||||||
|
"305": "小雨",
|
||||||
|
"306": "中雨",
|
||||||
|
"307": "大雨",
|
||||||
|
"308": "极端降雨",
|
||||||
|
"309": "毛毛雨/细雨",
|
||||||
|
"310": "暴雨",
|
||||||
|
"311": "大暴雨",
|
||||||
|
"312": "特大暴雨",
|
||||||
|
"313": "冻雨",
|
||||||
|
"314": "小到中雨",
|
||||||
|
"315": "中到大雨",
|
||||||
|
"316": "大到暴雨",
|
||||||
|
"317": "暴雨到大暴雨",
|
||||||
|
"318": "大暴雨到特大暴雨",
|
||||||
|
"350": "阵雨",
|
||||||
|
"351": "强阵雨",
|
||||||
|
"399": "雨",
|
||||||
|
"400": "小雪",
|
||||||
|
"401": "中雪",
|
||||||
|
"402": "大雪",
|
||||||
|
"403": "暴雪",
|
||||||
|
"404": "雨夹雪",
|
||||||
|
"405": "雨雪天气",
|
||||||
|
"406": "阵雨夹雪",
|
||||||
|
"407": "阵雪",
|
||||||
|
"408": "小到中雪",
|
||||||
|
"409": "中到大雪",
|
||||||
|
"410": "大到暴雪",
|
||||||
|
"456": "阵雨夹雪",
|
||||||
|
"457": "阵雪",
|
||||||
|
"499": "雪",
|
||||||
|
"500": "薄雾",
|
||||||
|
"501": "雾",
|
||||||
|
"502": "霾",
|
||||||
|
"503": "扬沙",
|
||||||
|
"504": "浮尘",
|
||||||
|
"507": "沙尘暴",
|
||||||
|
"508": "强沙尘暴",
|
||||||
|
"509": "浓雾",
|
||||||
|
"510": "强浓雾",
|
||||||
|
"511": "中度霾",
|
||||||
|
"512": "重度霾",
|
||||||
|
"513": "严重霾",
|
||||||
|
"514": "大雾",
|
||||||
|
"515": "特强浓雾",
|
||||||
|
"900": "热",
|
||||||
|
"901": "冷",
|
||||||
|
"999": "未知",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def fetch_city_info(location, api_key):
|
def fetch_city_info(location, api_key):
|
||||||
url = f"https://geoapi.qweather.com/v2/city/lookup?key={api_key}&location={location}&lang=zh"
|
url = f"https://geoapi.qweather.com/v2/city/lookup?key={api_key}&location={location}&lang=zh"
|
||||||
response = requests.get(url, headers=HEADERS).json()
|
response = requests.get(url, headers=HEADERS).json()
|
||||||
return response.get('location', [])[0] if response.get('location') else None
|
return response.get("location", [])[0] if response.get("location") else None
|
||||||
|
|
||||||
|
|
||||||
def fetch_weather_page(url):
|
def fetch_weather_page(url):
|
||||||
@@ -72,10 +122,14 @@ def parse_weather_info(soup):
|
|||||||
city_name = soup.select_one("h1.c-submenu__location").get_text(strip=True)
|
city_name = soup.select_one("h1.c-submenu__location").get_text(strip=True)
|
||||||
|
|
||||||
current_abstract = soup.select_one(".c-city-weather-current .current-abstract")
|
current_abstract = soup.select_one(".c-city-weather-current .current-abstract")
|
||||||
current_abstract = current_abstract.get_text(strip=True) if current_abstract else "未知"
|
current_abstract = (
|
||||||
|
current_abstract.get_text(strip=True) if current_abstract else "未知"
|
||||||
|
)
|
||||||
|
|
||||||
current_basic = {}
|
current_basic = {}
|
||||||
for item in soup.select(".c-city-weather-current .current-basic .current-basic___item"):
|
for item in soup.select(
|
||||||
|
".c-city-weather-current .current-basic .current-basic___item"
|
||||||
|
):
|
||||||
parts = item.get_text(strip=True, separator=" ").split(" ")
|
parts = item.get_text(strip=True, separator=" ").split(" ")
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
key, value = parts[1], parts[0]
|
key, value = parts[1], parts[0]
|
||||||
@@ -84,7 +138,9 @@ def parse_weather_info(soup):
|
|||||||
temps_list = []
|
temps_list = []
|
||||||
for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据
|
for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据
|
||||||
date = row.select_one(".date-bg .date").get_text(strip=True)
|
date = row.select_one(".date-bg .date").get_text(strip=True)
|
||||||
weather_code = row.select_one(".date-bg .icon")["src"].split("/")[-1].split(".")[0]
|
weather_code = (
|
||||||
|
row.select_one(".date-bg .icon")["src"].split("/")[-1].split(".")[0]
|
||||||
|
)
|
||||||
weather = WEATHER_CODE_MAP.get(weather_code, "未知")
|
weather = WEATHER_CODE_MAP.get(weather_code, "未知")
|
||||||
temps = [span.get_text(strip=True) for span in row.select(".tmp-cont .temp")]
|
temps = [span.get_text(strip=True) for span in row.select(".tmp-cont .temp")]
|
||||||
high_temp, low_temp = (temps[0], temps[-1]) if len(temps) >= 2 else (None, None)
|
high_temp, low_temp = (temps[0], temps[-1]) if len(temps) >= 2 else (None, None)
|
||||||
@@ -93,31 +149,47 @@ def parse_weather_info(soup):
|
|||||||
return city_name, current_abstract, current_basic, temps_list
|
return city_name, current_abstract, current_basic, temps_list
|
||||||
|
|
||||||
|
|
||||||
@register_function('get_weather', GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL)
|
@register_function("get_weather", GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL)
|
||||||
def get_weather(conn, location: str = None, lang: str = "zh_CN"):
|
def get_weather(conn, location: str = None, lang: str = "zh_CN"):
|
||||||
api_key = conn.config["plugins"]["get_weather"]["api_key"]
|
api_key = conn.config["plugins"]["get_weather"]["api_key"]
|
||||||
default_location = conn.config["plugins"]["get_weather"]["default_location"]
|
default_location = conn.config["plugins"]["get_weather"]["default_location"]
|
||||||
location = location or conn.client_ip_info.get("city") or default_location
|
client_ip = conn.client_ip
|
||||||
logger.bind(tag=TAG).debug(f"获取天气: {location}")
|
# 优先使用用户提供的location参数
|
||||||
|
if not location:
|
||||||
|
# 通过客户端IP解析城市
|
||||||
|
if client_ip:
|
||||||
|
# 动态解析IP对应的城市信息
|
||||||
|
ip_info = get_ip_info(client_ip, logger)
|
||||||
|
location = ip_info.get("city") if ip_info and "city" in ip_info else None
|
||||||
|
else:
|
||||||
|
# 若IP解析失败或无IP,使用默认位置
|
||||||
|
location = default_location
|
||||||
|
|
||||||
city_info = fetch_city_info(location, api_key)
|
city_info = fetch_city_info(location, api_key)
|
||||||
if not city_info:
|
if not city_info:
|
||||||
return ActionResponse(Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None)
|
return ActionResponse(
|
||||||
|
Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None
|
||||||
soup = fetch_weather_page(city_info['fxLink'])
|
)
|
||||||
|
soup = fetch_weather_page(city_info["fxLink"])
|
||||||
if not soup:
|
if not soup:
|
||||||
return ActionResponse(Action.REQLLM, None, "请求失败")
|
return ActionResponse(Action.REQLLM, None, "请求失败")
|
||||||
|
|
||||||
city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup)
|
city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup)
|
||||||
weather_report = f"根据下列数据,用{lang}回应用户的查询天气请求:\n{city_name}未来7天天气:\n"
|
|
||||||
for i, (date, weather, high, low) in enumerate(temps_list):
|
weather_report = f"您查询的位置是:{city_name}\n\n当前天气: {current_abstract}\n"
|
||||||
if high and low:
|
|
||||||
weather_report += f"{date}: {low}到{high}, {weather}\n"
|
# 添加有效的当前天气参数
|
||||||
weather_report += (
|
if current_basic:
|
||||||
f"当前天气: {current_abstract}\n"
|
weather_report += "详细参数:\n"
|
||||||
f"当前天气参数: {current_basic}\n"
|
for key, value in current_basic.items():
|
||||||
f"(确保只报告指定单日的天气情况,除非未来会出现异常天气;或者用户明确要求想要了解多日天气,如果未指定,默认报告今天的天气。"
|
if value != "0": # 过滤无效值
|
||||||
"参数为0的值不需要报告给用户,每次都报告体感温度,根据语境选择合适的参数内容告知用户,并对参数给出相应评价)"
|
weather_report += f" · {key}: {value}\n"
|
||||||
)
|
|
||||||
|
# 添加7天预报
|
||||||
|
weather_report += "\n未来7天预报:\n"
|
||||||
|
for date, weather, high, low in temps_list:
|
||||||
|
weather_report += f"{date}: {weather},气温 {low}~{high}\n"
|
||||||
|
|
||||||
|
# 提示语
|
||||||
|
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
|
||||||
|
|
||||||
return ActionResponse(Action.REQLLM, weather_report, None)
|
return ActionResponse(Action.REQLLM, weather_report, None)
|
||||||
@@ -13,7 +13,7 @@ def append_devices_to_prompt(conn):
|
|||||||
"functions", []
|
"functions", []
|
||||||
)
|
)
|
||||||
if "hass_get_state" in funcs or "hass_set_state" in funcs:
|
if "hass_get_state" in funcs or "hass_set_state" in funcs:
|
||||||
prompt = "下面是我家智能设备,可以通过homeassistant控制\n"
|
prompt = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n"
|
||||||
devices = conn.config["plugins"]["home_assistant"].get("devices", [])
|
devices = conn.config["plugins"]["home_assistant"].get("devices", [])
|
||||||
if len(devices) == 0:
|
if len(devices) == 0:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ class ToolType(Enum):
|
|||||||
NONE = (1, "调用完工具后,不做其他操作")
|
NONE = (1, "调用完工具后,不做其他操作")
|
||||||
WAIT = (2, "调用工具,等待函数返回")
|
WAIT = (2, "调用工具,等待函数返回")
|
||||||
CHANGE_SYS_PROMPT = (3, "修改系统提示词,切换角色性格或职责")
|
CHANGE_SYS_PROMPT = (3, "修改系统提示词,切换角色性格或职责")
|
||||||
SYSTEM_CTL = (4, "系统控制,影响正常的对话流程,如退出、播放音乐等,需要传递conn参数")
|
SYSTEM_CTL = (
|
||||||
|
4,
|
||||||
|
"系统控制,影响正常的对话流程,如退出、播放音乐等,需要传递conn参数",
|
||||||
|
)
|
||||||
IOT_CTL = (5, "IOT设备控制,需要传递conn参数")
|
IOT_CTL = (5, "IOT设备控制,需要传递conn参数")
|
||||||
MCP_CLIENT = (6, "MCP客户端")
|
MCP_CLIENT = (6, "MCP客户端")
|
||||||
|
|
||||||
@@ -30,12 +33,14 @@ class Action(Enum):
|
|||||||
self.code = code
|
self.code = code
|
||||||
self.message = message
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
class ActionResponse:
|
class ActionResponse:
|
||||||
def __init__(self, action: Action, result, response):
|
def __init__(self, action: Action, result, response):
|
||||||
self.action = action # 动作类型
|
self.action = action # 动作类型
|
||||||
self.result = result # 动作产生的结果
|
self.result = result # 动作产生的结果
|
||||||
self.response = response # 直接回复的内容
|
self.response = response # 直接回复的内容
|
||||||
|
|
||||||
|
|
||||||
class FunctionItem:
|
class FunctionItem:
|
||||||
def __init__(self, name, description, func, type):
|
def __init__(self, name, description, func, type):
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -43,8 +48,10 @@ class FunctionItem:
|
|||||||
self.func = func
|
self.func = func
|
||||||
self.type = type
|
self.type = type
|
||||||
|
|
||||||
|
|
||||||
class DeviceTypeRegistry:
|
class DeviceTypeRegistry:
|
||||||
"""设备类型注册表,用于管理IOT设备类型及其函数"""
|
"""设备类型注册表,用于管理IOT设备类型及其函数"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.type_functions = {} # type_signature -> {func_name: FunctionItem}
|
self.type_functions = {} # type_signature -> {func_name: FunctionItem}
|
||||||
|
|
||||||
@@ -53,7 +60,9 @@ class DeviceTypeRegistry:
|
|||||||
properties = sorted(descriptor["properties"].keys())
|
properties = sorted(descriptor["properties"].keys())
|
||||||
methods = sorted(descriptor["methods"].keys())
|
methods = sorted(descriptor["methods"].keys())
|
||||||
# 使用属性和方法的组合作为设备类型的唯一标识
|
# 使用属性和方法的组合作为设备类型的唯一标识
|
||||||
type_signature = f"{descriptor['name']}:{','.join(properties)}:{','.join(methods)}"
|
type_signature = (
|
||||||
|
f"{descriptor['name']}:{','.join(properties)}:{','.join(methods)}"
|
||||||
|
)
|
||||||
return type_signature
|
return type_signature
|
||||||
|
|
||||||
def get_device_functions(self, type_id):
|
def get_device_functions(self, type_id):
|
||||||
@@ -65,18 +74,23 @@ class DeviceTypeRegistry:
|
|||||||
if type_id not in self.type_functions:
|
if type_id not in self.type_functions:
|
||||||
self.type_functions[type_id] = functions
|
self.type_functions[type_id] = functions
|
||||||
|
|
||||||
|
|
||||||
# 初始化函数注册字典
|
# 初始化函数注册字典
|
||||||
all_function_registry = {}
|
all_function_registry = {}
|
||||||
device_type_registry = DeviceTypeRegistry()
|
device_type_registry = DeviceTypeRegistry()
|
||||||
|
|
||||||
|
|
||||||
def register_function(name, desc, type=None):
|
def register_function(name, desc, type=None):
|
||||||
"""注册函数到函数注册字典的装饰器"""
|
"""注册函数到函数注册字典的装饰器"""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
all_function_registry[name] = FunctionItem(name, desc, func, type)
|
all_function_registry[name] = FunctionItem(name, desc, func, type)
|
||||||
logger.bind(tag=TAG).debug(f"函数 '{name}' 已加载,可以注册使用")
|
logger.bind(tag=TAG).debug(f"函数 '{name}' 已加载,可以注册使用")
|
||||||
return func
|
return func
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
class FunctionRegistry:
|
class FunctionRegistry:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.function_registry = {}
|
self.function_registry = {}
|
||||||
@@ -89,7 +103,7 @@ class FunctionRegistry:
|
|||||||
self.logger.bind(tag=TAG).error(f"函数 '{name}' 未找到")
|
self.logger.bind(tag=TAG).error(f"函数 '{name}' 未找到")
|
||||||
return None
|
return None
|
||||||
self.function_registry[name] = func
|
self.function_registry[name] = func
|
||||||
self.logger.bind(tag=TAG).info(f"函数 '{name}' 注册成功")
|
self.logger.bind(tag=TAG).debug(f"函数 '{name}' 注册成功")
|
||||||
return func
|
return func
|
||||||
|
|
||||||
def unregister_function(self, name):
|
def unregister_function(self, name):
|
||||||
|
|||||||
@@ -446,7 +446,7 @@
|
|||||||
<h2>
|
<h2>
|
||||||
设备配置
|
设备配置
|
||||||
<span class="device-info">
|
<span class="device-info">
|
||||||
<span>MAC: <strong id="displayMac">00:11:22:33:44:55</strong></span>
|
<span>MAC: <strong id="displayMac"></strong></span>
|
||||||
<span>客户端: <strong id="displayClient">web_test_client</strong></span>
|
<span>客户端: <strong id="displayClient">web_test_client</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<button class="toggle-button" id="toggleConfig">编辑</button>
|
<button class="toggle-button" id="toggleConfig">编辑</button>
|
||||||
@@ -455,7 +455,7 @@
|
|||||||
<div class="control-panel">
|
<div class="control-panel">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<label for="deviceMac">设备MAC:</label>
|
<label for="deviceMac">设备MAC:</label>
|
||||||
<input type="text" id="deviceMac" value="00:11:22:33:44:55" placeholder="设备MAC地址">
|
<input type="text" id="deviceMac" placeholder="设备MAC地址">
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<label for="deviceName">设备名称:</label>
|
<label for="deviceName">设备名称:</label>
|
||||||
@@ -1582,6 +1582,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成随机MAC地址
|
||||||
|
function generateRandomMac() {
|
||||||
|
const hexDigits = '0123456789ABCDEF';
|
||||||
|
let mac = '';
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
if (i > 0) mac += ':';
|
||||||
|
for (let j = 0; j < 2; j++) {
|
||||||
|
mac += hexDigits.charAt(Math.floor(Math.random() * 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mac;
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化事件监听器
|
// 初始化事件监听器
|
||||||
function initEventListeners() {
|
function initEventListeners() {
|
||||||
connectButton.addEventListener('click', connectToServer);
|
connectButton.addEventListener('click', connectToServer);
|
||||||
@@ -1595,10 +1608,22 @@
|
|||||||
const displayMac = document.getElementById('displayMac');
|
const displayMac = document.getElementById('displayMac');
|
||||||
const displayClient = document.getElementById('displayClient');
|
const displayClient = document.getElementById('displayClient');
|
||||||
|
|
||||||
|
// 从localStorage加载MAC地址,如果没有则生成新的
|
||||||
|
let savedMac = localStorage.getItem('deviceMac');
|
||||||
|
if (!savedMac) {
|
||||||
|
savedMac = generateRandomMac();
|
||||||
|
localStorage.setItem('deviceMac', savedMac);
|
||||||
|
}
|
||||||
|
deviceMacInput.value = savedMac;
|
||||||
|
displayMac.textContent = savedMac;
|
||||||
|
|
||||||
// 更新显示的值
|
// 更新显示的值
|
||||||
function updateDisplayValues() {
|
function updateDisplayValues() {
|
||||||
displayMac.textContent = deviceMacInput.value;
|
const newMac = deviceMacInput.value;
|
||||||
|
displayMac.textContent = newMac;
|
||||||
displayClient.textContent = clientIdInput.value;
|
displayClient.textContent = clientIdInput.value;
|
||||||
|
// 保存MAC地址到localStorage
|
||||||
|
localStorage.setItem('deviceMac', newMac);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听输入变化
|
// 监听输入变化
|
||||||
|
|||||||
Reference in New Issue
Block a user