Compare commits

..
47 Commits
Author SHA1 Message Date
hrzandGitHub 80be8b2eaa Hot fix (#1007)
* update:优化代码

* fix:补回server.websocket配置

* update:修正版本号
2025-04-26 09:12:23 +08:00
hrzandGitHub 7d585615a9 update:细化版本对比 (#1004) 2025-04-25 23:58:43 +08:00
hrzandGitHub 3f3375dcac Auto ota update (#1001)
* update:修改ai_ota表编码

* update:更新教程

* add:增加OTA地址的配置

* update:将设备默认设置成自动升级

* update:设备管理可以设置某个设备是否自动升级

* update:判断客户端是否启动了自动升级

* update:校验OTA地址
2025-04-25 18:23:43 +08:00
hrzandGitHub f555eae653 Merge pull request #999 from xinnan-tech/auto-ota-update
Auto ota update
2025-04-25 17:56:47 +08:00
CGDandGitHub e805a6e037 Merge pull request #981 from xinnan-tech/aboutHandle-modify
打断时增加tts和音频任务清理
2025-04-25 09:58:15 +08:00
欣南科技andGitHub 3ed82ed7bb Merge pull request #982 from xinnan-tech/auto-ota-update
update:设置同类型OTA固件,只保留一个
2025-04-25 09:51:00 +08:00
hrz d638e512c9 update:设置同类型OTA固件,只保留一个 2025-04-25 09:48:25 +08:00
Sakura-RanChen 204b966935 打断时增加tts和音频任务清理 2025-04-25 09:34:22 +08:00
欣南科技andGitHub 8318ba5e55 Merge pull request #978 from xinnan-tech/auto-ota-update
update:OTA自动升级
2025-04-24 18:37:37 +08:00
hrz d39c595828 update:优化获取新固件方法 2025-04-24 18:36:04 +08:00
hrz 04154162c6 update:OTA自动升级 2025-04-24 18:27:38 +08:00
hrz 47f8e0f04c update:检验LLM密钥 2025-04-24 15:32:28 +08:00
hrz 7e4ada5f4b update:检验LLM密钥 2025-04-24 15:22:28 +08:00
hrz b0b4f87848 update:天气插件优化 2025-04-24 14:58:35 +08:00
欣南科技andGitHub d9085b82bf Merge pull request #967 from xinnan-tech/model-config-ui
update:优化模型配置左侧栏显示
2025-04-24 12:17:01 +08:00
hrz 8e5770ff59 修复tts名字过长 2025-04-24 12:13:42 +08:00
Sakura-RanChenandGitHub beb420c69d Merge pull request #968 from xinnan-tech/web-vue-ota-modify
Web vue ota modify
2025-04-24 11:54:08 +08:00
Junsen HuangandGitHub 1d8b898924 fix: index.js
修复router没有设置base
2025-04-24 11:46:29 +08:00
Junsen HuangandGitHub b5b4c5ced7 fix: vue.config.js
修复publicPath空串问题、默认是"/"
2025-04-24 11:45:24 +08:00
CGD b4e9d77895 update:修改了表格样式,增加了滑动条等,使其与其他页面基本对齐一致 2025-04-24 11:21:09 +08:00
hrzandGitHub 21a08b3c7a Merge pull request #933 from laride/feat/volces-ai-gateway
添加火山引擎边缘大模型网关 LLM/TTS
2025-04-24 11:19:28 +08:00
hrzandGitHub 6977d6bf73 Merge branch 'model-config-ui' into feat/volces-ai-gateway 2025-04-24 11:19:19 +08:00
hrzandGitHub 39f81dd0c0 Merge pull request #944 from laride/feat/add-public-path-env
添加前端部署路径环境变量
2025-04-24 11:16:20 +08:00
hrz b3191f5e0b update:优化tts异常捕获 2025-04-24 10:58:42 +08:00
hrzandGitHub 78f2f21142 Merge pull request #934 from taiping520/main
fix: 修复tts失败不重试问题
2025-04-24 10:43:32 +08:00
hrz 497fc1d1ae update:优化模型配置左侧栏显示 2025-04-24 10:41:14 +08:00
taiping520andGitHub d83e1c6fb2 Merge branch 'xinnan-tech:main' into main 2025-04-24 09:50:24 +08:00
CGD c5218d485a update:添加"加载中"样式,修改分页器样式 2025-04-24 09:33:46 +08:00
Sakura-RanChenandGitHub 19cbd3b8da Merge pull request #965 from xinnan-tech/py_test
Py test
2025-04-24 09:20:16 +08:00
CGD c0c5630bfb update:修改"获取天气"的逻辑顺序 2025-04-24 09:12:56 +08:00
CGD 969b5fcfbb update:"默认提示词"取消用户位置 2025-04-24 09:11:58 +08:00
欣南科技andGitHub 0739fa2195 Merge pull request #964 from xinnan-tech/update-fix
fix:取消docker版上传ota固件时默认的文件大小限制
2025-04-23 23:24:21 +08:00
hrz 3c62adab3c fix:取消docker版上传ota固件时默认的文件大小限制 2025-04-23 23:23:25 +08:00
欣南科技andGitHub edc601c35e Merge pull request #962 from xinnan-tech/update-fix
update:让alibl、等非原生llm使用functional时不卡顿
2025-04-23 21:30:40 +08:00
hrz 951370eee7 update:让alibl、等非原生llm使用function时不卡顿 2025-04-23 21:29:42 +08:00
hrzandGitHub fa81f4a150 Merge pull request #959 from TinsFox/main
update: 修改智控台登录地址为8002
2025-04-23 21:10:21 +08:00
TinsFox e6a90c40fb update: 修改智控台登录地址为8002 2025-04-23 20:25:04 +08:00
欣南科技andGitHub 2e0a1c00fa Merge pull request #955 from xinnan-tech/update-readme
update:增加HomeAssistant教程
2025-04-23 18:18:45 +08:00
hrz 109754fac9 update:增加HomeAssistant教程 2025-04-23 18:17:56 +08:00
taiping520andGitHub c3fbff3d7a Merge branch 'xinnan-tech:main' into main 2025-04-23 16:44:27 +08:00
欣南科技andGitHub 6dd967fe24 udpate:更新编译文档 (#948) 2025-04-23 11:49:24 +08:00
hrz f0bfdba944 udpate:更新编译文档 2025-04-23 11:48:20 +08:00
Liu Ruichao 7f76b5f4ee add PUBLIC_PATH env 2025-04-23 10:49:50 +08:00
taiping520andGitHub 35f4d8f15a Merge branch 'xinnan-tech:main' into main 2025-04-23 09:45:05 +08:00
Liu Ruichao d57482d966 feat: add Volces AI Gateway LLM/TTS 2025-04-22 17:04:29 +08:00
sczx-win10 3d7c2aff4b Merge remote-tracking branch 'origin/main' 2025-04-22 16:58:29 +08:00
sczx-win10 3757c5dde7 fix: 修复tts失败不重试问题 2025-04-22 16:44:45 +08:00
48 changed files with 1416 additions and 565 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# 配置Nginx # 配置Nginx
COPY docs/docker/nginx.conf /etc/nginx/conf.d/default.conf COPY docs/docker/nginx.conf /etc/nginx/nginx.conf
# 复制前端构建产物 # 复制前端构建产物
COPY --from=web-builder /app/dist /usr/share/nginx/html COPY --from=web-builder /app/dist /usr/share/nginx/html
+7 -4
View File
@@ -6,7 +6,7 @@
<p align="center"> <p align="center">
本项目为开源智能硬件项目 本项目为开源智能硬件项目
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a>提供后端服务<br/> <a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a>提供后端服务<br/>
根据<a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">小智通信协议</a>使用Python实现<br/> 根据<a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">小智通信协议</a>使用Python、Java、Vue实现<br/>
帮助您快速搭建小智服务器 帮助您快速搭建小智服务器
</p> </p>
@@ -15,7 +15,8 @@
· 简体中文 · 简体中文
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">更新日志</a> · <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">更新日志</a>
· <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a> · <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues ">反馈问题</a> · <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">反馈问题</a>
· <a href="./docs/FAQ.md">常见问题</a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases"> <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
@@ -147,14 +148,16 @@ server:
## 部署文档 ## 部署文档
![Banners](docs/images/banner2.png)
本项目提供两种部署方式,请根据您的具体需求选择: 本项目提供两种部署方式,请根据您的具体需求选择:
#### 🚀 部署方式选择 #### 🚀 部署方式选择
| 部署方式 | 特点 | 适用场景 | Docker部署文档 | 源码部署文档 | | 部署方式 | 特点 | 适用场景 | 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功能,数据存储在配置文件 | 低配置环境,无需数据库,仅支持虾哥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) | | **全模块安装** | 智能对话、IOT、OTA、智控台,数据存储在数据库 | 完整功能体验,支持虾哥最新固件 |[Docker运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | [本地源码运行全模块](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) |
> 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据 > 💡 提示:以下是按最新代码部署后的测试平台,有需要可烧录测试,并发为6个,每天会清空数据
+1 -1
View File
@@ -290,7 +290,7 @@ LLM:
这个信息很有用的,后面`编译esp32固件`需要用到。 这个信息很有用的,后面`编译esp32固件`需要用到。
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。 接下来,你就可以开始 [编译1.6.0版本esp32固件](firmware-build.md)了。
以下是一些常见问题,供参考: 以下是一些常见问题,供参考:
+37 -11
View File
@@ -122,11 +122,11 @@ http://localhost:8002/xiaozhi/doc.html
这时,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8002 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。 这时,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8002 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。
接下来要做件重要的事情: 接下来要做件重要的事情:
### 第一件重要的事情 ### 第一件重要的事情
使用超级管理员账号,登录系统,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值` 使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值`
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。 `server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
@@ -138,7 +138,12 @@ manager-api:
secret: 你的server.secret值 secret: 你的server.secret值
``` ```
1、把你刚才从`智控台`复制过来的`server.secret``参数值`复制到`.config.yaml`文件里的`secret`里。 1、把你刚才从`智控台`复制过来的`server.secret``参数值`复制到`.config.yaml`文件里的`secret`里。
2、注意,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
2、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
3、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
4、因为你是docker部署,把`url`改成下面的`http://xiaozhi-esp32-server-web:8002/xiaozhi`
类似这样的效果 类似这样的效果
``` ```
@@ -151,7 +156,7 @@ manager-api:
### 第二件重要的事情 ### 第二件重要的事情
使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮, 使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。 弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
## 5.重启xiaozhi-esp32-server ## 5.重启xiaozhi-esp32-server
@@ -182,7 +187,17 @@ Websocket接口:
ws://你电脑局域网的ip:8000/xiaozhi/v1/ ws://你电脑局域网的ip:8000/xiaozhi/v1/
``` ```
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了。 ### 第三件重要的事情
使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到参数编码是`server.websocket`,输入你的`Websocket接口`
使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到数编码是`server.ota`,输入你的`OTA接口`
接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
1、 [编译自己的esp32固件](firmware-build.md)了。
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
# 方式二:本地源码运行全模块 # 方式二:本地源码运行全模块
@@ -278,13 +293,13 @@ npm run serve
运行成功后,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8001 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。 运行成功后,你需要使用浏览器,打开`智控台`,链接:http://127.0.0.1:8001 ,注册第一个用户。第一个用户即是超级管理员,以后的用户都是普通用户。普通用户只能绑定设备和配置智能体;超级管理员可以进行模型管理、用户管理、参数配置等功能。
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮, 重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。 弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮, 重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。 弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
重要:注册成功后,使用超级管理员账号,登录系统,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮, 重要:注册成功后,使用超级管理员账号,登录智控台,在顶部菜单找到`模型配置`,然后在左侧栏点击`大语言模型`,找到第一条数据`智谱AI`,点击`修改`按钮,
弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。 弹出修改框后,将你注册到的`智谱AI`的密钥填写到`API密钥`中。然后点击保存。
## 5.安装Python环境 ## 5.安装Python环境
@@ -349,7 +364,7 @@ pip install -r requirements.txt
## 8.配置项目文件 ## 8.配置项目文件
使用超级管理员账号,登录智控台 http://127.0.0.1:8001 ,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值` 使用超级管理员账号,登录智控台 ,在顶部菜单找到`参数管理`,找到列表中第条数据,参数编码是`server.secret`,复制它到`参数值`
`server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。 `server.secret`需要说明一下,这个`参数值`很重要,作用是让我们的`Server`端连接`manager-api``server.secret`是每次从零部署manager模块时,会自动随机生成的密钥。
@@ -390,7 +405,7 @@ python app.py
25-02-23 12:01:09[core.websocket_server] - INFO - ======================================================= 25-02-23 12:01:09[core.websocket_server] - INFO - =======================================================
``` ```
由于你是全模块部署,因此你有两个重要的接口需要写入到esp32中 由于你是全模块部署,因此你有两个重要的接口。
OTA接口: OTA接口:
``` ```
@@ -402,7 +417,18 @@ Websocket接口:
ws://你电脑局域网的ip:8000/xiaozhi/v1/ ws://你电脑局域网的ip:8000/xiaozhi/v1/
``` ```
接下来,你就可以开始 [编译esp32固件](firmware-build.md)了 请你务必把以上两个接口地址写入到智控台中:他们将会影响websocket地址发放和自动升级功能
1、使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到参数编码是`server.websocket`,输入你的`Websocket接口`
2、使用超级管理员账号,登录智控台,在顶部菜单找到`参数管理`,找到数编码是`server.ota`,输入你的`OTA接口`
接下来,你就可以开始操作你的esp32设备了,你可以`自行编译esp32固件`也可以配置使用`虾哥编译好的1.6.1以上版本的固件`。两个任选一个
1、 [编译自己的esp32固件](firmware-build.md)了。
2、 [基于虾哥编译好的固件配置自定义服务器](firmware-setting.md)了。
# 常见问题 # 常见问题
+1 -35
View File
@@ -102,41 +102,7 @@ VAD:
### 7、我想通过小智控制电灯、空调、远程开关机等操作 💡 ### 7、我想通过小智控制电灯、空调、远程开关机等操作 💡
本项目,支持以工具调用的方式控制HomeAssistant设备 参考教程[ESP32设备与HomeAssistant集成指南](./homeassistant-integration.md)
1、首先选择一款支持function call支持的LLM,例如`ChatGLMLLM`
2、在配置文件中,将 `selected_module.Intent` 设置为 `function_call`
3、登录`HomeAssistant`,点击`左下角个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key。
在配置文件中,配置好你的home assistant的`devices`(被控制的设备)和`api_key``base_url`等信息。例如:
``` yaml
plugins
home_assistant:
devices:
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
- 卧室,台灯,switch.iot_cn_831898993_socn1_on_p_2_1
base_url: http://你的homeassistant地址:8123
api_key: 你的home assistant api访问令牌
```
最后,允许function_call 插件在配置文件中启用`hass_get_state`(必须)、`hass_set_state`(必须)、`hass_play_music`(不想用ha听音乐可以不启动),例如:
``` yaml
Intent:
...
function_call:
type: nointent
functions:
- change_role
- get_weather
- get_news
- hass_get_state
- hass_set_state
- hass_play_music
```
### 8、更多问题,可联系我们反馈 💬 ### 8、更多问题,可联系我们反馈 💬
+47 -20
View File
@@ -1,26 +1,53 @@
server { user root;
listen 8002; worker_processes 4;
server_name localhost;
# 静态资源服务(Vue项目) events {
location / { worker_connections 1024;
root /usr/share/nginx/html; }
try_files $uri $uri/ /index.html;
}
# API反向代理(Java项目) http {
location /xiaozhi/ { include mime.types;
proxy_pass http://127.0.0.1:8003; default_type application/octet-stream;
proxy_set_header Host $host; sendfile on;
proxy_cookie_path /api/ /; keepalive_timeout 300;
proxy_set_header Referer $http_referer; client_header_timeout 180s;
proxy_set_header Cookie $http_cookie; client_body_timeout 180s;
client_max_body_size 1024M;
proxy_connect_timeout 10; gzip on;
proxy_send_timeout 10; gzip_buffers 32 4K;
proxy_read_timeout 10; gzip_comp_level 6;
gzip_min_length 100;
gzip_types application/javascript text/css text/xml image/jpeg image/gif image/png;
gzip_disable "MSIE [1-6]\.";
gzip_vary on;
proxy_set_header X-Real-IP $remote_addr; server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 无域名访问,就用localhost
server_name localhost;
# 80端口
listen 8002;
# 转发到编译后到web目录
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
# 转发到manager-api
location /xiaozhi/ {
proxy_pass http://127.0.0.1:8003;
proxy_set_header Host $host;
proxy_cookie_path /manager/ /;
proxy_set_header Referer $http_referer;
proxy_set_header Cookie $http_cookie;
proxy_connect_timeout 10;
proxy_send_timeout 10;
proxy_read_timeout 10;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
} }
} }
+118
View File
@@ -0,0 +1,118 @@
# 编译1.6.0版本esp32固件
## 第1步 配置环境
先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
## 第2步 打开配置文件
配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
从这里下载虾哥[xiaozhi-esp32项目1.6.0版本源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
下载后,解压缩包,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
## 第3步 修改WEBSOCKET地址
找到`WEBSOCKET_URL``default`的内容,把`wss://api.tenclass.net/xiaozhi/v1/`
改成你自己的地址,例如,我的接口地址是`ws://192.168.1.25:8000/xiaozhi/v1/`,就把内容改成这个。
修改前:
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "wss://api.tenclass.net/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
修改后(示例)
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "ws://192.168.1.25:8000/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
## 第4步 设置编译参数
设置编译参数
```
# 终端命令行进入xiaozhi-esp32的根目录
cd xiaozhi-esp32
# 例如我使用的板子是esp32s3,所以设置编译目标为esp32s3,如果你的板子是其他型号,请替换成对应的型号
idf.py set-target esp32s3
# 进入菜单配置
idf.py menuconfig
```
![图片](images/build_setting01.png)
进入菜单配置后,再进入`Xiaozhi Assistant`,将`CONNECTION_TYPE`设置为`Websocket`
回退到主菜单,再进入`Xiaozhi Assistant`,将`BOARD_TYPE`设置你板子的具体型号
保存退出,回到终端命令行。
![图片](images/build_setting02.png)
## 第5步 编译固件
```
idf.py build
```
## 第6步 打包bin固件
```
cd scripts
python release.py
```
上面的打包命令执行完成后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin`
这个`merged-binary.bin`就是要烧录到硬件上的固件文件。
注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin`
,对你没有太大影响,请继续。
## 第7步 烧录固件
将esp32设备连接电脑,使用chrome浏览器,打开以下网址
```
https://espressif.github.io/esp-launchpad/
```
打开这个教程,[Flash工具/Web端烧录固件(无IDF开发环境)](https://ccnphfhqs21z.feishu.cn/wiki/Zpz4wXBtdimBrLk25WdcXzxcnNS)。
翻到:`方式二:ESP-Launchpad 浏览器WEB端烧录`,从`3. 烧录固件/下载到开发板`开始,按照教程操作。
烧录成功且联网成功后,通过唤醒词唤醒小智,留意server端输出的控制台信息。
## 常见问题
以下是一些常见问题,供参考:
[1、为什么我说的话,小智识别出来很多韩文、日文、英文](./FAQ.md)
[2、为什么会出现“TTS 任务出错 文件不存在”?](./FAQ.md)
[3、TTS 经常失败,经常超时](./FAQ.md)
[4、使用Wifi能连接自建服务器,但是4G模式却接不上](./FAQ.md)
[5、如何提高小智对话响应速度?](./FAQ.md)
[6、我说话很慢,停顿时小智老是抢话](./FAQ.md)
[7、我想通过小智控制电灯、空调、远程开关机等操作](./FAQ.md)
+41 -61
View File
@@ -1,76 +1,62 @@
# 编译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) 先按照这个教程配置项目环境[《Windows搭建 ESP IDF 5.3.2开发环境以及编译小智》](https://icnynnzcwou8.feishu.cn/wiki/JEYDwTTALi5s2zkGlFGcDiRknXf)
## 第2步 打开配置文件 ## 第3步 打开配置文件
配置好编译环境后,下载虾哥iaozhi-esp32项目源码, 配置好编译环境后,下载虾哥iaozhi-esp32项目源码,
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。 从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32)。
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip) 下载后,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件
从这里下载虾哥[xiaozhi-esp32项目源码](https://github.com/78/xiaozhi-esp32/archive/refs/tags/v1.6.0.zip)。
下载后,解压缩包,打开`xiaozhi-esp32/main/Kconfig.projbuild`文件。
## 第3步 修改WEBSOCKET地址
找到`WEBSOCKET_URL``default`的内容,把`wss://api.tenclass.net/xiaozhi/v1/`
改成你自己的地址,例如,我的接口地址是`ws://192.168.1.25:8000/xiaozhi/v1/`,就把内容改成这个。
修改前:
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "wss://api.tenclass.net/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
修改后(示例)
```
config WEBSOCKET_URL
depends on CONNECTION_TYPE_WEBSOCKET
string "Websocket URL"
default "ws://192.168.1.25:8000/xiaozhi/v1/"
help
Communication with the server through websocket after wake up.
```
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
注意:你的地址是`ws://`开头,不是`wss://`开头,一定不要写错了。
## 第4步 修改OTA地址 ## 第4步 修改OTA地址
如果你是全模块部署本项目,就修改OTA接口,如果你只是部署了8000端口的xiaozhi-server,可以继续沿用虾哥团队的OTA接口。如果你不修改OTA接口,请直接忽略本第4步,直接看第5步
找到`OTA_VERSION_URL``default`的内容,把`https://api.tenclass.net/xiaozhi/ota/` 找到`OTA_URL``default`的内容,把`https://api.tenclass.net/xiaozhi/ota/`
改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8002/xiaozhi/ota/`,就把内容改成这个。 改成你自己的地址,例如,我的接口地址是`http://192.168.1.25:8002/xiaozhi/ota/`,就把内容改成这个。
修改前: 修改前:
``` ```
config OTA_VERSION_URL config OTA_URL
string "OTA Version URL" string "Default OTA URL"
default "https://api.tenclass.net/xiaozhi/ota/" default "https://api.tenclass.net/xiaozhi/ota/"
help help
The application will access this URL to check for updates. The application will access this URL to check for new firmwares and server address.
``` ```
修改后: 修改后:
``` ```
config OTA_VERSION_URL config OTA_URL
string "OTA Version URL" string "Default OTA URL"
default "http://192.168.1.25:8002/xiaozhi/ota/" default "http://192.168.1.25:8002/xiaozhi/ota/"
help help
The application will access this URL to check for updates. The application will access this URL to check for new firmwares and server address.
``` ```
## 第5步 设置编译参数 ## 第4步 设置编译参数
设置编译参数 设置编译参数
@@ -91,32 +77,26 @@ idf.py menuconfig
![图片](images/build_setting02.png) ![图片](images/build_setting02.png)
## 第6步 编译固件 ## 第5步 编译固件
``` ```
idf.py build idf.py build
``` ```
如果是vscode安装的idf可以使用`F1`或者`ctrl+shift+p`,输入idf然后直接选择进行编译 ## 第6步 打包bin固件
还可以直接进行烧录不用接下来的操作
<img src="./images/vscode_idf.png" width="500px"/>
## 第7步 打包bin固件
``` ```
cd scripts cd scripts
python release.py python release.py
``` ```
编译成功后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin` 上面的打包命令执行完成后,会在项目根目录下的`build`目录下生成固件文件`merged-binary.bin`
这个`merged-binary.bin`就是要烧录到硬件上的固件文件。 这个`merged-binary.bin`就是要烧录到硬件上的固件文件。
注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin` 注意:如果执行到第二命令后,报了“zip”相关的错误,请忽略这个错误,只要`build`目录下生成固件文件`merged-binary.bin`
,对你没有太大影响,请继续。 ,对你没有太大影响,请继续。
## 第8步 烧录固件 ## 第7步 烧录固件
将esp32设备连接电脑,使用chrome浏览器,打开以下网址 将esp32设备连接电脑,使用chrome浏览器,打开以下网址
``` ```
+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灯”
@@ -0,0 +1,17 @@
package xiaozhi.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
/**
* RestTemplate配置
*/
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
@@ -89,6 +89,11 @@ public interface Constant {
*/ */
String SERVER_WEBSOCKET = "server.websocket"; String SERVER_WEBSOCKET = "server.websocket";
/**
* ota地址
*/
String SERVER_OTA = "server.ota";
/** /**
* 是否允许用户注册 * 是否允许用户注册
*/ */
@@ -172,5 +177,5 @@ public interface Constant {
/** /**
* 版本号 * 版本号
*/ */
public static final String VERSION = "0.3.10"; public static final String VERSION = "0.3.11";
} }
@@ -7,6 +7,7 @@ import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -79,4 +80,16 @@ public class DeviceController {
return new Result<Void>(); return new Result<Void>();
} }
@PutMapping("/enableOta/{id}/{status}")
@Operation(summary = "启用/关闭OTA自动升级")
@RequiresPermissions("sys:role:normal")
public Result<Void> enableOtaUpgrade(@PathVariable String id, @PathVariable Integer status) {
DeviceEntity entity = deviceService.selectById(id);
if (entity == null) {
return new Result<Void>().error("设备不存在");
}
entity.setAutoUpdate(status);
deviceService.updateById(entity);
return new Result<Void>();
}
} }
@@ -81,7 +81,11 @@ public class OTAController {
public ResponseEntity<String> getOTA() { public ResponseEntity<String> getOTA() {
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true); String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) { if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
return ResponseEntity.ok("OTA接口不正常,缺少websocket地址"); return ResponseEntity.ok("OTA接口不正常,缺少websocket地址,请登录智控台,在参数管理找到【server.websocket】配置");
}
String otaUrl = sysParamsService.getValue(Constant.SERVER_OTA, true);
if (StringUtils.isBlank(otaUrl) || otaUrl.equals("null")) {
return ResponseEntity.ok("OTA接口不正常,缺少ota地址,请登录智控台,在参数管理找到【server.ota】配置");
} }
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length); return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
} }
@@ -3,18 +3,14 @@ package xiaozhi.modules.device.service;
import java.util.List; import java.util.List;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.device.dto.DevicePageUserDTO; import xiaozhi.modules.device.dto.DevicePageUserDTO;
import xiaozhi.modules.device.dto.DeviceReportReqDTO; import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO; import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity; import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.vo.UserShowDeviceListVO; import xiaozhi.modules.device.vo.UserShowDeviceListVO;
public interface DeviceService { public interface DeviceService extends BaseService<DeviceEntity> {
/**
* 根据Mac地址获取设备信息
*/
DeviceEntity getDeviceById(String macAddress);
/** /**
* 检查设备是否激活 * 检查设备是否激活
@@ -17,4 +17,6 @@ public interface OtaService extends BaseService<OtaEntity> {
void update(OtaEntity entity); void update(OtaEntity entity);
void delete(String[] ids); void delete(String[] ids);
OtaEntity getLatestOta(String type);
} }
@@ -6,22 +6,26 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TimeZone; import java.util.TimeZone;
import java.util.concurrent.TimeUnit; import java.util.UUID;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant; import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.user.UserDetail; import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
@@ -31,7 +35,9 @@ import xiaozhi.modules.device.dto.DevicePageUserDTO;
import xiaozhi.modules.device.dto.DeviceReportReqDTO; import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO; import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity; import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.entity.OtaEntity;
import xiaozhi.modules.device.service.DeviceService; import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.device.service.OtaService;
import xiaozhi.modules.device.vo.UserShowDeviceListVO; import xiaozhi.modules.device.vo.UserShowDeviceListVO;
import xiaozhi.modules.security.user.SecurityUser; import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysParamsService;
@@ -39,32 +45,14 @@ import xiaozhi.modules.sys.service.SysUserUtilService;
@Slf4j @Slf4j
@Service @Service
@AllArgsConstructor
public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity> implements DeviceService { public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity> implements DeviceService {
private final DeviceDao deviceDao; private final DeviceDao deviceDao;
private final SysUserUtilService sysUserUtilService; private final SysUserUtilService sysUserUtilService;
private final RedisTemplate<String, Object> redisTemplate;
private final SysParamsService sysParamsService; private final SysParamsService sysParamsService;
private final RedisUtils redisUtils;
// 添加构造函数来初始化 deviceMapper private final OtaService otaService;
public DeviceServiceImpl(DeviceDao deviceDao, SysUserUtilService sysUserUtilService,
SysParamsService sysParamsService,
RedisTemplate<String, Object> redisTemplate) {
this.deviceDao = deviceDao;
this.sysUserUtilService = sysUserUtilService;
this.redisTemplate = redisTemplate;
this.sysParamsService = sysParamsService;
}
@Override
public DeviceEntity getDeviceById(String deviceId) {
LambdaQueryWrapper<DeviceEntity> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(DeviceEntity::getId, deviceId);
return deviceDao.selectOne(queryWrapper);
}
@Override @Override
public Boolean deviceActivation(String agentId, String activationCode) { public Boolean deviceActivation(String agentId, String activationCode) {
@@ -72,14 +60,14 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
throw new RenException("激活码不能为空"); throw new RenException("激活码不能为空");
} }
String deviceKey = "ota:activation:code:" + activationCode; String deviceKey = "ota:activation:code:" + activationCode;
Object cacheDeviceId = redisTemplate.opsForValue().get(deviceKey); Object cacheDeviceId = redisUtils.get(deviceKey);
if (cacheDeviceId == null) { if (cacheDeviceId == null) {
throw new RenException("激活码错误"); throw new RenException("激活码错误");
} }
String deviceId = (String) cacheDeviceId; String deviceId = (String) cacheDeviceId;
String safeDeviceId = deviceId.replace(":", "_").toLowerCase(); String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId); String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId);
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(cacheDeviceKey); Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(cacheDeviceKey);
if (cacheMap == null) { if (cacheMap == null) {
throw new RenException("激活码错误"); throw new RenException("激活码错误");
} }
@@ -109,6 +97,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
deviceEntity.setMacAddress(macAddress); deviceEntity.setMacAddress(macAddress);
deviceEntity.setUserId(user.getId()); deviceEntity.setUserId(user.getId());
deviceEntity.setCreator(user.getId()); deviceEntity.setCreator(user.getId());
deviceEntity.setAutoUpdate(1);
deviceEntity.setCreateDate(currentTime); deviceEntity.setCreateDate(currentTime);
deviceEntity.setUpdater(user.getId()); deviceEntity.setUpdater(user.getId());
deviceEntity.setUpdateDate(currentTime); deviceEntity.setUpdateDate(currentTime);
@@ -116,8 +105,8 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
deviceDao.insert(deviceEntity); deviceDao.insert(deviceEntity);
// 清理redis缓存 // 清理redis缓存
redisTemplate.delete(cacheDeviceKey); redisUtils.delete(cacheDeviceKey);
redisTemplate.delete(deviceKey); redisUtils.delete(deviceKey);
return true; return true;
} }
@@ -126,20 +115,24 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
DeviceReportReqDTO deviceReport) { DeviceReportReqDTO deviceReport) {
DeviceReportRespDTO response = new DeviceReportRespDTO(); DeviceReportRespDTO response = new DeviceReportRespDTO();
response.setServer_time(buildServerTime()); response.setServer_time(buildServerTime());
// todo: 此处是固件信息,目前是针对固件上传上来的版本号再返回回去
// 在未来开发了固件更新功能,需要更换此处代码, DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
// 或写定时任务定期请求虾哥的OTA,获取最新的版本讯息保存到服务内
DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware(); if (deviceById == null || deviceById.getAutoUpdate() != 0) {
firmware.setVersion(deviceReport.getApplication().getVersion()); String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
firmware.setUrl("http://localhost:8002/xiaozhi/ota/download"); DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
response.setFirmware(firmware); deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
response.setFirmware(firmware);
} else {
response.setFirmware(null);
}
// 添加WebSocket配置 // 添加WebSocket配置
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket(); DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
// 从系统参数获取WebSocket URL,如果未配置则使用默认值 // 从系统参数获取WebSocket URL,如果未配置则使用默认值
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true); String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) { if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
log.error("WebSocket URL is not configured"); log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/"; wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
websocket.setUrl(wsUrl); websocket.setUrl(wsUrl);
} else { } else {
@@ -148,18 +141,23 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
// 随机选择一个WebSocket URL // 随机选择一个WebSocket URL
websocket.setUrl(wsUrls[RandomUtil.randomInt(0, wsUrls.length)]); websocket.setUrl(wsUrls[RandomUtil.randomInt(0, wsUrls.length)]);
} else { } else {
log.error("WebSocket URL list is empty"); log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
websocket.setUrl("ws://xiaozhi.server.com:8000/xiaozhi/v1/"); websocket.setUrl("ws://xiaozhi.server.com:8000/xiaozhi/v1/");
} }
} }
response.setWebsocket(websocket); response.setWebsocket(websocket);
DeviceEntity deviceById = getDeviceById(macAddress); if (deviceById != null) {
if (deviceById != null) { // 如果设备存在,则更新上次连接时间 // 如果设备存在,则更新上次连接时间
deviceById.setLastConnectedAt(new Date()); deviceById.setLastConnectedAt(new Date());
if (deviceReport.getApplication() != null
&& StringUtils.isNotBlank(deviceReport.getApplication().getVersion())) {
deviceById.setAppVersion(deviceReport.getApplication().getVersion());
}
deviceDao.updateById(deviceById); deviceDao.updateById(deviceById);
} else { // 如果设备不存在,则生成激活码 } else {
// 如果设备不存在,则生成激活码
DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport); DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
response.setActivation(code); response.setActivation(code);
} }
@@ -252,7 +250,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
public String geCodeByDeviceId(String deviceId) { public String geCodeByDeviceId(String deviceId) {
String dataKey = getDeviceCacheKey(deviceId); String dataKey = getDeviceCacheKey(deviceId);
Map<Object, Object> cacheMap = redisTemplate.opsForHash().entries(dataKey); Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(dataKey);
if (cacheMap != null && cacheMap.containsKey("activation_code")) { if (cacheMap != null && cacheMap.containsKey("activation_code")) {
String cachedCode = (String) cacheMap.get("activation_code"); String cachedCode = (String) cacheMap.get("activation_code");
return cachedCode; return cachedCode;
@@ -299,13 +297,77 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
// 写入主数据 key // 写入主数据 key
String dataKey = getDeviceCacheKey(deviceId); String dataKey = getDeviceCacheKey(deviceId);
redisTemplate.opsForHash().putAll(dataKey, dataMap); redisUtils.set(dataKey, dataMap);
redisTemplate.expire(dataKey, 24, TimeUnit.HOURS);
// 写入反查激活码 key // 写入反查激活码 key
String codeKey = "ota:activation:code:" + newCode; String codeKey = "ota:activation:code:" + newCode;
redisTemplate.opsForValue().set(codeKey, deviceId, 24, TimeUnit.HOURS); redisUtils.set(codeKey, deviceId);
} }
return code; return code;
} }
private DeviceReportRespDTO.Firmware buildFirmwareInfo(String type, String currentVersion) {
if (StringUtils.isBlank(type)) {
return null;
}
if (StringUtils.isBlank(currentVersion)) {
currentVersion = "0.0.0";
}
OtaEntity ota = otaService.getLatestOta(type);
DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
String downloadUrl = null;
if (ota != null) {
// 如果设备没有版本信息,或者OTA版本比设备版本新,则返回下载地址
if (compareVersions(ota.getVersion(), currentVersion) > 0) {
String otaUrl = sysParamsService.getValue(Constant.SERVER_OTA, true);
if (StringUtils.isBlank(otaUrl) || otaUrl.equals("null")) {
log.error("OTA地址未配置,请登录智控台,在参数管理找到【server.ota】配置");
// 尝试从请求中获取
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.getRequestAttributes())
.getRequest();
otaUrl = request.getRequestURL().toString();
}
// 将URL中的/ota/替换为/otaMag/download/
String uuid = UUID.randomUUID().toString();
redisUtils.set(RedisKeys.getOtaIdKey(uuid), ota.getId());
downloadUrl = otaUrl.replace("/ota/", "/otaMag/download/") + uuid;
}
}
firmware.setVersion(ota == null ? currentVersion : ota.getVersion());
firmware.setUrl(downloadUrl == null ? "" : downloadUrl);
return firmware;
}
/**
* 比较两个版本号
*
* @param version1 版本1
* @param version2 版本2
* @return 如果version1 > version2返回1version1 < version2返回-1,相等返回0
*/
private static int compareVersions(String version1, String version2) {
if (version1 == null || version2 == null) {
return 0;
}
String[] v1Parts = version1.split("\\.");
String[] v2Parts = version2.split("\\.");
int length = Math.max(v1Parts.length, v2Parts.length);
for (int i = 0; i < length; i++) {
int v1 = i < v1Parts.length ? Integer.parseInt(v1Parts[i]) : 0;
int v2 = i < v2Parts.length ? Integer.parseInt(v2Parts[i]) : 0;
if (v1 > v2) {
return 1;
} else if (v1 < v2) {
return -1;
}
}
return 0;
}
} }
@@ -1,6 +1,8 @@
package xiaozhi.modules.device.service.impl; package xiaozhi.modules.device.service.impl;
import java.util.Arrays; import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -38,17 +40,17 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
@Override @Override
public void update(OtaEntity entity) { public void update(OtaEntity entity) {
// 检查是否存在相同名称、类型和版本的固件(排除当前记录) // 检查是否存在相同类型和版本的固件(排除当前记录)
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>() QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
.eq("firmware_name", entity.getFirmwareName())
.eq("type", entity.getType()) .eq("type", entity.getType())
.eq("version", entity.getVersion()) .eq("version", entity.getVersion())
.ne("id", entity.getId()); // 排除当前记录 .ne("id", entity.getId()); // 排除当前记录
if (baseDao.selectCount(queryWrapper) > 0) { if (baseDao.selectCount(queryWrapper) > 0) {
throw new RuntimeException("已存在相同名称、类型和版本的固件,请修改后重试"); throw new RuntimeException("已存在相同类型和版本的固件,请修改后重试");
} }
entity.setUpdateDate(new Date());
baseDao.updateById(entity); baseDao.updateById(entity);
} }
@@ -59,16 +61,25 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
@Override @Override
public boolean save(OtaEntity entity) { public boolean save(OtaEntity entity) {
// 检查是否存在相同名称、类型和版本的固件
QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>() QueryWrapper<OtaEntity> queryWrapper = new QueryWrapper<OtaEntity>()
.eq("firmware_name", entity.getFirmwareName()) .eq("type", entity.getType());
.eq("type", entity.getType()) // 同类固件只保留最新的一条
.eq("version", entity.getVersion()); List<OtaEntity> otaList = baseDao.selectList(queryWrapper);
if (otaList != null && otaList.size() > 0) {
if (baseDao.selectCount(queryWrapper) > 0) { OtaEntity otaBefore = otaList.getFirst();
throw new RuntimeException("已存在相同名称、类型和版本的固件,请勿重复添加"); entity.setId(otaBefore.getId());
baseDao.updateById(entity);
return true;
} }
return baseDao.insert(entity) > 0; return baseDao.insert(entity) > 0;
} }
@Override
public OtaEntity getLatestOta(String type) {
QueryWrapper<OtaEntity> wrapper = new QueryWrapper<>();
wrapper.eq("type", type)
.orderByDesc("update_date")
.last("LIMIT 1");
return baseDao.selectOne(wrapper);
}
} }
@@ -33,11 +33,15 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Override @Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) { public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
// 特殊用途的转换器
converters.add(new ByteArrayHttpMessageConverter()); converters.add(new ByteArrayHttpMessageConverter());
converters.add(new StringHttpMessageConverter());
converters.add(new ResourceHttpMessageConverter()); converters.add(new ResourceHttpMessageConverter());
converters.add(new AllEncompassingFormHttpMessageConverter());
// 通用转换器
converters.add(new StringHttpMessageConverter()); converters.add(new StringHttpMessageConverter());
converters.add(new AllEncompassingFormHttpMessageConverter());
// JSON 转换器
converters.add(jackson2HttpMessageConverter()); converters.add(jackson2HttpMessageConverter());
} }
@@ -4,6 +4,8 @@ import java.util.Map;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions; import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@@ -47,6 +50,7 @@ import xiaozhi.modules.sys.utils.WebSocketValidator;
public class SysParamsController { public class SysParamsController {
private final SysParamsService sysParamsService; private final SysParamsService sysParamsService;
private final ConfigService configService; private final ConfigService configService;
private final RestTemplate restTemplate;
@GetMapping("page") @GetMapping("page")
@Operation(summary = "分页") @Operation(summary = "分页")
@@ -97,6 +101,9 @@ public class SysParamsController {
// 验证WebSocket地址列表 // 验证WebSocket地址列表
validateWebSocketUrls(dto.getParamCode(), dto.getParamValue()); validateWebSocketUrls(dto.getParamCode(), dto.getParamValue());
// 验证OTA地址
validateOtaUrl(dto.getParamCode(), dto.getParamValue());
sysParamsService.update(dto); sysParamsService.update(dto);
configService.getConfig(false); configService.getConfig(false);
return new Result<Void>(); return new Result<Void>();
@@ -118,6 +125,11 @@ public class SysParamsController {
} }
for (String url : wsUrls) { for (String url : wsUrls) {
if (StringUtils.isNotBlank(url)) { if (StringUtils.isNotBlank(url)) {
// 检查是否包含localhost或127.0.0.1
if (url.contains("localhost") || url.contains("127.0.0.1")) {
throw new RenException("WebSocket地址不能使用localhost或127.0.0.1");
}
// 验证WebSocket地址格式 // 验证WebSocket地址格式
if (!WebSocketValidator.validateUrlFormat(url)) { if (!WebSocketValidator.validateUrlFormat(url)) {
throw new RenException("WebSocket地址格式不正确: " + url); throw new RenException("WebSocket地址格式不正确: " + url);
@@ -131,6 +143,46 @@ public class SysParamsController {
} }
} }
/**
* 验证OTA地址
*/
private void validateOtaUrl(String paramCode, String url) {
if (!paramCode.equals(Constant.SERVER_OTA)) {
return;
}
if (StringUtils.isBlank(url) || url.equals("null")) {
throw new RenException("OTA地址不能为空");
}
// 检查是否包含localhost或127.0.0.1
if (url.contains("localhost") || url.contains("127.0.0.1")) {
throw new RenException("OTA地址不能使用localhost或127.0.0.1");
}
// 验证URL格式
if (!url.toLowerCase().startsWith("http")) {
throw new RenException("OTA地址必须以http或https开头");
}
if (!url.endsWith("/ota/")) {
throw new RenException("OTA地址必须以/ota/结尾");
}
try {
// 发送GET请求
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
if (response.getStatusCode() != HttpStatus.OK) {
throw new RenException("OTA接口访问失败,状态码:" + response.getStatusCode());
}
// 检查响应内容是否包含OTA相关信息
String body = response.getBody();
if (body == null || !body.contains("OTA")) {
throw new RenException("OTA接口返回内容格式不正确,可能不是一个真实的OTA接口");
}
} catch (Exception e) {
throw new RenException("OTA接口验证失败:" + e.getMessage());
}
}
@PostMapping("/delete") @PostMapping("/delete")
@Operation(summary = "删除") @Operation(summary = "删除")
@LogOperation("删除") @LogOperation("删除")
@@ -0,0 +1,49 @@
-- 添加火山引擎边缘大模型网关(LLM + TTS)
INSERT INTO `ai_model_config` VALUES ('LLM_VolcesAiGatewayLLM', 'LLM', 'VolcesAiGatewayTTS', '火山引擎边缘大模型网关', 0, 1, '{\"type\": \"openai\", \"base_url\": \"https://ai-gateway.vei.volces.com/v1\", \"model_name\": \"doubao-pro-32k-functioncall\", \"api_key\": \"你的网关访问密钥\"}', NULL, NULL, 14, NULL, NULL, NULL, NULL);
INSERT INTO `ai_model_config` VALUES ('TTS_VolcesAiGatewayTTS', 'TTS', 'VolcesAiGatewayTTS', '火山引擎边缘大模型网关', 0, 1, '{\"type\": \"openai\", \"api_key\": \"你的网关访问密钥\", \"api_url\": \"https://ai-gateway.vei.volces.com/v1/audio/speech\", \"model\": \"doubao-tts\", \"voice\": \"zh_male_shaonianzixin_moon_bigtts\", \"speed\": 1, \"output_dir\": \"tmp/\"}', NULL, NULL, 15, NULL, NULL, NULL, NULL);
-- 添加火山引擎边缘大模型网关语音合成音色
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0001', 'TTS_VolcesAiGatewayTTS', '灿灿/Shiny', 'zh_female_cancan_mars_bigtts', '中文、美式英语', NULL, NULL, 1, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0002', 'TTS_VolcesAiGatewayTTS', '清新女声', 'zh_female_qingxinnvsheng_mars_bigtts', '中文', NULL, NULL, 2, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0003', 'TTS_VolcesAiGatewayTTS', '爽快思思/Skye', 'zh_female_shuangkuaisisi_moon_bigtts', '中文、美式英语', NULL, NULL, 3, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0004', 'TTS_VolcesAiGatewayTTS', '温暖阿虎/Alvin', 'zh_male_wennuanahu_moon_bigtts', '中文、美式英语', NULL, NULL, 4, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0005', 'TTS_VolcesAiGatewayTTS', '少年梓辛/Brayan', 'zh_male_shaonianzixin_moon_bigtts', '中文、美式英语', NULL, NULL, 5, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0006', 'TTS_VolcesAiGatewayTTS', '知性女声', 'zh_female_zhixingnvsheng_mars_bigtts', '中文', NULL, NULL, 6, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0007', 'TTS_VolcesAiGatewayTTS', '清爽男大', 'zh_male_qingshuangnanda_mars_bigtts', '中文', NULL, NULL, 7, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0008', 'TTS_VolcesAiGatewayTTS', '邻家女孩', 'zh_female_linjianvhai_moon_bigtts', '中文', NULL, NULL, 8, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0009', 'TTS_VolcesAiGatewayTTS', '渊博小叔', 'zh_male_yuanboxiaoshu_moon_bigtts', '中文', NULL, NULL, 9, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0010', 'TTS_VolcesAiGatewayTTS', '阳光青年', 'zh_male_yangguangqingnian_moon_bigtts', '中文', NULL, NULL, 10, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0011', 'TTS_VolcesAiGatewayTTS', '甜美小源', 'zh_female_tianmeixiaoyuan_moon_bigtts', '中文', NULL, NULL, 11, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0012', 'TTS_VolcesAiGatewayTTS', '清澈梓梓', 'zh_female_qingchezizi_moon_bigtts', '中文', NULL, NULL, 12, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0013', 'TTS_VolcesAiGatewayTTS', '解说小明', 'zh_male_jieshuoxiaoming_moon_bigtts', '中文', NULL, NULL, 13, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0014', 'TTS_VolcesAiGatewayTTS', '开朗姐姐', 'zh_female_kailangjiejie_moon_bigtts', '中文', NULL, NULL, 14, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0015', 'TTS_VolcesAiGatewayTTS', '邻家男孩', 'zh_male_linjiananhai_moon_bigtts', '中文', NULL, NULL, 15, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0016', 'TTS_VolcesAiGatewayTTS', '甜美悦悦', 'zh_female_tianmeiyueyue_moon_bigtts', '中文', NULL, NULL, 16, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0017', 'TTS_VolcesAiGatewayTTS', '心灵鸡汤', 'zh_female_xinlingjitang_moon_bigtts', '中文', NULL, NULL, 17, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0018', 'TTS_VolcesAiGatewayTTS', '知性温婉', 'ICL_zh_female_zhixingwenwan_tob', '中文', NULL, NULL, 18, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0019', 'TTS_VolcesAiGatewayTTS', '暖心体贴', 'ICL_zh_male_nuanxintitie_tob', '中文', NULL, NULL, 19, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0020', 'TTS_VolcesAiGatewayTTS', '温柔文雅', 'ICL_zh_female_wenrouwenya_tob', '中文', NULL, NULL, 20, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0021', 'TTS_VolcesAiGatewayTTS', '开朗轻快', 'ICL_zh_male_kailangqingkuai_tob', '中文', NULL, NULL, 21, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0022', 'TTS_VolcesAiGatewayTTS', '活泼爽朗', 'ICL_zh_male_huoposhuanglang_tob', '中文', NULL, NULL, 22, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0023', 'TTS_VolcesAiGatewayTTS', '率真小伙', 'ICL_zh_male_shuaizhenxiaohuo_tob', '中文', NULL, NULL, 23, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0024', 'TTS_VolcesAiGatewayTTS', '温柔小哥', 'zh_male_wenrouxiaoge_mars_bigtts', '中文', NULL, NULL, 24, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0025', 'TTS_VolcesAiGatewayTTS', 'Smith', 'en_male_smith_mars_bigtts', '英式英语', NULL, NULL, 25, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0026', 'TTS_VolcesAiGatewayTTS', 'Anna', 'en_female_anna_mars_bigtts', '英式英语', NULL, NULL, 26, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0027', 'TTS_VolcesAiGatewayTTS', 'Adam', 'en_male_adam_mars_bigtts', '美式英语', NULL, NULL, 27, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0028', 'TTS_VolcesAiGatewayTTS', 'Sarah', 'en_female_sarah_mars_bigtts', '澳洲英语', NULL, NULL, 28, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0029', 'TTS_VolcesAiGatewayTTS', 'Dryw', 'en_male_dryw_mars_bigtts', '澳洲英语', NULL, NULL, 29, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0030', 'TTS_VolcesAiGatewayTTS', 'かずね(和音)', 'multi_male_jingqiangkanye_moon_bigtts', '日语、西语', NULL, NULL, 30, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0031', 'TTS_VolcesAiGatewayTTS', 'はるこ(晴子)', 'multi_female_shuangkuaisisi_moon_bigtts', '日语、西语', NULL, NULL, 31, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0032', 'TTS_VolcesAiGatewayTTS', 'ひろし(広志)', 'multi_male_wanqudashu_moon_bigtts', '日语、西语', NULL, NULL, 32, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0033', 'TTS_VolcesAiGatewayTTS', 'あけみ(朱美)', 'multi_female_gaolengyujie_moon_bigtts', '日语', NULL, NULL, 33, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0034', 'TTS_VolcesAiGatewayTTS', 'Amanda', 'en_female_amanda_mars_bigtts', '美式英语', NULL, NULL, 34, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0035', 'TTS_VolcesAiGatewayTTS', 'Jackson', 'en_male_jackson_mars_bigtts', '美式英语', NULL, NULL, 35, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0036', 'TTS_VolcesAiGatewayTTS', '京腔侃爷/Harmony', 'zh_male_jingqiangkanye_moon_bigtts', '中文-北京口音、英文', NULL, NULL, 36, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0037', 'TTS_VolcesAiGatewayTTS', '湾湾小何', 'zh_female_wanwanxiaohe_moon_bigtts', '中文-台湾口音', NULL, NULL, 37, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0038', 'TTS_VolcesAiGatewayTTS', '湾区大叔', 'zh_female_wanqudashu_moon_bigtts', '中文-广东口音', NULL, NULL, 38, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0039', 'TTS_VolcesAiGatewayTTS', '呆萌川妹', 'zh_female_daimengchuanmei_moon_bigtts', '中文-四川口音', NULL, NULL, 39, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0040', 'TTS_VolcesAiGatewayTTS', '广州德哥', 'zh_male_guozhoudege_moon_bigtts', '中文-广东口音', NULL, NULL, 40, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0041', 'TTS_VolcesAiGatewayTTS', '北京小爷', 'zh_male_beijingxiaoye_moon_bigtts', '中文-北京口音', NULL, NULL, 41, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0042', 'TTS_VolcesAiGatewayTTS', '浩宇小哥', 'zh_male_haoyuxiaoge_moon_bigtts', '中文-青岛口音', NULL, NULL, 42, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0043', 'TTS_VolcesAiGatewayTTS', '广西远舟', 'zh_male_guangxiyuanzhou_moon_bigtts', '中文-广西口音', NULL, NULL, 43, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0044', 'TTS_VolcesAiGatewayTTS', '妹坨洁儿', 'zh_female_meituojieer_moon_bigtts', '中文-长沙口音', NULL, NULL, 44, NULL, NULL, NULL, NULL);
INSERT INTO `ai_tts_voice` VALUES ('TTS_VolcesAiGatewayTTS_0045', 'TTS_VolcesAiGatewayTTS', '豫州子轩', 'zh_male_yuzhouzixuan_moon_bigtts', '中文-河南口音', NULL, NULL, 45, NULL, NULL, NULL, NULL);
@@ -1,6 +0,0 @@
-- 删除server.ip和server.port,改成在xiaozhi-server中配置,方便运行多个websocket
delete from `sys_params` where id = 100;
delete from `sys_params` where id = 101;
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (106, 'server.websocket', 'null', 'string', 1, 'websocket地址,多个用;分隔');
@@ -1,8 +1,17 @@
-- 本文件用于初始化固件信息表,无需手动执行,在项目启动时会自动执行 -- 增加server.ota,用于配置ota地址
-- -------------------------------------------------------
-- 初始化固件信息表 delete from `sys_params` where id = 100;
drop table if exists `ai_ota`; delete from `sys_params` where id = 101;
CREATE TABLE `ai_ota` (
delete from `sys_params` where id = 106;
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (106, 'server.websocket', 'null', 'string', 1, 'websocket地址,多个用;分隔');
delete from `sys_params` where id = 107;
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (107, 'server.ota', 'null', 'string', 1, 'ota地址');
-- 增加固件信息表
CREATE TABLE IF NOT EXISTS `ai_ota` (
`id` varchar(32) NOT NULL COMMENT 'ID', `id` varchar(32) NOT NULL COMMENT 'ID',
`firmware_name` varchar(100) DEFAULT NULL COMMENT '固件名称', `firmware_name` varchar(100) DEFAULT NULL COMMENT '固件名称',
`type` varchar(50) DEFAULT NULL COMMENT '固件类型', `type` varchar(50) DEFAULT NULL COMMENT '固件类型',
@@ -16,4 +25,6 @@ CREATE TABLE `ai_ota` (
`creator` bigint DEFAULT NULL COMMENT '创建者', `creator` bigint DEFAULT NULL COMMENT '创建者',
`create_date` datetime DEFAULT NULL COMMENT '创建时间', `create_date` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='固件信息表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='固件信息表';
update ai_device set auto_update = 1;
@@ -58,13 +58,6 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202504181536.sql path: classpath:db/changelog/202504181536.sql
- changeSet:
id: 202504180758
author: jiangkunyin
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504180758.sql
- changeSet: - changeSet:
id: 202504221135 id: 202504221135
author: John author: John
@@ -73,9 +66,16 @@ databaseChangeLog:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202504221135.sql path: classpath:db/changelog/202504221135.sql
- changeSet: - changeSet:
id: 202504221646 id: 202504221555
author: John author: John
changes: changes:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202504221646.sql path: classpath:db/changelog/202504221555.sql
- changeSet:
id: 202504251422
author: jiangkunyin
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202504251422.sql
+1
View File
@@ -1,3 +1,4 @@
VUE_APP_API_BASE_URL=/xiaozhi VUE_APP_API_BASE_URL=/xiaozhi
VUE_APP_PUBLIC_PATH=/
# 是否开启CDN # 是否开启CDN
VUE_APP_USE_CDN=false VUE_APP_USE_CDN=false
+1
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"core-js": "^3.41.0", "core-js": "^3.41.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"element-ui": "^2.15.14", "element-ui": "^2.15.14",
"flyio": "^0.6.14", "flyio": "^0.6.14",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
@@ -51,4 +51,20 @@ export default {
}); });
}).send(); }).send();
}, },
enableOtaUpgrade(id, status, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/device/enableOta/${id}/${status}`)
.method('PUT')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail((err) => {
console.error('更新OTA状态失败:', err)
this.$message.error(err.msg || '更新OTA状态失败')
RequestService.reAjaxFun(() => {
this.enableOtaUpgrade(id, status, callback)
})
}).send()
},
} }
@@ -1,11 +1,11 @@
<template> <template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose"> <el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose" @open="handleOpen">
<el-form ref="form" :model="form" :rules="rules" label-width="100px"> <el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="固件名称" prop="firmwareName"> <el-form-item label="固件名称" prop="firmwareName">
<el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input> <el-input v-model="form.firmwareName" placeholder="请输入固件名称(板子+版本号)"></el-input>
</el-form-item> </el-form-item>
<el-form-item label="固件类型" prop="type"> <el-form-item label="固件类型" prop="type">
<el-select v-model="form.type" placeholder="请选择固件类型" style="width: 100%;" filterable> <el-select v-model="form.type" placeholder="请选择固件类型" style="width: 100%;" filterable :disabled="isTypeDisabled">
<el-option v-for="item in firmwareTypes" :key="item.key" :label="item.name" :value="item.key"></el-option> <el-option v-for="item in firmwareTypes" :key="item.key" :label="item.name" :value="item.key"></el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -13,11 +13,17 @@
<el-input v-model="form.version" placeholder="请输入版本号(x.x.x格式)"></el-input> <el-input v-model="form.version" placeholder="请输入版本号(x.x.x格式)"></el-input>
</el-form-item> </el-form-item>
<el-form-item label="固件文件" prop="firmwarePath"> <el-form-item label="固件文件" prop="firmwarePath">
<el-upload class="upload-demo" action="#" :http-request="handleUpload" :before-upload="beforeUpload" <el-upload ref="upload" class="upload-demo" action="#" :http-request="handleUpload"
:accept="'.bin,.apk'" :limit="1" :multiple="false" :auto-upload="true"> :before-upload="beforeUpload" :accept="'.bin,.apk'" :limit="1" :multiple="false" :auto-upload="true"
:on-remove="handleRemove">
<el-button size="small" type="primary">点击上传</el-button> <el-button size="small" type="primary">点击上传</el-button>
<div slot="tip" class="el-upload__tip">只能上传固件文件(.bin/.apk)且不超过100MB</div> <div slot="tip" class="el-upload__tip">只能上传固件文件(.bin/.apk)且不超过100MB</div>
</el-upload> </el-upload>
<el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress"
:status="uploadStatus"></el-progress>
<div class="hint-text">
<span>温馨提示请上传xiaozhi.bin文件而不是merged-binary.bin文件</span>
</div>
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="remark"> <el-form-item label="备注" prop="remark">
<el-input type="textarea" v-model="form.remark" placeholder="请输入备注信息"></el-input> <el-input type="textarea" v-model="form.remark" placeholder="请输入备注信息"></el-input>
@@ -53,6 +59,9 @@ export default {
data() { data() {
return { return {
firmwareTypes: FIRMWARE_TYPES, firmwareTypes: FIRMWARE_TYPES,
uploadProgress: 0,
uploadStatus: '',
isUploading: false,
rules: { rules: {
firmwareName: [ firmwareName: [
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' } { required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
@@ -70,6 +79,12 @@ export default {
} }
} }
}, },
computed: {
isTypeDisabled() {
// 如果有id,说明是编辑模式,禁用类型选择
return !!this.form.id
}
},
methods: { methods: {
handleClose() { handleClose() {
this.$refs.form.clearValidate(); this.$refs.form.clearValidate();
@@ -108,16 +123,72 @@ export default {
}, },
handleUpload(options) { handleUpload(options) {
const { file } = options const { file } = options
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = true
// 使用setTimeout实现简单的0-50%过渡
const timer = setTimeout(() => {
if (this.uploadProgress < 50) { // 只有当进度小于50%时才设置
this.uploadProgress = 50
}
}, 1000)
Api.ota.uploadFirmware(file, (res) => { Api.ota.uploadFirmware(file, (res) => {
clearTimeout(timer) // 清除定时器
res = res.data res = res.data
if (res.code === 0) { if (res.code === 0) {
this.form.firmwarePath = res.data this.form.firmwarePath = res.data
this.form.size = file.size this.form.size = file.size
this.uploadProgress = 100
this.uploadStatus = 'success'
this.$message.success('固件文件上传成功') this.$message.success('固件文件上传成功')
// 延迟2秒后隐藏进度条
setTimeout(() => {
this.isUploading = false
}, 2000)
} else { } else {
this.uploadStatus = 'exception'
this.$message.error(res.msg || '文件上传失败') this.$message.error(res.msg || '文件上传失败')
this.isUploading = false
}
}, (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
// 只有当进度大于50%时才更新
if (progress > 50) {
this.uploadProgress = progress
}
// 如果上传完成但还没收到成功响应,保持进度条显示
if (progress === 100) {
this.uploadStatus = ''
}
} }
}) })
},
handleRemove() {
this.form.firmwarePath = ''
this.form.size = 0
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = false
},
handleOpen() {
// 重置上传相关状态
this.uploadProgress = 0
this.uploadStatus = ''
this.isUploading = false
// 重置表单中的文件相关字段
if (!this.form.id) { // 只在新增时重置
this.form.firmwarePath = ''
this.form.size = 0
// 重置上传组件
this.$nextTick(() => {
if (this.$refs.upload) {
this.$refs.upload.clearFiles()
}
})
}
} }
} }
} }
@@ -133,4 +204,12 @@ export default {
padding-top: 5px; padding-top: 5px;
color: #909399; color: #909399;
} }
.hint-text {
display: flex;
align-items: center;
gap: 8px;
color: #979db1;
font-size: 11px;
}
</style> </style>
+1
View File
@@ -87,6 +87,7 @@ const routes = [
] ]
const router = new VueRouter({ const router = new VueRouter({
base: process.env.VUE_APP_PUBLIC_PATH || '/',
routes routes
}) })
+23 -19
View File
@@ -15,15 +15,9 @@
<div class="content-panel"> <div class="content-panel">
<div class="content-area"> <div class="content-area">
<el-card class="device-card" shadow="never"> <el-card class="device-card" shadow="never">
<el-table <el-table ref="deviceTable" :data="paginatedDeviceList" class="transparent-table"
ref="deviceTable" :header-cell-class-name="headerCellClassName" v-loading="loading" element-loading-text="拼命加载中"
:data="paginatedDeviceList" element-loading-spinner="el-icon-loading" element-loading-background="rgba(255, 255, 255, 0.7)">
class="transparent-table"
:header-cell-class-name="headerCellClassName"
v-loading="loading"
element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)">
<el-table-column label="选择" align="center" width="120"> <el-table-column label="选择" align="center" width="120">
<template slot-scope="scope"> <template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox> <el-checkbox v-model="scope.row.selected"></el-checkbox>
@@ -34,7 +28,7 @@
{{ getFirmwareTypeName(scope.row.model) }} {{ getFirmwareTypeName(scope.row.model) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="固件版本" prop="firmwareVersion" align="center" ></el-table-column> <el-table-column label="固件版本" prop="firmwareVersion" align="center"></el-table-column>
<el-table-column label="Mac地址" prop="macAddress" align="center"></el-table-column> <el-table-column label="Mac地址" prop="macAddress" align="center"></el-table-column>
<el-table-column label="绑定时间" prop="bindTime" align="center"></el-table-column> <el-table-column label="绑定时间" prop="bindTime" align="center"></el-table-column>
<el-table-column label="最近对话" prop="lastConversation" align="center"></el-table-column> <el-table-column label="最近对话" prop="lastConversation" align="center"></el-table-column>
@@ -53,8 +47,8 @@
</el-table-column> </el-table-column>
<el-table-column label="OTA升级" align="center"> <el-table-column label="OTA升级" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-switch v-model="scope.row.otaSwitch" size="mini" active-color="#13ce66" <el-switch v-model="scope.row.otaSwitch" size="mini" active-color="#13ce66" inactive-color="#ff4949"
inactive-color="#ff4949"></el-switch> @change="handleOtaSwitchChange(scope.row)"></el-switch>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" align="center"> <el-table-column label="操作" align="center">
@@ -326,6 +320,16 @@ export default {
const firmwareType = FIRMWARE_TYPES.find(item => item.key === type); const firmwareType = FIRMWARE_TYPES.find(item => item.key === type);
return firmwareType ? firmwareType.name : type; return firmwareType ? firmwareType.name : type;
}, },
handleOtaSwitchChange(row) {
Api.device.enableOtaUpgrade(row.device_id, row.otaSwitch ? 1 : 0, ({ data }) => {
if (data.code === 0) {
this.$message.success(row.otaSwitch ? '已设置成自动升级' : '已关闭自动升级')
} else {
row.otaSwitch = !row.otaSwitch
this.$message.error(data.msg || '操作失败')
}
})
},
} }
}; };
</script> </script>
@@ -472,12 +476,13 @@ export default {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
} }
::v-deep .el-card__body {
padding: 15px; ::v-deep .el-card__body {
display: flex; padding: 15px;
flex-direction: column; display: flex;
flex: 1; flex-direction: column;
overflow: hidden; flex: 1;
overflow: hidden;
} }
.table_bottom { .table_bottom {
@@ -691,5 +696,4 @@ export default {
::v-deep .el-table::before { ::v-deep .el-table::before {
display: none !important; display: none !important;
} }
</style> </style>
+18 -21
View File
@@ -44,19 +44,11 @@
<!-- 右侧内容 --> <!-- 右侧内容 -->
<div class="content-area"> <div class="content-area">
<el-card class="model-card" shadow="never"> <el-card class="model-card" shadow="never">
<el-table <el-table ref="modelTable" style="width: 100%" v-loading="loading" element-loading-text="拼命加载中"
ref="modelTable" element-loading-spinner="el-icon-loading" element-loading-background="rgba(255, 255, 255, 0.7)"
style="width: 100%" :header-cell-style="{ background: 'transparent' }" :data="modelList" class="data-table"
v-loading="loading" header-row-class-name="table-header" :header-cell-class-name="headerCellClassName"
element-loading-text="拼命加载中" @selection-change="handleSelectionChange">
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-style="{ background: 'transparent' }"
:data="modelList"
class="data-table"
header-row-class-name="table-header"
:header-cell-class-name="headerCellClassName"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center"></el-table-column> <el-table-column type="selection" width="55" align="center"></el-table-column>
<el-table-column label="模型ID" prop="id" align="center"></el-table-column> <el-table-column label="模型ID" prop="id" align="center"></el-table-column>
<el-table-column label="模型名称" prop="modelName" align="center"></el-table-column> <el-table-column label="模型名称" prop="modelName" align="center"></el-table-column>
@@ -136,6 +128,9 @@
<TtsModel :visible.sync="ttsDialogVisible" :ttsModelId="selectedTtsModelId" /> <TtsModel :visible.sync="ttsDialogVisible" :ttsModelId="selectedTtsModelId" />
<AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" /> <AddModelDialog :modelType="activeTab" :visible.sync="addDialogVisible" @confirm="handleAddConfirm" />
</div> </div>
<el-footer>
<version-footer />
</el-footer>
</div> </div>
</template> </template>
@@ -145,9 +140,9 @@ import AddModelDialog from "@/components/AddModelDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import ModelEditDialog from "@/components/ModelEditDialog.vue"; import ModelEditDialog from "@/components/ModelEditDialog.vue";
import TtsModel from "@/components/TtsModel.vue"; import TtsModel from "@/components/TtsModel.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default { export default {
components: { HeaderBar, ModelEditDialog, TtsModel, AddModelDialog }, components: { HeaderBar, ModelEditDialog, TtsModel, AddModelDialog, VersionFooter },
data() { data() {
return { return {
addDialogVisible: false, addDialogVisible: false,
@@ -520,8 +515,9 @@ export default {
.nav-panel .el-menu-item { .nav-panel .el-menu-item {
height: 50px; height: 50px;
background: #e9f0ff;
line-height: 50px; line-height: 50px;
border-radius: 4px; border-radius: 4px 0 0 4px !important;
transition: all 0.3s; transition: all 0.3s;
display: flex !important; display: flex !important;
justify-content: flex-end; justify-content: flex-end;
@@ -532,8 +528,7 @@ export default {
} }
.nav-panel .el-menu-item.is-active { .nav-panel .el-menu-item.is-active {
background: #e9f0ff; background: #5778ff;
color: #0ba6f4 !important;
position: relative; position: relative;
padding-left: 40px !important; padding-left: 40px !important;
} }
@@ -546,7 +541,7 @@ export default {
transform: translateY(-50%); transform: translateY(-50%);
width: 13px; width: 13px;
height: 13px; height: 13px;
background: #409EFF; background: #fff;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 4px rgba(64, 158, 255, 0.5); box-shadow: 0 0 4px rgba(64, 158, 255, 0.5);
} }
@@ -758,7 +753,7 @@ export default {
} }
::v-deep .nav-panel .el-menu-item.is-active .menu-text { ::v-deep .nav-panel .el-menu-item.is-active .menu-text {
color: #409EFF !important; color: #fff !important;
} }
::v-deep .data-table { ::v-deep .data-table {
@@ -929,17 +924,19 @@ export default {
background-color: rgba(255, 255, 255, 0.6) !important; background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
::v-deep .el-loading-spinner .circular { ::v-deep .el-loading-spinner .circular {
width: 28px; width: 28px;
height: 28px; height: 28px;
} }
::v-deep .el-loading-spinner .path { ::v-deep .el-loading-spinner .path {
stroke: #6b8cff; stroke: #6b8cff;
} }
::v-deep .el-loading-text { ::v-deep .el-loading-text {
color: #6b8cff !important; color: #6b8cff !important;
font-size: 14px; font-size: 14px;
margin-top: 8px; margin-top: 8px;
} }
</style> </style>
+128 -15
View File
@@ -15,7 +15,9 @@
<div class="content-panel"> <div class="content-panel">
<div class="content-area"> <div class="content-area">
<el-card class="params-card" shadow="never"> <el-card class="params-card" shadow="never">
<el-table ref="paramsTable" :data="paramsList" class="transparent-table" <el-table ref="paramsTable" :data="paramsList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-class-name="headerCellClassName"> :header-cell-class-name="headerCellClassName">
<el-table-column label="选择" align="center" width="120"> <el-table-column label="选择" align="center" width="120">
<template slot-scope="scope"> <template slot-scope="scope">
@@ -41,6 +43,11 @@
{{ formatDate(scope.row.createDate) }} {{ formatDate(scope.row.createDate) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="更新时间" prop="updateDate" align="center">
<template slot-scope="scope">
{{ formatDate(scope.row.updateDate) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center"> <el-table-column label="操作" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-button size="mini" type="text" <el-button size="mini" type="text"
@@ -91,7 +98,9 @@
<!-- 新增/编辑固件对话框 --> <!-- 新增/编辑固件对话框 -->
<firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm" @submit="handleSubmit" <firmware-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="firmwareForm" @submit="handleSubmit"
@cancel="dialogVisible = false" /> @cancel="dialogVisible = false" />
<el-footer>
<version-footer />
</el-footer>
</div> </div>
</template> </template>
@@ -99,14 +108,16 @@
import Api from "@/apis/api"; import Api from "@/apis/api";
import FirmwareDialog from "@/components/FirmwareDialog.vue"; import FirmwareDialog from "@/components/FirmwareDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue";
import { FIRMWARE_TYPES } from "@/utils"; import { FIRMWARE_TYPES } from "@/utils";
import { formatDate, formatFileSize } from "@/utils/format"; import { formatDate, formatFileSize } from "@/utils/format";
export default { export default {
components: { HeaderBar, FirmwareDialog }, components: { HeaderBar, FirmwareDialog, VersionFooter },
data() { data() {
return { return {
searchName: "", searchName: "",
loading: false,
paramsList: [], paramsList: [],
firmwareList: [], firmwareList: [],
currentPage: 1, currentPage: 1,
@@ -158,6 +169,7 @@ export default {
this.fetchFirmwareList(); this.fetchFirmwareList();
}, },
fetchFirmwareList() { fetchFirmwareList() {
this.loading = true;
const params = { const params = {
pageNum: this.currentPage, pageNum: this.currentPage,
pageSize: this.pageSize, pageSize: this.pageSize,
@@ -166,6 +178,7 @@ export default {
order: "desc" order: "desc"
}; };
Api.ota.getOtaList(params, (res) => { Api.ota.getOtaList(params, (res) => {
this.loading = false;
res = res.data res = res.data
if (res.code === 0) { if (res.code === 0) {
this.firmwareList = res.data.list.map(item => ({ this.firmwareList = res.data.list.map(item => ({
@@ -460,12 +473,20 @@ export default {
.params-card { .params-card {
background: white; background: white;
border: none; flex: 1;
box-shadow: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; border: none;
box-shadow: none;
overflow: hidden; overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
} }
.table_bottom { .table_bottom {
@@ -513,7 +534,7 @@ export default {
.custom-pagination { .custom-pagination {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 5px;
.el-select { .el-select {
margin-right: 8px; margin-right: 8px;
@@ -580,21 +601,67 @@ export default {
.page-size-select { .page-size-select {
width: 100px; width: 100px;
} margin-right: 10px;
.custom-selection-header { :deep(.el-input__inner) {
text-align: center !important; height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__suffix) {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
} }
:deep(.transparent-table) { :deep(.transparent-table) {
background-color: transparent; background: white;
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
.el-table__body-wrapper {
flex: 1;
overflow-y: auto;
max-height: none !important;
}
.el-table__header-wrapper { .el-table__header-wrapper {
flex-shrink: 0; flex-shrink: 0;
} }
.el-table__header th { .el-table__header th {
background-color: white !important; background: white !important;
color: black; color: black;
font-weight: 600; font-weight: 600;
height: 40px; height: 40px;
@@ -603,10 +670,12 @@ export default {
border-bottom: 1px solid #e4e7ed; border-bottom: 1px solid #e4e7ed;
} }
.el-table__body-wrapper { .el-table__body tr {
background-color: white;
td { td {
background-color: transparent; border-top: 1px solid rgba(0, 0, 0, 0.04);
border-bottom: 1px solid #e4e7ed; border-bottom: 1px solid rgba(0, 0, 0, 0.04);
padding: 8px 0; padding: 8px 0;
height: 40px; height: 40px;
color: #606266; color: #606266;
@@ -617,6 +686,10 @@ export default {
.el-table__row:hover>td { .el-table__row:hover>td {
background-color: #f5f7fa !important; background-color: #f5f7fa !important;
} }
&::before {
display: none;
}
} }
:deep(.el-table .el-button--text) { :deep(.el-table .el-button--text) {
@@ -640,4 +713,44 @@ export default {
background-color: #5f70f3 !important; background-color: #5f70f3 !important;
border-color: #5f70f3 !important; border-color: #5f70f3 !important;
} }
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
.el-table {
--table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height);
.el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px);
}
}
@media (min-width: 1144px) {
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
}
:deep(.transparent-table) {
.el-table__body tr {
td {
padding-top: 16px;
padding-bottom: 16px;
}
&+tr {
margin-top: 10px;
}
}
}
}
</style> </style>
+105 -110
View File
@@ -15,15 +15,10 @@
<div class="content-panel"> <div class="content-panel">
<div class="content-area"> <div class="content-area">
<el-card class="params-card" shadow="never"> <el-card class="params-card" shadow="never">
<el-table <el-table ref="paramsTable" :data="paramsList" class="transparent-table" v-loading="loading"
ref="paramsTable" element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
:data="paramsList" element-loading-background="rgba(255, 255, 255, 0.7)"
class="transparent-table" :header-cell-class-name="headerCellClassName">
v-loading="loading"
element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-class-name="headerCellClassName">
<el-table-column label="选择" align="center" width="120"> <el-table-column label="选择" align="center" width="120">
<template slot-scope="scope"> <template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox> <el-checkbox v-model="scope.row.selected"></el-checkbox>
@@ -48,14 +43,11 @@
</el-button> </el-button>
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button> <el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" <el-button size="mini" type="danger" icon="el-icon-delete"
@click="deleteSelectedParams">删除</el-button> @click="deleteSelectedParams">删除</el-button>
</div> </div>
<div class="custom-pagination"> <div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select"> <el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option <el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`"
v-for="item in pageSizeOptions"
:key="item"
:label="`${item}条/页`"
:value="item"> :value="item">
</el-option> </el-option>
</el-select> </el-select>
@@ -83,7 +75,9 @@
<!-- 新增/编辑参数对话框 --> <!-- 新增/编辑参数对话框 -->
<param-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="paramForm" @submit="handleSubmit" <param-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="paramForm" @submit="handleSubmit"
@cancel="dialogVisible = false" /> @cancel="dialogVisible = false" />
<el-footer>
<version-footer />
</el-footer>
</div> </div>
</template> </template>
@@ -91,9 +85,9 @@
import Api from "@/apis/api"; import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import ParamDialog from "@/components/ParamDialog.vue"; import ParamDialog from "@/components/ParamDialog.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default { export default {
components: { HeaderBar, ParamDialog }, components: { HeaderBar, ParamDialog, VersionFooter },
data() { data() {
return { return {
searchCode: "", searchCode: "",
@@ -146,29 +140,29 @@ export default {
this.fetchParams(); this.fetchParams();
}, },
fetchParams() { fetchParams() {
this.loading = true; this.loading = true;
Api.admin.getParamsList( Api.admin.getParamsList(
{ {
page: this.currentPage, page: this.currentPage,
limit: this.pageSize, limit: this.pageSize,
paramCode: this.searchCode, paramCode: this.searchCode,
}, },
({ data }) => { ({ data }) => {
this.loading = false; this.loading = false;
if (data.code === 0) { if (data.code === 0) {
this.paramsList = data.data.list.map(item => ({ this.paramsList = data.data.list.map(item => ({
...item, ...item,
selected: false selected: false
})); }));
this.total = data.data.total; this.total = data.data.total;
} else { } else {
this.$message.error({ this.$message.error({
message: data.msg || '获取参数列表失败', message: data.msg || '获取参数列表失败',
showClose: true showClose: true
}); });
} }
} }
); );
}, },
handleSearch() { handleSearch() {
this.currentPage = 1; this.currentPage = 1;
@@ -197,33 +191,33 @@ export default {
}, },
handleSubmit({ form, done }) { handleSubmit({ form, done }) {
if (form.id) { if (form.id) {
// 编辑 // 编辑
Api.admin.updateParam(form, ({ data }) => { Api.admin.updateParam(form, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.$message.success({ this.$message.success({
message:"修改成功", message: "修改成功",
showClose:true showClose: true
});
this.dialogVisible = false;
this.fetchParams();
}
done && done();
}); });
this.dialogVisible = false; } else {
this.fetchParams(); // 新增
} Api.admin.addParam(form, ({ data }) => {
done && done(); if (data.code === 0) {
}); this.$message.success({
} else { message: "新增成功",
// 新增 showClose: true
Api.admin.addParam(form, ({ data }) => { });
if (data.code === 0) { this.dialogVisible = false;
this.$message.success({ this.fetchParams();
message:"新增成功", }
showClose:true done && done();
}); });
this.dialogVisible = false; }
this.fetchParams();
}
done && done();
});
}
}, },
deleteSelectedParams() { deleteSelectedParams() {
@@ -243,8 +237,8 @@ export default {
if (Array.isArray(row) && row.length === 0) { if (Array.isArray(row) && row.length === 0) {
this.$message.warning({ this.$message.warning({
message:"请先选择需要删除的参数", message: "请先选择需要删除的参数",
showClose:true showClose: true
}); });
return; return;
} }
@@ -259,8 +253,8 @@ export default {
const ids = params.map(param => param.id); const ids = params.map(param => param.id);
if (ids.some(id => isNaN(id))) { if (ids.some(id => isNaN(id))) {
this.$message.error({ this.$message.error({
message:'存在无效的参数ID', message: '存在无效的参数ID',
showClose: true showClose: true
}); });
return; return;
} }
@@ -341,16 +335,16 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; margin: 5px 22px;
border-radius: 15px; border-radius: 15px;
min-height: calc(100vh - 24vh); min-height: calc(100vh - 24vh);
height: auto; height: auto;
max-height: 80vh; max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
background: rgba(237, 242, 255, 0.5); background: rgba(237, 242, 255, 0.5);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.operation-bar { .operation-bar {
@@ -402,21 +396,21 @@ export default {
} }
.params-card { .params-card {
background: white; background: white;
flex: 1; flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; border: none;
box-shadow: none;
overflow: hidden; overflow: hidden;
}
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
} }
.table_bottom { .table_bottom {
@@ -467,7 +461,7 @@ export default {
gap: 10px; gap: 10px;
.el-select { .el-select {
margin-right: 8px; margin-right: 8px;
} }
.pagination-btn:first-child, .pagination-btn:first-child,
@@ -535,15 +529,17 @@ export default {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.el-table__body-wrapper { .el-table__body-wrapper {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
max-height: none !important; max-height: none !important;
} }
.el-table__header-wrapper { .el-table__header-wrapper {
flex-shrink: 0; flex-shrink: 0;
} }
.el-table__header th { .el-table__header th {
background: white !important; background: white !important;
color: black; color: black;
@@ -670,32 +666,31 @@ export default {
} }
.el-table { .el-table {
--table-max-height: calc(100vh - 40vh); --table-max-height: calc(100vh - 40vh);
max-height: var(--table-max-height); max-height: var(--table-max-height);
.el-table__body-wrapper { .el-table__body-wrapper {
max-height: calc(var(--table-max-height) - 40px); max-height: calc(var(--table-max-height) - 40px);
} }
} }
:deep(.el-loading-mask) { :deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important; background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
:deep(.el-loading-spinner .circular) { :deep(.el-loading-spinner .circular) {
width: 28px; width: 28px;
height: 28px; height: 28px;
} }
:deep(.el-loading-spinner .path) { :deep(.el-loading-spinner .path) {
stroke: #6b8cff; stroke: #6b8cff;
} }
:deep(.el-loading-text) { :deep(.el-loading-text) {
color: #6b8cff !important; color: #6b8cff !important;
font-size: 14px; font-size: 14px;
margin-top: 8px; margin-top: 8px;
} }
</style> </style>
+62 -65
View File
@@ -15,18 +15,13 @@
<div class="content-panel"> <div class="content-panel">
<div class="content-area"> <div class="content-area">
<el-card class="user-card" shadow="never"> <el-card class="user-card" shadow="never">
<el-table <el-table ref="userTable" :data="userList" class="transparent-table" v-loading="loading"
ref="userTable" element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
:data="userList" element-loading-background="rgba(255, 255, 255, 0.7)">
class="transparent-table"
v-loading="loading"
element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)">
<el-table-column label="选择" align="center" width="120"> <el-table-column label="选择" align="center" width="120">
<template slot-scope="scope"> <template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox> <el-checkbox v-model="scope.row.selected"></el-checkbox>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="用户Id" prop="userid" align="center"></el-table-column> <el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column> <el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
@@ -61,11 +56,7 @@
</div> </div>
<div class="custom-pagination"> <div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select"> <el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option <el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
v-for="item in pageSizeOptions"
:key="item"
:label="`${item}条/页`"
:value="item">
</el-option> </el-option>
</el-select> </el-select>
@@ -90,16 +81,19 @@
</div> </div>
<view-password-dialog :visible.sync="showViewPassword" :password="currentPassword" /> <view-password-dialog :visible.sync="showViewPassword" :password="currentPassword" />
<el-footer>
<version-footer />
</el-footer>
</div> </div>
</template> </template>
<script> <script>
import Api from "@/apis/api"; import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue";
import ViewPasswordDialog from "@/components/ViewPasswordDialog.vue"; import ViewPasswordDialog from "@/components/ViewPasswordDialog.vue";
export default { export default {
components: { HeaderBar, ViewPasswordDialog }, components: { HeaderBar, ViewPasswordDialog, VersionFooter },
data() { data() {
return { return {
showViewPassword: false, showViewPassword: false,
@@ -138,7 +132,7 @@ export default {
}, },
}, },
methods: { methods: {
handlePageSizeChange(val) { handlePageSizeChange(val) {
this.pageSize = val; this.pageSize = val;
this.currentPage = 1; this.currentPage = 1;
this.fetchUsers(); this.fetchUsers();
@@ -169,10 +163,10 @@ export default {
this.fetchUsers(); this.fetchUsers();
}, },
handleSelectAll() { handleSelectAll() {
this.isAllSelected = !this.isAllSelected; this.isAllSelected = !this.isAllSelected;
this.userList.forEach(row => { this.userList.forEach(row => {
row.selected = this.isAllSelected; row.selected = this.isAllSelected;
}); });
}, },
batchDelete() { batchDelete() {
const selectedUsers = this.userList.filter(user => user.selected); const selectedUsers = this.userList.filter(user => user.selected);
@@ -240,12 +234,12 @@ export default {
}); });
}, },
batchEnable() { batchEnable() {
const selectedUsers = this.userList.filter(user => user.selected); const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 1); this.handleChangeStatus(selectedUsers, 1);
}, },
batchDisable() { batchDisable() {
const selectedUsers = this.userList.filter(user => user.selected); const selectedUsers = this.userList.filter(user => user.selected);
this.handleChangeStatus(selectedUsers, 0); this.handleChangeStatus(selectedUsers, 0);
}, },
resetPassword(row) { resetPassword(row) {
this.$confirm("重置后将会生成新密码,是否继续?", "提示", { this.$confirm("重置后将会生成新密码,是否继续?", "提示", {
@@ -573,6 +567,7 @@ export default {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.el-table__body-wrapper { .el-table__body-wrapper {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -648,47 +643,47 @@ export default {
} }
.page-size-select { .page-size-select {
width: 100px; width: 100px;
margin-right: 10px; margin-right: 10px;
:deep(.el-input__inner) { :deep(.el-input__inner) {
height: 32px; height: 32px;
line-height: 32px; line-height: 32px;
border-radius: 4px; border-radius: 4px;
border: 1px solid #e4e7ed; border: 1px solid #e4e7ed;
background: #dee7ff; background: #dee7ff;
color: #606266; color: #606266;
font-size: 14px; font-size: 14px;
} }
:deep(.el-input__suffix) { :deep(.el-input__suffix) {
right: 6px; right: 6px;
width: 15px; width: 15px;
height: 20px; height: 20px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
top: 6px; top: 6px;
border-radius: 4px; border-radius: 4px;
} }
:deep(.el-input__suffix-inner) { :deep(.el-input__suffix-inner) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
:deep(.el-icon-arrow-up:before) { :deep(.el-icon-arrow-up:before) {
content: ""; content: "";
display: inline-block; display: inline-block;
border-left: 6px solid transparent; border-left: 6px solid transparent;
border-right: 6px solid transparent; border-right: 6px solid transparent;
border-top: 9px solid #606266; border-top: 9px solid #606266;
position: relative; position: relative;
transform: rotate(0deg); transform: rotate(0deg);
transition: transform 0.3s; transition: transform 0.3s;
} }
} }
.el-table { .el-table {
@@ -704,17 +699,19 @@ export default {
background-color: rgba(255, 255, 255, 0.6) !important; background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
:deep(.el-loading-spinner .circular) { :deep(.el-loading-spinner .circular) {
width: 28px; width: 28px;
height: 28px; height: 28px;
} }
:deep(.el-loading-spinner .path) { :deep(.el-loading-spinner .path) {
stroke: #6b8cff; stroke: #6b8cff;
} }
:deep(.el-loading-text) { :deep(.el-loading-text) {
color: #6b8cff !important; color: #6b8cff !important;
font-size: 14px; font-size: 14px;
margin-top: 8px; margin-top: 8px;
} }
</style> </style>
+2 -1
View File
@@ -39,7 +39,7 @@ const cdnResources = {
const useCDN = process.env.VUE_APP_USE_CDN === 'true'; const useCDN = process.env.VUE_APP_USE_CDN === 'true';
module.exports = defineConfig({ module.exports = defineConfig({
productionSourceMap: process.env.NODE_ENV === 'production' ? false : true, // 生产环境不生成 source map productionSourceMap: process.env.NODE_ENV !=='production', // 生产环境不生成 source map
devServer: { devServer: {
port: 8001, // 指定端口为 8001 port: 8001, // 指定端口为 8001
proxy: { proxy: {
@@ -52,6 +52,7 @@ module.exports = defineConfig({
overlay: false, // 不显示 webpack 错误覆盖层 overlay: false, // 不显示 webpack 错误覆盖层
}, },
}, },
publicPath: process.env.VUE_APP_PUBLIC_PATH || "/",
chainWebpack: config => { chainWebpack: config => {
// 修改 HTML 插件配置,动态插入 CDN 链接 // 修改 HTML 插件配置,动态插入 CDN 链接
+25
View File
@@ -314,6 +314,17 @@ LLM:
bot_id: "你的bot_id" bot_id: "你的bot_id"
user_id: "你的user_id" user_id: "你的user_id"
personal_access_token: 你的coze个人令牌 personal_access_token: 你的coze个人令牌
VolcesAiGatewayLLM:
# 火山引擎 - 边缘大模型网关
# 定义LLM API类型
type: openai
# 先开通服务,打开以下网址,创建网关访问密钥,搜索并勾选 Doubao-pro-32k-functioncall ,开通
# 如果需要使用边缘大模型网关提供的语音合成,一并勾选 Doubao-语音合成 ,另见 TTS.VolcesAiGatewayTTS 配置
# https://console.volcengine.com/vei/aigateway/
# 开通后,进入这里获取密钥:https://console.volcengine.com/vei/aigateway/tokens-list
base_url: https://ai-gateway.vei.volces.com/v1
model_name: doubao-pro-32k-functioncall
api_key: 你的网关访问密钥
LMStudioLLM: LMStudioLLM:
# 定义LLM API类型 # 定义LLM API类型
type: openai type: openai
@@ -382,6 +393,20 @@ TTS:
output_dir: tmp/ output_dir: tmp/
access_token: 你的coze web key access_token: 你的coze web key
response_format: wav response_format: wav
VolcesAiGatewayTTS:
type: openai
# 火山引擎 - 边缘大模型网关
# 先开通服务,打开以下网址,创建网关访问密钥,搜索并勾选 Doubao-语音合成 ,开通
# 如果需要使用边缘大模型网关提供的 LLM,一并勾选 Doubao-pro-32k-functioncall ,另见 LLM.VolcesAiGatewayLLM 配置
# https://console.volcengine.com/vei/aigateway/
# 开通后,进入这里获取密钥:https://console.volcengine.com/vei/aigateway/tokens-list
api_key: 你的网关访问密钥
api_url: https://ai-gateway.vei.volces.com/v1/audio/speech
model: doubao-tts
# 音色列表见 https://www.volcengine.com/docs/6561/1257544
voice: zh_male_shaonianzixin_moon_bigtts
speed: 1
output_dir: tmp/
FishSpeech: FishSpeech:
# 定义TTS API类型 # 定义TTS API类型
#启动tts方法: #启动tts方法:
+1 -1
View File
@@ -3,7 +3,7 @@ import sys
from loguru import logger from loguru import logger
from config.config_loader import load_config from config.config_loader import load_config
SERVER_VERSION = "0.3.10" SERVER_VERSION = "0.3.11"
def get_module_abbreviation(module_name, module_dict): def get_module_abbreviation(module_name, module_dict):
+8 -9
View File
@@ -225,13 +225,6 @@ class ConnectionHandler:
self._initialize_memory() self._initialize_memory()
"""加载意图识别""" """加载意图识别"""
self._initialize_intent() self._initialize_intent()
"""加载位置信息"""
self.client_ip_info = get_ip_info(self.client_ip, self.logger)
if self.client_ip_info is not None and "city" in self.client_ip_info:
self.logger.bind(tag=TAG).info(f"Client ip info: {self.client_ip_info}")
self.prompt = self.prompt + f"\nuser location:{self.client_ip_info}"
self.dialogue.update_system_message(self.prompt)
def _initialize_private_config(self): def _initialize_private_config(self):
read_config_from_api = self.config.get("read_config_from_api", False) read_config_from_api = self.config.get("read_config_from_api", False)
@@ -885,7 +878,7 @@ class ConnectionHandler:
self.executor = None self.executor = None
# 清空任务队列 # 清空任务队列
self._clear_queues() self.clear_queues()
if ws: if ws:
await ws.close() await ws.close()
@@ -893,8 +886,11 @@ class ConnectionHandler:
await self.websocket.close() await self.websocket.close()
self.logger.bind(tag=TAG).info("连接资源已释放") self.logger.bind(tag=TAG).info("连接资源已释放")
def _clear_queues(self): def clear_queues(self):
# 清空所有任务队列 # 清空所有任务队列
self.logger.bind(tag=TAG).info(
f"开始清理: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
)
for q in [self.tts_queue, self.audio_play_queue]: for q in [self.tts_queue, self.audio_play_queue]:
if not q: if not q:
continue continue
@@ -906,6 +902,9 @@ class ConnectionHandler:
q.queue.clear() q.queue.clear()
# 添加毒丸信号到队列,确保线程退出 # 添加毒丸信号到队列,确保线程退出
# q.queue.put(None) # q.queue.put(None)
self.logger.bind(tag=TAG).info(
f"清理结束: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
)
def reset_vad_states(self): def reset_vad_states(self):
self.client_audio_buffer = bytearray() self.client_audio_buffer = bytearray()
@@ -10,6 +10,7 @@ async def handleAbortMessage(conn):
logger.bind(tag=TAG).info("Abort message received") logger.bind(tag=TAG).info("Abort message received")
# 设置成打断状态,会自动打断llm、tts任务 # 设置成打断状态,会自动打断llm、tts任务
conn.client_abort = True conn.client_abort = True
conn.clear_queues()
# 打断客户端说话状态 # 打断客户端说话状态
await conn.websocket.send(json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id})) await conn.websocket.send(json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id}))
conn.clearSpeakStatus() conn.clearSpeakStatus()
@@ -2,10 +2,12 @@ from config.logger import setup_logging
from http import HTTPStatus from http import HTTPStatus
from dashscope import Application from dashscope import Application
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
from core.utils.util import check_model_key
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
class LLMProvider(LLMProviderBase): class LLMProvider(LLMProviderBase):
def __init__(self, config): def __init__(self, config):
self.api_key = config["api_key"] self.api_key = config["api_key"]
@@ -13,27 +15,32 @@ class LLMProvider(LLMProviderBase):
self.base_url = config.get("base_url") self.base_url = config.get("base_url")
self.is_No_prompt = config.get("is_no_prompt") self.is_No_prompt = config.get("is_no_prompt")
self.memory_id = config.get("ali_memory_id") self.memory_id = config.get("ali_memory_id")
check_model_key("AliBLLLM", self.api_key)
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
try: try:
# 处理dialogue # 处理dialogue
if self.is_No_prompt: if self.is_No_prompt:
dialogue.pop(0) dialogue.pop(0)
logger.bind(tag=TAG).debug(f"【阿里百练API服务】处理后的dialogue: {dialogue}") logger.bind(tag=TAG).debug(
f"【阿里百练API服务】处理后的dialogue: {dialogue}"
)
# 构造调用参数 # 构造调用参数
call_params = { call_params = {
"api_key": self.api_key, "api_key": self.api_key,
"app_id": self.app_id, "app_id": self.app_id,
"session_id": session_id, "session_id": session_id,
"messages": dialogue "messages": dialogue,
} }
if self.memory_id != False: if self.memory_id != False:
# 百练memory需要prompt参数 # 百练memory需要prompt参数
prompt = dialogue[-1].get("content") prompt = dialogue[-1].get("content")
call_params["memory_id"] = self.memory_id call_params["memory_id"] = self.memory_id
call_params["prompt"] = prompt call_params["prompt"] = prompt
logger.bind(tag=TAG).debug(f"【阿里百练API服务】处理后的prompt: {prompt}") logger.bind(tag=TAG).debug(
f"【阿里百练API服务】处理后的prompt: {prompt}"
)
responses = Application.call(**call_params) responses = Application.call(**call_params)
if responses.status_code != HTTPStatus.OK: if responses.status_code != HTTPStatus.OK:
@@ -44,9 +51,15 @@ class LLMProvider(LLMProviderBase):
) )
yield "【阿里百练API服务响应异常】" yield "【阿里百练API服务响应异常】"
else: else:
logger.bind(tag=TAG).debug(f"【阿里百练API服务】构造参数: {call_params}") logger.bind(tag=TAG).debug(
f"【阿里百练API服务】构造参数: {call_params}"
)
yield responses.output.text yield responses.output.text
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}") logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
yield "【LLM服务响应异常】" yield "【LLM服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"阿里百练暂未实现完整的工具调用(function call")
return self.response(session_id, dialogue)
@@ -1,14 +1,17 @@
from config.logger import setup_logging from config.logger import setup_logging
import requests
import json import json
import re
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
import os
# official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py) # official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py)
from cozepy import COZE_CN_BASE_URL from cozepy import COZE_CN_BASE_URL
from cozepy import Coze, TokenAuth, Message, ChatStatus, MessageContentType, ChatEventType # noqa from cozepy import (
Coze,
TokenAuth,
Message,
ChatEventType,
) # noqa
from core.providers.llm.system_prompt import get_system_prompt_for_function from core.providers.llm.system_prompt import get_system_prompt_for_function
from core.utils.util import check_model_key
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -20,6 +23,7 @@ class LLMProvider(LLMProviderBase):
self.bot_id = str(config.get("bot_id")) self.bot_id = str(config.get("bot_id"))
self.user_id = str(config.get("user_id")) self.user_id = str(config.get("user_id"))
self.session_conversation_map = {} # 存储session_id和conversation_id的映射 self.session_conversation_map = {} # 存储session_id和conversation_id的映射
check_model_key("CozeLLM", self.personal_access_token)
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
coze_api_token = self.personal_access_token coze_api_token = self.personal_access_token
@@ -48,22 +52,22 @@ class LLMProvider(LLMProviderBase):
print(event.message.content, end="", flush=True) print(event.message.content, end="", flush=True)
yield event.message.content yield event.message.content
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
if len(dialogue) == 2 and functions is not None and len(functions) > 0: if len(dialogue) == 2 and functions is not None and len(functions) > 0:
# 第一次调用llm, 取最后一条用户消息,附加tool提示词 # 第一次调用llm, 取最后一条用户消息,附加tool提示词
last_msg = dialogue[-1]["content"] last_msg = dialogue[-1]["content"]
function_str = json.dumps(functions, ensure_ascii=False) function_str = json.dumps(functions, ensure_ascii=False)
modify_msg = get_system_prompt_for_function(function_str) + last_msg modify_msg = get_system_prompt_for_function(function_str) + last_msg
dialogue[-1]["content"] = modify_msg dialogue[-1]["content"] = modify_msg
# 如果最后一个是 role="tool",附加到user上 # 如果最后一个是 role="tool",附加到user上
if len(dialogue) > 1 and dialogue[-1]["role"] == "tool": if len(dialogue) > 1 and dialogue[-1]["role"] == "tool":
assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n" assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n"
while len(dialogue) > 1 : while len(dialogue) > 1:
if dialogue[-1]["role"] == "user": if dialogue[-1]["role"] == "user":
dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"] dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"]
break break
dialogue.pop() dialogue.pop()
for token in self.response(session_id, dialogue): for token in self.response(session_id, dialogue):
yield token, None yield token, None
@@ -3,6 +3,7 @@ from config.logger import setup_logging
import requests import requests
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
from core.providers.llm.system_prompt import get_system_prompt_for_function from core.providers.llm.system_prompt import get_system_prompt_for_function
from core.utils.util import check_model_key
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -14,6 +15,7 @@ class LLMProvider(LLMProviderBase):
self.mode = config.get("mode", "chat-messages") self.mode = config.get("mode", "chat-messages")
self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip("/") self.base_url = config.get("base_url", "https://api.dify.ai/v1").rstrip("/")
self.session_conversation_map = {} # 存储session_id和conversation_id的映射 self.session_conversation_map = {} # 存储session_id和conversation_id的映射
check_model_key("DifyLLM", self.api_key)
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
try: try:
@@ -60,7 +62,9 @@ class LLMProvider(LLMProviderBase):
conversation_id # 更新映射 conversation_id # 更新映射
) )
# 过滤 message_replace 事件,此事件会全量推一次 # 过滤 message_replace 事件,此事件会全量推一次
if event.get("event") != "message_replace" and event.get("answer"): if event.get("event") != "message_replace" and event.get(
"answer"
):
yield event["answer"] yield event["answer"]
elif self.mode == "workflows/run": elif self.mode == "workflows/run":
for line in r.iter_lines(): for line in r.iter_lines():
@@ -76,29 +80,31 @@ class LLMProvider(LLMProviderBase):
if line.startswith(b"data: "): if line.startswith(b"data: "):
event = json.loads(line[6:]) event = json.loads(line[6:])
# 过滤 message_replace 事件,此事件会全量推一次 # 过滤 message_replace 事件,此事件会全量推一次
if event.get("event") != "message_replace" and event.get("answer"): if event.get("event") != "message_replace" and event.get(
"answer"
):
yield event["answer"] yield event["answer"]
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error in response generation: {e}") logger.bind(tag=TAG).error(f"Error in response generation: {e}")
yield "【服务响应异常】" yield "【服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
if len(dialogue) == 2 and functions is not None and len(functions) > 0: if len(dialogue) == 2 and functions is not None and len(functions) > 0:
# 第一次调用llm, 取最后一条用户消息,附加tool提示词 # 第一次调用llm, 取最后一条用户消息,附加tool提示词
last_msg = dialogue[-1]["content"] last_msg = dialogue[-1]["content"]
function_str = json.dumps(functions, ensure_ascii=False) function_str = json.dumps(functions, ensure_ascii=False)
modify_msg = get_system_prompt_for_function(function_str) + last_msg modify_msg = get_system_prompt_for_function(function_str) + last_msg
dialogue[-1]["content"] = modify_msg dialogue[-1]["content"] = modify_msg
# 如果最后一个是 role="tool",附加到user上 # 如果最后一个是 role="tool",附加到user上
if len(dialogue) > 1 and dialogue[-1]["role"] == "tool": if len(dialogue) > 1 and dialogue[-1]["role"] == "tool":
assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n" assistant_msg = "\ntool call result: " + dialogue[-1]["content"] + "\n\n"
while len(dialogue) > 1 : while len(dialogue) > 1:
if dialogue[-1]["role"] == "user": if dialogue[-1]["role"] == "user":
dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"] dialogue[-1]["content"] = assistant_msg + dialogue[-1]["content"]
break break
dialogue.pop() dialogue.pop()
for token in self.response(session_id, dialogue): for token in self.response(session_id, dialogue):
yield token, None yield token, None
@@ -2,6 +2,7 @@ import json
from config.logger import setup_logging from config.logger import setup_logging
import requests import requests
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
from core.utils.util import check_model_key
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -13,6 +14,7 @@ class LLMProvider(LLMProviderBase):
self.base_url = config.get("base_url") self.base_url = config.get("base_url")
self.detail = config.get("detail", False) self.detail = config.get("detail", False)
self.variables = config.get("variables", {}) self.variables = config.get("variables", {})
check_model_key("FastGPTLLM", self.api_key)
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
try: try:
@@ -21,37 +23,36 @@ class LLMProvider(LLMProviderBase):
# 发起流式请求 # 发起流式请求
with requests.post( with requests.post(
f"{self.base_url}/chat/completions", f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"}, headers={"Authorization": f"Bearer {self.api_key}"},
json={ json={
"stream": True, "stream": True,
"chatId": session_id, "chatId": session_id,
"detail": self.detail, "detail": self.detail,
"variables": self.variables, "variables": self.variables,
"messages": [ "messages": [{"role": "user", "content": last_msg["content"]}],
{ },
"role": "user", stream=True,
"content": last_msg["content"]
}
]
},
stream=True
) as r: ) as r:
for line in r.iter_lines(): for line in r.iter_lines():
if line: if line:
try: try:
if line.startswith(b'data: '): if line.startswith(b"data: "):
if line[6:].decode('utf-8') == '[DONE]': if line[6:].decode("utf-8") == "[DONE]":
break break
data = json.loads(line[6:]) data = json.loads(line[6:])
if 'choices' in data and len(data['choices']) > 0: if "choices" in data and len(data["choices"]) > 0:
delta = data['choices'][0].get('delta', {}) delta = data["choices"][0].get("delta", {})
if delta and 'content' in delta and delta['content'] is not None: if (
content = delta['content'] delta
if '<think>' in content: and "content" in delta
and delta["content"] is not None
):
content = delta["content"]
if "<think>" in content:
continue continue
if '</think>' in content: if "</think>" in content:
continue continue
yield content yield content
@@ -62,4 +63,8 @@ class LLMProvider(LLMProviderBase):
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"Error in response generation: {e}") logger.bind(tag=TAG).error(f"Error in response generation: {e}")
yield "【服务响应异常】" yield "【服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"fastgpt暂未实现完整的工具调用(function call")
return self.response(session_id, dialogue)
@@ -134,3 +134,7 @@ class LLMProvider(LLMProviderBase):
yield f"JSON解码错误:{e}" yield f"JSON解码错误:{e}"
except Exception as e: except Exception as e:
yield f"发生错误:{e}" yield f"发生错误:{e}"
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"gemini暂未实现完整的工具调用(function call")
return self.response(session_id, dialogue)
+16 -8
View File
@@ -26,13 +26,19 @@ class TTSProviderBase(ABC):
max_repeat_time = 5 max_repeat_time = 5
text = MarkdownCleaner.clean_markdown(text) text = MarkdownCleaner.clean_markdown(text)
while not os.path.exists(tmp_file) and max_repeat_time > 0: while not os.path.exists(tmp_file) and max_repeat_time > 0:
asyncio.run(self.text_to_speak(text, tmp_file)) try:
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): if not os.path.exists(tmp_file):
max_repeat_time = max_repeat_time - 1 max_repeat_time -= 1
logger.bind(tag=TAG).error(f"语音生成失败: {text}:{tmp_file},再试{max_repeat_time}") if max_repeat_time > 0:
logger.bind(tag=TAG).error(f"再试{max_repeat_time}")
if max_repeat_time > 0: if max_repeat_time > 0:
logger.bind(tag=TAG).info(f"语音生成成功: {text}:{tmp_file},重试{5 - max_repeat_time}") logger.bind(tag=TAG).info(
f"语音生成成功: {text}:{tmp_file},重试{5 - max_repeat_time}"
)
return tmp_file return tmp_file
except Exception as e: except Exception as e:
@@ -48,9 +54,11 @@ class TTSProviderBase(ABC):
# 获取文件后缀名 # 获取文件后缀名
file_type = os.path.splitext(audio_file_path)[1] file_type = os.path.splitext(audio_file_path)[1]
if file_type: if file_type:
file_type = file_type.lstrip('.') file_type = file_type.lstrip(".")
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞 # 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
audio = AudioSegment.from_file(audio_file_path, format=file_type, parameters=["-nostdin"]) audio = AudioSegment.from_file(
audio_file_path, format=file_type, parameters=["-nostdin"]
)
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配) # 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2) audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
@@ -72,11 +80,11 @@ class TTSProviderBase(ABC):
# 按帧处理所有音频数据(包括最后一帧可能补零) # 按帧处理所有音频数据(包括最后一帧可能补零)
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
# 获取当前帧的二进制数据 # 获取当前帧的二进制数据
chunk = raw_data[i:i + frame_size * 2] chunk = raw_data[i : i + frame_size * 2]
# 如果最后一帧不足,补零 # 如果最后一帧不足,补零
if len(chunk) < frame_size * 2: if len(chunk) < frame_size * 2:
chunk += b'\x00' * (frame_size * 2 - len(chunk)) chunk += b"\x00" * (frame_size * 2 - len(chunk))
# 转换为numpy数组处理 # 转换为numpy数组处理
np_frame = np.frombuffer(chunk, dtype=np.int16) np_frame = np.frombuffer(chunk, dtype=np.int16)
+1 -2
View File
@@ -159,7 +159,6 @@ def check_model_key(modelType, modelKey):
raise ValueError( raise ValueError(
"你还没配置" + modelType + "的密钥,请检查一下所使用的LLM是否配置了密钥" "你还没配置" + modelType + "的密钥,请检查一下所使用的LLM是否配置了密钥"
) )
return False
return True return True
@@ -211,7 +210,7 @@ def check_ffmpeg_installed():
def extract_json_from_string(input_string): def extract_json_from_string(input_string):
"""提取字符串中的 JSON 部分""" """提取字符串中的 JSON 部分"""
pattern = r"(\{.*\})" pattern = r"(\{.*\})"
match = re.search(pattern, input_string, re.DOTALL) #添加 re.DOTALL match = re.search(pattern, input_string, re.DOTALL) # 添加 re.DOTALL
if match: if match:
return match.group(1) # 返回提取的 JSON 字符串 return match.group(1) # 返回提取的 JSON 字符串
return None return None
@@ -2,6 +2,7 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from config.logger import setup_logging from config.logger import setup_logging
from plugins_func.register import register_function, ToolType, ActionResponse, Action from plugins_func.register import register_function, ToolType, ActionResponse, Action
from core.utils.util import get_ip_info
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -12,55 +13,104 @@ GET_WEATHER_FUNCTION_DESC = {
"name": "get_weather", "name": "get_weather",
"description": ( "description": (
"获取某个地点的天气,用户应提供一个位置,比如用户说杭州天气,参数为:杭州。" "获取某个地点的天气,用户应提供一个位置,比如用户说杭州天气,参数为:杭州。"
"如果用户说的是省份,默认用省会城市。如果用户说的不是省份或城市而是一个地名," "如果用户说的是省份,默认用省会城市。如果用户说的不是省份或城市而是一个地名,默认用该地所在省份的省会城市。"
"默认用该地所在省份的省会城市。" "如果用户没有指明地点,说“天气怎么样”,”今天天气如何“,location参数为空"
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"location": { "location": {
"type": "string", "type": "string",
"description": "地点名,例如杭州。可选参数,如果不提供则不传" "description": "地点名,例如杭州。可选参数,如果不提供则不传",
}, },
"lang": { "lang": {
"type": "string", "type": "string",
"description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN" "description": "返回用户使用的语言code,例如zh_CN/zh_HK/en_US/ja_JP等,默认zh_CN",
} },
}, },
"required": ["lang"] "required": ["lang"],
} },
} },
} }
HEADERS = { HEADERS = {
'User-Agent': ( "User-Agent": (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
'(KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36' "(KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36"
) )
} }
# 天气代码 https://dev.qweather.com/docs/resource/icons/#weather-icons # 天气代码 https://dev.qweather.com/docs/resource/icons/#weather-icons
WEATHER_CODE_MAP = { WEATHER_CODE_MAP = {
"100": "", "101": "多云", "102": "少云", "103": "晴间多云", "104": "", "100": "",
"150": "", "151": "多云", "152": "少云", "153": "晴间多云", "101": "多云",
"300": "阵雨", "301": "强阵雨", "302": "雷阵雨", "303": "强雷阵雨", "304": "雷阵雨伴有冰雹", "102": "少云",
"305": "小雨", "306": "中雨", "307": "大雨", "308": "极端降雨", "309": "毛毛雨/细雨", "103": "晴间多云",
"310": "暴雨", "311": "大暴雨", "312": "特大暴雨", "313": "冻雨", "314": "小到中雨", "104": "",
"315": "中到大雨", "316": "大到暴雨", "317": "暴雨到大暴雨", "318": "大暴雨到特大暴雨", "150": "",
"350": "阵雨", "351": "强阵雨", "399": "", "151": "多云",
"400": "小雪", "401": "中雪", "402": "大雪", "403": "暴雪", "404": "雨夹雪", "152": "少云",
"405": "雨雪天气", "406": "阵雨夹雪", "407": "阵雪", "408": "小到中雪", "409": "中到大雪", "410": "大到暴雪", "153": "晴间多云",
"456": "阵雨夹雪", "457": "阵雪", "499": "", "300": "阵雨",
"500": "薄雾", "501": "", "502": "", "503": "扬沙", "504": "浮尘", "301": "强阵雨",
"507": "沙尘暴", "508": "强沙尘暴", "302": "雷阵雨",
"509": "浓雾", "510": "强浓雾", "511": "中度霾", "512": "重度霾", "513": "严重霾", "514": "大雾", "515": "特强浓雾", "303": "强雷阵雨",
"900": "", "901": "", "999": "未知" "304": "雷阵雨伴有冰雹",
"305": "小雨",
"306": "中雨",
"307": "大雨",
"308": "极端降雨",
"309": "毛毛雨/细雨",
"310": "暴雨",
"311": "大暴雨",
"312": "特大暴雨",
"313": "冻雨",
"314": "小到中雨",
"315": "中到大雨",
"316": "大到暴雨",
"317": "暴雨到大暴雨",
"318": "大暴雨到特大暴雨",
"350": "阵雨",
"351": "强阵雨",
"399": "",
"400": "小雪",
"401": "中雪",
"402": "大雪",
"403": "暴雪",
"404": "雨夹雪",
"405": "雨雪天气",
"406": "阵雨夹雪",
"407": "阵雪",
"408": "小到中雪",
"409": "中到大雪",
"410": "大到暴雪",
"456": "阵雨夹雪",
"457": "阵雪",
"499": "",
"500": "薄雾",
"501": "",
"502": "",
"503": "扬沙",
"504": "浮尘",
"507": "沙尘暴",
"508": "强沙尘暴",
"509": "浓雾",
"510": "强浓雾",
"511": "中度霾",
"512": "重度霾",
"513": "严重霾",
"514": "大雾",
"515": "特强浓雾",
"900": "",
"901": "",
"999": "未知",
} }
def fetch_city_info(location, api_key): def fetch_city_info(location, api_key):
url = f"https://geoapi.qweather.com/v2/city/lookup?key={api_key}&location={location}&lang=zh" url = f"https://geoapi.qweather.com/v2/city/lookup?key={api_key}&location={location}&lang=zh"
response = requests.get(url, headers=HEADERS).json() response = requests.get(url, headers=HEADERS).json()
return response.get('location', [])[0] if response.get('location') else None return response.get("location", [])[0] if response.get("location") else None
def fetch_weather_page(url): def fetch_weather_page(url):
@@ -72,10 +122,14 @@ def parse_weather_info(soup):
city_name = soup.select_one("h1.c-submenu__location").get_text(strip=True) city_name = soup.select_one("h1.c-submenu__location").get_text(strip=True)
current_abstract = soup.select_one(".c-city-weather-current .current-abstract") current_abstract = soup.select_one(".c-city-weather-current .current-abstract")
current_abstract = current_abstract.get_text(strip=True) if current_abstract else "未知" current_abstract = (
current_abstract.get_text(strip=True) if current_abstract else "未知"
)
current_basic = {} current_basic = {}
for item in soup.select(".c-city-weather-current .current-basic .current-basic___item"): for item in soup.select(
".c-city-weather-current .current-basic .current-basic___item"
):
parts = item.get_text(strip=True, separator=" ").split(" ") parts = item.get_text(strip=True, separator=" ").split(" ")
if len(parts) == 2: if len(parts) == 2:
key, value = parts[1], parts[0] key, value = parts[1], parts[0]
@@ -84,7 +138,9 @@ def parse_weather_info(soup):
temps_list = [] temps_list = []
for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据 for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据
date = row.select_one(".date-bg .date").get_text(strip=True) date = row.select_one(".date-bg .date").get_text(strip=True)
weather_code = row.select_one(".date-bg .icon")["src"].split("/")[-1].split(".")[0] weather_code = (
row.select_one(".date-bg .icon")["src"].split("/")[-1].split(".")[0]
)
weather = WEATHER_CODE_MAP.get(weather_code, "未知") weather = WEATHER_CODE_MAP.get(weather_code, "未知")
temps = [span.get_text(strip=True) for span in row.select(".tmp-cont .temp")] temps = [span.get_text(strip=True) for span in row.select(".tmp-cont .temp")]
high_temp, low_temp = (temps[0], temps[-1]) if len(temps) >= 2 else (None, None) high_temp, low_temp = (temps[0], temps[-1]) if len(temps) >= 2 else (None, None)
@@ -93,31 +149,53 @@ def parse_weather_info(soup):
return city_name, current_abstract, current_basic, temps_list return city_name, current_abstract, current_basic, temps_list
@register_function('get_weather', GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL) @register_function("get_weather", GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL)
def get_weather(conn, location: str = None, lang: str = "zh_CN"): def get_weather(conn, location: str = None, lang: str = "zh_CN"):
api_key = conn.config["plugins"]["get_weather"]["api_key"] api_key = conn.config["plugins"]["get_weather"]["api_key"]
default_location = conn.config["plugins"]["get_weather"]["default_location"] default_location = conn.config["plugins"]["get_weather"]["default_location"]
location = location or conn.client_ip_info.get("city") or default_location print("用户设置的default_location为:", default_location)
logger.bind(tag=TAG).debug(f"获取天气: {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) city_info = fetch_city_info(location, api_key)
if not city_info: if not city_info:
return ActionResponse(Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None) return ActionResponse(
Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None
soup = fetch_weather_page(city_info['fxLink']) )
soup = fetch_weather_page(city_info["fxLink"])
if not soup: if not soup:
return ActionResponse(Action.REQLLM, None, "请求失败") return ActionResponse(Action.REQLLM, None, "请求失败")
city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup) city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup)
weather_report = f"根据下列数据,用{lang}回应用户的查询天气请求:\n{city_name}未来7天天气:\n"
for i, (date, weather, high, low) in enumerate(temps_list):
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", [] "functions", []
) )
if "hass_get_state" in funcs or "hass_set_state" in funcs: if "hass_get_state" in funcs or "hass_set_state" in funcs:
prompt = "下面是我家智能设备,可以通过homeassistant控制\n" prompt = "\n下面是我家智能设备列表(位置,设备名,entity_id),可以通过homeassistant控制\n"
devices = conn.config["plugins"]["home_assistant"].get("devices", []) devices = conn.config["plugins"]["home_assistant"].get("devices", [])
if len(devices) == 0: if len(devices) == 0:
return return