Merge branch 'model-config-ui' into feat/volces-ai-gateway

This commit is contained in:
hrz
2025-04-24 11:19:19 +08:00
committed by GitHub
68 changed files with 2601 additions and 898 deletions
+1
View File
@@ -164,3 +164,4 @@ main/manager-api/.vscode
# Ignore webpack cache directory
main/manager-web/.webpack_cache/
main/xiaozhi-server/mysql
uploadfile
+1 -1
View File
@@ -23,7 +23,7 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/*
# 配置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
+7 -4
View File
@@ -6,7 +6,7 @@
<p align="center">
本项目为开源智能硬件项目
<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>
@@ -15,7 +15,8 @@
· 简体中文
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">更新日志</a>
· <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues ">反馈问题</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">反馈问题</a>
· <a href="./docs/FAQ.md">常见问题</a>
</p>
<p align="center">
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
@@ -147,14 +148,16 @@ server:
## 部署文档
![Banners](docs/images/banner2.png)
本项目提供两种部署方式,请根据您的具体需求选择:
#### 🚀 部署方式选择
| 部署方式 | 特点 | 适用场景 | Docker部署文档 | 源码部署文档 |
|---------|------|---------|---------|---------|
| **最简化安装** | 智能对话、IOT功能,数据存储在配置文件 | 低配置环境,无需数据库 | [Docker只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) | [本地源码只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)|
| **全模块安装** | 智能对话、IOT、OTA、智控台,数据存储在数据库 | 完整功能体验 |[Docker运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | [本地源码运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) |
| **最简化安装** | 智能对话、IOT功能,数据存储在配置文件 | 低配置环境,无需数据库,仅支持虾哥1.6.0及以下固件版本 | [Docker只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) | [本地源码只运行Server](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)|
| **全模块安装** | 智能对话、IOT、OTA、智控台,数据存储在数据库 | 完整功能体验,支持虾哥最新固件 |[Docker运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | [本地源码运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) |
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据
+1 -1
View File
@@ -290,7 +290,7 @@ LLM:
这个信息很有用的,后面`编译esp32固件`需要用到。
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。
接下来,你就可以开始 [编译1.6.0版本esp32固件](firmware-build.md)了。
以下是一些常见问题,供参考:
+11 -3
View File
@@ -182,7 +182,11 @@ Websocket接口:
ws://你电脑局域网的ip:8000/xiaozhi/v1/
```
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。
接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
1、 [编译自己的esp32固件](firmware-build.md)了。
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
# 方式二:本地源码运行全模块
@@ -349,7 +353,7 @@ pip install -r requirements.txt
## 8.配置项目文件
使用超级管理员账号,登录智控台 http://127.0.0.1:8001 ,在顶部菜单找到`参数管理`,找到列表中第三条数据,参数编码是`server.secret`,复制它到`参数值`
使用超级管理员账号,登录智控台 http://127.0.0.1:8002 ,在顶部菜单找到`参数管理`,找到列表中第三条数据,参数编码是`server.secret`,复制它到`参数值`
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
@@ -402,7 +406,11 @@ Websocket接口:
ws://你电脑局域网的ip:8000/xiaozhi/v1/
```
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。
接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
1、 [编译自己的esp32固件](firmware-build.md)了。
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
# 常见问题
+1 -35
View File
@@ -102,41 +102,7 @@ VAD:
### 7、我想通过小智控制电灯、空调、远程开关机等操作 💡
本项目,支持以工具调用的方式控制HomeAssistant设备
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
```
参考教程[ESP32设备与HomeAssistant集成指南](./homeassistant-integration.md)
### 8、更多问题,可联系我们反馈 💬
+47 -20
View File
@@ -1,26 +1,53 @@
server {
listen 8002;
server_name localhost;
user root;
worker_processes 4;
# 静态资源服务(Vue项目)
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
events {
worker_connections 1024;
}
# API反向代理(Java项目)
location /xiaozhi/ {
proxy_pass http://127.0.0.1:8003;
proxy_set_header Host $host;
proxy_cookie_path /api/ /;
proxy_set_header Referer $http_referer;
proxy_set_header Cookie $http_cookie;
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 300;
client_header_timeout 180s;
client_body_timeout 180s;
client_max_body_size 1024M;
proxy_connect_timeout 10;
proxy_send_timeout 10;
proxy_read_timeout 10;
gzip on;
gzip_buffers 32 4K;
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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
server {
# 无域名访问,就用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;
}
}
}
+124
View File
@@ -0,0 +1,124 @@
# 编译1.6.0版本esp32固件
## 第1步 配置环境
先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
## 第2步 打开配置文件
配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
下载后,解压缩包,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
## 第3步 修改WEBSOCKET地址
找到`WEBSOCKET_URL``default`的内容,把`wss://api.tenclass.net/xiaozhi/v1/`
改成你自己的地址,例如,我的接口地址是`ws://192.168.1.25:8000/xiaozhi/v1/`,就把内容改成这个。
修改前:
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "wss://api.tenclass.net/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
修改后(示例)
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "ws://192.168.1.25:8000/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
## 第4步 设置编译参数
设置编译参数
```
# 终端命令行进入xiaozhi-esp32的根目录
cd xiaozhi-esp32
# 例如我使用的板子是esp32s3,所以设置编译目标为esp32s3,如果你的板子是其他型号,请替换成对应的型号
idf.py set-target esp32s3
# 进入菜单配置
idf.py menuconfig
```
![图片](images/build_setting01.png)
进入菜单配置后,再进入`Xiaozhi Assistant`,将`CONNECTION_TYPE`设置为`Websocket`
回退到主菜单,再进入`Xiaozhi Assistant`,将`BOARD_TYPE`设置你板子的具体型号
保存退出,回到终端命令行。
![图片](images/build_setting02.png)
## 第5步 编译固件
```
idf.py build
```
如果是vscode安装的idf可以使用`F1`或者`ctrl+shift+p`,输入idf然后直接选择进行编译
还可以直接进行烧录不用接下来的操作
<img src="./images/vscode_idf.png" width="500px"/>
## 第6步 打包bin固件
```
cd scripts
python release.py
```
编译成功后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin`
这个`merged-binary.bin`就是要烧录到硬件上的固件文件。
注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin`
,对你没有太大影响,请继续。
## 第7步 烧录固件
将esp32设备连接电脑,使用chrome浏览器,打开以下网址
```
https://espressif.github.io/esp-launchpad/
```
打开这个教程,[Flash工具/Web端烧录固件(无IDF开发环境)](https://ccnphfhqs21z.feishu.cn/wiki/Zpz4wXBtdimBrLk25WdcXzxcnNS)。
翻到:`方式二:ESP-Launchpad 浏览器WEB端烧录`,从`3. 烧录固件/下载到开发板`开始,按照教程操作。
烧录成功且联网成功后,通过唤醒词唤醒小智,留意server端输出的控制台信息。
## 常见问题
以下是一些常见问题,供参考:
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
[3、TTS 经常失败,经常超时](./FAQ.md)
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
[5、如何提高小智对话响应速度?](./FAQ.md)
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
+33 -47
View File
@@ -1,54 +1,40 @@
# 编译esp32固件
# esp32固件编译
## 第1步 配置环境
## 第1步 准备你的ota地址
如果你按照教程使用的是全模块部署,就应该会有ota地址。
此刻,请你用浏览器打开你的ota地址,例如我的ota地址
```
http://192.168.1.25:8002/xiaozhi/ota/
```
如果显示“OTA接口运行正常,websocket集群数量:X”。那就往下。
如果显示“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)
## 第2步 打开配置文件
## 第3步 打开配置文件
配置好编译环境后,下载虾哥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项目源码](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://`开头,一定不要写错了。
下载后,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件
## 第4步 修改OTA地址
如果你是全模块部署本项目,就修改OTA接口,如果你只是部署了8000端口的xiaozhi-server,可以继续沿用虾哥团队的OTA接口。如果你不修改OTA接口,请直接忽略本第4步,直接看第5步
找到`OTA_VERSION_URL``default`的内容,把`https://api.tenclass.net/xiaozhi/ota/`
改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8002/xiaozhi/ota/`,就把内容改成这个。
@@ -70,7 +56,7 @@ config OTA_VERSION_URL
The application will access this URL to check for updates.
```
## 第5步 设置编译参数
## 第4步 设置编译参数
设置编译参数
@@ -91,7 +77,7 @@ idf.py menuconfig
![图片](images/build_setting02.png)
## 第6步 编译固件
## 第5步 编译固件
```
idf.py build
@@ -103,7 +89,7 @@ idf.py build
<img src="./images/vscode_idf.png" width="500px"/>
## 第7步 打包bin固件
## 第6步 打包bin固件
```
cd scripts
@@ -116,7 +102,7 @@ python release.py
注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin`
,对你没有太大影响,请继续。
## 第8步 烧录固件
## 第7步 烧录固件
将esp32设备连接电脑,使用chrome浏览器,打开以下网址
```
+53
View File
@@ -0,0 +1,53 @@
# 基于虾哥编译好的固件配置自定义服务器
## 第1步 确认版本
烧录虾哥已经编译好的1.6.1版本固件
## 第2步 准备你的ota地址
如果你按照教程使用的是全模块部署,就应该会有ota地址。
此刻,请你用浏览器打开你的ota地址,例如我的ota地址
```
https://2662r3426b.vicp.fun/xiaozhi/ota/
```
如果显示“OTA接口运行正常,websocket集群数量:X”。那就往下。
如果显示“OTA接口运行不正常”,大概是你还没在`智控台`配置`Websocket`地址。那就:
- 1、使用超级管理员登录智控台
- 2、顶部菜单点击`参数管理`
- 3、在列表中找到`server.websocket`项目,输入你的`Websocket`地址。例如我的就是
```
wss://2662r3426b.vicp.fun/xiaozhi/v1/
```
配置完后,再使用浏览器刷新你的ota接口地址,看看是不是正常了。如果还不正常就,就再次确认一下Websocket是否正常启动,是否配置了Websocket地址。
## 第3步 进入配网模式
进入机器的配网模式,在页面顶部,点击“高级选项”,在里面输入你服务器的`ota`地址,点击保存。重启设备
## 第4步 唤醒小智,查看日志输出
唤醒小智,看看日志是不是正常输出。
## 常见问题
以下是一些常见问题,供参考:
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
[3、TTS 经常失败,经常超时](./FAQ.md)
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
[5、如何提高小智对话响应速度?](./FAQ.md)
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
+111
View File
@@ -0,0 +1,111 @@
# ESP32设备与HomeAssistant集成指南
## 简介
本文档将指导您如何将ESP32设备与HomeAssistant进行集成。
## 前提条件
- 已安装并配置好`HomeAssistant`
- 已经在HomeAssistant中集成`Xiaomi Home`,并将米家的设备导入进`HomeAssistant`
- `xiaozhi-esp32-server智控台`能正常使用
- 本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用
## 网络环境
1、我的`HomeAssistant`部署在下面,版本是`2025.3.4`
```
http://192.168.4.7:8123
```
2、我的`xiaozhi-esp32-server智控台``HomeAssistant`部署在同一台机器的另一个端口,版本是`0.3.10`
```
http://192.168.4.7:8002
```
## 配置步骤
### 1. 登录`HomeAssistant`整理需要控制的设备清单
登录`HomeAssistant`,点击`左下角的设置`,然后进入`设备与服务`,再点击顶部的`实体`
然后在实体中搜索你相关控制的开关,结果出来后,在列表中,点击其中一个结果,这是会出现一个开关的界面。
在开关的界面,我们尝试点击开关,看看是开发会随着我们的点击开/关。如果能操作,说明是正常联网的。
接着在开关面板找到设置按钮,点击后,可以查看这个开关的`实体标识符`
我们打开一个记事本,按照这样格式整理一条数据:
位置+英文逗号+设备名称+英文逗号+`实体标识符`+英文分号
例如,我在公司,我有一个玩具灯,他的标识符是switch.cuco_cn_460494544_cp1_on_p_2_1,那么就这个写这一条数据
```
公司,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;
```
当然最后我可能要操作两个灯,我的最终的结果是:
```
公司,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1;
公司,台灯,switch.iot_cn_831898993_socn1_on_p_2_1;
```
这段字符,我们成为“设备清单字符”需要保存好,等一下有用。
### 2. 登录`HomeAssistant`拿到开发密钥
登录`HomeAssistant`,点击`左下角个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key。
### 3. 登录`智控台`
使用管理员账号,登录`智控台`。点击顶部菜单`参数管理`,搜索`plugins.home_assistant.`,会有三条结果出来
编辑`plugins.home_assistant.devices`,把刚才整理的设备清单字符粘贴进去。
编辑`plugins.home_assistant.base_url`,把你部署的`HomeAssistant`接口地址粘贴进去,我粘贴进去的地址是这样的
```
http://192.168.4.7:8123
```
编辑`plugins.home_assistant.api_key`,把你从`HomeAssistant`复制过来的密钥,粘贴进去
### 4. 设置`意图识别`函数
在智控台,点击顶部菜单“模型配置”,在左侧栏,找到“意图识别”,找到id为`Intent_function_call`的意图,点击编辑
然后在弹框中,在原来的基础上追加两个函数:“hass_get_state”和“hass_set_state”
修改前
```
change_role;get_weather;get_news;play_music
```
修改后
```
change_role;get_weather;get_news;play_music;hass_get_state;hass_set_state
```
### 6. 手动重启xiaozhi-server
重启xiaozhi-server程序
### 5. 确认角色配置是否设置了函数意图识别
在智控台,点击顶部菜单“智能体管理”,找到设备所在的智能体,点击“配置角色”
确认意图识别(Intent),是否选择“函数调用意图识别”
### 6. 唤醒设别进行控制
尝试和esp32说,“打开XXX灯”
@@ -84,6 +84,11 @@ public interface Constant {
*/
String SERVER_SECRET = "server.secret";
/**
* websocket地址
*/
String SERVER_WEBSOCKET = "server.websocket";
/**
* 是否允许用户注册
*/
@@ -167,5 +172,5 @@ public interface Constant {
/**
* 版本号
*/
public static final String VERSION = "0.3.9";
public static final String VERSION = "0.3.11";
}
@@ -80,6 +80,20 @@ public class RedisKeys {
* 获取版本号Key
*/
public static String getVersionKey() {
return "system:version";
return "sys:version";
}
/**
* OTA固件ID的Key
*/
public static String getOtaIdKey(String uuid) {
return "ota:id:" + uuid;
}
/**
* OTA固件下载次数的Key
*/
public static String getOtaDownloadCountKey(String uuid) {
return "ota:download:count:" + uuid;
}
}
@@ -23,11 +23,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.device.utils.NetworkUtil;
import xiaozhi.modules.sys.service.SysParamsService;
@Tag(name = "设备管理", description = "OTA 相关接口")
@Slf4j
@@ -36,6 +38,7 @@ import xiaozhi.modules.device.utils.NetworkUtil;
@RequestMapping("/ota/")
public class OTAController {
private final DeviceService deviceService;
private final SysParamsService sysParamsService;
@Operation(summary = "OTA版本和设备激活状态检查")
@PostMapping
@@ -76,7 +79,11 @@ public class OTAController {
@GetMapping
@Hidden
public ResponseEntity<String> getOTA() {
return ResponseEntity.ok("OTA接口运行正常");
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
return ResponseEntity.ok("OTA接口不正常,缺少websocket地址");
}
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
}
@SneakyThrows
@@ -0,0 +1,291 @@
package xiaozhi.modules.device.controller;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.UUID;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.device.entity.OtaEntity;
import xiaozhi.modules.device.service.OtaService;
@Tag(name = "设备管理", description = "OTA 相关接口")
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/otaMag")
public class OTAMagController {
private static final Logger logger = LoggerFactory.getLogger(OTAController.class);
private final OtaService otaService;
private final RedisUtils redisUtils;
@GetMapping
@Operation(summary = "分页查询 OTA 固件信息")
@Parameters({
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true)
})
@RequiresPermissions("sys:role:superAdmin")
public Result<PageData<OtaEntity>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
ValidatorUtils.validateEntity(params);
PageData<OtaEntity> page = otaService.page(params);
return new Result<PageData<OtaEntity>>().ok(page);
}
@GetMapping("{id}")
@Operation(summary = "信息 OTA 固件信息")
@RequiresPermissions("sys:role:superAdmin")
public Result<OtaEntity> get(@PathVariable("id") String id) {
OtaEntity data = otaService.selectById(id);
return new Result<OtaEntity>().ok(data);
}
@PostMapping
@Operation(summary = "保存 OTA 固件信息")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> save(@RequestBody OtaEntity entity) {
if (entity == null) {
return new Result<Void>().error("固件信息不能为空");
}
if (StringUtils.isBlank(entity.getFirmwareName())) {
return new Result<Void>().error("固件名称不能为空");
}
if (StringUtils.isBlank(entity.getType())) {
return new Result<Void>().error("固件类型不能为空");
}
if (StringUtils.isBlank(entity.getVersion())) {
return new Result<Void>().error("版本号不能为空");
}
try {
otaService.save(entity);
return new Result<Void>();
} catch (RuntimeException e) {
return new Result<Void>().error(e.getMessage());
}
}
@DeleteMapping("/{id}")
@Operation(summary = "OTA 删除")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> delete(@PathVariable("id") String[] ids) {
if (ids == null || ids.length == 0) {
return new Result<Void>().error("删除的固件ID不能为空");
}
otaService.delete(ids);
return new Result<Void>();
}
@PutMapping("/{id}")
@Operation(summary = "修改 OTA 固件信息")
@RequiresPermissions("sys:role:superAdmin")
public Result<?> update(@PathVariable("id") String id, @RequestBody OtaEntity entity) {
if (entity == null) {
return new Result<>().error("固件信息不能为空");
}
entity.setId(id);
try {
otaService.update(entity);
return new Result<>();
} catch (RuntimeException e) {
return new Result<>().error(e.getMessage());
}
}
@GetMapping("/getDownloadUrl/{id}")
@Operation(summary = "获取 OTA 固件下载链接")
@RequiresPermissions("sys:role:superAdmin")
public Result<String> getDownloadUrl(@PathVariable("id") String id) {
String uuid = UUID.randomUUID().toString();
redisUtils.set(RedisKeys.getOtaIdKey(uuid), id);
return new Result<String>().ok(uuid);
}
@GetMapping("/download/{uuid}")
@Operation(summary = "下载固件文件")
public ResponseEntity<byte[]> downloadFirmware(@PathVariable("uuid") String uuid) {
String id = (String) redisUtils.get(RedisKeys.getOtaIdKey(uuid));
if (StringUtils.isBlank(id)) {
return ResponseEntity.notFound().build();
}
// 检查下载次数
String downloadCountKey = RedisKeys.getOtaDownloadCountKey(uuid);
Integer downloadCount = (Integer) redisUtils.get(downloadCountKey);
if (downloadCount == null) {
downloadCount = 0;
}
// 如果下载次数超过3次,返回404
if (downloadCount >= 3) {
redisUtils.delete(downloadCountKey);
redisUtils.delete(RedisKeys.getOtaIdKey(uuid));
logger.warn("Download limit exceeded for UUID: {}", uuid);
return ResponseEntity.notFound().build();
}
redisUtils.set(downloadCountKey, downloadCount + 1);
try {
// 获取固件信息
OtaEntity otaEntity = otaService.selectById(id);
if (otaEntity == null || StringUtils.isBlank(otaEntity.getFirmwarePath())) {
logger.warn("Firmware not found or path is empty for ID: {}", id);
return ResponseEntity.notFound().build();
}
// 获取文件路径 - 确保路径是绝对路径或正确的相对路径
String firmwarePath = otaEntity.getFirmwarePath();
Path path;
// 检查是否是绝对路径
if (Paths.get(firmwarePath).isAbsolute()) {
path = Paths.get(firmwarePath);
} else {
// 如果是相对路径,则从当前工作目录解析
path = Paths.get(System.getProperty("user.dir"), firmwarePath);
}
logger.info("Attempting to download firmware for ID: {}, DB path: {}, resolved path: {}",
id, firmwarePath, path.toAbsolutePath());
if (!Files.exists(path) || !Files.isRegularFile(path)) {
// 尝试直接从firmware目录下查找文件名
String fileName = new File(firmwarePath).getName();
Path altPath = Paths.get(System.getProperty("user.dir"), "firmware", fileName);
logger.info("File not found at primary path, trying alternative path: {}", altPath.toAbsolutePath());
if (Files.exists(altPath) && Files.isRegularFile(altPath)) {
path = altPath;
} else {
logger.error("Firmware file not found at either path: {} or {}",
path.toAbsolutePath(), altPath.toAbsolutePath());
return ResponseEntity.notFound().build();
}
}
// 读取文件内容
byte[] fileContent = Files.readAllBytes(path);
// 设置响应头
String originalFilename = otaEntity.getType() + "_" + otaEntity.getVersion();
if (firmwarePath.contains(".")) {
String extension = firmwarePath.substring(firmwarePath.lastIndexOf("."));
originalFilename += extension;
}
// 清理文件名,移除不安全字符
String safeFilename = originalFilename.replaceAll("[^a-zA-Z0-9._-]", "_");
logger.info("Providing download for firmware ID: {}, filename: {}, size: {} bytes",
id, safeFilename, fileContent.length);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeFilename + "\"")
.body(fileContent);
} catch (IOException e) {
logger.error("Error reading firmware file for ID: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
} catch (Exception e) {
logger.error("Unexpected error during firmware download for ID: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/upload")
@Operation(summary = "上传固件文件")
@RequiresPermissions("sys:role:superAdmin")
public Result<String> uploadFirmware(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return new Result<String>().error("上传文件不能为空");
}
// 检查文件扩展名
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
return new Result<String>().error("文件名不能为空");
}
String extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase();
if (!extension.equals(".bin") && !extension.equals(".apk")) {
return new Result<String>().error("只允许上传.bin和.apk格式的文件");
}
try {
// 计算文件的MD5值
String md5 = calculateMD5(file);
// 设置存储路径
String uploadDir = "uploadfile";
Path uploadPath = Paths.get(uploadDir);
// 如果目录不存在,创建目录
if (!Files.exists(uploadPath)) {
Files.createDirectories(uploadPath);
}
// 使用MD5作为文件名,固定使用.bin扩展名
String uniqueFileName = md5 + extension;
Path filePath = uploadPath.resolve(uniqueFileName);
// 检查文件是否已存在
if (Files.exists(filePath)) {
return new Result<String>().ok(filePath.toString());
}
// 保存文件
Files.copy(file.getInputStream(), filePath);
// 返回文件路径
return new Result<String>().ok(filePath.toString());
} catch (IOException | NoSuchAlgorithmException e) {
return new Result<String>().error("文件上传失败:" + e.getMessage());
}
}
private String calculateMD5(MultipartFile file) throws IOException, NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(file.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
@@ -0,0 +1,15 @@
package xiaozhi.modules.device.dao;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.device.entity.OtaEntity;
/**
* OTA固件管理
*/
@Mapper
public interface OtaDao extends BaseMapper<OtaEntity> {
}
@@ -19,6 +19,9 @@ public class DeviceReportRespDTO {
@Schema(description = "固件版本信息")
private Firmware firmware;
@Schema(description = "WebSocket配置")
private Websocket websocket;
@Getter
@Setter
@@ -60,4 +63,11 @@ public class DeviceReportRespDTO {
@Schema(description = "时区偏移量,单位为分钟")
private Integer timezone_offset;
}
@Getter
@Setter
public static class Websocket {
@Schema(description = "WebSocket服务器地址")
private String url;
}
}
@@ -0,0 +1,61 @@
package xiaozhi.modules.device.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("ai_ota")
@Schema(description = "固件信息")
public class OtaEntity {
@TableId(type = IdType.ASSIGN_UUID)
@Schema(description = "ID")
private String id;
@Schema(description = "固件名称")
private String firmwareName;
@Schema(description = "固件类型")
private String type;
@Schema(description = "版本号")
private String version;
@Schema(description = "文件大小(字节)")
private Long size;
@Schema(description = "备注/说明")
private String remark;
@Schema(description = "固件路径")
private String firmwarePath;
@Schema(description = "排序")
private Integer sort;
@Schema(description = "更新者")
@TableField(fill = FieldFill.UPDATE)
private Long updater;
@Schema(description = "更新时间")
@TableField(fill = FieldFill.UPDATE)
private Date updateDate;
@Schema(description = "创建者")
@TableField(fill = FieldFill.INSERT)
private Long creator;
@Schema(description = "创建时间")
@TableField(fill = FieldFill.INSERT)
private Date createDate;
}
@@ -0,0 +1,20 @@
package xiaozhi.modules.device.service;
import java.util.Map;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.device.entity.OtaEntity;
/**
* OTA固件管理
*/
public interface OtaService extends BaseService<OtaEntity> {
PageData<OtaEntity> page(Map<String, Object> params);
boolean save(OtaEntity entity);
void update(OtaEntity entity);
void delete(String[] ids);
}
@@ -18,6 +18,7 @@ import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.hutool.core.util.RandomUtil;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
@@ -36,6 +37,7 @@ import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.service.SysUserUtilService;
@Slf4j
@Service
public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity> implements DeviceService {
@@ -43,18 +45,18 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
private final SysUserUtilService sysUserUtilService;
private final String frontedUrl;
private final RedisTemplate<String, Object> redisTemplate;
private final SysParamsService sysParamsService;
// 添加构造函数来初始化 deviceMapper
public DeviceServiceImpl(DeviceDao deviceDao, SysUserUtilService sysUserUtilService,
SysParamsService sysParamsService,
RedisTemplate<String, Object> redisTemplate) {
this.deviceDao = deviceDao;
this.sysUserUtilService = sysUserUtilService;
this.frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
this.redisTemplate = redisTemplate;
this.sysParamsService = sysParamsService;
}
@Override
@@ -132,6 +134,27 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
firmware.setUrl("http://localhost:8002/xiaozhi/ota/download");
response.setFirmware(firmware);
// 添加WebSocket配置
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
// 从系统参数获取WebSocket URL,如果未配置则使用默认值
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
log.error("WebSocket URL is not configured");
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
websocket.setUrl(wsUrl);
} else {
String[] wsUrls = wsUrl.split("\\;");
if (wsUrls.length > 0) {
// 随机选择一个WebSocket URL
websocket.setUrl(wsUrls[RandomUtil.randomInt(0, wsUrls.length)]);
} else {
log.error("WebSocket URL list is empty");
websocket.setUrl("ws://xiaozhi.server.com:8000/xiaozhi/v1/");
}
}
response.setWebsocket(websocket);
DeviceEntity deviceById = getDeviceById(macAddress);
if (deviceById != null) { // 如果设备存在,则更新上次连接时间
deviceById.setLastConnectedAt(new Date());
@@ -250,11 +273,13 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
if (StringUtils.isNotBlank(cachedCode)) {
code.setCode(cachedCode);
String frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
code.setMessage(frontedUrl + "\n" + cachedCode);
code.setChallenge(deviceId);
} else {
String newCode = RandomUtil.randomNumbers(6);
code.setCode(newCode);
String frontedUrl = sysParamsService.getValue(Constant.SERVER_FRONTED_URL, true);
code.setMessage(frontedUrl + "\n" + newCode);
code.setChallenge(deviceId);
@@ -0,0 +1,74 @@
package xiaozhi.modules.device.service.impl;
import java.util.Arrays;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.micrometer.common.util.StringUtils;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.modules.device.dao.OtaDao;
import xiaozhi.modules.device.entity.OtaEntity;
import xiaozhi.modules.device.service.OtaService;
@Service
public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implements OtaService {
@Override
public PageData<OtaEntity> page(Map<String, Object> params) {
IPage<OtaEntity> page = baseDao.selectPage(
getPage(params, "update_date", true),
getWrapper(params));
return new PageData<>(page.getRecords(), page.getTotal());
}
private QueryWrapper<OtaEntity> getWrapper(Map<String, Object> params) {
String firmwareName = (String) params.get("firmwareName");
QueryWrapper<OtaEntity> wrapper = new QueryWrapper<>();
wrapper.like(StringUtils.isNotBlank(firmwareName), "firmware_name", firmwareName);
return wrapper;
}
@Override
public void update(OtaEntity entity) {
// 检查是否存在相同名称、类型和版本的固件(排除当前记录)
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
.eq("firmware_name", entity.getFirmwareName())
.eq("type", entity.getType())
.eq("version", entity.getVersion())
.ne("id", entity.getId()); // 排除当前记录
if (baseDao.selectCount(queryWrapper) > 0) {
throw new RuntimeException("已存在相同名称、类型和版本的固件,请修改后重试");
}
baseDao.updateById(entity);
}
@Override
public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
}
@Override
public boolean save(OtaEntity entity) {
// 检查是否存在相同名称、类型和版本的固件
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
.eq("firmware_name", entity.getFirmwareName())
.eq("type", entity.getType())
.eq("version", entity.getVersion());
if (baseDao.selectCount(queryWrapper) > 0) {
throw new RuntimeException("已存在相同名称、类型和版本的固件,请勿重复添加");
}
return baseDao.insert(entity) > 0;
}
}
@@ -69,6 +69,7 @@ public class ShiroConfig {
*/
Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/ota/**", "anon");
filterMap.put("/otaMag/download/**", "anon");
filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon");
filterMap.put("/v3/api-docs/**", "anon");
@@ -2,6 +2,7 @@ package xiaozhi.modules.sys.controller;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -20,6 +21,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import xiaozhi.common.annotation.LogOperation;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.Result;
import xiaozhi.common.validator.AssertUtils;
@@ -30,6 +32,7 @@ import xiaozhi.common.validator.group.UpdateGroup;
import xiaozhi.modules.config.service.ConfigService;
import xiaozhi.modules.sys.dto.SysParamsDTO;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.utils.WebSocketValidator;
/**
* 参数管理
@@ -91,11 +94,43 @@ public class SysParamsController {
// 效验数据
ValidatorUtils.validateEntity(dto, UpdateGroup.class, DefaultGroup.class);
// 验证WebSocket地址列表
validateWebSocketUrls(dto.getParamCode(), dto.getParamValue());
sysParamsService.update(dto);
configService.getConfig(false);
return new Result<Void>();
}
/**
* 验证WebSocket地址列表
*
* @param urls WebSocket地址列表,以分号分隔
* @return 验证结果
*/
private void validateWebSocketUrls(String paramCode, String urls) {
if (!paramCode.equals(Constant.SERVER_WEBSOCKET)) {
return;
}
String[] wsUrls = urls.split("\\;");
if (wsUrls.length == 0) {
throw new RenException("WebSocket地址列表不能为空");
}
for (String url : wsUrls) {
if (StringUtils.isNotBlank(url)) {
// 验证WebSocket地址格式
if (!WebSocketValidator.validateUrlFormat(url)) {
throw new RenException("WebSocket地址格式不正确: " + url);
}
// 测试WebSocket连接
if (!WebSocketValidator.testConnection(url)) {
throw new RenException("WebSocket连接测试失败: " + url);
}
}
}
}
@PostMapping("/delete")
@Operation(summary = "删除")
@LogOperation("删除")
@@ -0,0 +1,46 @@
package xiaozhi.modules.sys.utils;
import java.util.concurrent.CompletableFuture;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
public class WebSocketTestHandler implements WebSocketHandler {
private final CompletableFuture<Boolean> future;
public WebSocketTestHandler(CompletableFuture<Boolean> future) {
this.future = future;
}
@Override
public void afterConnectionEstablished(WebSocketSession session) {
future.complete(true);
try {
session.close();
} catch (Exception e) {
// 忽略关闭异常
}
}
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) {
// 不需要处理消息
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
future.complete(false);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) {
// 连接关闭时不做任何处理
}
@Override
public boolean supportsPartialMessages() {
return false;
}
}
@@ -0,0 +1,58 @@
package xiaozhi.modules.sys.utils;
import java.net.URI;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.socket.client.WebSocketClient;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
public class WebSocketValidator {
private static final Logger logger = LoggerFactory.getLogger(WebSocketValidator.class);
// WebSocket URL正则表达式
private static final Pattern WS_URL_PATTERN = Pattern
.compile("^wss?://[\\w.-]+(?:\\.[\\w.-]+)*(?::\\d+)?(?:/[\\w.-]*)*$");
/**
* 验证WebSocket地址格式
*
* @param url WebSocket地址
* @return 是否有效
*/
public static boolean validateUrlFormat(String url) {
if (StringUtils.isBlank(url)) {
return false;
}
return WS_URL_PATTERN.matcher(url.trim()).matches();
}
/**
* 测试WebSocket连接
*
* @param url WebSocket地址
* @return 是否可连接
*/
public static boolean testConnection(String url) {
if (!validateUrlFormat(url)) {
return false;
}
try {
WebSocketClient client = new StandardWebSocketClient();
CompletableFuture<Boolean> future = new CompletableFuture<>();
client.doHandshake(new WebSocketTestHandler(future), null, URI.create(url));
// 等待最多5秒获取连接结果
return future.get(5, TimeUnit.SECONDS);
} catch (Exception e) {
logger.error("WebSocket连接测试失败: {}", url, e);
return false;
}
}
}
@@ -24,7 +24,6 @@ public class TimbreDataDTO {
private String name;
@Schema(description = "备注")
@NotBlank(message = "{timbre.remark.require}")
private String remark;
@Schema(description = "排序")
@@ -40,6 +39,5 @@ public class TimbreDataDTO {
private String ttsVoice;
@Schema(description = "音频播放地址")
@NotBlank(message = "{timbre.voiceDemo.require}")
private String voiceDemo;
}
@@ -1,3 +0,0 @@
-- 对0.3.0版本之前的参数进行修改
update `sys_params` set param_value = '.mp3;.wav;.p3' where param_code = 'plugins.play_music.music_ext';
update `ai_model_config` set config_json = '{\"type\": \"intent_llm\", \"llm\": \"LLM_ChatGLMLLM\"}' where id = 'Intent_intent_llm';
@@ -0,0 +1,19 @@
-- 本文件用于初始化固件信息表,无需手动执行,在项目启动时会自动执行
-- -------------------------------------------------------
-- 初始化固件信息表
drop table if exists `ai_ota`;
CREATE TABLE `ai_ota` (
`id` varchar(32) NOT NULL COMMENT 'ID',
`firmware_name` varchar(100) DEFAULT NULL COMMENT '固件名称',
`type` varchar(50) DEFAULT NULL COMMENT '固件类型',
`version` varchar(50) DEFAULT NULL COMMENT '版本号',
`size` bigint DEFAULT NULL COMMENT '文件大小(字节)',
`remark` varchar(500) DEFAULT NULL COMMENT '备注/说明',
`firmware_path` varchar(255) DEFAULT NULL COMMENT '固件路径',
`sort` int unsigned DEFAULT '0' COMMENT '排序',
`updater` bigint DEFAULT NULL COMMENT '更新者',
`update_date` datetime DEFAULT NULL COMMENT '更新时间',
`creator` bigint DEFAULT NULL COMMENT '创建者',
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='固件信息表';
@@ -1,2 +0,0 @@
delete from `ai_model_config` where id = 'Intent_function_call';
INSERT INTO `ai_model_config` VALUES ('Intent_function_call', 'Intent', 'function_call', '函数调用意图识别', 0, 1, '{\"type\": \"function_call\", \"functions\": \"change_role;get_weather;get_news;play_music\"}', NULL, NULL, 3, NULL, NULL, NULL, NULL);
@@ -1,19 +0,0 @@
-- 删除无用模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_LLM_doubao';
delete from `ai_model_provider` where id = 'SYSTEM_LLM_chatglm';
delete from `ai_model_provider` where id = 'SYSTEM_TTS_302ai';
delete from `ai_model_provider` where id = 'SYSTEM_TTS_gizwits';
-- 添加模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_ASR_TencentASR';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_TencentASR', 'ASR', 'tencent', '腾讯语音识别', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 4, 1, NOW(), 1, NOW());
-- 添加腾讯语音合成模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_TTS_TencentTTS';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_TTS_TencentTTS', 'TTS', 'tencent', '腾讯语音合成', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"region","label":"区域","type":"string"},{"key":"voice","label":"音色ID","type":"string"}]', 5, 1, NOW(), 1, NOW());
-- 添加腾讯语音合成音色
delete from `ai_tts_voice` where id = 'TTS_TencentTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_TencentTTS0001', 'TTS_TencentTTS', '智瑜', '101001', '中文', NULL, NULL, 1, NULL, NULL, NULL, NULL);
@@ -0,0 +1,88 @@
-- 删除无用模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_LLM_doubao';
delete from `ai_model_provider` where id = 'SYSTEM_LLM_chatglm';
delete from `ai_model_provider` where id = 'SYSTEM_TTS_302ai';
delete from `ai_model_provider` where id = 'SYSTEM_TTS_gizwits';
-- 添加模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_ASR_TencentASR';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_TencentASR', 'ASR', 'tencent', '腾讯语音识别', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 4, 1, NOW(), 1, NOW());
-- 添加腾讯语音合成模型供应器
delete from `ai_model_provider` where id = 'SYSTEM_TTS_TencentTTS';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_TTS_TencentTTS', 'TTS', 'tencent', '腾讯语音合成', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"secret_id","label":"Secret ID","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"region","label":"区域","type":"string"},{"key":"voice","label":"音色ID","type":"string"}]', 5, 1, NOW(), 1, NOW());
-- 添加edge音色
delete from `ai_tts_voice` where id in ('TTS_EdgeTTS0001', 'TTS_EdgeTTS0002', 'TTS_EdgeTTS0003', 'TTS_EdgeTTS0004', 'TTS_EdgeTTS0005', 'TTS_EdgeTTS0006', 'TTS_EdgeTTS0007', 'TTS_EdgeTTS0008', 'TTS_EdgeTTS0009', 'TTS_EdgeTTS0010', 'TTS_EdgeTTS0011');
INSERT INTO `ai_tts_voice` VALUES
('TTS_EdgeTTS0001', 'TTS_EdgeTTS', 'EdgeTTS女声-晓晓', 'zh-CN-XiaoxiaoNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0002', 'TTS_EdgeTTS', 'EdgeTTS男声-云扬', 'zh-CN-YunyangNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0003', 'TTS_EdgeTTS', 'EdgeTTS女声-晓伊', 'zh-CN-XiaoyiNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0004', 'TTS_EdgeTTS', 'EdgeTTS男声-云健', 'zh-CN-YunjianNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0005', 'TTS_EdgeTTS', 'EdgeTTS男声-云希', 'zh-CN-YunxiNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0006', 'TTS_EdgeTTS', 'EdgeTTS男声-云夏', 'zh-CN-YunxiaNeural', '普通话', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0007', 'TTS_EdgeTTS', 'EdgeTTS女声-辽宁小贝', 'zh-CN-liaoning-XiaobeiNeural', '辽宁', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0008', 'TTS_EdgeTTS', 'EdgeTTS女声-陕西小妮', 'zh-CN-shaanxi-XiaoniNeural', '陕西', NULL, NULL, 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0009', 'TTS_EdgeTTS', 'EdgeTTS女声-香港海佳', 'zh-HK-HiuGaaiNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0010', 'TTS_EdgeTTS', 'EdgeTTS女声-香港海曼', 'zh-HK-HiuMaanNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL),
('TTS_EdgeTTS0011', 'TTS_EdgeTTS', 'EdgeTTS男声-香港万龙', 'zh-HK-WanLungNeural', '粤语', 'General', 'Friendly, Positive', 1, NULL, NULL, NULL, NULL);
-- DoubaoTTS音色
delete from `ai_tts_voice` where id in ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS0002', 'TTS_DoubaoTTS0003', 'TTS_DoubaoTTS0004', 'TTS_DoubaoTTS0005');
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS', '通用女声', 'BV001_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV001.mp3', NULL, 3, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0002', 'TTS_DoubaoTTS', '通用男声', 'BV002_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV002.mp3', NULL, 2, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0003', 'TTS_DoubaoTTS', '阳光男生', 'BV056_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV056.mp3', NULL, 4, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0004', 'TTS_DoubaoTTS', '奶气萌娃', 'BV051_streaming', '普通话', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV051.mp3', NULL, 5, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0005', 'TTS_DoubaoTTS', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '普通话', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 6, NULL, NULL, NULL, NULL);
-- 修正CosyVoiceSiliconflow音色
delete from `ai_tts_voice` where id in ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow0002');
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow', 'CosyVoice男声', 'FunAudioLLM/CosyVoice2-0.5B:alex', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0002', 'TTS_CosyVoiceSiliconflow', 'CosyVoice女声', 'FunAudioLLM/CosyVoice2-0.5B:bella', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
-- CozeCnTTS音色
delete from `ai_tts_voice` where id = 'TTS_CozeCnTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_CozeCnTTS0001', 'TTS_CozeCnTTS', 'CozeCn音色', '7426720361733046281', '中文', NULL, NULL, 7, NULL, NULL, NULL, NULL);
-- MinimaxTTS音色
delete from `ai_tts_voice` where id = 'TTS_MinimaxTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxTTS0001', 'TTS_MinimaxTTS', 'Minimax少女音', 'female-shaonv', '中文', NULL, NULL, 8, NULL, NULL, NULL, NULL);
-- AliyunTTS音色
delete from `ai_tts_voice` where id = 'TTS_AliyunTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunTTS0001', 'TTS_AliyunTTS', '阿里云小云', 'xiaoyun', '中文', NULL, NULL, 9, NULL, NULL, NULL, NULL);
-- TTS302AI音色
delete from `ai_tts_voice` where id = 'TTS_TTS302AI0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_TTS302AI0001', 'TTS_TTS302AI', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 10, NULL, NULL, NULL, NULL);
-- GizwitsTTS音色
delete from `ai_tts_voice` where id = 'TTS_GizwitsTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_GizwitsTTS0001', 'TTS_GizwitsTTS', '机智云湾湾', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/%E6%B9%BE%E6%B9%BE%E5%B0%8F%E4%BD%95.mp3', NULL, 11, NULL, NULL, NULL, NULL);
-- ACGNTTS音色
delete from `ai_tts_voice` where id = 'TTS_ACGNTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_ACGNTTS0001', 'TTS_ACGNTTS', 'ACG音色', '1695', '中文', NULL, NULL, 12, NULL, NULL, NULL, NULL);
-- OpenAITTS音色
delete from `ai_tts_voice` where id = 'TTS_OpenAITTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_OpenAITTS0001', 'TTS_OpenAITTS', 'OpenAI男声', 'onyx', '中文', NULL, NULL, 13, NULL, NULL, NULL, NULL);
-- 添加腾讯语音合成音色
delete from `ai_tts_voice` where id = 'TTS_TencentTTS0001';
INSERT INTO `ai_tts_voice` VALUES ('TTS_TencentTTS0001', 'TTS_TencentTTS', '智瑜', '101001', '中文', NULL, NULL, 1, NULL, NULL, NULL, NULL);
-- 其他音色
delete from `ai_tts_voice` where id = 'TTS_FishSpeech0000';
INSERT INTO `ai_tts_voice` VALUES ('TTS_FishSpeech0000', 'TTS_FishSpeech', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
delete from `ai_tts_voice` where id = 'TTS_GPT_SOVITS_V20000';
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V20000', 'TTS_GPT_SOVITS_V2', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
delete from `ai_tts_voice` where id in ('TTS_GPT_SOVITS_V30000', 'TTS_CustomTTS0000');
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V30000', 'TTS_GPT_SOVITS_V3', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CustomTTS0000', 'TTS_CustomTTS', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
@@ -0,0 +1,6 @@
-- 删除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地址,多个用;分隔');
@@ -1,44 +0,0 @@
-- 本文件用于初始化音色数据,无需手动执行,在项目启动时会自动执行
-- ----------------------------------------------------
-- 初始化音色数据
DELETE FROM `ai_tts_voice`;
-- EdgeTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_EdgeTTS0001', 'TTS_EdgeTTS', 'EdgeTTS女声', 'zh-CN-XiaoxiaoNeural', '中文', 'https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_hz_ihsph/ljhwZthlaukjlkulzlp/portal/bigtts/EdgeTTS.mp3', NULL, 1, NULL, NULL, NULL, NULL);
-- DoubaoTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0001', 'TTS_DoubaoTTS', '通用女声', 'BV001_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV001.mp3', NULL, 3, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0002', 'TTS_DoubaoTTS', '通用男声', 'BV002_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV002.mp3', NULL, 2, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0003', 'TTS_DoubaoTTS', '阳光男生', 'BV056_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV056.mp3', NULL, 4, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_DoubaoTTS0004', 'TTS_DoubaoTTS', '奶气萌娃', 'BV051_streaming', '中文', 'https://lf3-speech.bytetos.com/obj/speech-tts-external/portal/Portal_Demo_BV051.mp3', NULL, 5, NULL, NULL, NULL, NULL);
-- CosyVoiceSiliconflow音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_CosyVoiceSiliconflow0001', 'TTS_CosyVoiceSiliconflow', 'CosyVoice男声', 'alex', '中文', 'https://example.com/cosyvoice/alex.mp3', NULL, 6, NULL, NULL, NULL, NULL);
-- CozeCnTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_CozeCnTTS0001', 'TTS_CozeCnTTS', 'CozeCn音色', '7426720361733046281', '中文', 'https://example.com/cozecn/voice.mp3', NULL, 7, NULL, NULL, NULL, NULL);
-- MinimaxTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_MinimaxTTS0001', 'TTS_MinimaxTTS', 'Minimax少女音', 'female-shaonv', '中文', 'https://example.com/minimax/female-shaonv.mp3', NULL, 8, NULL, NULL, NULL, NULL);
-- AliyunTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_AliyunTTS0001', 'TTS_AliyunTTS', '阿里云小云', 'xiaoyun', '中文', 'https://example.com/aliyun/xiaoyun.mp3', NULL, 9, NULL, NULL, NULL, NULL);
-- TTS302AI音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_TTS302AI0001', 'TTS_TTS302AI', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://example.com/302ai/wanwanxiaohe.mp3', NULL, 10, NULL, NULL, NULL, NULL);
-- GizwitsTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_GizwitsTTS0001', 'TTS_GizwitsTTS', '机智云湾湾', 'zh_female_wanwanxiaohe_moon_bigtts', '中文', 'https://example.com/gizwits/wanwanxiaohe.mp3', NULL, 11, NULL, NULL, NULL, NULL);
-- ACGNTTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_ACGNTTS0001', 'TTS_ACGNTTS', 'ACG音色', '1695', '中文', 'https://example.com/acgn/voice.mp3', NULL, 12, NULL, NULL, NULL, NULL);
-- OpenAITTS音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_OpenAITTS0001', 'TTS_OpenAITTS', 'OpenAI男声', 'onyx', '中文', 'https://example.com/openai/onyx.mp3', NULL, 13, NULL, NULL, NULL, NULL);
-- 其他音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_FishSpeech0000', 'TTS_FishSpeech', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V20000', 'TTS_GPT_SOVITS_V2', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_GPT_SOVITS_V30000', 'TTS_GPT_SOVITS_V3', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_CustomTTS0000', 'TTS_CustomTTS', '', '', '中文', '', NULL, 8, NULL, NULL, NULL, NULL);
@@ -16,13 +16,6 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202503141346.sql
- changeSet:
id: 2025_tts_voive
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/2025_tts_voive.sql
- changeSet:
id: 202504082211
author: John
@@ -66,16 +59,30 @@ databaseChangeLog:
encoding: utf8
path: classpath:db/changelog/202504181536.sql
- changeSet:
id: 202504211118
id: 202504180758
author: jiangkunyin
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504180758.sql
- changeSet:
id: 202504221135
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504211118.sql
path: classpath:db/changelog/202504221135.sql
- changeSet:
id: 202504221646
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504221646.sql
- changeSet:
id: 202504221555
author: John
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504221555.sql
path: classpath:db/changelog/202504221555.sql
@@ -28,10 +28,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
timbre.languages.require=\u97F3\u8272\u7684\u8BED\u8A00\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.name.require=\u97F3\u8272\u7684\u540D\u79F0\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.remark.require=\u97F3\u8272\u7684\u5907\u6CE8\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
ota.device.not.found=\u8BBE\u5907\u672A\u627E\u5230
ota.device.need.bind={0}
@@ -27,10 +27,8 @@ sysuser.uuid.require=The unique identifier cannot be empty
timbre.languages.require=The language of the timbre cannot be empty
timbre.name.require=The name of the timbre cannot be empty
timbre.remark.require=The remark of the timbre cannot be empty
timbre.ttsModelId.require=The TTS model ID of the timbre cannot be empty
timbre.ttsVoice.require=The TTS voice of the timbre cannot be empty
timbre.voiceDemo.require=The voice demo of the timbre cannot be empty
ota.device.not.found=Device not found
ota.device.need.bind={0}
@@ -27,10 +27,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
timbre.languages.require=\u97F3\u8272\u7684\u8A9E\u8A00\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.name.require=\u97F3\u8272\u7684\u540D\u7A31\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.remark.require=\u97F3\u8272\u7684\u5099\u6CE8\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u9375\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7DE8\u78BC\u4E0D\u53EF\u4EE5\u70BA\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653E\u5730\u5740\u4E0D\u53EF\u4EE5\u70BA\u7A7A
ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230
ota.device.need.bind={0}
@@ -27,10 +27,8 @@ sysuser.uuid.require=\u552F\u4E00\u6807\u8BC6\u4E0D\u80FD\u4E3A\u7A7A
timbre.languages.require=\u97F3\u8272\u7684\u8BED\u8A00\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.name.require=\u97F3\u8272\u7684\u540D\u79F0\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.remark.require=\u97F3\u8272\u7684\u5907\u6CE8\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsModelId.require=\u97F3\u8272\u7684tts\u4E3B\u952E\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.ttsVoice.require=\u97F3\u8272\u7684\u7F16\u7801\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
timbre.voiceDemo.require=\u97F3\u8272\u7684\u64AD\u653EDemo\u4E0D\u53EF\u4EE5\u4E3A\u7A7A
ota.device.not.found=\u8A2D\u5099\u672A\u627E\u5230
ota.device.need.bind={0}
+1
View File
@@ -1,3 +1,4 @@
VUE_APP_API_BASE_URL=/xiaozhi
VUE_APP_PUBLIC_PATH=/
# 是否开启CDN
VUE_APP_USE_CDN=false
+1
View File
@@ -10,6 +10,7 @@
"dependencies": {
"core-js": "^3.41.0",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"element-ui": "^2.15.14",
"flyio": "^0.6.14",
"normalize.css": "^8.0.1",
File diff suppressed because one or more lines are too long
+2
View File
@@ -5,6 +5,7 @@ import device from './module/device.js'
import model from './module/model.js'
import timbre from "./module/timbre.js"
import user from './module/user.js'
import ota from './module/ota.js'
/**
* 接口地址
@@ -31,4 +32,5 @@ export default {
device,
model,
timbre,
ota
}
+123
View File
@@ -0,0 +1,123 @@
import { getServiceUrl } from '../api';
import RequestService from '../httpRequest';
export default {
// 分页查询OTA固件信息
getOtaList(params, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag`)
.method('GET')
.data(params)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('获取OTA固件列表失败:', err);
RequestService.reAjaxFun(() => {
this.getOtaList(params, callback);
});
}).send();
},
// 获取单个OTA固件信息
getOtaInfo(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/${id}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('获取OTA固件信息失败:', err);
RequestService.reAjaxFun(() => {
this.getOtaInfo(id, callback);
});
}).send();
},
// 保存OTA固件信息
saveOta(entity, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag`)
.method('POST')
.data(entity)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('保存OTA固件信息失败:', err);
RequestService.reAjaxFun(() => {
this.saveOta(entity, callback);
});
}).send();
},
// 更新OTA固件信息
updateOta(id, entity, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/${id}`)
.method('PUT')
.data(entity)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('更新OTA固件信息失败:', err);
RequestService.reAjaxFun(() => {
this.updateOta(id, entity, callback);
});
}).send();
},
// 删除OTA固件
deleteOta(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/${id}`)
.method('DELETE')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('删除OTA固件失败:', err);
RequestService.reAjaxFun(() => {
this.deleteOta(id, callback);
});
}).send();
},
// 上传固件文件
uploadFirmware(file, callback) {
const formData = new FormData();
formData.append('file', file);
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/upload`)
.method('POST')
.data(formData)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('上传固件文件失败:', err);
RequestService.reAjaxFun(() => {
this.uploadFirmware(file, callback);
});
}).send();
},
// 获取固件下载链接
getDownloadUrl(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/otaMag/getDownloadUrl/${id}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
console.error('获取下载链接失败:', err);
RequestService.reAjaxFun(() => {
this.getDownloadUrl(id, callback);
});
}).send();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,198 @@
<template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose" @open="handleOpen">
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="固件名称" prop="firmwareName">
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
</el-form-item>
<el-form-item label="固件类型" prop="type">
<el-select v-model="form.type" placeholder="请选择固件类型" style="width: 100%;" filterable>
<el-option v-for="item in firmwareTypes" :key="item.key" :label="item.name" :value="item.key"></el-option>
</el-select>
</el-form-item>
<el-form-item label="版本号" prop="version">
<el-input v-model="form.version" placeholder="请输入版本号(x.x.x格式)"></el-input>
</el-form-item>
<el-form-item label="固件文件" prop="firmwarePath">
<el-upload ref="upload" class="upload-demo" action="#" :http-request="handleUpload"
:before-upload="beforeUpload" :accept="'.bin,.apk'" :limit="1" :multiple="false" :auto-upload="true"
:on-remove="handleRemove">
<el-button size="small" type="primary">点击上传</el-button>
<div slot="tip" class="el-upload__tip">只能上传固件文件(.bin/.apk)且不超过100MB</div>
</el-upload>
<el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress"
:status="uploadStatus"></el-progress>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input type="textarea" v-model="form.remark" placeholder="请输入备注信息"></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="handleCancel"> </el-button>
<el-button type="primary" @click="handleSubmit"> </el-button>
</div>
</el-dialog>
</template>
<script>
import Api from '@/apis/api';
import { FIRMWARE_TYPES } from '@/utils';
export default {
name: 'FirmwareDialog',
props: {
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
form: {
type: Object,
default: () => ({})
}
},
data() {
return {
firmwareTypes: FIRMWARE_TYPES,
uploadProgress: 0,
uploadStatus: '',
isUploading: false,
rules: {
firmwareName: [
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择固件类型', trigger: 'change' }
],
version: [
{ required: true, message: '请输入版本号', trigger: 'blur' },
{ pattern: /^\d+\.\d+\.\d+$/, message: '版本号格式不正确,请输入x.x.x格式', trigger: 'blur' }
],
firmwarePath: [
{ required: false, message: '请上传固件文件', trigger: 'change' }
]
}
}
},
methods: {
handleClose() {
this.$refs.form.clearValidate();
this.$emit('cancel');
},
handleCancel() {
this.$refs.form.clearValidate();
this.$emit('cancel');
},
handleSubmit() {
this.$refs.form.validate(valid => {
if (valid) {
// 如果是新增模式且没有上传文件,则提示错误
if (!this.form.id && !this.form.firmwarePath) {
this.$message.error('请上传固件文件')
return
}
// 提交成功后将关闭对话框的逻辑交给父组件处理
this.$emit('submit', this.form)
}
})
},
beforeUpload(file) {
const isValidSize = file.size / 1024 / 1024 < 100
const isValidType = ['.bin', '.apk'].some(ext => file.name.toLowerCase().endsWith(ext))
if (!isValidType) {
this.$message.error('只能上传.bin/.apk格式的固件文件!')
return false
}
if (!isValidSize) {
this.$message.error('固件文件大小不能超过100MB!')
return false
}
return true
},
handleUpload(options) {
const { file } = options
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = true
// 使用setTimeout实现简单的0-50%过渡
const timer = setTimeout(() => {
if (this.uploadProgress < 50) { // 只有当进度小于50%时才设置
this.uploadProgress = 50
}
}, 1000)
Api.ota.uploadFirmware(file, (res) => {
clearTimeout(timer) // 清除定时器
res = res.data
if (res.code === 0) {
this.form.firmwarePath = res.data
this.form.size = file.size
this.uploadProgress = 100
this.uploadStatus = 'success'
this.$message.success('固件文件上传成功')
// 延迟2秒后隐藏进度条
setTimeout(() => {
this.isUploading = false
}, 2000)
} else {
this.uploadStatus = 'exception'
this.$message.error(res.msg || '文件上传失败')
this.isUploading = false
}
}, (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
// 只有当进度大于50%时才更新
if (progress > 50) {
this.uploadProgress = progress
}
// 如果上传完成但还没收到成功响应,保持进度条显示
if (progress === 100) {
this.uploadStatus = ''
}
}
})
},
handleRemove() {
this.form.firmwarePath = ''
this.form.size = 0
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = false
},
handleOpen() {
// 重置上传相关状态
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = false
// 重置表单中的文件相关字段
if (!this.form.id) { // 只在新增时重置
this.form.firmwarePath = ''
this.form.size = 0
// 重置上传组件
this.$nextTick(() => {
if (this.$refs.upload) {
this.$refs.upload.clearFiles()
}
})
}
}
}
}
</script>
<style lang="scss" scoped>
.upload-demo {
text-align: left;
}
.el-upload__tip {
line-height: 1.2;
padding-top: 5px;
color: #909399;
}
</style>
@@ -32,6 +32,12 @@
:style="{ filter: $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" />
参数管理
</div>
<div v-if="isSuperAdmin" class="equipment-management"
:class="{ 'active-tab': $route.path === '/ota-management' }" @click="goOtaManagement">
<img loading="lazy" alt="" src="@/assets/header/firmware_update.png"
:style="{ filter: $route.path === '/ota-management' ? 'brightness(0) invert(1)' : 'None' }" />
OTA管理
</div>
</div>
<!-- 右侧元素 -->
@@ -105,6 +111,9 @@ export default {
goParamManagement() {
this.$router.push('/params-management')
},
goOtaManagement() {
this.$router.push('/ota-management')
},
// 获取用户信息
fetchUserInfo() {
userApi.getUserInfo(({ data }) => {
+11 -7
View File
@@ -62,13 +62,6 @@ const routes = [
return import('../views/ModelConfig.vue')
}
},
{
path: '/test',
name: 'TestServer',
component: function () {
return import('../views/test.vue')
}
},
{
path: '/params-management',
name: 'ParamsManagement',
@@ -80,6 +73,17 @@ const routes = [
title: '参数管理'
}
},
{
path: '/ota-management',
name: 'OtaManagement',
component: function () {
return import('../views/OtaManagement.vue')
},
meta: {
requiresAuth: true,
title: 'OTA管理'
}
},
]
const router = new VueRouter({
+21
View File
@@ -0,0 +1,21 @@
// 日期格式化函数
export function formatDate(date) {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hour = String(d.getHours()).padStart(2, '0');
const minute = String(d.getMinutes()).padStart(2, '0');
const second = String(d.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
// 文件大小格式化函数
export function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + units[i];
}
+60
View File
@@ -136,3 +136,63 @@ export function getUUID() {
})
}
/**
* 固件类型选项
*/
export const FIRMWARE_TYPES = [
{ "key": "bread-compact-wifi", "name": "面包板新版接线(WiFi" },
{ "key": "bread-compact-wifi-lcd", "name": "面包板新版接线(WiFi+ LCD" },
{ "key": "bread-compact-ml307", "name": "面包板新版接线(ML307 AT" },
{ "key": "bread-compact-esp32", "name": "面包板(WiFi ESP32 DevKit" },
{ "key": "bread-compact-esp32-lcd", "name": "面包板(WiFi+ LCD ESP32 DevKit" },
{ "key": "df-k10", "name": "DFRobot 行空板 k10" },
{ "key": "esp32-cgc", "name": "ESP32 CGC" },
{ "key": "esp-box-3", "name": "ESP BOX 3" },
{ "key": "esp-box", "name": "ESP BOX" },
{ "key": "esp-box-lite", "name": "ESP BOX Lite" },
{ "key": "kevin-box-1", "name": "Kevin Box 1" },
{ "key": "kevin-box-2", "name": "Kevin Box 2" },
{ "key": "kevin-c3", "name": "Kevin C3" },
{ "key": "kevin-sp-v3-dev", "name": "Kevin SP V3开发板" },
{ "key": "kevin-sp-v4-dev", "name": "Kevin SP V4开发板" },
{ "key": "kevin-yuying-313lcd", "name": "鱼鹰科技3.13LCD开发板" },
{ "key": "lichuang-dev", "name": "立创·实战派ESP32-S3开发板" },
{ "key": "lichuang-c3-dev", "name": "立创·实战派ESP32-C3开发板" },
{ "key": "magiclick-2p4", "name": "神奇按钮 Magiclick_2.4" },
{ "key": "magiclick-2p5", "name": "神奇按钮 Magiclick_2.5" },
{ "key": "magiclick-c3", "name": "神奇按钮 Magiclick_C3" },
{ "key": "magiclick-c3-v2", "name": "神奇按钮 Magiclick_C3_v2" },
{ "key": "m5stack-core-s3", "name": "M5Stack CoreS3" },
{ "key": "atoms3-echo-base", "name": "AtomS3 + Echo Base" },
{ "key": "atoms3r-echo-base", "name": "AtomS3R + Echo Base" },
{ "key": "atoms3r-cam-m12-echo-base", "name": "AtomS3R CAM/M12 + Echo Base" },
{ "key": "atommatrix-echo-base", "name": "AtomMatrix + Echo Base" },
{ "key": "xmini-c3", "name": "虾哥 Mini C3" },
{ "key": "esp32s3-korvo2-v3", "name": "ESP32S3_KORVO2_V3开发板" },
{ "key": "esp-sparkbot", "name": "ESP-SparkBot开发板" },
{ "key": "esp-spot-s3", "name": "ESP-Spot-S3" },
{ "key": "esp32-s3-touch-amoled-1.8", "name": "Waveshare ESP32-S3-Touch-AMOLED-1.8" },
{ "key": "esp32-s3-touch-lcd-1.85c", "name": "Waveshare ESP32-S3-Touch-LCD-1.85C" },
{ "key": "esp32-s3-touch-lcd-1.85", "name": "Waveshare ESP32-S3-Touch-LCD-1.85" },
{ "key": "esp32-s3-touch-lcd-1.46", "name": "Waveshare ESP32-S3-Touch-LCD-1.46" },
{ "key": "esp32-s3-touch-lcd-3.5", "name": "Waveshare ESP32-S3-Touch-LCD-3.5" },
{ "key": "tudouzi", "name": "土豆子" },
{ "key": "lilygo-t-circle-s3", "name": "LILYGO T-Circle-S3" },
{ "key": "lilygo-t-cameraplus-s3", "name": "LILYGO T-CameraPlus-S3" },
{ "key": "movecall-moji-esp32s3", "name": "Movecall Moji 小智AI衍生版" },
{ "key": "movecall-cuican-esp32s3", "name": "Movecall CuiCan 璀璨·AI吊坠" },
{ "key": "atk-dnesp32s3", "name": "正点原子DNESP32S3开发板" },
{ "key": "atk-dnesp32s3-box", "name": "正点原子DNESP32S3-BOX" },
{ "key": "du-chatx", "name": "嘟嘟开发板CHATX(wifi)" },
{ "key": "taiji-pi-s3", "name": "太极小派esp32s3" },
{ "key": "xingzhi-cube-0.85tft-wifi", "name": "无名科技星智0.85(WIFI)" },
{ "key": "xingzhi-cube-0.85tft-ml307", "name": "无名科技星智0.85(ML307)" },
{ "key": "xingzhi-cube-0.96oled-wifi", "name": "无名科技星智0.96(WIFI)" },
{ "key": "xingzhi-cube-0.96oled-ml307", "name": "无名科技星智0.96(ML307)" },
{ "key": "xingzhi-cube-1.54tft-wifi", "name": "无名科技星智1.54(WIFI)" },
{ "key": "xingzhi-cube-1.54tft-ml307", "name": "无名科技星智1.54(ML307)" },
{ "key": "sensecap-watcher", "name": "SenseCAP Watcher" },
{ "key": "doit-s3-aibox", "name": "四博智联AI陪伴盒子" },
{ "key": "mixgo-nova", "name": "元控·青春" }
]
+29 -26
View File
@@ -5,8 +5,8 @@
<div class="operation-bar">
<h2 class="page-title">设备管理</h2>
<div class="right-operations">
<el-input placeholder="请输入设备型号或Mac地址查询" v-model="searchKeyword"
class="search-input" @keyup.enter.native="handleSearch" clearable />
<el-input placeholder="请输入设备型号或Mac地址查询" v-model="searchKeyword" class="search-input"
@keyup.enter.native="handleSearch" clearable />
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
</div>
</div>
@@ -29,7 +29,11 @@
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="设备型号" prop="model" align="center"></el-table-column>
<el-table-column label="设备型号" prop="model" align="center">
<template slot-scope="scope">
{{ getFirmwareTypeName(scope.row.model) }}
</template>
</el-table-column>
<el-table-column label="固件版本" prop="firmwareVersion" align="center" ></el-table-column>
<el-table-column label="Mac地址" prop="macAddress" align="center"></el-table-column>
<el-table-column label="绑定时间" prop="bindTime" align="center"></el-table-column>
@@ -39,7 +43,8 @@
<el-input v-if="scope.row.isEdit" v-model="scope.row.remark" size="mini"
@blur="stopEditRemark(scope.$index)"></el-input>
<span v-else>
<i v-if="!scope.row.remark" class="el-icon-edit" @click="startEditRemark(scope.$index, scope.row)"></i>
<i v-if="!scope.row.remark" class="el-icon-edit"
@click="startEditRemark(scope.$index, scope.row)"></i>
<span v-else @click="startEditRemark(scope.$index, scope.row)">
{{ scope.row.remark }}
</span>
@@ -69,26 +74,17 @@
<el-button type="success" size="mini" class="add-device-btn" @click="handleAddDevice">
新增
</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete"
@click="deleteSelected">解绑</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteSelected">解绑</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option
v-for="item in pageSizeOptions"
:key="item"
:label="`${item}条/页`"
:value="item">
</el-option>
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
<button
v-for="page in visiblePages"
:key="page"
class="pagination-btn"
:class="{ active: page === currentPage }"
@click="goToPage(page)">
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
@@ -110,6 +106,7 @@
import Api from '@/apis/api';
import AddDeviceDialog from "@/components/AddDeviceDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import { FIRMWARE_TYPES } from "@/utils";
export default {
components: { HeaderBar, AddDeviceDialog },
@@ -311,7 +308,7 @@ export default {
rawBindTime: new Date(device.createDate).getTime()
};
})
.sort((a, b) => a.rawBindTime - b.rawBindTime);
.sort((a, b) => a.rawBindTime - b.rawBindTime);
this.activeSearchKeyword = "";
this.searchKeyword = "";
} else {
@@ -319,12 +316,16 @@ export default {
}
});
},
headerCellClassName({columnIndex}) {
headerCellClassName({ columnIndex }) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
}
},
getFirmwareTypeName(type) {
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
return firmwareType ? firmwareType.name : type;
},
}
};
</script>
@@ -393,12 +394,12 @@ export default {
transition: border-color 0.2s;
}
::v-deep .page-size-select{
::v-deep .page-size-select {
width: 100px;
margin-right: 8px;
}
::v-deep .page-size-select .el-input__inner{
::v-deep .page-size-select .el-input__inner {
height: 32px;
line-height: 32px;
border-radius: 4px;
@@ -407,7 +408,8 @@ export default {
color: #606266;
font-size: 14px;
}
::v-deep .page-size-select .el-input__suffix{
::v-deep .page-size-select .el-input__suffix {
right: 6px;
width: 15px;
height: 20px;
@@ -418,13 +420,14 @@ export default {
border-radius: 4px;
}
::v-deep .page-size-select .el-input__suffix-inner{
::v-deep .page-size-select .el-input__suffix-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
::v-deep .page-size-select .el-icon-arrow-up:before{
::v-deep .page-size-select .el-icon-arrow-up:before {
content: "";
display: inline-block;
border-left: 6px solid transparent;
+18 -21
View File
@@ -44,19 +44,11 @@
<!-- 右侧内容 -->
<div class="content-area">
<el-card class="model-card" shadow="never">
<el-table
ref="modelTable"
style="width: 100%"
v-loading="loading"
element-loading-text="拼命加载中"
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 ref="modelTable" style="width: 100%" v-loading="loading" element-loading-text="拼命加载中"
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 label="模型ID" prop="id" 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" />
<AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" />
</div>
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
@@ -145,9 +140,9 @@ import AddModelDialog from "@/components/AddModelDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import ModelEditDialog from "@/components/ModelEditDialog.vue";
import TtsModel from "@/components/TtsModel.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default {
components: { HeaderBar, ModelEditDialog, TtsModel, AddModelDialog },
components: { HeaderBar, ModelEditDialog, TtsModel, AddModelDialog, VersionFooter },
data() {
return {
addDialogVisible: false,
@@ -520,8 +515,9 @@ export default {
.nav-panel .el-menu-item {
height: 50px;
background: #e9f0ff;
line-height: 50px;
border-radius: 4px;
border-radius: 4px 0 0 4px !important;
transition: all 0.3s;
display: flex !important;
justify-content: flex-end;
@@ -532,8 +528,7 @@ export default {
}
.nav-panel .el-menu-item.is-active {
background: #e9f0ff;
color: #0ba6f4 !important;
background: #5778ff;
position: relative;
padding-left: 40px !important;
}
@@ -546,7 +541,7 @@ export default {
transform: translateY(-50%);
width: 13px;
height: 13px;
background: #409EFF;
background: #fff;
border-radius: 50%;
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 {
color: #409EFF !important;
color: #fff !important;
}
::v-deep .data-table {
@@ -929,17 +924,19 @@ export default {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
::v-deep .el-loading-spinner .circular {
width: 28px;
height: 28px;
}
::v-deep .el-loading-spinner .path {
stroke: #6b8cff;
}
::v-deep .el-loading-text {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,646 @@
<template>
<div class="welcome">
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">固件管理</h2>
<div class="right-operations">
<el-input placeholder="请输入固件名称查询" v-model="searchName" class="search-input"
@keyup.enter.native="handleSearch" clearable />
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
</div>
</div>
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="params-card" shadow="never">
<el-table ref="paramsTable" :data="paramsList" class="transparent-table"
:header-cell-class-name="headerCellClassName">
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="固件名称" prop="firmwareName" align="center"></el-table-column>
<el-table-column label="固件类型" prop="type" align="center">
<template slot-scope="scope">
{{ getFirmwareTypeName(scope.row.type) }}
</template>
</el-table-column>
<el-table-column label="版本号" prop="version" align="center"></el-table-column>
<el-table-column label="文件大小" prop="size" align="center">
<template slot-scope="scope">
{{ formatFileSize(scope.row.size) }}
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" align="center"
show-overflow-tooltip></el-table-column>
<el-table-column label="创建时间" prop="createDate" align="center">
<template slot-scope="scope">
{{ formatDate(scope.row.createDate) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template slot-scope="scope">
<el-button size="mini" type="text"
@click="downloadFirmware(scope.row)">下载</el-button>
<el-button size="mini" type="text" @click="editParam(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click="deleteParam(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table_bottom">
<div class="ctrl_btn">
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
<el-button size="mini" type="success" @click="showAddDialog"
style="background: #5bc98c;border: None;">新增</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete"
@click="deleteSelectedParams">删除</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
:value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
首页
</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
上一页
</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn"
:class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
下一页
</button>
<span class="total-text">{{ total }}条记录</span>
</div>
</div>
</el-card>
</div>
</div>
</div>
<!-- 新增/编辑固件对话框 -->
<firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm" @submit="handleSubmit"
@cancel="dialogVisible = false" />
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
<script>
import Api from "@/apis/api";
import FirmwareDialog from "@/components/FirmwareDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue";
import { FIRMWARE_TYPES } from "@/utils";
import { formatDate, formatFileSize } from "@/utils/format";
export default {
components: { HeaderBar, FirmwareDialog, VersionFooter },
data() {
return {
searchName: "",
paramsList: [],
firmwareList: [],
currentPage: 1,
pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
total: 0,
dialogVisible: false,
dialogTitle: "新增固件",
isAllSelected: false,
firmwareForm: {
id: null,
firmwareName: "",
type: "",
version: "",
size: 0,
remark: "",
firmwarePath: ""
},
};
},
created() {
this.fetchFirmwareList();
},
computed: {
pageCount() {
return Math.ceil(this.total / this.pageSize);
},
visiblePages() {
const pages = [];
const maxVisible = 3;
let start = Math.max(1, this.currentPage - 1);
let end = Math.min(this.pageCount, start + maxVisible - 1);
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
},
},
methods: {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.fetchFirmwareList();
},
fetchFirmwareList() {
const params = {
pageNum: this.currentPage,
pageSize: this.pageSize,
firmwareName: this.searchName || "",
orderField: "create_date",
order: "desc"
};
Api.ota.getOtaList(params, (res) => {
res = res.data
if (res.code === 0) {
this.firmwareList = res.data.list.map(item => ({
...item,
selected: false
}));
this.paramsList = this.firmwareList;
this.total = res.data.total || 0;
} else {
this.firmwareList = [];
this.paramsList = [];
this.total = 0;
this.$message.error({
message: res?.data?.msg || '获取固件列表失败',
showClose: true
});
}
});
},
handleSearch() {
this.currentPage = 1;
this.fetchFirmwareList();
},
handleSelectAll() {
this.isAllSelected = !this.isAllSelected;
this.firmwareList.forEach(row => {
row.selected = this.isAllSelected;
});
},
showAddDialog() {
this.dialogTitle = "新增固件";
// 完全重置表单数据
this.firmwareForm = {
id: null,
firmwareName: "",
type: "",
version: "",
size: 0,
remark: "",
firmwarePath: ""
};
this.$nextTick(() => {
// 重置表单的校验状态
if (this.$refs.firmwareDialog && this.$refs.firmwareDialog.$refs.form) {
this.$refs.firmwareDialog.$refs.form.clearValidate();
}
});
this.dialogVisible = true;
},
editParam(row) {
this.dialogTitle = "编辑固件";
this.firmwareForm = { ...row };
this.dialogVisible = true;
},
handleSubmit(form) {
if (form.id) {
// 编辑
Api.ota.updateOta(form.id, form, (res) => {
res = res.data;
if (res.code === 0) {
this.$message.success({
message: "修改成功",
showClose: true
});
this.dialogVisible = false;
this.fetchFirmwareList();
} else {
this.$message.error({
message: res.msg || "修改失败",
showClose: true
});
}
});
} else {
// 新增
Api.ota.saveOta(form, (res) => {
res = res.data;
if (res.code === 0) {
this.$message.success({
message: "新增成功",
showClose: true
});
this.dialogVisible = false;
this.fetchFirmwareList();
} else {
this.$message.error({
message: res.msg || "新增失败",
showClose: true
});
}
});
}
},
deleteSelectedParams() {
const selectedRows = this.firmwareList.filter(row => row.selected);
if (selectedRows.length === 0) {
this.$message.warning({
message: "请先选择需要删除的固件",
showClose: true
});
return;
}
this.deleteParam(selectedRows);
},
deleteParam(row) {
// 处理单个参数或参数数组
const params = Array.isArray(row) ? row : [row];
if (Array.isArray(row) && row.length === 0) {
this.$message.warning({
message: "请先选择需要删除的参数",
showClose: true
});
return;
}
const paramCount = params.length;
this.$confirm(`确定要删除选中的${paramCount}个固件吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
distinguishCancelAndClose: true
}).then(() => {
const ids = params.map(param => param.id);
if (ids.some(id => !id)) {
this.$message.error({
message: '存在无效的参数ID',
showClose: true
});
return;
}
Api.ota.deleteOta(ids, (res) => {
res = res.data;
if (res.code === 0) {
this.$message.success({
message: `成功删除${paramCount}个固件`,
showClose: true
});
this.fetchFirmwareList();
} else {
this.$message.error({
message: res.msg || '删除失败,请重试',
showClose: true
});
}
});
}).catch(action => {
if (action === 'cancel') {
this.$message({
type: 'info',
message: '已取消删除操作',
duration: 1000
});
} else {
this.$message({
type: 'info',
message: '操作已关闭',
duration: 1000
});
}
});
},
headerCellClassName({ columnIndex }) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
},
goFirst() {
this.currentPage = 1;
this.fetchFirmwareList();
},
goPrev() {
if (this.currentPage > 1) {
this.currentPage--;
this.fetchFirmwareList();
}
},
goNext() {
if (this.currentPage < this.pageCount) {
this.currentPage++;
this.fetchFirmwareList();
}
},
goToPage(page) {
this.currentPage = page;
this.fetchFirmwareList();
},
downloadFirmware(firmware) {
if (!firmware || !firmware.id) {
this.$message.error('固件信息不完整');
return;
}
// 先获取下载链接
Api.ota.getDownloadUrl(firmware.id, (res) => {
if (res.data.code === 0) {
const uuid = res.data.data;
const baseUrl = process.env.VUE_APP_API_BASE_URL || '';
window.open(`${window.location.origin}${baseUrl}/otaMag/download/${uuid}`);
} else {
this.$message.error('获取下载链接失败');
}
});
},
formatDate,
formatFileSize,
getFirmwareTypeName(type) {
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
return firmwareType ? firmwareType.name : type;
},
},
};
</script>
<style lang="scss" scoped>
.welcome {
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
position: relative;
flex-direction: column;
background-size: cover;
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.right-operations {
display: flex;
gap: 10px;
margin-left: auto;
}
.search-input {
width: 240px;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
.content-panel {
flex: 1;
display: flex;
overflow: hidden;
height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
}
.content-area {
flex: 1;
height: 100%;
min-width: 600px;
overflow: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.params-card {
background: white;
border: none;
box-shadow: none;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding-bottom: 10px;
}
.ctrl_btn {
display: flex;
gap: 8px;
padding-left: 26px;
.el-button {
min-width: 72px;
height: 32px;
padding: 7px 12px 7px 10px;
font-size: 12px;
border-radius: 4px;
line-height: 1;
font-weight: 500;
border: none;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
}
.el-button--primary {
background: #5f70f3;
color: white;
}
.el-button--danger {
background: #fd5b63;
color: white;
}
}
.custom-pagination {
display: flex;
align-items: center;
gap: 10px;
.el-select {
margin-right: 8px;
}
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-last-child(2),
.pagination-btn:nth-child(3) {
min-width: 60px;
height: 32px;
padding: 0 12px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #d7dce6;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(245, 247, 250, 0.3);
}
}
.pagination-btn.active {
background: #5f70f3 !important;
color: #ffffff !important;
border-color: #5f70f3 !important;
&:hover {
background: #6d7cf5 !important;
}
}
}
.total-text {
margin-left: 10px;
color: #606266;
font-size: 14px;
}
.page-size-select {
width: 100px;
}
.custom-selection-header {
text-align: center !important;
}
:deep(.transparent-table) {
background-color: transparent;
.el-table__header-wrapper {
flex-shrink: 0;
}
.el-table__header th {
background-color: white !important;
color: black;
font-weight: 600;
height: 40px;
padding: 8px 0;
font-size: 14px;
border-bottom: 1px solid #e4e7ed;
}
.el-table__body-wrapper {
td {
background-color: transparent;
border-bottom: 1px solid #e4e7ed;
padding: 8px 0;
height: 40px;
color: #606266;
font-size: 14px;
}
}
.el-table__row:hover>td {
background-color: #f5f7fa !important;
}
}
:deep(.el-table .el-button--text) {
color: #7079aa !important;
}
:deep(.el-table .el-button--text:hover) {
color: #5a64b5 !important;
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
border-color: #cccccc !important;
}
:deep(.el-checkbox__inner:hover) {
border-color: #cccccc !important;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #5f70f3 !important;
border-color: #5f70f3 !important;
}
</style>
+105 -110
View File
@@ -15,15 +15,10 @@
<div class="content-panel">
<div class="content-area">
<el-card class="params-card" shadow="never">
<el-table
ref="paramsTable"
:data="paramsList"
class="transparent-table"
v-loading="loading"
element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-class-name="headerCellClassName">
<el-table ref="paramsTable" :data="paramsList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-class-name="headerCellClassName">
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
@@ -48,14 +43,11 @@
</el-button>
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete"
@click="deleteSelectedParams">删除</el-button>
@click="deleteSelectedParams">删除</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option
v-for="item in pageSizeOptions"
:key="item"
:label="`${item}条/页`"
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
:value="item">
</el-option>
</el-select>
@@ -83,7 +75,9 @@
<!-- 新增/编辑参数对话框 -->
<param-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="paramForm" @submit="handleSubmit"
@cancel="dialogVisible = false" />
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
@@ -91,9 +85,9 @@
import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue";
import ParamDialog from "@/components/ParamDialog.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default {
components: { HeaderBar, ParamDialog },
components: { HeaderBar, ParamDialog, VersionFooter },
data() {
return {
searchCode: "",
@@ -146,29 +140,29 @@ export default {
this.fetchParams();
},
fetchParams() {
this.loading = true;
Api.admin.getParamsList(
{
page: this.currentPage,
limit: this.pageSize,
paramCode: this.searchCode,
},
({ data }) => {
this.loading = false;
if (data.code === 0) {
this.paramsList = data.data.list.map(item => ({
...item,
selected: false
}));
this.total = data.data.total;
} else {
this.$message.error({
message: data.msg || '获取参数列表失败',
showClose: true
});
}
}
);
this.loading = true;
Api.admin.getParamsList(
{
page: this.currentPage,
limit: this.pageSize,
paramCode: this.searchCode,
},
({ data }) => {
this.loading = false;
if (data.code === 0) {
this.paramsList = data.data.list.map(item => ({
...item,
selected: false
}));
this.total = data.data.total;
} else {
this.$message.error({
message: data.msg || '获取参数列表失败',
showClose: true
});
}
}
);
},
handleSearch() {
this.currentPage = 1;
@@ -197,33 +191,33 @@ export default {
},
handleSubmit({ form, done }) {
if (form.id) {
// 编辑
Api.admin.updateParam(form, ({ data }) => {
if (data.code === 0) {
this.$message.success({
message:"修改成功",
showClose:true
if (form.id) {
// 编辑
Api.admin.updateParam(form, ({ data }) => {
if (data.code === 0) {
this.$message.success({
message: "修改成功",
showClose: true
});
this.dialogVisible = false;
this.fetchParams();
}
done && done();
});
this.dialogVisible = false;
this.fetchParams();
}
done && done();
});
} else {
// 新增
Api.admin.addParam(form, ({ data }) => {
if (data.code === 0) {
this.$message.success({
message:"新增成功",
showClose:true
} else {
// 新增
Api.admin.addParam(form, ({ data }) => {
if (data.code === 0) {
this.$message.success({
message: "新增成功",
showClose: true
});
this.dialogVisible = false;
this.fetchParams();
}
done && done();
});
this.dialogVisible = false;
this.fetchParams();
}
done && done();
});
}
}
},
deleteSelectedParams() {
@@ -243,8 +237,8 @@ export default {
if (Array.isArray(row) && row.length === 0) {
this.$message.warning({
message:"请先选择需要删除的参数",
showClose:true
message: "请先选择需要删除的参数",
showClose: true
});
return;
}
@@ -259,8 +253,8 @@ export default {
const ids = params.map(param => param.id);
if (ids.some(id => isNaN(id))) {
this.$message.error({
message:'存在无效的参数ID',
showClose: true
message: '存在无效的参数ID',
showClose: true
});
return;
}
@@ -341,16 +335,16 @@ export default {
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
@@ -402,21 +396,21 @@ export default {
}
.params-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
background: white;
flex: 1;
display: flex;
flex-direction: column;
flex: 1;
border: none;
box-shadow: none;
overflow: hidden;
}
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
}
.table_bottom {
@@ -467,7 +461,7 @@ export default {
gap: 10px;
.el-select {
margin-right: 8px;
margin-right: 8px;
}
.pagination-btn:first-child,
@@ -535,15 +529,17 @@ export default {
width: 100%;
display: flex;
flex-direction: column;
.el-table__body-wrapper {
flex: 1;
overflow-y: auto;
max-height: none !important;
flex: 1;
overflow-y: auto;
max-height: none !important;
}
.el-table__header-wrapper {
flex-shrink: 0;
flex-shrink: 0;
}
.el-table__header th {
background: white !important;
color: black;
@@ -670,32 +666,31 @@ export default {
}
.el-table {
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
.el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px);
}
.el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px);
}
}
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style>
+62 -65
View File
@@ -15,18 +15,13 @@
<div class="content-panel">
<div class="content-area">
<el-card class="user-card" shadow="never">
<el-table
ref="userTable"
:data="userList"
class="transparent-table"
v-loading="loading"
element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)">
<el-table ref="userTable" :data="userList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)">
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
@@ -61,11 +56,7 @@
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option
v-for="item in pageSizeOptions"
:key="item"
:label="`${item}条/页`"
:value="item">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
</el-option>
</el-select>
@@ -90,16 +81,19 @@
</div>
<view-password-dialog :visible.sync="showViewPassword" :password="currentPassword" />
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
<script>
import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue";
import ViewPasswordDialog from "@/components/ViewPasswordDialog.vue";
export default {
components: { HeaderBar, ViewPasswordDialog },
components: { HeaderBar, ViewPasswordDialog, VersionFooter },
data() {
return {
showViewPassword: false,
@@ -138,7 +132,7 @@ export default {
},
},
methods: {
handlePageSizeChange(val) {
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.fetchUsers();
@@ -169,10 +163,10 @@ export default {
this.fetchUsers();
},
handleSelectAll() {
this.isAllSelected = !this.isAllSelected;
this.userList.forEach(row => {
row.selected = this.isAllSelected;
});
this.isAllSelected = !this.isAllSelected;
this.userList.forEach(row => {
row.selected = this.isAllSelected;
});
},
batchDelete() {
const selectedUsers = this.userList.filter(user => user.selected);
@@ -240,12 +234,12 @@ export default {
});
},
batchEnable() {
const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 1);
const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 1);
},
batchDisable() {
const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 0);
const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 0);
},
resetPassword(row) {
this.$confirm("重置后将会生成新密码,是否继续?", "提示", {
@@ -573,6 +567,7 @@ export default {
width: 100%;
display: flex;
flex-direction: column;
.el-table__body-wrapper {
flex: 1;
overflow-y: auto;
@@ -648,47 +643,47 @@ export default {
}
.page-size-select {
width: 100px;
margin-right: 10px;
width: 100px;
margin-right: 10px;
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__suffix) {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix) {
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-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
}
.el-table {
@@ -704,17 +699,19 @@ export default {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
</style>
-400
View File
@@ -1,400 +0,0 @@
<template>
<div class="container">
<h1 class="title">XiaoZhi ESP32 Server 测试助手</h1>
<div class="chat-container" ref="chatContainer">
<div v-for="(message, index) in messages" :key="index"
:class="['message', message.role === 'user' ? 'user' : 'assistant']">
<span>{{ message.content }}</span>
</div>
</div>
<div class="controls">
<button @click="toggleRecording" :disabled="wsStatus !== 'connected'" :class="{ recording: isRecording }">
{{ isRecording ? '停止录音' : '开始录音' }}
</button>
<p>WebSocket: {{ wsStatus }}</p>
</div>
</div>
</template>
<script>
import { OpusDecoder } from 'opus-decoder';
import Recorder from 'opus-recorder';
export default {
name: 'TestPage',
data() {
return {
messages: [],
wsStatus: 'disconnected',
isRecording: false,
ws: null,
recorder: null,
stream: null,
audioContext: null,
sourceNode: null,
audioDecoder: null,
audioBufferQueue: [], // 当前句子的音频缓冲区
playbackQueue: [], // 播放队列,存储待播放的句子
isPlaying: false,
}
},
methods: {
connectWebSocket() {
this.ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.wsStatus = 'connected';
console.log('WebSocket 连接成功');
this.ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
this.audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
};
this.ws.onmessage = async (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
console.log('收到文本消息:', msg);
if (msg.type === 'stt') {
this.messages.push({ role: 'user', content: msg.text });
this.scrollToBottom();
} else if (msg.type === 'llm') {
this.messages.push({ role: 'assistant', content: msg.text });
this.scrollToBottom();
} else if (msg.type === 'tts') {
if (msg.state === 'sentence_start') {
this.audioBufferQueue = [];
const lastMessage = this.messages[this.messages.length - 1];
if (!lastMessage || lastMessage.content !== msg.text) {
this.messages.push({ role: 'assistant', content: msg.text });
this.scrollToBottom();
}
} else if (msg.state === 'sentence_end') {
if (this.audioBufferQueue.length > 0) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
} else if (msg.state === 'stop') {
console.log('TTS 任务结束');
if (this.audioBufferQueue.length > 0) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
}
} else if (msg.type === 'hello') {
console.log('收到服务器初始化消息:', msg);
}
} else if (event.data instanceof ArrayBuffer) {
console.log('收到音频帧,大小:', event.data.byteLength, '字节');
const opusFrame = new Uint8Array(event.data);
const frameHead = Array.from(opusFrame.slice(0, 8))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
console.log('音频帧前8字节:', frameHead);
try {
const decoded = this.audioDecoder.decodeFrame(opusFrame);
console.log('解码结果:', decoded);
if (decoded && decoded.channelData && decoded.channelData[0]) {
const pcmData = decoded.channelData[0];
if (pcmData.length > 0) {
this.audioBufferQueue.push(pcmData);
console.log('解码音频帧,PCM 数据长度:', pcmData.length);
if (this.audioBufferQueue.length >= 5 && this.playbackQueue.length === 0 && !this.isPlaying) {
this.playbackQueue.push([...this.audioBufferQueue]);
this.audioBufferQueue = [];
this.playNextInQueue();
}
} else {
console.warn('解码成功,但 PCM 数据长度为 0');
}
} else {
console.warn('解码结果无效:', decoded);
}
} catch (e) {
console.error('Opus 解码错误:', e);
}
}
};
this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
this.wsStatus = 'error';
};
this.ws.onclose = () => {
this.wsStatus = 'disconnected';
console.log('WebSocket 断开');
setTimeout(this.connectWebSocket, 1000);
};
},
scrollToBottom() {
this.$nextTick(() => {
if (this.$refs.chatContainer) {
this.$refs.chatContainer.scrollTop = this.$refs.chatContainer.scrollHeight;
}
});
},
async playNextInQueue() {
if (this.isPlaying || this.playbackQueue.length === 0) {
return;
}
this.isPlaying = true;
if (!this.audioContext || this.audioContext.state === 'closed') {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
}
const pcmBuffers = this.playbackQueue.shift();
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
if (totalLength === 0) {
console.error('音频缓冲区总长度为 0,无法播放');
this.isPlaying = false;
this.playNextInQueue();
return;
}
const mergedPcm = new Float32Array(totalLength);
let offset = 0;
for (const pcm of pcmBuffers) {
mergedPcm.set(pcm, offset);
offset += pcm.length;
}
const audioBuffer = this.audioContext.createBuffer(1, totalLength, 16000);
audioBuffer.getChannelData(0).set(mergedPcm);
const source = this.audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.audioContext.destination);
source.onended = () => {
this.isPlaying = false;
console.log('音频播放结束');
this.playNextInQueue();
};
source.start();
console.log('开始播放音频,总长度:', totalLength);
},
stripOggContainer(data) {
let arrayBuffer;
if (data instanceof ArrayBuffer) {
arrayBuffer = data;
} else if (data.buffer instanceof ArrayBuffer) {
arrayBuffer = data.buffer;
} else {
console.error('输入数据类型不支持:', data);
return [data];
}
const dataView = new DataView(arrayBuffer);
const frames = [];
let offset = 0;
while (offset < arrayBuffer.byteLength) {
if (dataView.getUint32(offset) === 0x4F676753) { // 'OggS'
const segmentTableLength = dataView.getUint8(offset + 26);
const segmentTableOffset = offset + 27;
let segmentOffset = segmentTableOffset + segmentTableLength;
for (let i = 0; i < segmentTableLength; i++) {
const segmentLength = dataView.getUint8(segmentTableOffset + i);
const segmentData = arrayBuffer.slice(segmentOffset, segmentOffset + segmentLength);
segmentOffset += segmentLength;
const header = new TextDecoder().decode(segmentData.slice(0, 8));
if (header === 'OpusHead' || header === 'OpusTags') {
console.log(`跳过 ${header},大小: ${segmentLength} 字节`);
} else if (segmentLength >= 50 && segmentLength <= 300) {
frames.push(segmentData);
}
}
offset = segmentOffset;
} else {
const remainingLength = arrayBuffer.byteLength - offset;
if (remainingLength >= 50 && remainingLength <= 300) {
frames.push(arrayBuffer.slice(offset));
}
break;
}
}
if (frames.length === 0) {
console.warn('未找到有效裸 Opus 帧,返回原始数据');
return [arrayBuffer];
}
console.log('剥离后找到', frames.length, '个裸 Opus 帧');
return frames;
},
async initRecorder() {
console.log('开始初始化录音');
try {
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('获取麦克风权限成功,stream:', this.stream);
if (!this.audioContext || this.audioContext.state === 'closed') {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
}
this.sourceNode = this.audioContext.createMediaStreamSource(this.stream);
this.recorder = new Recorder({
encoderPath: '/encoderWorker.min.js',
sampleRate: 16000,
numberOfChannels: 1,
frameSize: 960,
encoderApplication: 2048,
encoderFrameSize: 60,
encoderBitRate: 24000,
monitorGain: 0,
sourceNode: this.sourceNode,
ogg: false,
streamPages: false,
maxFramesPerPage: 1,
});
this.recorder.ondataavailable = (data) => {
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
const frames = this.stripOggContainer(data);
frames.forEach((frame, index) => {
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
console.log(`${index} 大小: ${frame.byteLength} 字节,前8字节: ${frameHead}`);
if (frame.byteLength < 50 || frame.byteLength > 300) {
console.warn(`${index} 大小异常: ${frame.byteLength} 字节,预期 50-300 字节`);
return;
}
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(frame);
console.log('发送裸 Opus 帧:', frame.byteLength);
} else {
console.warn('WebSocket 未连接,跳过发送');
}
});
};
this.recorder.onstart = () => {
console.log('录音已启动');
};
this.recorder.onstop = () => {
console.log('录音停止');
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
this.sourceNode = null;
this.scrollToBottom();
};
console.log('Recorder 初始化成功');
} catch (err) {
console.error('初始化录音失败:', err);
alert('无法访问麦克风或录音初始化失败,请检查权限');
}
},
async toggleRecording() {
console.log('点击 toggleRecording,当前状态:', this.isRecording, 'WebSocket 状态:', this.wsStatus);
if (!this.recorder) {
await this.initRecorder();
if (!this.recorder) {
console.error('recorder 初始化失败');
return;
}
}
if (this.isRecording) {
console.log('停止录音');
this.recorder.stop();
this.isRecording = false;
} else {
try {
console.log('开始录音');
await this.initRecorder();
await this.recorder.start();
console.log('录音开始后,状态:', this.recorder.state);
this.isRecording = true;
} catch (err) {
console.error('录音启动失败:', err);
}
}
},
},
mounted() {
console.log('组件挂载,初始化 WebSocket');
this.connectWebSocket();
},
destroyed() {
if (this.ws) this.ws.close();
if (this.stream) this.stream.getTracks().forEach(track => track.stop());
if (this.recorder) this.recorder.stop();
if (this.audioContext) this.audioContext.close();
if (this.audioDecoder) this.audioDecoder.destroy();
}
};
</script>
<style scoped>
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: Arial, sans-serif;
}
.title {
text-align: center;
font-size: 24px;
margin-bottom: 20px;
}
.chat-container {
height: 60vh;
overflow-y: auto;
border: 1px solid #ccc;
padding: 10px;
background: #f9f9f9;
border-radius: 5px;
}
.message {
margin: 10px 0;
padding: 8px 12px;
border-radius: 5px;
max-width: 70%;
}
.user {
background: #007bff;
color: white;
margin-left: auto;
}
.assistant {
background: #e9ecef;
color: black;
}
.controls {
text-align: center;
margin-top: 20px;
}
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background: #007bff;
color: white;
border: none;
border-radius: 5px;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
button.recording {
background: #dc3545;
}
</style>
+2 -1
View File
@@ -39,7 +39,7 @@ const cdnResources = {
const useCDN = process.env.VUE_APP_USE_CDN === 'true';
module.exports = defineConfig({
productionSourceMap: process.env.NODE_ENV === 'production' ? false : true, // 生产环境不生成 source map
productionSourceMap: process.env.NODE_ENV !=='production', // 生产环境不生成 source map
devServer: {
port: 8001, // 指定端口为 8001
proxy: {
@@ -52,6 +52,7 @@ module.exports = defineConfig({
overlay: false, // 不显示 webpack 错误覆盖层
},
},
publicPath: process.env.VUE_APP_PUBLIC_PATH || "",
chainWebpack: config => {
// 修改 HTML 插件配置,动态插入 CDN 链接
@@ -69,6 +69,11 @@ def get_config_from_api(config):
"url": config["manager-api"].get("url", ""),
"secret": config["manager-api"].get("secret", ""),
}
if config.get("server"):
config_data["server"] = {
"ip": config["server"].get("ip", ""),
"port": config["server"].get("port", ""),
}
return config_data
+1 -1
View File
@@ -3,7 +3,7 @@ import sys
from loguru import logger
from config.config_loader import load_config
SERVER_VERSION = "0.3.9"
SERVER_VERSION = "0.3.11"
def get_module_abbreviation(module_name, module_dict):
+3
View File
@@ -5,6 +5,9 @@
# 先启动manager-api和manager-web,注册一个账号,第一个注册的账号为管理员
# 使用管理员,进入【参数管理】页面,找到【server.secret】,复制它到参数值,注意每次从零部署,server.secret都会变化
# 打开本data目录下的.config.yaml文件,修改manager-api.secret为刚才复制出来的server.secret
server:
ip: 0.0.0.0
port: 8000
manager-api:
# 你的manager-api的地址,最好使用局域网ip
url: http://127.0.0.1:8002/xiaozhi
+6 -6
View File
@@ -226,12 +226,12 @@ class ConnectionHandler:
"""加载意图识别"""
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)
# 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):
read_config_from_api = self.config.get("read_config_from_api", False)
@@ -6,6 +6,7 @@ from core.providers.llm.base import LLMProviderBase
TAG = __name__
logger = setup_logging()
class LLMProvider(LLMProviderBase):
def __init__(self, config):
self.api_key = config["api_key"]
@@ -19,21 +20,25 @@ class LLMProvider(LLMProviderBase):
# 处理dialogue
if self.is_No_prompt:
dialogue.pop(0)
logger.bind(tag=TAG).debug(f"【阿里百练API服务】处理后的dialogue: {dialogue}")
logger.bind(tag=TAG).debug(
f"【阿里百练API服务】处理后的dialogue: {dialogue}"
)
# 构造调用参数
call_params = {
"api_key": self.api_key,
"app_id": self.app_id,
"session_id": session_id,
"messages": dialogue
"messages": dialogue,
}
if self.memory_id != False:
# 百练memory需要prompt参数
prompt = dialogue[-1].get("content")
call_params["memory_id"] = self.memory_id
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)
if responses.status_code != HTTPStatus.OK:
@@ -44,9 +49,15 @@ class LLMProvider(LLMProviderBase):
)
yield "【阿里百练API服务响应异常】"
else:
logger.bind(tag=TAG).debug(f"【阿里百练API服务】构造参数: {call_params}")
logger.bind(tag=TAG).debug(
f"【阿里百练API服务】构造参数: {call_params}"
)
yield responses.output.text
except Exception as e:
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
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)
@@ -21,37 +21,36 @@ class LLMProvider(LLMProviderBase):
# 发起流式请求
with requests.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"stream": True,
"chatId": session_id,
"detail": self.detail,
"variables": self.variables,
"messages": [
{
"role": "user",
"content": last_msg["content"]
}
]
},
stream=True
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"stream": True,
"chatId": session_id,
"detail": self.detail,
"variables": self.variables,
"messages": [{"role": "user", "content": last_msg["content"]}],
},
stream=True,
) as r:
for line in r.iter_lines():
if line:
try:
if line.startswith(b'data: '):
if line[6:].decode('utf-8') == '[DONE]':
if line.startswith(b"data: "):
if line[6:].decode("utf-8") == "[DONE]":
break
data = json.loads(line[6:])
if 'choices' in data and len(data['choices']) > 0:
delta = data['choices'][0].get('delta', {})
if delta and 'content' in delta and delta['content'] is not None:
content = delta['content']
if '<think>' in content:
if "choices" in data and len(data["choices"]) > 0:
delta = data["choices"][0].get("delta", {})
if (
delta
and "content" in delta
and delta["content"] is not None
):
content = delta["content"]
if "<think>" in content:
continue
if '</think>' in content:
if "</think>" in content:
continue
yield content
@@ -62,4 +61,8 @@ class LLMProvider(LLMProviderBase):
except Exception as 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}"
except Exception as 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)
+16 -8
View File
@@ -26,13 +26,19 @@ class TTSProviderBase(ABC):
max_repeat_time = 5
text = MarkdownCleaner.clean_markdown(text)
while not os.path.exists(tmp_file) and max_repeat_time > 0:
asyncio.run(self.text_to_speak(text, tmp_file))
try:
asyncio.run(self.text_to_speak(text, tmp_file))
except Exception as e:
logger.bind(tag=TAG).error(f"语音生成失败: {text},错误: {e}")
if not os.path.exists(tmp_file):
max_repeat_time = max_repeat_time - 1
logger.bind(tag=TAG).error(f"语音生成失败: {text}:{tmp_file},再试{max_repeat_time}")
max_repeat_time -= 1
if max_repeat_time > 0:
logger.bind(tag=TAG).error(f"再试{max_repeat_time}")
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}"
)
return tmp_file
except Exception as e:
@@ -48,9 +54,11 @@ class TTSProviderBase(ABC):
# 获取文件后缀名
file_type = os.path.splitext(audio_file_path)[1]
if file_type:
file_type = file_type.lstrip('.')
file_type = file_type.lstrip(".")
# 读取音频文件,-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位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
@@ -72,11 +80,11 @@ class TTSProviderBase(ABC):
# 按帧处理所有音频数据(包括最后一帧可能补零)
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:
chunk += b'\x00' * (frame_size * 2 - len(chunk))
chunk += b"\x00" * (frame_size * 2 - len(chunk))
# 转换为numpy数组处理
np_frame = np.frombuffer(chunk, dtype=np.int16)
+1 -1
View File
@@ -24,7 +24,7 @@ class WebSocketServer:
async def start(self):
server_config = self.config["server"]
host = server_config["ip"]
host = server_config.get("ip", "0.0.0.0")
port = int(server_config.get("port", 8000))
self.logger.bind(tag=TAG).info(
+5 -1
View File
@@ -47,6 +47,10 @@ services:
- SPRING_DATASOURCE_DRUID_PASSWORD=123456
- SPRING_DATA_REDIS_HOST=xiaozhi-esp32-server-redis
- SPRING_DATA_REDIS_PORT=6379
volumes:
# 配置文件目录
- ./uploadfile:/app/uploadfile
xiaozhi-esp32-server-db:
image: mysql:latest
container_name: xiaozhi-esp32-server-db
@@ -59,7 +63,7 @@ services:
networks:
- default
expose:
- "3306"
- 3306
volumes:
- ./mysql/data:/var/lib/mysql
environment:
@@ -2,6 +2,7 @@ import requests
from bs4 import BeautifulSoup
from config.logger import setup_logging
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from core.utils.util import get_ip_info
TAG = __name__
logger = setup_logging()
@@ -11,9 +12,12 @@ GET_WEATHER_FUNCTION_DESC = {
"function": {
"name": "get_weather",
"description": (
"获取某个地点的天气,用户应提供一个位置,比如用户说杭州天气,参数为:杭州。"
"获取某个地点的天气信息。当用户询问与天气相关的问题(例如'今天的天气怎么样?''明天会下雨吗?''广州天气如何?'等),"
"或对话中包含'天气'字眼时,调用此功能。用户可以提供具体位置(如城市名),"
"如果未提供位置,则自动获取用户当前位置查询天气。"
"如果用户说的是省份,默认用省会城市。如果用户说的不是省份或城市而是一个地名,"
"默认用该地所在省份的省会城市。"
"当IP解析失败会使用默认地址"
),
"parameters": {
"type": "object",
@@ -57,6 +61,7 @@ WEATHER_CODE_MAP = {
"900": "", "901": "", "999": "未知"
}
def fetch_city_info(location, api_key):
url = f"https://geoapi.qweather.com/v2/city/lookup?key={api_key}&location={location}&lang=zh"
response = requests.get(url, headers=HEADERS).json()
@@ -97,27 +102,47 @@ def parse_weather_info(soup):
def get_weather(conn, location: str = None, lang: str = "zh_CN"):
api_key = conn.config["plugins"]["get_weather"]["api_key"]
default_location = conn.config["plugins"]["get_weather"]["default_location"]
location = location or conn.client_ip_info.get("city") or default_location
logger.bind(tag=TAG).debug(f"获取天气: {location}")
print("用户设置的default_location为:", default_location)
client_ip = conn.client_ip
print("用户提供的location为:", location)
# 优先使用用户提供的location参数
if not location:
# 通过客户端IP解析城市
if client_ip:
# 动态解析IP对应的城市信息
ip_info = get_ip_info(client_ip, logger)
print("解析用户IP后的城市为:", ip_info)
location = ip_info.get("city") if ip_info and "city" in ip_info else None
else:
# 若IP解析失败或无IP,使用默认位置
location = default_location
print("解析失败,将使用默认地址", location)
city_info = fetch_city_info(location, api_key)
if not city_info:
return ActionResponse(Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None)
soup = fetch_weather_page(city_info['fxLink'])
if not soup:
return ActionResponse(Action.REQLLM, None, "请求失败")
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):
if high and low:
weather_report += f"{date}: {low}{high}, {weather}\n"
weather_report += (
f"当前天气: {current_abstract}\n"
f"当前天气参数: {current_basic}\n"
f"(确保只报告指定单日的天气情况,除非未来会出现异常天气;或者用户明确要求想要了解多日天气,如果未指定,默认报告今天的天气。"
"参数为0的值不需要报告给用户,每次都报告体感温度,根据语境选择合适的参数内容告知用户,并对参数给出相应评价)"
)
return ActionResponse(Action.REQLLM, weather_report, None)
print(f"当前查询的城市名称是: {city_name}")
weather_report = f"您查询的位置是:{city_name}\n\n当前天气: {current_abstract}\n"
# 添加有效的当前天气参数
if current_basic:
weather_report += "详细参数:\n"
for key, value in current_basic.items():
if value != "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)
@@ -13,7 +13,7 @@ def append_devices_to_prompt(conn):
"functions", []
)
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", [])
if len(devices) == 0:
return