Compare commits

...
89 Commits
Author SHA1 Message Date
欣南科技andGitHub 30796c6f81 Merge pull request #1262 from xinnan-tech/web-headerbar-modify
update:修复init_memorybug
2025-05-15 16:14:27 +08:00
hrz 86b5eb1818 update:修复init_memorybug 2025-05-15 16:13:32 +08:00
hrzandGitHub 696405a8bf Merge pull request #1256 from xinnan-tech/web-headerbar-modify
优化headerbar遮挡问题
2025-05-15 15:47:55 +08:00
Sakura-RanChen a2c2eab6b3 no message 2025-05-15 15:46:53 +08:00
Sakura-RanChen ead9ee81fb fix:summaryMemory key错误 2025-05-15 15:39:05 +08:00
Sakura-RanChen bd140af714 优化headerbar遮挡问题 2025-05-15 09:54:59 +08:00
欣南科技andGitHub 96008fa78f Merge pull request #1249 from xinnan-tech/manager-local-mem
fix:short_momery可能为空的bug
2025-05-14 23:01:03 +08:00
hrz da4d0f515c fix:short_momery可能为空的bug 2025-05-14 22:59:55 +08:00
欣南科技andGitHub 67e6a22da9 Merge pull request #1248 from xinnan-tech/manager-local-mem
增加智控台管理【本地记忆】功能
2025-05-14 22:29:59 +08:00
hrz b791fb1494 update:优化总结内容 2025-05-14 22:28:36 +08:00
hrzandGitHub 9e329bf19f Merge pull request #1245 from CaixyPromise/chore/setting-ota-firmware-in-old-version
chore: 支持在老版本激活设备时,OTA返回有效的硬件信息。
2025-05-14 20:32:16 +08:00
hrzandGitHub 3906e8a942 Delete main/manager-web/pnpm-lock.yaml 2025-05-14 20:31:55 +08:00
hrzandGitHub f74faea065 Merge pull request #1243 from xinnan-tech/py_weather_fix
Py weather fix
2025-05-14 20:30:56 +08:00
hrzandGitHub 7d3759acce 统一无意义域名 2025-05-14 20:28:40 +08:00
caixypromise 172962f711 chore: 支持在老版本激活设备时,OTA返回有效的硬件信息。
如果设备未激活,当前OTA会返回一个空的的firmware响应体。但老版本(如1.4.7)会因为缺少firmware响应体无法走通OTA版本检查流程。老版本相关代码如下
```CPP
// ota.cc - version 1.4.7 -line 131
cJSON *firmware = cJSON_GetObjectItem(root, "firmware");
    if (firmware == NULL) { // firmware为空
        ESP_LOGE(TAG, "Failed to get firmware object");
        cJSON_Delete(root);
        return false;
    }
```
因此,在老版本激活设备时,OTA将直接返回的当前激活设备上传硬件的信息,以便老版本设备能够走通OTA版本检查流程。后续用户可以再通过智控台更新OTA版本。
2025-05-14 19:55:20 +08:00
Sakura-RanChen 36109e3b88 增加获取默认值,配置文件提示 2025-05-14 17:17:03 +08:00
Sakura-RanChen 7c63b7c062 no message 2025-05-14 16:50:15 +08:00
Sakura-RanChen 1efaf048f5 优化供应器编辑 2025-05-14 16:48:35 +08:00
Sakura-RanChen ac52714498 weather-fix 2025-05-14 16:30:53 +08:00
Sakura-RanChenandGitHub c3de89f504 Merge pull request #1233 from xinnan-tech/py_test
修复“A客户端断开连接执行"保存记忆并关闭连接"过程中,B客户端创建连接会被阻塞”问题
2025-05-14 14:57:41 +08:00
CGDandGitHub 6f6a645d0a Merge pull request #1238 from xinnan-tech/web-Supplier-Page
增加供应器管理页面,优化页面样式和数据校验
2025-05-14 14:54:33 +08:00
CGDandGitHub 5605352144 Merge branch 'main' into py_test 2025-05-14 14:52:57 +08:00
Sakura-RanChen ef433f446d 增加供应器管理页面,优化页面样式和数据校验 2025-05-14 14:43:36 +08:00
hrz fe8f200364 update:连接ota接口时,异步更新最新连接时间 2025-05-14 14:42:59 +08:00
hrz be3a030302 update:优化记忆体布局 2025-05-14 12:09:29 +08:00
CGD cd6d4488d1 update:"使用线程池异步保存记忆" 2025-05-14 11:22:09 +08:00
hrzandGitHub da3a5df5c3 Merge pull request #1230 from ljwwd2/main
增加智控台管理【本地记忆】功能
2025-05-14 10:52:57 +08:00
欣南科技andGitHub 4864407d61 Merge pull request #1232 from xinnan-tech/test-exit
fix:修正warn为warning
2025-05-14 10:41:31 +08:00
hrz f6f7879d6e fix:修正warn为warning 2025-05-14 10:40:40 +08:00
hrzandGitHub dab1f14a59 Merge pull request #1228 from CaixyPromise/feature/custom-end-prompt
feature: 新增结束词prompt自定义能力。
2025-05-14 10:05:31 +08:00
hrzandGitHub 51ca914206 end_prompt默认为开启 2025-05-14 10:04:30 +08:00
ljwwd2 933ffb8ca2 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml
2025-05-14 03:31:31 +08:00
ljwwd2 2f5f20a257 增加智控台管理【本地记忆】功能 2025-05-14 03:27:32 +08:00
caixypromise 92544f89f3 feature: 新增结束词prompt自定义能力。
新增在python服务端在config配置内自定义结束词提示词(end_prompt),仅在enable为True时触发结束聊天,否则自动默认清理资源退出。

close(#963)
2025-05-14 00:12:31 +08:00
欣南科技andGitHub f1cf1acbfa Merge pull request #1226 from xinnan-tech/fix-fun-server
fix:修复智控台fish-speech无法按声音播放bug
2025-05-13 18:29:16 +08:00
hrz f22008389c fix:修复智控台fish-speech无法按声音播放bug 2025-05-13 18:28:27 +08:00
欣南科技andGitHub 91de372cbc Merge pull request #1222 from xinnan-tech/fix-fun-server
update:优化ASR_FunASRServer文档
2025-05-13 15:19:02 +08:00
hrz 811bd94934 update:优化ASR_FunASRServer文档 2025-05-13 15:18:23 +08:00
hrzandGitHub 5987488b3d Merge pull request #1199 from whosmyqueen/feature/add-fun-asr-auth
[feature] 添加FunServerAsr认证
2025-05-13 14:21:16 +08:00
hrzandGitHub 966c9e972d Merge branch 'main' into feature/add-fun-asr-auth 2025-05-13 14:21:06 +08:00
欣南科技andGitHub 464261a1b7 Merge pull request #1218 from xinnan-tech/hot-fix
update:优化未开启记忆的聊天记录按钮样式
2025-05-13 11:21:13 +08:00
hrz 363dcef81a update:优化未开启记忆的聊天记录按钮样式 2025-05-13 11:18:03 +08:00
GoodyHaoandGitHub 4439617c50 Merge pull request #1204 from GOODDAYDAY/main
feat: 优化上报命名含义,增加不同上报开关
2025-05-13 10:58:33 +08:00
欣南科技andGitHub 5d2fa3d65d Merge pull request #1215 from xinnan-tech/hot-fix
update:修复fishspeech的normalize获取值转换bug
2025-05-13 10:14:51 +08:00
hrz 8930ab0af6 update:修复fishspeech的normalize获取值转换bug 2025-05-13 10:14:15 +08:00
欣南科技andGitHub 6af6fe22fc Merge pull request #1211 from xinnan-tech/enhance-gemini-with-proxy
Enhance gemini with proxy
2025-05-13 08:50:31 +08:00
hrzandGitHub dd577a7843 Merge pull request #1208 from CaixyPromise/update/enhance-gemini-with-proxy
Update: 提升Gemini代理配置能力,支持function_call调用与stream流式对话能力
2025-05-12 21:54:24 +08:00
caixypromise f772155674 chore: 删除无用的包引入。 2025-05-12 21:39:13 +08:00
caixypromise 3ce204338a chore: 删除无用的包引入。 2025-05-12 21:38:23 +08:00
caixypromise 168bc9ef45 update: 改进gemini连通代理能力,自动测试与装配合适的代理配置信息。 2025-05-12 21:33:43 +08:00
caixypromise bfc3d4888b Merge remote-tracking branch 'upstream/main' into update/enhance-gemini-with-proxy 2025-05-12 21:29:42 +08:00
goodyhao 0148b115db Merge branch 'feature/upload-optimise'
# Conflicts:
#	main/xiaozhi-server/core/connection.py
#	main/xiaozhi-server/core/handle/reportHandle.py
2025-05-12 19:16:29 +08:00
goodyhao 0b11953f6f feat: 优化上报命名含义,增加不同上报开关 2025-05-12 19:14:04 +08:00
欣南科技andGitHub 6e210faacf Merge pull request #1206 from xinnan-tech/manager-plugin-api
聊天记录支持配置
2025-05-12 18:30:12 +08:00
hrz 4ef755e10b update:优化插件显示 2025-05-12 18:28:52 +08:00
hrz 62b7cc5509 update:更新版本号 2025-05-12 18:10:34 +08:00
hrz 0e1ec05110 Merge branch 'manager-plugin-api' of https://github.com/xinnan-tech/xiaozhi-esp32-server into manager-plugin-api 2025-05-12 18:07:28 +08:00
hrz dd38fc74db update:右智控台下发聊天记录上报策略 2025-05-12 18:06:58 +08:00
GoodyHao d619d7ac76 feat: 优化上报命名含义,增加不同上报开关 2025-05-12 17:55:54 +08:00
Sakura-RanChen 37f7511ba0 Merge branch 'manager-plugin-api' of https://github.com/xinnan-tech/xiaozhi-esp32-server into manager-plugin-api 2025-05-12 16:49:46 +08:00
Sakura-RanChen afd03ba1b5 完善插件管理页面 2025-05-12 16:48:34 +08:00
hrz ac7b02d28a update:保存智能体配置时,删除对应的历史记录 2025-05-12 16:31:06 +08:00
hrz 4a0bdbf779 update:聊天记录上报模式和记忆模式绑定 2025-05-12 15:49:44 +08:00
whosmyqueen 164dada46a feat(core): 为 FunASR 服务添加 API 密钥支持并优化结果处理
- 在 FunASRWebSocket 类中添加 API密钥配置
- 在 WebSocket连接时添加认证头信息- 使用正则表达式处理返回结果,提取有用信息
2025-05-12 14:38:58 +08:00
hrz fd1c4ec792 Merge branch 'manager-plugin-api' of https://github.com/xinnan-tech/xiaozhi-esp32-server into manager-plugin-api 2025-05-12 14:16:35 +08:00
hrz add000d221 update:数据库增加聊天上报策略字段 2025-05-12 14:16:08 +08:00
hrzandGitHub def598f728 Merge pull request #1198 from xinnan-tech/web-Plug
增加插件管理的页面
2025-05-12 14:13:05 +08:00
hrzandGitHub 0294885fbf Merge branch 'manager-plugin-api' into web-Plug 2025-05-12 14:12:54 +08:00
Sakura-RanChen c0ec4338f8 增加插件管理的页面 2025-05-12 14:10:05 +08:00
whosmyqueen fb7f6f73bd Merge branch 'main' into feature/add-fun-asr-auth 2025-05-12 13:10:27 +08:00
caixypromise e04c38e557 Merge branch 'refs/heads/feature/gemini-llm' into update/enhance-gemini-with-proxy 2025-05-12 04:38:09 +08:00
hrz bfb5420733 update:修改记录类型为0、1、2 2025-05-11 19:23:01 +08:00
hrzandGitHub 9e6fc32903 Merge pull request #1175 from ljwwd2/main
聊天记录支持配置
2025-05-11 19:06:27 +08:00
hrz 217ceb1c7a update:优化新增固件框宽度显示 2025-05-11 19:04:39 +08:00
hrz 846f89664e update:优化非LLM使用functioncall意图识别的提示 2025-05-11 16:11:44 +08:00
hrz 0e30f69e96 update:优化小图显示 2025-05-11 16:10:37 +08:00
ljwwd2 af9eb538f4 Merge remote-tracking branch 'origin/main' 2025-05-11 03:25:23 +08:00
ljwwd2 66a0beea33 前端-根据agentId获取智能体配置 2025-05-11 03:25:04 +08:00
ljwwd2andGitHub 411e73bb35 Merge branch 'xinnan-tech:main' into main 2025-05-11 03:23:26 +08:00
ljwwd2 852e222f5d 前端-聊天记录支持配置 2025-05-11 03:19:40 +08:00
hrz 054ceab4f4 update:优化非LLM使用functioncall的提示 2025-05-10 22:22:27 +08:00
欣南科技andGitHub d9149ff572 Merge pull request #1180 from xinnan-tech/feature/ha-llm
恢复HomeAssistant作为LLM使用
2025-05-10 21:55:08 +08:00
hrz 7f331690a0 update:增加误用function_call的提示 2025-05-10 21:52:31 +08:00
ljwwd2 a5698dfff0 聊天记录支持配置 2025-05-10 02:24:55 +08:00
ljwwd2 1e5866da06 聊天记录支持配置 2025-05-10 02:18:28 +08:00
whosmyqueen 7e712c5003 build(db): 更新模型供应器和配置
- 更新 ai_model_provider表中的字段配置
- 更新 ai_model_config 表中的配置 JSON- 添加新的数据库变更脚本202505091552.sql
- 在 db.changelog-master.yaml 中引用新脚本
2025-05-09 16:01:09 +08:00
Junsen HuangandGitHub 474eeb9e68 Merge pull request #1102 from CaixyPromise/feature/ha-llm
feature: 恢复HA的LLM能力。补充关于HA的LLM大模型和MCP集成方法文档
2025-05-07 01:18:24 +08:00
caixypromise 0c0386f4ba feature: 新增Gemini模型支持function_call功能,支持在代理模式下使用流式stream对话模式。 2025-05-04 23:24:33 +08:00
caixypromise 6c2b1cb589 feature: 恢复HA的LLM支持能力。
docs: 更新Home Assistant-integration.md内容,补充提交关于HA集成的说法,介绍LLM大模型和MCP集成方法。

issue #616
2025-05-04 23:22:33 +08:00
67 changed files with 3249 additions and 459 deletions
+72
View File
@@ -0,0 +1,72 @@
登录AutoDL,租赁镜像
选择镜像:
```
PyTorch / 2.1.0 / 3.10(ubuntu22.04) / cuda 12.1
```
机器开机后,设置学术加速
```
source /etc/network_turbo
```
进入工作目录
```
cd autodl-tmp/
```
拉取项目
```
git clone https://gitclone.com/github.com/fishaudio/fish-speech.git ; cd fish-speech
```
安装依赖
```
pip install -e.
```
如果报错,安装portaudio
```
apt-get install portaudio19-dev -y
```
安装后执行
```
pip install torch==2.3.1 torchvision==0.18.1 torchaudio==2.3.1 --index-url https://download.pytorch.org/whl/cu121
```
下载模型
```
cd tools
python download_models.py
```
下载完模型后运行接口
```
python -m tools.api_server --listen 0.0.0.0:6006
```
然后用浏览器去到aotodl实例页面
```
https://autodl.com/console/instance/list
```
如下图点击你刚才机器的`自定义服务`按钮,开启端口转发服务
![自定义服务](images/fishspeech/autodl-01.png)
端口转发服务设置完成后,你本地电脑打开网址`http://localhost:6006/`,就可以访问fish-speech的接口了
![服务预览](images/fishspeech/autodl-02.png)
如果你是单模块部署,核心配置如下
```
selected_module:
TTS: FishSpeech
TTS:
FishSpeech:
reference_audio: ["config/assets/wakeup_words.wav",]
reference_text: ["哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦",]
api_key: "123"
api_url: "http://127.0.0.1:6006/v1/tts"
```
然后重启服务
+157 -20
View File
@@ -1,4 +1,8 @@
# ESP32设备与HomeAssistant集成指南 # 小智ESP32-开源服务端与HomeAssistant集成指南
[TOC]
-----
## 简介 ## 简介
@@ -7,26 +11,58 @@
## 前提条件 ## 前提条件
- 已安装并配置好`HomeAssistant` - 已安装并配置好`HomeAssistant`
- 已经在HomeAssistant中集成`Xiaomi Home`,并将米家的设备导入进`HomeAssistant`
- `xiaozhi-esp32-server智控台`能正常使用
- 本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用 - 本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用
## 网络环境 ## 开始前的操作(必要)
### 1. 获取HA的网络网络地址信息
请访问你Home Assistant的网络地址,例如,我的HA的地址是192.168.4.7,端口则是默认的8123,则在浏览器打开
1、我的`HomeAssistant`部署在下面,版本是`2025.3.4`
``` ```
http://192.168.4.7:8123 http://192.168.4.7:8123
``` ```
2、我的`xiaozhi-esp32-server智控台``HomeAssistant`部署在同一台机器的另一个端口,版本是`0.3.10` > 手动查询 HA 的 IP 地址方法**(仅限小智esp32-server和HA部署在同一个网络设备[例如同一个wifi]下)**:
>
> 1. 进入 Home Assistant(前端)。
>
> 2. 点击左下角 **设置(Settings** → **系统(System** → **网络(Network**。
>
> 3. 滑到最底部`Home Assistant 网址(Home Assistant website)`区域,在`本地网络(local network)`中,点击`眼睛`按钮,可以看到当前使用的 IP 地址(如 `192.168.1.10`)和网络接口。点击`复制连接(copy link)`可以直接复制。
>
> ![image-20250504051716417](images/image-ha-integration-01.png)
或,您已经设置了直接可以访问的Home Assistant的OAuth地址,您也可以在浏览器内直接访问
``` ```
http://192.168.4.7:8002 http://homeassistant.local:8123
``` ```
### 2. 登录`Home Assistant`拿到开发密钥
## 配置步骤 登录`HomeAssistant`,点击`左下角头像 -> 个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key,并复制保存,后续的方法都需要使用这个api key且仅出现一次(小tips: 您可以保存生成的二维码图像,后续可以扫描二维码再此提取api key)。
### 1. 登录`HomeAssistant`整理需要控制的设备清单 ## 方法1:小智社区共建的HA调用功能
### 功能描述
- 如您后续需要增加新的设备,该方法需要手动重启`xiaozhi-esp32-server服务端`以此更新设备信息**(重要**)。
- 需要您确保已经在HomeAssistant中集成`Xiaomi Home`,并将米家的设备导入进`HomeAssistant`
- 需要您确保`xiaozhi-esp32-server智控台`能正常使用。
- 我的`xiaozhi-esp32-server智控台``HomeAssistant`部署在同一台机器的另一个端口,版本是`0.3.10`
```
http://192.168.4.7:8002
```
### 配置步骤
#### 1. 登录`HomeAssistant`整理需要控制的设备清单
登录`HomeAssistant`,点击`左下角的设置`,然后进入`设备与服务`,再点击顶部的`实体`。 登录`HomeAssistant`,点击`左下角的设置`,然后进入`设备与服务`,再点击顶部的`实体`。
@@ -56,12 +92,7 @@ http://192.168.4.7:8002
这段字符,我们成为“设备清单字符”需要保存好,等一下有用。 这段字符,我们成为“设备清单字符”需要保存好,等一下有用。
### 2. 登录`HomeAssistant`拿到开发密钥 #### 2. 登录`智控台`
登录`HomeAssistant`,点击`左下角个人`,切换`安全`导航栏,划到底部`长期访问令牌`生成api_key。
### 3. 登录`智控台`
使用管理员账号,登录`智控台`。点击顶部菜单`参数管理`,搜索`plugins.home_assistant.`,会有三条结果出来 使用管理员账号,登录`智控台`。点击顶部菜单`参数管理`,搜索`plugins.home_assistant.`,会有三条结果出来
@@ -77,7 +108,7 @@ http://192.168.4.7:8123
编辑`plugins.home_assistant.api_key`,把你从`HomeAssistant`复制过来的密钥,粘贴进去 编辑`plugins.home_assistant.api_key`,把你从`HomeAssistant`复制过来的密钥,粘贴进去
### 4. 设置`意图识别`函数 #### 3. 设置`意图识别`函数
在智控台,点击顶部菜单“模型配置”,在左侧栏,找到“意图识别”,找到id为`Intent_function_call`的意图,点击编辑 在智控台,点击顶部菜单“模型配置”,在左侧栏,找到“意图识别”,找到id为`Intent_function_call`的意图,点击编辑
@@ -94,18 +125,124 @@ change_role;get_weather;get_news;play_music
change_role;get_weather;get_news;play_music;hass_get_state;hass_set_state change_role;get_weather;get_news;play_music;hass_get_state;hass_set_state
``` ```
### 6. 手动重启xiaozhi-server #### 4. 手动重启xiaozhi-server
重启xiaozhi-server程序 重启xiaozhi-server程序
### 5. 确认角色配置是否设置了函数意图识别 #### 5. 确认角色配置是否设置了函数意图识别
在智控台,点击顶部菜单“智能体管理”,找到设备所在的智能体,点击“配置角色” 在智控台,点击顶部菜单“智能体管理”,找到设备所在的智能体,点击“配置角色”
确认意图识别(Intent),是否选择“函数调用意图识别” 确认意图识别(Intent),是否选择“函数调用意图识别”
### 6. 唤醒设别进行控制 #### 6. 唤醒设别进行控制
尝试和esp32说,“打开XXX灯” 尝试和esp32说,“打开XXX灯”
## 方法2:小智将Home Assistant的语音助手作为LLM工具
### 功能描述
- 该方法有一个比较严重的缺点——**该方法无法使用小智开源生态的function_call插件功能的能力**,因为使用Home Assistant作为小智的LLM工具会将意图识别能力转让给Home Assistant。但是**这个方法是能体验到原生的Home Assistant操作功能,且小智的聊天能力不变**。如实在介意可以使用同样是Home Assistant支持的[方法3](##方法3:使用Home Assistant的MCP服务(推荐)),能够最大程度体验到Home Assistant的功能。
### 配置步骤:
#### 1. 配置Home Assistant的大模型语音助手。
**需要您提前配置好Home Assistant的语音助手或大模型工具。**
#### 2. 获取Home Assistant的语言助手的Agent ID.
1. 进入Home Assistant页面内。左侧点击`开发者助手`。
2. 在打开的`开发者助手`内,点击`动作`选项卡(如图示操作1),在页面内的选项栏`动作`中,找到或输入`conversation.process(对话-处理)`并选择`对话(conversation: 处理`(如图示操作2)。
![image-20250504043539343](images/image-ha-integration-02.png)
3. 在页面内勾选`代理(agent)`选项,在变成常亮的`对话代理(conversation agent)`内选择您步骤一配置好的语音助手名称,如图示,我这边配置好的是`ZhipuAi`并选择。
![image-20250504043854760](images/image-ha-integration-03.png)
4. 选中后,点击表单左下方的`进入YAML模式`。
![image-20250504043951126](images/image-ha-integration-04.png)
5. 复制其中的agent-id的值,例如图示中我的是`01JP2DYMBDF7F4ZA2DMCF2AGX2`(仅供参考)。
![image-20250504044046466](images/image-ha-integration-05.png)
6. 切换到小智开源服务端`xiaozhi-esp32-server`的`config.yaml`文件内,在LLM配置中,找到Home Assistant,设置您的Home Assistant的网络地址,Api key和刚刚查询到的agent_id。
7. 修改`config.yaml`文件内的`selected_module`属性的`LLM`为`HomeAssistant``Intent`为`nointent`。
8. 重启小智开源服务端`xiaozhi-esp32-server`即可正常使用。
## 方法3:使用Home Assistant的MCP服务(推荐)
### 功能描述
- 需要您提前在Home Assistant内集成并安装好HA集成——[Model Context Protocol Server](https://www.home-assistant.io/integrations/mcp_server/)。
- 这个方法与方法2都是HA官方提供的解决方法,与方法2不同的是,您可以正常使用小智开源服务端`xiaozhi-esp32-server`的开源共建的插件,同时允许您随意使用任何一个支持function_call功能的LLM大模型。
### 配置步骤
#### 1. 安装Home Assistant的MCP服务集成。
集成官方网址——[Model Context Protocol Server](https://www.home-assistant.io/integrations/mcp_server/)。。
或跟随以下手动操作。
> - 前往Home Assistant页面的**[设置 > 设备和服务(Settings > Devices & Services.](https://my.home-assistant.io/redirect/integrations)**。
>
> - 在右下角,选择 **[添加集成(Add Integration](https://my.home-assistant.io/redirect/config_flow_start?domain=mcp_server)**按钮。
>
> - 从列表中选择**模型上下文协议服务器(Model Context Protocol Server**。
>
> - 按照屏幕上的说明完成设置。
#### 2. 配置小智开源服务端MCP配置信息
切换到小智开源服务端`xiaozhi-esp32-server`的`mcp_server_settings.json`文件内,在`"mcpServers"`的括号内添加以下内容:
```json
"Home Assistant": {
"command": "mcp-proxy",
"args": [
"http://YOUR_HA_HOST/mcp_server/sse"
],
"env": {
"API_ACCESS_TOKEN": "YOUR_API_ACCESS_TOKEN"
}
},
```
注意:
1. **替换配置:**
- 替换`args`内的`YOUR_HA_HOST`为您的HA服务地址,如果你的服务地址已经包含了https/http字样(例如`http://192.168.1.101:8123`),则只需要填入`192.168.1.101:8123`即可。
- 将`env`内`API_ACCESS_TOKEN`的`YOUR_API_ACCESS_TOKEN`替换成您之前获取到的开发密钥api key。
2. **如果你添加配置是在`"mcpServers"`的括号内后续没有新的`mcpServers`的配置时,需要把最后的逗号`,`移除**,否则可能会解析失败。
**最后效果参考以下(参考如下)**:
```json
"mcpServers": {
"Home Assistant": {
"command": "mcp-proxy",
"args": [
"http://192.168.1.101:8123/mcp_server/sse"
],
"env": {
"API_ACCESS_TOKEN": "abcd.efghi.jkl"
}
}
}
```
#### 3. 配置小智开源服务端的系统配置
1. **选择任意一款支持function_call的LLM大模型作为小智的LLM聊天助手(但不要选择Home Assistant作为LLM工具)**,本次我选择的模型是:免费的ChatGLM,它支持functioncall函数调用,但部分时候调用不太稳定,如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-1-5-pro-32k-250115。
2. 切换到小智开源服务端`xiaozhi-esp32-server`的`config.yaml`文件内,设置您的LLM大模型配置,并且将`selected_module`配置的`Intent`调整为`function_call`。
3. 重启小智开源服务端`xiaozhi-esp32-server`即可正常使用。
Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,40 @@
package xiaozhi.common.config;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration
@EnableAsync
@EnableAspectJAutoProxy(exposeProxy = true)
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("AsyncThread-");
// 设置拒绝策略:由调用线程执行
executor.setRejectedExecutionHandler(new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
try {
// 如果线程池已满,则由调用线程执行
r.run();
} catch (Exception e) {
throw new RuntimeException("执行异步任务失败", e);
}
}
});
executor.initialize();
return executor;
}
}
@@ -1,5 +1,7 @@
package xiaozhi.common.constant; package xiaozhi.common.constant;
import lombok.Getter;
/** /**
* 常量 * 常量
* Copyright (c) 人人开源 All rights reserved. * Copyright (c) 人人开源 All rights reserved.
@@ -109,6 +111,11 @@ public interface Constant {
*/ */
String FILE_EXTENSION_SEG = "."; String FILE_EXTENSION_SEG = ".";
/**
* 无记忆
*/
String MEMORY_NO_MEM = "Memory_nomem";
enum SysBaseParam { enum SysBaseParam {
/** /**
* 系统全称 * 系统全称
@@ -174,8 +181,23 @@ public interface Constant {
} }
} }
@Getter
enum ChatHistoryConfEnum {
IGNORE(0, "不记录"),
RECORD_TEXT(1, "记录文本"),
RECORD_TEXT_AUDIO(2, "文本音频都记录");
private final int code;
private final String name;
ChatHistoryConfEnum(int code, String name) {
this.code = code;
this.name = name;
}
}
/** /**
* 版本号 * 版本号
*/ */
public static final String VERSION = "0.4.2"; public static final String VERSION = "0.4.3";
} }
@@ -39,6 +39,7 @@ import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatSessionDTO; import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
import xiaozhi.modules.agent.dto.AgentCreateDTO; import xiaozhi.modules.agent.dto.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO; import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentTemplateEntity;
@@ -46,6 +47,7 @@ import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService; import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService; import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.security.user.SecurityUser; import xiaozhi.modules.security.user.SecurityUser;
@@ -109,6 +111,8 @@ public class AgentController {
entity.setMemModelId(template.getMemModelId()); entity.setMemModelId(template.getMemModelId());
entity.setIntentModelId(template.getIntentModelId()); entity.setIntentModelId(template.getIntentModelId());
entity.setSystemPrompt(template.getSystemPrompt()); entity.setSystemPrompt(template.getSystemPrompt());
entity.setSummaryMemory(template.getSummaryMemory());
entity.setChatHistoryConf(template.getChatHistoryConf());
entity.setLangCode(template.getLangCode()); entity.setLangCode(template.getLangCode());
entity.setLanguage(template.getLanguage()); entity.setLanguage(template.getLanguage());
} }
@@ -125,10 +129,26 @@ public class AgentController {
return new Result<String>().ok(entity.getId()); return new Result<String>().ok(entity.getId());
} }
@PutMapping("/saveMemory/{macAddress}")
@Operation(summary = "根据设备id更新智能体")
public Result<Void> updateByDeviceId(@PathVariable String macAddress, @RequestBody @Valid AgentMemoryDTO dto) {
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device == null) {
return new Result<>();
}
AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO();
agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory());
return updateAgentById(device.getAgentId(), agentUpdateDTO);
}
@PutMapping("/{id}") @PutMapping("/{id}")
@Operation(summary = "更新智能体") @Operation(summary = "更新智能体")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) { public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) {
return updateAgentById(id, dto);
}
private Result<Void> updateAgentById(String id, AgentUpdateDTO dto) {
// 先查询现有实体 // 先查询现有实体
AgentEntity existingEntity = agentService.getAgentById(id); AgentEntity existingEntity = agentService.getAgentById(id);
if (existingEntity == null) { if (existingEntity == null) {
@@ -166,6 +186,12 @@ public class AgentController {
if (dto.getSystemPrompt() != null) { if (dto.getSystemPrompt() != null) {
existingEntity.setSystemPrompt(dto.getSystemPrompt()); existingEntity.setSystemPrompt(dto.getSystemPrompt());
} }
if (dto.getSummaryMemory() != null) {
existingEntity.setSummaryMemory(dto.getSummaryMemory());
}
if (dto.getChatHistoryConf() != null) {
existingEntity.setChatHistoryConf(dto.getChatHistoryConf());
}
if (dto.getLangCode() != null) { if (dto.getLangCode() != null) {
existingEntity.setLangCode(dto.getLangCode()); existingEntity.setLangCode(dto.getLangCode());
} }
@@ -181,8 +207,16 @@ public class AgentController {
existingEntity.setUpdater(user.getId()); existingEntity.setUpdater(user.getId());
existingEntity.setUpdatedAt(new Date()); existingEntity.setUpdatedAt(new Date());
// 更新记忆策略
if (existingEntity.getMemModelId() == null || existingEntity.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
// 删除所有记录
agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, true);
existingEntity.setSummaryMemory("");
} else if (existingEntity.getChatHistoryConf() != null && existingEntity.getChatHistoryConf() == 1) {
// 删除音频数据
agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, false);
}
agentService.updateById(existingEntity); agentService.updateById(existingEntity);
return new Result<>(); return new Result<>();
} }
@@ -193,7 +227,7 @@ public class AgentController {
// 先删除关联的设备 // 先删除关联的设备
deviceService.deleteByAgentId(id); deviceService.deleteByAgentId(id);
// 删除关联的聊天记录 // 删除关联的聊天记录
agentChatHistoryService.deleteByAgentId(id); agentChatHistoryService.deleteByAgentId(id, true, true);
// 再删除智能体 // 再删除智能体
agentService.deleteById(id); agentService.deleteById(id);
return new Result<>(); return new Result<>();
@@ -28,4 +28,11 @@ public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity
* @param agentId 智能体ID * @param agentId 智能体ID
*/ */
void deleteHistoryByAgentId(String agentId); void deleteHistoryByAgentId(String agentId);
/**
* 根据智能体ID删除音频ID
*
* @param agentId 智能体ID
*/
void deleteAudioIdByAgentId(String agentId);
} }
@@ -27,9 +27,16 @@ public class AgentDTO {
@Schema(description = "大语言模型名称", example = "llm_model_01") @Schema(description = "大语言模型名称", example = "llm_model_01")
private String llmModelName; private String llmModelName;
@Schema(description = "记忆模型ID", example = "mem_model_01")
private String memModelId;
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助") @Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助")
private String systemPrompt; private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
private String summaryMemory;
@Schema(description = "最后连接时间", example = "2024-03-20 10:00:00") @Schema(description = "最后连接时间", example = "2024-03-20 10:00:00")
private Date lastConnectedAt; private Date lastConnectedAt;
@@ -0,0 +1,19 @@
package xiaozhi.modules.agent.dto;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 智能体记忆更新DTO
*/
@Data
@Schema(description = "智能体记忆更新对象")
public class AgentMemoryDTO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
private String summaryMemory;
}
@@ -45,6 +45,13 @@ public class AgentUpdateDTO implements Serializable {
@Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助", required = false) @Schema(description = "角色设定参数", example = "你是一个专业的客服助手,负责回答用户问题并提供帮助", required = false)
private String systemPrompt; private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
private String summaryMemory;
@Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)", example = "3", required = false)
private Integer chatHistoryConf;
@Schema(description = "语言编码", example = "zh_CN", required = false) @Schema(description = "语言编码", example = "zh_CN", required = false)
private String langCode; private String langCode;
@@ -48,9 +48,16 @@ public class AgentEntity {
@Schema(description = "意图模型标识") @Schema(description = "意图模型标识")
private String intentModelId; private String intentModelId;
@Schema(description = "聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)")
private Integer chatHistoryConf;
@Schema(description = "角色设定参数") @Schema(description = "角色设定参数")
private String systemPrompt; private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
private String summaryMemory;
@Schema(description = "语言编码") @Schema(description = "语言编码")
private String langCode; private String langCode;
@@ -69,11 +69,20 @@ public class AgentTemplateEntity implements Serializable {
*/ */
private String intentModelId; private String intentModelId;
/**
* 聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)
*/
private Integer chatHistoryConf;
/** /**
* 角色设定参数 * 角色设定参数
*/ */
private String systemPrompt; private String systemPrompt;
/**
* 总结记忆
*/
private String summaryMemory;
/** /**
* 语言编码 * 语言编码
*/ */
@@ -39,7 +39,9 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
/** /**
* 根据智能体ID删除聊天记录 * 根据智能体ID删除聊天记录
* *
* @param agentId 智能体ID * @param agentId 智能体ID
* @param deleteAudio 是否删除音频
* @param deleteText 是否删除文本
*/ */
void deleteByAgentId(String agentId); void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText);
} }
@@ -2,12 +2,14 @@ package xiaozhi.modules.agent.service.biz.impl;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.Objects;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO; import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
@@ -47,8 +49,33 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
Byte chatType = report.getChatType(); Byte chatType = report.getChatType();
log.info("小智设备聊天上报请求: macAddress={}, type={}", macAddress, chatType); log.info("小智设备聊天上报请求: macAddress={}, type={}", macAddress, chatType);
// 1. base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表 // 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
if (agentEntity == null) {
return Boolean.FALSE;
}
Integer chatHistoryConf = agentEntity.getChatHistoryConf();
String agentId = agentEntity.getId();
if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT.getCode())) {
saveChatText(report, agentId, macAddress, null);
} else if (Objects.equals(chatHistoryConf, Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode())) {
String audioId = saveChatAudio(report);
saveChatText(report, agentId, macAddress, audioId);
}
// 更新设备最后对话时间
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
return Boolean.TRUE;
}
/**
* base64解码report.getOpusDataBase64(),存入ai_agent_chat_audio表
*/
private String saveChatAudio(AgentChatHistoryReportDTO report) {
String audioId = null; String audioId = null;
if (report.getAudioBase64() != null && !report.getAudioBase64().isEmpty()) { if (report.getAudioBase64() != null && !report.getAudioBase64().isEmpty()) {
try { try {
byte[] audioData = Base64.getDecoder().decode(report.getAudioBase64()); byte[] audioData = Base64.getDecoder().decode(report.getAudioBase64());
@@ -56,20 +83,18 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
log.info("音频数据保存成功,audioId={}", audioId); log.info("音频数据保存成功,audioId={}", audioId);
} catch (Exception e) { } catch (Exception e) {
log.error("音频数据保存失败", e); log.error("音频数据保存失败", e);
return false; return null;
} }
} }
return audioId;
}
// 2. 组装上报数据 /**
// 2.1 根据设备MAC地址查询对应的默认智能体,判断是否需要上报 * 组装上报数据
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress); */
if (agentEntity == null) { private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId) {
return false;
}
String agentId = agentEntity.getId();
log.info("设备 {} 对应智能体 {} 上报", macAddress, agentEntity.getId());
// 2.2 构建聊天记录实体 // 构建聊天记录实体
AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder() AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
.macAddress(macAddress) .macAddress(macAddress)
.agentId(agentId) .agentId(agentId)
@@ -79,10 +104,9 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
.audioId(audioId) .audioId(audioId)
.build(); .build();
// 3. 保存数据 // 保存数据
agentChatHistoryService.save(entity); agentChatHistoryService.save(entity);
// 4. 更新设备最后对话时间
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date()); log.info("设备 {} 对应智能体 {} 上报成功", macAddress, agentId);
return Boolean.TRUE;
} }
} }
@@ -78,8 +78,16 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void deleteByAgentId(String agentId) { public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
baseMapper.deleteAudioByAgentId(agentId); if (deleteAudio) {
baseMapper.deleteHistoryByAgentId(agentId); baseMapper.deleteAudioByAgentId(agentId);
}
if (deleteAudio && !deleteText) {
baseMapper.deleteAudioIdByAgentId(agentId);
}
if (deleteText) {
baseMapper.deleteHistoryByAgentId(agentId);
}
} }
} }
@@ -13,6 +13,7 @@ import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
@@ -46,7 +47,15 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
@Override @Override
public AgentEntity getAgentById(String id) { public AgentEntity getAgentById(String id) {
return agentDao.selectById(id); AgentEntity agent = agentDao.selectById(id);
if (agent != null && agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode());
} else if (agent != null && agent.getMemModelId() != null
&& !agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)
&& agent.getChatHistoryConf() == null) {
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode());
}
return agent;
} }
@Override @Override
@@ -93,6 +102,9 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
// 获取 LLM 模型名称 // 获取 LLM 模型名称
dto.setLlmModelName(modelConfigService.getModelNameById(agent.getLlmModelId())); dto.setLlmModelName(modelConfigService.getModelNameById(agent.getLlmModelId()));
// 获取记忆模型名称
dto.setMemModelId(agent.getMemModelId());
// 获取 TTS 音色名称 // 获取 TTS 音色名称
dto.setTtsVoiceName(timbreModelService.getTimbreNameById(agent.getTtsVoiceId())); dto.setTtsVoiceName(timbreModelService.getTimbreNameById(agent.getTtsVoiceId()));
@@ -9,6 +9,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisKeys;
@@ -64,6 +65,7 @@ public class ConfigServiceImpl implements ConfigService {
null, null,
null, null,
null, null,
null,
agent.getVadModelId(), agent.getVadModelId(),
agent.getAsrModelId(), agent.getAsrModelId(),
null, null,
@@ -108,6 +110,17 @@ public class ConfigServiceImpl implements ConfigService {
// 获取单台设备每天最多输出字数 // 获取单台设备每天最多输出字数
String deviceMaxOutputSize = sysParamsService.getValue("device_max_output_size", true); String deviceMaxOutputSize = sysParamsService.getValue("device_max_output_size", true);
result.put("device_max_output_size", deviceMaxOutputSize); result.put("device_max_output_size", deviceMaxOutputSize);
// 获取聊天记录配置
Integer chatHistoryConf = agent.getChatHistoryConf();
if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
chatHistoryConf = Constant.ChatHistoryConfEnum.IGNORE.getCode();
} else if (agent.getMemModelId() != null
&& !agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)
&& agent.getChatHistoryConf() == null) {
chatHistoryConf = Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode();
}
result.put("chat_history_conf", chatHistoryConf);
// 如果客户端已实例化模型,则不返回 // 如果客户端已实例化模型,则不返回
String alreadySelectedVadModelId = (String) selectedModule.get("VAD"); String alreadySelectedVadModelId = (String) selectedModule.get("VAD");
if (alreadySelectedVadModelId != null && alreadySelectedVadModelId.equals(agent.getVadModelId())) { if (alreadySelectedVadModelId != null && alreadySelectedVadModelId.equals(agent.getVadModelId())) {
@@ -122,6 +135,7 @@ public class ConfigServiceImpl implements ConfigService {
buildModuleConfig( buildModuleConfig(
agent.getAgentName(), agent.getAgentName(),
agent.getSystemPrompt(), agent.getSystemPrompt(),
agent.getSummaryMemory(),
voice, voice,
agent.getVadModelId(), agent.getVadModelId(),
agent.getAsrModelId(), agent.getAsrModelId(),
@@ -222,6 +236,7 @@ public class ConfigServiceImpl implements ConfigService {
private void buildModuleConfig( private void buildModuleConfig(
String assistantName, String assistantName,
String prompt, String prompt,
String summaryMemory,
String voice, String voice,
String vadModelId, String vadModelId,
String asrModelId, String asrModelId,
@@ -282,5 +297,6 @@ public class ConfigServiceImpl implements ConfigService {
prompt = prompt.replace("{{assistant_name}}", StringUtils.isBlank(assistantName) ? "小智" : assistantName); prompt = prompt.replace("{{assistant_name}}", StringUtils.isBlank(assistantName) ? "小智" : assistantName);
} }
result.put("prompt", prompt); result.put("prompt", prompt);
result.put("summaryMemory", summaryMemory);
} }
} }
@@ -9,6 +9,8 @@ import java.util.TimeZone;
import java.util.UUID; import java.util.UUID;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.aop.framework.AopContext;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.ServletRequestAttributes;
@@ -54,6 +56,24 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
private final OtaService otaService; private final OtaService otaService;
@Async
public void updateDeviceConnectionInfo(String agentId, String deviceId, String appVersion) {
try {
DeviceEntity device = new DeviceEntity();
device.setId(deviceId);
device.setLastConnectedAt(new Date());
if (StringUtils.isNotBlank(appVersion)) {
device.setAppVersion(appVersion);
}
deviceDao.updateById(device);
if (StringUtils.isNotBlank(agentId)) {
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), new Date());
}
} catch (Exception e) {
log.error("异步更新设备连接信息失败", e);
}
}
@Override @Override
public Boolean deviceActivation(String agentId, String activationCode) { public Boolean deviceActivation(String agentId, String activationCode) {
if (StringUtils.isBlank(activationCode)) { if (StringUtils.isBlank(activationCode)) {
@@ -118,14 +138,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
DeviceEntity deviceById = getDeviceByMacAddress(macAddress); DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
// 只有在设备绑定且autoUpdate不为0的情况下才返回固件升级信息 // 设备绑定,则返回当前上传的固件信息(不更新)以此兼容旧固件版本
if (deviceById != null && deviceById.getAutoUpdate() != 0) { if (deviceById == null) {
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType(); DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type, firmware.setVersion(deviceReport.getApplication().getVersion());
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion()); firmware.setUrl("http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL");
response.setFirmware(firmware); response.setFirmware(firmware);
} else { } else {
response.setFirmware(null); // 只有在设备已绑定且autoUpdate不为0的情况下才返回固件升级信息
if (deviceById.getAutoUpdate() != 0) {
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
response.setFirmware(firmware);
}
} }
// 添加WebSocket配置 // 添加WebSocket配置
@@ -150,13 +176,12 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
response.setWebsocket(websocket); response.setWebsocket(websocket);
if (deviceById != null) { if (deviceById != null) {
// 如果设备存在,则更新上次连接时间 // 如果设备存在,则异步更新上次连接时间和版本信息
deviceById.setLastConnectedAt(new Date()); String appVersion = deviceReport.getApplication() != null ? deviceReport.getApplication().getVersion()
if (deviceReport.getApplication() != null : null;
&& StringUtils.isNotBlank(deviceReport.getApplication().getVersion())) { // 通过Spring代理调用异步方法
deviceById.setAppVersion(deviceReport.getApplication().getVersion()); ((DeviceServiceImpl) AopContext.currentProxy()).updateDeviceConnectionInfo(deviceById.getAgentId(),
} deviceById.getId(), appVersion);
deviceDao.updateById(deviceById);
} else { } else {
// 如果设备不存在,则生成激活码 // 如果设备不存在,则生成激活码
DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport); DeviceReportRespDTO.Activation code = buildActivation(macAddress, deviceReport);
@@ -385,4 +410,4 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
} }
return 0; return 0;
} }
} }
@@ -86,6 +86,7 @@ public class ShiroConfig {
// 将config路径使用server服务过滤器 // 将config路径使用server服务过滤器
filterMap.put("/config/**", "server"); filterMap.put("/config/**", "server");
filterMap.put("/agent/chat-history/report", "server"); filterMap.put("/agent/chat-history/report", "server");
filterMap.put("/agent/saveMemory/**", "server");
filterMap.put("/agent/play/**", "anon"); filterMap.put("/agent/play/**", "anon");
filterMap.put("/**", "oauth2"); filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap); shiroFilter.setFilterChainDefinitionMap(filterMap);
@@ -0,0 +1,25 @@
-- 更新模型供应器表
UPDATE `ai_model_provider` SET fields = '[{"key": "host", "type": "string", "label": "服务地址"}, {"key": "port", "type": "number", "label": "端口号"}, {"key": "type", "type": "string", "label": "服务类型"}, {"key": "is_ssl", "type": "boolean", "label": "是否使用SSL"}, {"key": "api_key", "type": "string", "label": "API密钥"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]' WHERE id = 'SYSTEM_ASR_FunASRServer';
-- 更新模型配置表
UPDATE `ai_model_config` SET
config_json = '{"host": "127.0.0.1", "port": 10096, "type": "fun_server", "is_ssl": true, "api_key": "none", "output_dir": "tmp/"}',
`doc_link` = 'https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md',
`remark` = '独立部署FunASR,使用FunASR的API服务,只需要五句话
第一句:mkdir -p ./funasr-runtime-resources/models
第二句:sudo docker run -p 10096:10095 -it --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12
上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime
不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 &
上一句话执行后会进入到容器,继续第五句:tail -f log.txt
第五句话执行完后,会看到模型下载日志,下载完后就可以连接使用了
以上是使用CPU推理,如果有GPU,详细参考:https://github.com/modelscope/FunASR/blob/main/runtime/docs/SDK_advanced_guide_online_zh.md' WHERE `id` = 'ASR_FunASRServer';
-- FishSpeech配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/fish-speech-integration.md',
`remark` = 'FishSpeech配置说明:
1. 需要本地部署FishSpeech服务
2. 支持自定义音色
3. 本地推理,无需网络连接
4. 输出文件保存在tmp/目录
5. 可参照教程https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/fish-speech-integration.md' WHERE `id` = 'TTS_FishSpeech';
@@ -0,0 +1,6 @@
-- 添加聊天记录配置字段
ALTER TABLE `ai_agent`
ADD COLUMN `chat_history_conf` tinyint NOT NULL DEFAULT 0 COMMENT '聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)' AFTER `system_prompt`;
ALTER TABLE `ai_agent_template`
ADD COLUMN `chat_history_conf` tinyint NOT NULL DEFAULT 0 COMMENT '聊天记录配置(0不记录 1仅记录文本 2记录文本和语音)' AFTER `system_prompt`;
@@ -0,0 +1,6 @@
-- 添加总结记忆字段
ALTER TABLE `ai_agent`
ADD COLUMN `summary_memory` text COMMENT '总结记忆' AFTER `system_prompt`;
ALTER TABLE `ai_agent_template`
ADD COLUMN `summary_memory` text COMMENT '总结记忆' AFTER `system_prompt`;
@@ -0,0 +1,7 @@
update ai_agent_template set system_prompt = replace(system_prompt, '我是', '你是');
delete from sys_params where id in (500,501,402);
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (500, 'end_prompt.enable', 'true', 'boolean', 1, '是否开启结束语');
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (501, 'end_prompt.prompt', '请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!', 'string', 1, '结束提示词');
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (402, 'plugins.get_weather.api_host', 'mj7p3y7naa.re.qweatherapi.com', 'string', 1, '开发者apihost');
@@ -113,4 +113,32 @@ databaseChangeLog:
changes: changes:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202505091409.sql path: classpath:db/changelog/202505091409.sql
- changeSet:
id: 202505091555
author: whosmyqueen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505091555.sql
- changeSet:
id: 202505111914
author: hrz
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505111914.sql
- changeSet:
id: 202505122348
author: ljwwd2
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505122348.sql
- changeSet:
id: 202505142037
author: hrz
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505142037.sql
@@ -28,11 +28,17 @@
SELECT audio_id SELECT audio_id
FROM ai_agent_chat_history FROM ai_agent_chat_history
WHERE agent_id = #{agentId} WHERE agent_id = #{agentId}
); )
</delete> </delete>
<update id="deleteAudioIdByAgentId">
UPDATE ai_agent_chat_history
SET audio_id = NULL
WHERE agent_id = #{agentId}
</update>
<delete id="deleteHistoryByAgentId"> <delete id="deleteHistoryByAgentId">
DELETE FROM ai_agent_chat_history DELETE FROM ai_agent_chat_history
WHERE agent_id = #{agentId}; WHERE agent_id = #{agentId}
</delete> </delete>
</mapper> </mapper>
+3 -3
View File
@@ -50,9 +50,9 @@ export default {
}).send(); }).send();
}, },
// 获取智能体配置 // 获取智能体配置
getDeviceConfig(deviceId, callback) { getDeviceConfig(agentId, callback) {
RequestService.sendRequest() RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${deviceId}`) .url(`${getServiceUrl()}/agent/${agentId}`)
.method('GET') .method('GET')
.success((res) => { .success((res) => {
RequestService.clearRequestTime(); RequestService.clearRequestTime();
@@ -61,7 +61,7 @@ export default {
.fail((err) => { .fail((err) => {
console.error('获取配置失败:', err); console.error('获取配置失败:', err);
RequestService.reAjaxFun(() => { RequestService.reAjaxFun(() => {
this.getDeviceConfig(deviceId, callback); this.getDeviceConfig(agentId, callback);
}); });
}).send(); }).send();
}, },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 88 KiB

+15 -2
View File
@@ -26,8 +26,12 @@
<div class="settings-btn" @click="handleDeviceManage"> <div class="settings-btn" @click="handleDeviceManage">
设备管理({{ device.deviceCount }}) 设备管理({{ device.deviceCount }})
</div> </div>
<div class="settings-btn" @click="handleChatHistory"> <div class="settings-btn" @click="handleChatHistory"
聊天记录 :class="{ 'disabled-btn': device.memModelId === 'Memory_nomem' }">
<el-tooltip v-if="device.memModelId === 'Memory_nomem'" content="未开启记忆" placement="top">
<span>聊天记录</span>
</el-tooltip>
<span v-else>聊天记录</span>
</div> </div>
</div> </div>
<div class="version-info"> <div class="version-info">
@@ -77,6 +81,9 @@ export default {
this.$router.push({ path: '/device-management', query: { agentId: this.device.agentId } }); this.$router.push({ path: '/device-management', query: { agentId: this.device.agentId } });
}, },
handleChatHistory() { handleChatHistory() {
if (this.device.memModelId === 'Memory_nomem') {
return
}
this.$emit('chat-history', { agentId: this.device.agentId, agentName: this.device.agentName }) this.$emit('chat-history', { agentId: this.device.agentId, agentName: this.device.agentName })
} }
} }
@@ -120,6 +127,12 @@ export default {
color: #979db1; color: #979db1;
font-weight: 400; font-weight: 400;
} }
.disabled-btn {
background: #e6e6e6;
color: #999;
cursor: not-allowed;
}
</style> </style>
<style> <style>
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose" @open="handleOpen"> <el-dialog :title="title" :visible.sync="dialogVisible" @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>
@@ -22,7 +22,7 @@
<el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress" <el-progress v-if="isUploading || uploadStatus === 'success'" :percentage="uploadProgress"
:status="uploadStatus"></el-progress> :status="uploadStatus"></el-progress>
<div class="hint-text"> <div class="hint-text">
<span>温馨提示请上传xiaozhi.bin文件而不是merged-binary.bin文件</span> <span>温馨提示请上传合并前的xiaozhi.bin文件而不是合并后的merged-binary.bin文件</span>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="remark"> <el-form-item label="备注" prop="remark">
@@ -198,13 +198,13 @@ export default {
if (!this.form.id) { // 只在新增时重置 if (!this.form.id) { // 只在新增时重置
this.form.firmwarePath = '' this.form.firmwarePath = ''
this.form.size = 0 this.form.size = 0
// 重置上传组件
this.$nextTick(() => {
if (this.$refs.upload) {
this.$refs.upload.clearFiles()
}
})
} }
// 无论是否编辑模式,都重置上传组件
this.$nextTick(() => {
if (this.$refs.upload) {
this.$refs.upload.clearFiles()
}
})
} }
} }
} }
@@ -229,7 +229,6 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: #979db1; font-size: 14px;
font-size: 11px;
} }
</style> </style>
@@ -0,0 +1,410 @@
<template>
<el-drawer :visible.sync="dialogVisible" direction="rtl" size="50%" :wrapperClosable="false" :withHeader="false">
<!-- 自定义标题区域 -->
<div class="custom-header">
<div class="header-left">
<h3 class="bold-title">功能管理</h3>
</div>
<button class="custom-close-btn" @click="closeDialog">×</button>
</div>
<div class="function-manager">
<!-- 左侧未选功能 -->
<div class="function-column">
<div class="column-header">
<h4 class="column-title">未选功能</h4>
<el-button type="text" @click="selectAll" class="select-all-btn">全选</el-button>
</div>
<div class="function-list">
<div v-for="func in unselected" :key="func.name" class="function-item">
<el-checkbox :label="func.name" v-model="selectedNames" @change="(val) => handleCheckboxChange(func, val)" @click.native.stop></el-checkbox>
<div class="func-tag" @click="handleFunctionClick(func)">
<div class="color-dot" :style="{backgroundColor: getFunctionColor(func.name)}"></div>
<span>{{ func.name }}</span>
</div>
<el-tooltip class="item" effect="dark" :content="func.description || '暂无功能描述'" placement="top">
<img src="@/assets/home/info.png" alt="" class="info-icon">
</el-tooltip>
</div>
</div>
</div>
<!-- 中间已选功能 -->
<div class="function-column">
<div class="column-header">
<h4 class="column-title">已选功能</h4>
<el-button type="text" @click="deselectAll" class="select-all-btn">全选</el-button>
</div>
<div class="function-list">
<div v-for="func in selectedList" :key="func.name" class="function-item">
<el-checkbox :label="func.name" v-model="selectedNames" @change="(val) => handleCheckboxChange(func, val)" @click.native.stop></el-checkbox>
<div class="func-tag" @click="handleFunctionClick(func)">
<div class="color-dot" :style="{backgroundColor: getFunctionColor(func.name)}"></div>
<span>{{ func.name }}</span>
</div>
</div>
</div>
</div>
<!-- 右侧参数配置 -->
<div class="params-column">
<h4 v-if="currentFunction" class="column-title">参数配置 - {{ currentFunction.name }}</h4>
<div v-if="currentFunction" class="params-container">
<el-form :model="currentFunction" size="mini" class="param-form" v-loading="loading" element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading" element-loading-background="rgba(255, 255, 255, 0.7)">
<el-form-item v-for="(value, key) in currentFunction.params" :key="key" :label="key" class="param-item">
<el-input v-model="currentFunction.params[key]" size="mini" class="param-input" @change="(val) => handleParamChange(currentFunction, key, val)"/>
</el-form-item>
</el-form>
</div>
<div v-else class="empty-tip">请选择已配置的功能进行参数设置</div>
</div>
</div>
<div class="drawer-footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="saveSelection">保存配置</el-button>
</div>
</el-drawer>
</template>
<script>
export default {
props: {
value: Boolean,
functions: {
type: Array,
default: () => []
}
},
data() {
return {
dialogVisible: this.value,
selectedNames: [],
currentFunction: null,
modifiedFunctions: {},
allFunctions: [
{name: '天气', params: {city: '北京'}, description: '查看指定城市的天气情况'},
{name: '新闻', params: {type: '科技'}, description: '获取最新科技类新闻资讯'},
{name: '工具', params: {category: '常用'}, description: '提供常用工具集合'},
{name: '退出', params: {}, description: '退出当前系统'},
{name: '音乐', params: {genre: '流行'}, description: '播放流行音乐'},
{name: '翻译', params: {from: '中文', to: '英文'}, description: '提供中英文互译功能'},
{name: '计算', params: {precision: '2'}, description: '提供精确计算功能'},
{name: '日历', params: {view: '月'}, description: '查看月历视图'}
],
functionColorMap: [
'#FF6B6B', '#4ECDC4', '#45B7D1',
'#96CEB4', '#FFEEAD', '#D4A5A5', '#A2836E'
],
tempFunctions: {},
// 添加一个标志位来跟踪是否已经保存
hasSaved: false,
loading: false,
}
},
computed: {
selectedList() {
return this.allFunctions.filter(f => this.selectedNames.includes(f.name));
},
unselected() {
return this.allFunctions.filter(f => !this.selectedNames.includes(f.name));
}
},
watch: {
value(newVal) {
this.dialogVisible = newVal;
if (newVal) {
this.selectedNames = this.functions.map(f => f.name);
this.currentFunction = this.selectedList[0] || null;
}
},
dialogVisible(newVal) {
this.$emit('input', newVal);
}
},
methods: {
handleFunctionClick(func) {
if (this.selectedNames.includes(func.name)) {
this.loading = true;
setTimeout(() => {
const tempFunc = this.tempFunctions[func.name];
this.currentFunction = tempFunc ? tempFunc : JSON.parse(JSON.stringify(func));
this.loading = false;
}, 300);
}
},
handleParamChange(func, key, value) {
if (!this.tempFunctions[func.name]) {
this.tempFunctions[func.name] = JSON.parse(JSON.stringify(func));
}
this.tempFunctions[func.name].params[key] = value;
},
handleCheckboxChange(func, checked) {
if (checked) {
if (!this.selectedNames.includes(func.name)) {
this.selectedNames = [...this.selectedNames, func.name];
}
} else {
this.selectedNames = this.selectedNames.filter(name => name !== func.name);
}
if (this.selectedList.length > 0) {
this.currentFunction = this.selectedList[0];
} else {
this.currentFunction = null;
}
},
selectAll() {
this.selectedNames = [...this.allFunctions.map(f => f.name)];
if (this.selectedList.length > 0) {
this.currentFunction = JSON.parse(JSON.stringify(this.selectedList[0]));
}
},
deselectAll() {
this.selectedNames = [];
this.currentFunction = null;
},
closeDialog() {
this.tempFunctions = {};
this.selectedNames = this.functions.map(f => f.name);
this.currentFunction = null;
this.dialogVisible = false;
this.$emit('input', false);
this.$emit('dialog-closed', false);
},
saveSelection() {
Object.keys(this.tempFunctions).forEach(name => {
this.modifiedFunctions[name] = JSON.parse(JSON.stringify(this.tempFunctions[name]));
});
this.tempFunctions = {};
this.hasSaved = true;
const selected = this.selectedList.map(f => {
const modified = this.modifiedFunctions[f.name];
return modified || f;
}).map(f => ({
...f,
params: JSON.parse(JSON.stringify(f.params))
}));
this.$emit('update-functions', selected);
this.dialogVisible = false;
this.$message.success('配置保存成功');
// 通知父组件对话框已关闭且已保存
this.$emit('dialog-closed', true);
},
getFunctionColor(name) {
const hash = [...name].reduce((acc, char) => acc + char.charCodeAt(0), 0);
return this.functionColorMap[hash % 7];
}
}
}
</script>
<style lang="scss" scoped>
.function-manager {
display: grid;
grid-template-columns: minmax(120px, 0.5fr) minmax(120px, 0.5fr) minmax(200px, 2fr);
gap: 12px;
height: calc(70vh - 60px);
}
.custom-header {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #EBEEF5;
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.bold-title {
font-size: 18px;
font-weight: bold;
margin: 0;
}
.select-all-btn {
padding: 0;
height: auto;
font-size: 14px;
}
}
.function-column {
position: relative;
width: auto;
padding: 10px;
overflow-y: auto;
border-right: 1px solid #EBEEF5;
scrollbar-width: none;
}
.function-column::-webkit-scrollbar {
display: none;
}
.function-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.function-item {
padding: 8px 12px;
margin: 4px 0;
width: 100%;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
background-color: #f5f7fa;
}
}
.params-column {
min-width: 280px;
padding: 10px;
overflow-y: auto;
scrollbar-width: none;
}
.params-column::-webkit-scrollbar {
display: none;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.column-title {
text-align: center;
width: 100%;
}
.func-tag {
display: flex;
align-items: center;
cursor: pointer;
flex-grow: 1;
margin-left: 8px;
}
.color-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
margin-right: 8px;
border-radius: 50%;
}
.param-form {
::v-deep .el-form-item {
display: flex;
align-items: center;
margin-bottom: 12px;
.el-form-item__label {
font-size: 14px !important;
color: #606266;
text-align: left;
padding-right: 10px;
flex-shrink: 0;
width: auto !important;
}
.el-form-item__content {
margin-left: 0 !important;
flex-grow: 1;
.el-input__inner {
text-align: left;
padding-left: 8px;
width: 100%;
}
}
}
}
.params-container {
padding: 16px;
border-radius: 4px;
min-width: 280px;
}
.empty-tip {
padding: 20px;
color: #909399;
text-align: center;
}
.param-input {
width: 100%;
}
.drawer-footer {
position: absolute;
bottom: 0;
width: 100%;
border-top: 1px solid #e8e8e8;
padding: 10px 16px;
text-align: center;
background: #fff;
}
.info-icon {
width: 16px;
height: 16px;
margin-right: 1vh;
}
.custom-close-btn {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
width: 35px;
height: 35px;
border-radius: 50%;
border: 2px solid #cfcfcf;
background: none;
font-size: 30px;
font-weight: lighter;
color: #cfcfcf;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
padding: 0;
outline: none;
transition: all 0.3s;
}
.custom-close-btn:hover {
color: #409EFF;
border-color: #409EFF;
}
::v-deep .el-checkbox__label {
display: none;
}
</style>
+57 -63
View File
@@ -35,12 +35,12 @@
OTA管理 OTA管理
</div> </div>
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown" <el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown"
:class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' }"> :class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' }" @visible-change="handleParamDropdownVisibleChange">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
<img loading="lazy" alt="" src="@/assets/header/param_management.png" <img loading="lazy" alt="" src="@/assets/header/param_management.png"
:style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' ? 'brightness(0) invert(1)' : 'None' }" /> :style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' ? 'brightness(0) invert(1)' : 'None' }" />
参数字典 参数字典
<i class="el-icon-arrow-down el-icon--right"></i> <i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': paramDropdownVisible }"></i>
</span> </span>
<el-dropdown-menu slot="dropdown"> <el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="goParamManagement"> <el-dropdown-item @click.native="goParamManagement">
@@ -49,22 +49,26 @@
<el-dropdown-item @click.native="goDictManagement"> <el-dropdown-item @click.native="goDictManagement">
字典管理 字典管理
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item @click.native="goProviderManagement">
供应器管理
</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</el-dropdown> </el-dropdown>
</div> </div>
<!-- 右侧元素 --> <!-- 右侧元素 -->
<div class="header-right"> <div class="header-right">
<div class="search-container" v-if="$route.path === '/home'"> <div class="search-container" v-if="$route.path === '/home' && !(isSuperAdmin && isSmallScreen)">
<el-input v-model="search" placeholder="输入名称搜索.." class="custom-search-input" <el-input v-model="search" placeholder="输入名称搜索.." class="custom-search-input"
@keyup.enter.native="handleSearch"> @keyup.enter.native="handleSearch">
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i> <i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i>
</el-input> </el-input>
</div> </div>
<img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img" /> <img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img" />
<el-dropdown trigger="click" class="user-dropdown"> <el-dropdown trigger="click" class="user-dropdown" @visible-change="handleUserDropdownVisibleChange">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
{{ userInfo.username || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i> {{ userInfo.username || '加载中...' }}
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': userDropdownVisible }"></i>
</span> </span>
<el-dropdown-menu slot="dropdown"> <el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="showChangePasswordDialog">修改密码</el-dropdown-item> <el-dropdown-item @click.native="showChangePasswordDialog">修改密码</el-dropdown-item>
@@ -84,7 +88,6 @@ import userApi from '@/apis/module/user';
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import ChangePasswordDialog from './ChangePasswordDialog.vue'; // 引入修改密码弹窗组件 import ChangePasswordDialog from './ChangePasswordDialog.vue'; // 引入修改密码弹窗组件
export default { export default {
name: 'HeaderBar', name: 'HeaderBar',
components: { components: {
@@ -98,7 +101,10 @@ export default {
username: '', username: '',
mobile: '' mobile: ''
}, },
isChangePasswordDialogVisible: false // 控制修改密码弹窗的显示 isChangePasswordDialogVisible: false, // 控制修改密码弹窗的显示
userDropdownVisible: false,
paramDropdownVisible: false,
isSmallScreen: false
} }
}, },
computed: { computed: {
@@ -108,7 +114,13 @@ export default {
} }
}, },
mounted() { mounted() {
this.fetchUserInfo() this.fetchUserInfo();
this.checkScreenSize();
window.addEventListener('resize', this.checkScreenSize);
},
//移除事件监听器
beforeDestroy() {
window.removeEventListener('resize', this.checkScreenSize);
}, },
methods: { methods: {
goHome() { goHome() {
@@ -130,6 +142,9 @@ export default {
goDictManagement() { goDictManagement() {
this.$router.push('/dict-management') this.$router.push('/dict-management')
}, },
goProviderManagement() {
this.$router.push('/provider-management')
},
// 获取用户信息 // 获取用户信息
fetchUserInfo() { fetchUserInfo() {
userApi.getUserInfo(({ data }) => { userApi.getUserInfo(({ data }) => {
@@ -139,7 +154,9 @@ export default {
} }
}) })
}, },
checkScreenSize() {
this.isSmallScreen = window.innerWidth <= 1386;
},
// 处理搜索 // 处理搜索
handleSearch() { handleSearch() {
const searchValue = this.search.trim(); const searchValue = this.search.trim();
@@ -184,6 +201,13 @@ export default {
}); });
} }
}, },
handleUserDropdownVisibleChange(visible) {
this.userDropdownVisible = visible;
},
// 监听第二个下拉菜单的可见状态变化
handleParamDropdownVisibleChange(visible) {
this.paramDropdownVisible = visible;
},
// 使用 mapActions 引入 Vuex 的 logout action // 使用 mapActions 引入 Vuex 的 logout action
...mapActions(['logout']) ...mapActions(['logout'])
@@ -191,7 +215,7 @@ export default {
} }
</script> </script>
<style scoped> <style lang="scss" scoped>
.header { .header {
background: #f6fcfe66; background: #f6fcfe66;
border: 1px solid #fff; border: 1px solid #fff;
@@ -243,8 +267,6 @@ export default {
} }
.equipment-management { .equipment-management {
padding: 0 9px;
width: px;
height: 30px; height: 30px;
border-radius: 15px; border-radius: 15px;
background: #deeafe; background: #deeafe;
@@ -260,7 +282,8 @@ export default {
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
/* 防止导航按钮被压缩 */ /* 防止导航按钮被压缩 */
padding: 0px 15px; padding: 0 15px;
position: relative;
} }
.equipment-management.active-tab { .equipment-management.active-tab {
@@ -309,6 +332,25 @@ export default {
flex-shrink: 0; flex-shrink: 0;
} }
.more-dropdown {
padding-right: 20px;
}
.more-dropdown .el-dropdown-link {
display: flex;
align-items: center;
gap: 7px;
}
.rotate-down {
transform: rotate(180deg);
transition: transform 0.3s ease;
}
.el-icon-arrow-down {
transition: transform 0.3s ease;
}
/* 响应式调整 */ /* 响应式调整 */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.header-center { .header-center {
@@ -316,50 +358,11 @@ export default {
} }
.equipment-management { .equipment-management {
width: 70px; width: 79px;
font-size: 9px; font-size: 9px;
} }
} }
@media (max-width: 1024px) {
.search-container {
margin-right: 10px;
max-width: 150px;
}
.header-right {
gap: 5px;
}
}
@media (max-width: 900px) {
.header-left {
margin-right: auto;
}
.search-container {
max-width: 150px;
}
}
@media (max-width: 768px) {
.search-container {
max-width: 145px;
}
.custom-search-input>>>.el-input__inner {
padding-left: 10px;
font-size: 11px;
}
}
@media (max-width: 600px) {
.search-container {
max-width: 120px;
min-width: 100px;
}
}
.equipment-management.more-dropdown { .equipment-management.more-dropdown {
position: relative; position: relative;
} }
@@ -378,13 +381,4 @@ export default {
color: #606266; color: #606266;
white-space: nowrap; white-space: nowrap;
} }
@media (max-width: 768px) {
.equipment-management.more-dropdown .el-dropdown-menu {
position: fixed;
right: 10px;
top: 60px;
z-index: 2000;
}
}
</style> </style>
@@ -0,0 +1,429 @@
<template>
<el-dialog :visible="visible" @update:visible="handleVisibleChange" width="57%" center custom-class="custom-dialog" :show-close="false" class="center-dialog">
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -15px; margin-bottom: 20px; text-align: center;">
{{ title }}
</div>
<button class="custom-close-btn" @click="handleClose">×</button>
<el-form :model="form" label-width="100px" :rules="rules" ref="form" class="custom-form">
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<el-form-item label="类别" prop="model_type" style="flex: 1;">
<el-select v-model="form.model_type" placeholder="请选择类别" class="custom-input-bg" style="width: 100%;">
<el-option v-for="item in modelTypes" :key="item.value" :label="item.label" :value="item.value">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="供应器编码" prop="provider_code" style="flex: 1;">
<el-input v-model="form.provider_code" placeholder="请输入供应器编码" class="custom-input-bg"></el-input>
</el-form-item>
</div>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<el-form-item label="名称" prop="name" style="flex: 1;">
<el-input v-model="form.name" placeholder="请输入供应器名称" class="custom-input-bg"></el-input>
</el-form-item>
<el-form-item label="排序" prop="sort" style="flex: 1;">
<el-input-number v-model="form.sort" :min="0" controls-position="right" class="custom-input-bg" style="width: 100%;"></el-input-number>
</el-form-item>
</div>
<div style="font-size: 20px; font-weight: bold; color: #3d4566; margin-bottom: 15px;">
字段配置
<div style="display: inline-block; float: right;">
<el-button type="primary" @click="addField" size="small" style="background: #5bc98c; border: none;" :disabled="hasIncompleteFields">
添加
</el-button>
<el-button type="primary" @click="toggleSelectAllFields" size="small" style="background: #5f70f3; border: none; margin-left: 10px;">
{{ isAllFieldsSelected ? '取消全选' : '全选' }}
</el-button>
<el-button type="danger" @click="batchRemoveFields" size="small" style="background: red; border: none; margin-left: 10px;">
批量删除
</el-button>
</div>
</div>
<div style="height: 2px; background: #e9e9e9; margin-bottom: 22px;"></div>
<div class="fields-container">
<el-table :data="form.fields" style="width: 100%;" border size="medium" :key="tableKey">
<el-table-column label="选择" align="center" width="50">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected" @change="handleFieldSelectChange"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="字段key">
<template slot-scope="scope">
<template v-if="scope.row.editing">
<el-input v-model="scope.row.key" placeholder="字段key"></el-input>
</template>
<template v-else>
{{ scope.row.key }}
</template>
</template>
</el-table-column>
<el-table-column label="字段标签">
<template slot-scope="scope">
<template v-if="scope.row.editing">
<el-input v-model="scope.row.label" placeholder="字段标签"></el-input>
</template>
<template v-else>
{{ scope.row.label }}
</template>
</template>
</el-table-column>
<el-table-column label="字段类型">
<template slot-scope="scope">
<template v-if="scope.row.editing">
<el-select v-model="scope.row.type" placeholder="类型">
<el-option label="字符串" value="string"></el-option>
<el-option label="数字" value="number"></el-option>
<el-option label="布尔值" value="boolean"></el-option>
<el-option label="字典" value="dict"></el-option>
</el-select>
</template>
<template v-else>
{{ getTypeLabel(scope.row.type) }}
</template>
</template>
</el-table-column>
<el-table-column label="默认值">
<template slot-scope="scope">
<template v-if="scope.row.editing">
<el-input v-model="scope.row.default_value" placeholder="请输入默认值"></el-input>
</template>
<template v-else>
{{ scope.row.default_value }}
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center">
<template slot-scope="scope">
<el-button v-if="!scope.row.editing" type="primary" size="mini" @click="startEditing(scope.row)">
编辑
</el-button>
<el-button v-else type="success" size="mini" @click="stopEditing(scope.row)">
完成
</el-button>
<el-button type="danger" size="mini" @click="removeField(scope.$index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-form>
</div>
<div style="display: flex; justify-content: center;">
<el-button type="primary" @click="submit" class="save-btn" :loading="saving">保存</el-button>
</div>
</el-dialog>
</template>
<script>
export default {
props: {
title: String,
visible: Boolean,
form: Object,
modelTypes: Array
},
data() {
return {
saving: false,
rules: {
model_type: [{required: true, message: '请选择类别', trigger: 'change'}],
provider_code: [{required: true, message: '请输入供应器编码', trigger: 'blur'}],
name: [{required: true, message: '请输入供应器名称', trigger: 'blur'}]
},
isAllFieldsSelected: false,
tableKey: 0 // 用于强制表格重新渲染
};
},
computed: {
hasIncompleteFields() {
return this.form.fields && this.form.fields.some(field =>
!field.key || !field.label || !field.type
);
}
},
methods: {
getTypeLabel(type) {
const typeMap = {
'string': '字符串',
'number': '数字',
'boolean': '布尔值',
'dict': '字典'
};
return typeMap[type];
},
startEditing(row) {
this.$set(row, 'editing', true);
},
stopEditing(row) {
this.$set(row, 'editing', false);
const index = this.form.fields.indexOf(row);
if (index > -1) {
this.form.fields.splice(index, 1);
this.form.fields.push(row);
this.forceTableRerender();
}
},
handleFieldSelectChange() {
this.isAllFieldsSelected = this.form.fields.length > 0 &&
this.form.fields.every(field => field.selected);
},
toggleSelectAllFields() {
this.isAllFieldsSelected = !this.isAllFieldsSelected;
this.form.fields = this.form.fields.map(field => ({
...field,
selected: this.isAllFieldsSelected
}));
},
handleVisibleChange(val) {
this.$emit('update:visible', val);
if (!val) {
this.resetForm();
}
},
handleClose() {
this.resetForm();
this.$emit('update:visible', false);
this.$emit('cancel');
},
addField() {
if (this.hasIncompleteFields) {
this.$message.warning({
message: '请先完成当前字段的编辑',
showClose: true
});
return;
}
this.form.fields.unshift({
key: '',
label: '',
type: 'string',
default_value: '',
selected: false,
editing: true
});
this.forceTableRerender();
},
removeField(index) {
this.$confirm('确定要删除该字段吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.form.fields = this.form.fields.filter((_, i) => i !== index);
this.updateSelectAllStatus();
this.forceTableRerender();
this.$message.success({
message: '删除成功',
showClose: true
});
}).catch(() => {
this.$message.info({
message: '已取消删除',
showClose: true
});
});
},
batchRemoveFields() {
const selectedFields = this.form.fields.filter(field => field.selected);
if (selectedFields.length === 0) {
this.$message.warning({
message: '请先选择要删除的字段',
showClose: true
});
return;
}
this.$confirm(`确定要删除选中的 ${selectedFields.length} 个字段吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.form.fields = this.form.fields.filter(field => !field.selected);
this.isAllFieldsSelected = false;
this.forceTableRerender();
this.$message.success({
message: `成功删除 ${selectedFields.length} 个字段`,
showClose: true
});
}).catch(() => {
this.$message.info({
message: '已取消删除',
showClose: true
});
});
},
updateSelectAllStatus() {
this.isAllFieldsSelected = this.form.fields.length > 0 &&
this.form.fields.every(field => field.selected);
},
forceTableRerender() {
this.tableKey += 1; // 改变key值强制表格重新渲染
},
submit() {
this.$refs.form.validate(valid => {
if (valid) {
const editingField = this.form.fields.find(field => field.editing);
if (editingField) {
this.$message.warning({
message: '请先完成当前字段的编辑',
showClose: true
});
return;
}
this.form.fields = this.form.fields.map(field => ({
...field,
selected: false
}));
this.isAllFieldsSelected = false;
this.saving = true;
this.$emit('submit', {
form: this.form,
done: () => {
this.saving = false;
this.resetForm();
}
});
}
});
},
resetForm() {
this.$refs.form.resetFields();
if (this.form.fields) {
this.form.fields.forEach(field => {
field.selected = false;
field.editing = false;
});
}
this.isAllFieldsSelected = false;
this.forceTableRerender();
},
},
watch: {
visible(val) {
if (!val) {
this.resetForm();
}
}
}
};
</script>
<style lang="scss" scoped>
::v-deep .custom-dialog.el-dialog {
margin-top: 0 !important;
border-radius: 20px !important;
}
::v-deep .custom-dialog .el-dialog__header {
padding: 0;
border-bottom: none;
}
.custom-close-btn {
position: absolute;
top: 20px;
right: 20px;
width: 35px;
height: 35px;
border-radius: 50%;
border: 2px solid #cfcfcf;
background: none;
font-size: 30px;
font-weight: lighter;
color: #cfcfcf;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
padding: 0;
outline: none;
}
.custom-close-btn:hover {
color: #409EFF;
border-color: #409EFF;
}
.custom-form .el-form-item {
margin-bottom: 20px;
}
.custom-form .el-form-item__label {
color: #3d4566;
font-weight: normal;
text-align: right;
padding-right: 20px;
}
.custom-input-bg .el-input__inner {
background-color: #f6f8fc;
height: 32px;
}
.custom-input-bg .el-input__inner::-webkit-input-placeholder {
color: #9c9f9e;
}
.fields-container {
margin-bottom: 20px;
}
.save-btn {
background: #e6f0fd;
color: #237ff4;
border: 1px solid #b3d1ff;
width: 150px;
height: 40px;
font-size: 16px;
transition: all 0.3s ease;
}
.save-btn:hover {
background: linear-gradient(to right, #237ff4, #9c40d5);
color: white;
border: none;
}
.el-table {
border-radius: 4px;
}
.el-table::before {
display: none;
}
.el-table th, .el-table td {
padding: 8px 0;
}
.el-button.is-circle{
border-radius: 2px;
}
</style>
@@ -336,6 +336,14 @@ export default {
}, },
saveEdit(row) { saveEdit(row) {
if (!row.voiceCode || !row.voiceName || !row.languageType) {
this.$message.error({
message: '音色编码、音色名称和语言类型不能为空',
showClose: true
});
return;
}
try { try {
const params = { const params = {
id: row.id, id: row.id,
@@ -408,6 +416,12 @@ export default {
}, },
addNew() { addNew() {
const hasEditing = this.ttsModels.some(row => row.editing);
if (hasEditing) {
this.$message.warning('请先完成当前编辑再新增');
return;
}
const maxSort = this.ttsModels.length > 0 const maxSort = this.ttsModels.length > 0
? Math.max(...this.ttsModels.map(item => Number(item.sort) || 0)) ? Math.max(...this.ttsModels.map(item => Number(item.sort) || 0))
: 0; : 0;
+8 -1
View File
@@ -90,7 +90,14 @@ const routes = [
component: function () { component: function () {
return import('../views/DictManagement.vue') return import('../views/DictManagement.vue')
} }
} },
{
path: '/provider-management',
name: 'ProviderManagement',
component: function () {
return import('../views/ProviderManagement.vue')
}
},
] ]
const router = new VueRouter({ const router = new VueRouter({
base: process.env.VUE_APP_PUBLIC_PATH || '/', base: process.env.VUE_APP_PUBLIC_PATH || '/',
@@ -0,0 +1,936 @@
<template>
<div class="welcome">
<HeaderBar/>
<div class="operation-bar">
<h2 class="page-title">供应器管理</h2>
<div class="right-operations">
<el-dropdown trigger="click" @command="handleSelectModelType" @visible-change="handleDropdownVisibleChange">
<el-button class="category-btn">
类别筛选 {{ selectedModelTypeLabel }}<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down':DropdownVisible }"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="">全部</el-dropdown-item>
<el-dropdown-item v-for="item in modelTypes" :key="item.value" :command="item.value">
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-input placeholder="请输入供应器名称查询" v-model="searchName" class="search-input" @keyup.enter.native="handleSearch" clearable/>
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
</div>
</div>
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="params-card" shadow="never">
<el-table ref="providersTable" :data="filteredProvidersList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)"
:header-cell-class-name="headerCellClassName">
<el-table-column label="选择" align="center" width="120">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="类别" prop="model_type" align="center" width="200">
<template slot="header" slot-scope="scope">
<el-dropdown trigger="click" @command="handleSelectModelType" @visible-change="isDropdownOpen = $event">
<span class="dropdown-trigger" :class="{ 'active': isDropdownOpen }">
类别{{ selectedModelTypeLabel }} <i class="dropdown-arrow" :class="{ 'is-active': isDropdownOpen }"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="">全部</el-dropdown-item>
<el-dropdown-item v-for="item in modelTypes" :key="item.value" :command="item.value">
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<template slot-scope="scope">
<el-tag :type="getModelTypeTag(scope.row.model_type)">
{{ getModelTypeLabel(scope.row.model_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="供应器编码" prop="provider_code" align="center" width="150"></el-table-column>
<el-table-column label="名称" prop="name" align="center"></el-table-column>
<el-table-column label="字段配置" align="center">
<template slot-scope="scope">
<el-popover placement="top-start" width="400" trigger="hover">
<div v-for="field in scope.row.fields" :key="field.key" class="field-item">
<span class="field-label">{{ field.label }}:</span>
<span class="field-type">{{ field.type }}</span>
<span v-if="isSensitiveField(field.key)" class="sensitive-tag">敏感</span>
</div>
<el-button slot="reference" size="mini" type="text">查看字段</el-button>
</el-popover>
</template>
</el-table-column>
<el-table-column label="排序" prop="sort" align="center" width="80"></el-table-column>
<el-table-column label="操作" align="center" width="180">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="editProvider(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click="deleteProvider(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table_bottom">
<div class="ctrl_btn">
<el-button size="mini" type="primary" class="select-all-btn" @click="handleSelectAll">
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
<el-button size="mini" type="danger" icon="el-icon-delete" @click="deleteSelectedProviders">删除
</el-button>
</div>
<div class="custom-pagination">
<el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<el-option v-for="item in pageSizeOptions" :key="item" :label="`${item}条/页`" :value="item">
</el-option>
</el-select>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">
首页
</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">
上一页
</button>
<button v-for="page in visiblePages" :key="page" class="pagination-btn" :class="{ active: page === currentPage }" @click="goToPage(page)">
{{ page }}
</button>
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">
下一页
</button>
<span class="total-text">{{ total }}条记录</span>
</div>
</div>
</el-card>
</div>
</div>
</div>
<!-- 新增/编辑供应器对话框 -->
<provider-dialog :title="dialogTitle" :visible.sync="dialogVisible" :form="providerForm" :model-types="modelTypes" @submit="handleSubmit" @cancel="dialogVisible = false"/>
<el-footer>
<version-footer/>
</el-footer>
</div>
</template>
<script>
import HeaderBar from "@/components/HeaderBar.vue";
import ProviderDialog from "@/components/ProviderDialog.vue";
import VersionFooter from "@/components/VersionFooter.vue";
export default {
components: {HeaderBar, ProviderDialog, VersionFooter},
data() {
return {
searchName: "",
searchModelType: "",
providersList: [],
modelTypes: [
{value: "ASR", label: "语音识别"},
{value: "TTS", label: "语音合成"},
{value: "LLM", label: "大语言模型"},
{value: "Intent", label: "意图识别"},
{value: "Memory", label: "记忆模块"},
{value: "VAD", label: "语音活动检测"}
],
currentPage: 1,
loading: false,
pageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
total: 0,
dialogVisible: false,
dialogTitle: "新增供应器",
isAllSelected: false,
isDropdownOpen: false,
sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key"],
providerForm: {
id: null,
model_type: "",
provider_code: "",
name: "",
fields: [],
sort: 0
},
DropdownVisible: false,
};
},
created() {
this.fetchProviders();
},
computed: {
selectedModelTypeLabel() {
if (!this.searchModelType) return "(全部)";
const selectedType = this.modelTypes.find(item => item.value === this.searchModelType);
return selectedType ? `${selectedType.label}` : "";
},
pageCount() {
return Math.ceil(this.total / this.pageSize);
},
visiblePages() {
const pages = [];
const maxVisible = 3;
let start = Math.max(1, this.currentPage - 1);
let end = Math.min(this.pageCount, start + maxVisible - 1);
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
},
filteredProvidersList() {
let list = this.providersList.filter(item => {
const nameMatch = item.name.toLowerCase().includes(this.searchName.toLowerCase());
const typeMatch = !this.searchModelType || item.model_type === this.searchModelType;
return nameMatch && typeMatch;
});
list.sort((a, b) => a.sort - b.sort);
// 分页处理
const start = (this.currentPage - 1) * this.pageSize;
return list.slice(start, start + this.pageSize);
}
},
methods: {
fetchProviders() {
this.loading = true;
// 模拟API请求延迟
setTimeout(() => {
this.loading = false;
// 模拟数据 - 从数据库结构中提取
this.providersList = [
{
id: "SYSTEM_ASR_DoubaoASR",
model_type: "ASR",
provider_code: "doubao",
name: "火山引擎语音识别",
fields: JSON.parse('[{"key": "appid", "type": "string", "label": "应用ID"}, {"key": "access_token", "type": "string", "label": "访问令牌"}, {"key": "cluster", "type": "string", "label": "集群"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]'),
sort: 3,
selected: false
},
{
id: "SYSTEM_ASR_FunASR",
model_type: "ASR",
provider_code: "fun_local",
name: "FunASR语音识别",
fields: JSON.parse('[{"key": "model_dir", "type": "string", "label": "模型目录"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]'),
sort: 1,
selected: false
},
{
id: "SYSTEM_LLM_openai",
model_type: "LLM",
provider_code: "openai",
name: "OpenAI接口",
fields: JSON.parse('[{"key": "base_url", "type": "string", "label": "基础URL"}, {"key": "model_name", "type": "string", "label": "模型名称"}, {"key": "api_key", "type": "string", "label": "API密钥"}, {"key": "temperature", "type": "number", "label": "温度"}, {"key": "max_tokens", "type": "number", "label": "最大令牌数"}]'),
sort: 1,
selected: false
},
{
id: "SYSTEM_TTS_edge",
model_type: "TTS",
provider_code: "edge",
name: "Edge TTS",
fields: JSON.parse('[{"key": "voice", "type": "string", "label": "音色"}, {"key": "output_dir", "type": "string", "label": "输出目录"}]'),
sort: 1,
selected: false
},
{
id: "SYSTEM_Memory_mem0ai",
model_type: "Memory",
provider_code: "mem0ai",
name: "Mem0AI记忆",
fields: JSON.parse('[{"key": "api_key", "type": "string", "label": "API密钥"}]'),
sort: 1,
selected: false
},
{
id: "SYSTEM_VAD_SileroVAD",
model_type: "VAD",
provider_code: "silero",
name: "SileroVAD语音活动检测",
fields: JSON.parse('[{"key": "threshold", "type": "number", "label": "检测阈值"}, {"key": "model_dir", "type": "string", "label": "模型目录"}, {"key": "min_silence_duration_ms", "type": "number", "label": "最小静音时长"}]'),
sort: 1,
selected: false
},
{
id: "SYSTEM_Intent_nointent",
model_type: "Intent",
provider_code: "nointent",
name: "无意图识别",
fields: [],
sort: 1,
selected: false
},
{
id: "SYSTEM_TTS_aliyun",
model_type: "TTS",
provider_code: "aliyun",
name: "阿里云TTS",
fields: JSON.parse('[{"key": "output_dir", "type": "string", "label": "输出目录"}, {"key": "appkey", "type": "string", "label": "应用密钥"}, {"key": "token", "type": "string", "label": "访问令牌"}, {"key": "voice", "type": "string", "label": "音色"}, {"key": "access_key_id", "type": "string", "label": "访问密钥ID"}, {"key": "access_key_secret", "type": "string", "label": "访问密钥密码"}]'),
sort: 9,
selected: false
},
{
id: "SYSTEM_LLM_coze",
model_type: "LLM",
provider_code: "coze",
name: "Coze接口",
fields: JSON.parse('[{"key": "bot_id", "type": "string", "label": "机器人ID"}, {"key": "user_id", "type": "string", "label": "用户ID"}, {"key": "personal_access_token", "type": "string", "label": "个人访问令牌"}]'),
sort: 6,
selected: false
},
{
id: "SYSTEM_TTS_TencentTTS",
model_type: "TTS",
provider_code: "tencent",
name: "腾讯语音合成",
fields: JSON.parse('[{"key": "appid", "type": "string", "label": "应用ID"}, {"key": "secret_id", "type": "string", "label": "Secret ID"}, {"key": "secret_key", "type": "string", "label": "Secret Key"}, {"key": "output_dir", "type": "string", "label": "输出目录"}, {"key": "region", "type": "string", "label": "区域"}, {"key": "voice", "type": "string", "label": "音色ID"}]'),
sort: 5,
selected: false
}
];
this.total = this.providersList.length;
}, 500);
},
handleSearch() {
this.currentPage = 1;
this.fetchProviders();
},
handleSelectModelType(value) {
this.isDropdownOpen = false;
this.searchModelType = value;
this.handleSearch();
},
handleSelectAll() {
this.isAllSelected = !this.isAllSelected;
this.providersList.forEach(row => {
row.selected = this.isAllSelected;
});
},
showAddDialog() {
this.dialogTitle = "新增供应器";
this.providerForm = {
id: null,
model_type: "",
provider_code: "",
name: "",
fields: [],
sort: 0
};
this.dialogVisible = true;
},
editProvider(row) {
this.dialogTitle = "编辑供应器";
this.providerForm = {
...row,
fields: JSON.parse(JSON.stringify(row.fields))
};
this.dialogVisible = true;
},
handleSubmit({form, done}) {
this.loading = true;
setTimeout(() => {
this.loading = false;
if (form.id) {
// 模拟编辑操作
const index = this.providersList.findIndex(p => p.id === form.id);
if (index !== -1) {
this.providersList.splice(index, 1, {
...form,
fields: typeof form.fields === 'string' ? JSON.parse(form.fields) : form.fields
});
this.$message.success({
message: "修改成功",
showClose: true
});
}
} else {
// 模拟新增操作
const newId = `SYSTEM_${form.model_type}_${form.provider_code}`;
this.providersList.unshift({
...form,
id: newId,
fields: typeof form.fields === 'string' ? JSON.parse(form.fields) : form.fields,
selected: false
});
this.total += 1;
this.$message.success({
message: "新增成功",
showClose: true
});
}
this.dialogVisible = false;
done && done();
}, 500);
},
deleteSelectedProviders() {
const selectedRows = this.providersList.filter(row => row.selected);
if (selectedRows.length === 0) {
this.$message.warning({
message: "请先选择需要删除的供应器",
showClose: true
});
return;
}
this.deleteProvider(selectedRows);
},
deleteProvider(row) {
const providers = Array.isArray(row) ? row : [row];
const providerCount = providers.length;
this.$confirm(`确定要删除选中的${providerCount}个供应器吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const ids = providers.map(provider => provider.id);
// 模拟删除操作
this.providersList = this.providersList.filter(p => !ids.includes(p.id));
this.total = this.providersList.length;
this.$message.success({
message: `成功删除${providerCount}个供应器`,
showClose: true
});
}).catch(() => {
this.$message({
type: 'info',
message: '已取消删除',
showClose: true,
duration: 1000
});
});
},
getModelTypeTag(type) {
const typeMap = {
'ASR': 'success',
'TTS': 'warning',
'LLM': 'danger',
'Intent': 'info',
'Memory': '',
'VAD': 'primary'
};
return typeMap[type] || '';
},
getModelTypeLabel(type) {
const typeItem = this.modelTypes.find(item => item.value === type);
return typeItem ? typeItem.label : type;
},
isSensitiveField(fieldKey) {
return this.sensitive_keys.some(key =>
fieldKey.toLowerCase().includes(key.toLowerCase())
);
},
handlePageSizeChange(val) {
this.pageSize = val;
this.currentPage = 1;
this.fetchProviders();
},
headerCellClassName({columnIndex}) {
if (columnIndex === 0) {
return "custom-selection-header";
}
return "";
},
goFirst() {
this.currentPage = 1;
this.fetchProviders();
},
goPrev() {
if (this.currentPage > 1) {
this.currentPage--;
this.fetchProviders();
}
},
goNext() {
if (this.currentPage < this.pageCount) {
this.currentPage++;
this.fetchProviders();
}
},
goToPage(page) {
this.currentPage = page;
this.fetchProviders();
},
handleDropdownVisibleChange(visible) {
this.DropdownVisible = visible;
},
},
};
</script>
<style lang="scss" scoped>
.welcome {
min-width: 900px;
min-height: 506px;
height: 100vh;
display: flex;
position: relative;
flex-direction: column;
background-size: cover;
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd) center;
-webkit-background-size: cover;
-o-background-size: cover;
overflow: hidden;
}
.main-wrapper {
margin: 5px 22px;
border-radius: 15px;
min-height: calc(100vh - 24vh);
height: auto;
max-height: 80vh;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237, 242, 255, 0.5);
display: flex;
flex-direction: column;
}
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.right-operations {
display: flex;
gap: 10px;
margin-left: auto;
}
.search-input {
width: 240px;
}
.btn-search {
background: linear-gradient(135deg, #6b8cff, #a966ff);
border: none;
color: white;
}
.content-panel {
flex: 1;
display: flex;
overflow: hidden;
height: 100%;
border-radius: 15px;
background: transparent;
border: 1px solid #fff;
}
.content-area {
flex: 1;
height: 100%;
min-width: 600px;
overflow: auto;
background-color: white;
display: flex;
flex-direction: column;
}
.el-card{
border: none;
}
.provider-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
box-shadow: none;
overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
}
.table_bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding-bottom: 10px;
}
.ctrl_btn {
display: flex;
gap: 8px;
padding-left: 26px;
.el-button {
min-width: 72px;
height: 32px;
padding: 7px 12px 7px 10px;
font-size: 12px;
border-radius: 4px;
line-height: 1;
font-weight: 500;
border: none;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
}
.el-button--primary {
background: #5f70f3;
color: white;
}
.el-button--danger {
background: #fd5b63;
color: white;
}
}
.custom-pagination {
display: flex;
align-items: center;
gap: 10px;
.el-select {
margin-right: 8px;
}
.pagination-btn:first-child,
.pagination-btn:nth-child(2),
.pagination-btn:nth-last-child(2),
.pagination-btn:nth-child(3) {
min-width: 60px;
height: 32px;
padding: 0 12px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #d7dce6;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.pagination-btn:not(:first-child):not(:nth-child(3)):not(:nth-child(2)):not(:nth-last-child(2)) {
min-width: 28px;
height: 32px;
padding: 0;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
color: #606266;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(245, 247, 250, 0.3);
}
}
.pagination-btn.active {
background: #5f70f3 !important;
color: #ffffff !important;
border-color: #5f70f3 !important;
&:hover {
background: #6d7cf5 !important;
}
}
.total-text {
color: #909399;
font-size: 14px;
margin-left: 10px;
}
}
:deep(.transparent-table) {
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 {
flex-shrink: 0;
}
.el-table__header th {
background: white !important;
color: black;
}
&::before {
display: none;
}
.el-table__body tr {
background-color: white;
td {
border-top: 1px solid rgba(0, 0, 0, 0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
}
}
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
border-color: #cccccc !important;
}
:deep(.el-checkbox__inner:hover) {
border-color: #cccccc !important;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #5f70f3 !important;
border-color: #5f70f3 !important;
}
@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;
}
}
}
}
:deep(.el-table .el-button--text) {
color: #7079aa;
}
:deep(.el-table .el-button--text:hover) {
color: #5a64b5;
}
.el-button--success {
background: #5bc98c;
color: white;
}
:deep(.el-table .cell) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.page-size-select {
width: 100px;
margin-right: 10px;
:deep(.el-input__inner) {
height: 32px;
line-height: 32px;
border-radius: 4px;
border: 1px solid #e4e7ed;
background: #dee7ff;
color: #606266;
font-size: 14px;
}
:deep(.el-input__suffix) {
right: 6px;
width: 15px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
top: 6px;
border-radius: 4px;
}
:deep(.el-input__suffix-inner) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
:deep(.el-icon-arrow-up:before) {
content: "";
display: inline-block;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #606266;
position: relative;
transform: rotate(0deg);
transition: transform 0.3s;
}
}
:deep(.el-table) {
.el-table__body-wrapper {
transition: height 0.3s ease;
}
}
.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);
}
}
:deep(.el-loading-mask) {
background-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(2px);
}
:deep(.el-loading-spinner .circular) {
width: 28px;
height: 28px;
}
:deep(.el-loading-spinner .path) {
stroke: #6b8cff;
}
:deep(.el-loading-text) {
color: #6b8cff !important;
font-size: 14px;
margin-top: 8px;
}
.field-item {
padding: 5px 0;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
.field-label {
flex: 1;
font-weight: bold;
}
.field-type {
width: 80px;
color: #666;
}
.sensitive-tag {
margin-left: 10px;
color: #f56c6c;
font-size: 12px;
}
}
.dropdown-trigger {
font-size: 14px;
color: #303133;
cursor: pointer;
display: flex;
align-items: center;
&:hover {
color: #409EFF;
}
}
.dropdown-trigger.active {
color: #409EFF;
}
.dropdown-arrow {
display: inline-block;
margin-left: 5px;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 7px solid black;
position: relative;
transition: transform 0.3s ease;
transform: rotate(0deg);
&.is-active {
transform: rotate(180deg);
border-top-color: #409EFF;
}
}
.rotate-down {
transform: rotate(180deg);
transition: transform 0.3s ease;
}
.el-icon-arrow-down {
transition: transform 0.3s ease;
}
.dropdown-trigger {
font-size: 14px;
color: #303133;
cursor: pointer;
display: flex;
align-items: center;
&:hover {
color: #409EFF;
}
}
.dropdown-trigger.active {
color: #409EFF;
}
</style>
+230 -62
View File
@@ -15,9 +15,17 @@
<img loading="lazy" src="@/assets/home/setting-user.png" alt=""> <img loading="lazy" src="@/assets/home/setting-user.png" alt="">
</div> </div>
<span class="header-title">{{ form.agentName }}</span> <span class="header-title">{{ form.agentName }}</span>
<button class="custom-close-btn" @click="goToHome"> <div class="header-actions">
× <div class="hint-text">
</button> <img loading="lazy" src="@/assets/home/info.png" alt="">
<span>保存配置后需要重启设备新的配置才会生效</span>
</div>
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
<el-button class="reset-btn" @click="resetConfig">重置</el-button>
<button class="custom-close-btn" @click="goToHome">
×
</button>
</div>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
@@ -26,7 +34,7 @@
<div class="form-grid"> <div class="form-grid">
<div class="form-column"> <div class="form-column">
<el-form-item label="助手昵称:"> <el-form-item label="助手昵称:">
<el-input v-model="form.agentName" class="form-input" /> <el-input v-model="form.agentName" class="form-input" maxlength="10" />
</el-form-item> </el-form-item>
<el-form-item label="角色模版:"> <el-form-item label="角色模版:">
<div class="template-container"> <div class="template-container">
@@ -37,9 +45,15 @@
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="角色介绍:"> <el-form-item label="角色介绍:">
<el-input type="textarea" rows="12" resize="none" placeholder="请输入内容" v-model="form.systemPrompt" <el-input type="textarea" rows="9" resize="none" placeholder="请输入内容" v-model="form.systemPrompt"
maxlength="2000" show-word-limit class="form-textarea" /> maxlength="2000" show-word-limit class="form-textarea" />
</el-form-item> </el-form-item>
<el-form-item label="记忆:">
<el-input type="textarea" rows="6" resize="none" v-model="form.summaryMemory" maxlength="2000"
show-word-limit class="form-textarea"
:disabled="form.model.memModelId !== 'Memory_mem_local_short'" />
</el-form-item>
<el-form-item label="语言编码:" style="display: none;"> <el-form-item label="语言编码:" style="display: none;">
<el-input v-model="form.langCode" placeholder="请输入语言编码,如:zh_CN" maxlength="10" show-word-limit <el-input v-model="form.langCode" placeholder="请输入语言编码,如:zh_CN" maxlength="10" show-word-limit
class="form-input" /> class="form-input" />
@@ -48,24 +62,48 @@
<el-input v-model="form.language" placeholder="请输入交互语种,如:中文" maxlength="10" show-word-limit <el-input v-model="form.language" placeholder="请输入交互语种,如:中文" maxlength="10" show-word-limit
class="form-input" /> class="form-input" />
</el-form-item> </el-form-item>
<div class="action-bar">
<el-button type="primary" class="save-btn" @click="saveConfig">保存配置</el-button>
<el-button class="reset-btn" @click="resetConfig">重置</el-button>
<div class="hint-text">
<img loading="lazy" src="@/assets/home/red-info.png" alt="">
<span>保存配置后需要重启设备新的配置才会生效</span>
</div>
</div>
</div> </div>
<div class="form-column"> <div class="form-column">
<el-form-item v-for="(model, index) in models" :key="`model-${index}`" :label="model.label" <el-form-item v-for="(model, index) in models" :key="`model-${index}`" :label="model.label"
class="model-item"> class="model-item">
<el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="form-select"> <div class="model-select-wrapper">
<el-option v-for="(item, optionIndex) in modelOptions[model.type]" <el-select v-model="form.model[model.key]" filterable placeholder="请选择" class="form-select"
:key="`option-${index}-${optionIndex}`" :label="item.label" :value="item.value" /> @change="handleModelChange(model.type, $event)">
</el-select> <el-option v-for="(item, optionIndex) in modelOptions[model.type]"
:key="`option-${index}-${optionIndex}`" :label="item.label" :value="item.value" />
</el-select>
<div v-if="showFunctionIcons(model.type)" class="function-icons">
<el-tooltip v-for="func in currentFunctions" :key="func.name" effect="dark" placement="top"
popper-class="custom-tooltip">
<div slot="content">
<div><strong>功能名称:</strong> {{ func.name }}</div>
<div v-if="Object.keys(func.params).length > 0">
<strong>参数配置:</strong>
<div v-for="(value, key) in func.params" :key="key">
{{ key }}: {{ value }}
</div>
</div>
<div v-else>无参数配置</div>
</div>
<div class="icon-dot" :style="{ backgroundColor: getFunctionColor(func.name) }">
{{ func.name.charAt(0) }}
</div>
</el-tooltip>
<el-button class="edit-function-btn" @click="showFunctionDialog = true"
:class="{ 'active-btn': showFunctionDialog }">
编辑功能
</el-button>
</div>
<div v-if="model.type === 'Memory' && form.model.memModelId !== 'Memory_nomem'"
class="chat-history-options">
<el-radio-group v-model="form.chatHistoryConf" @change="updateChatHistoryConf">
<el-radio-button :label="1">上报文字</el-radio-button>
<el-radio-button :label="2">上报文字+语音</el-radio-button>
</el-radio-group>
</div>
</div>
</el-form-item> </el-form-item>
<el-form-item label="角色音色"> <el-form-item label="角色音色">
<el-select v-model="form.ttsVoiceId" placeholder="请选择" class="form-select"> <el-select v-model="form.ttsVoiceId" placeholder="请选择" class="form-select">
<el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label" <el-option v-for="(item, index) in voiceOptions" :key="`voice-${index}`" :label="item.label"
:value="item.value" /> :value="item.value" />
@@ -75,28 +113,33 @@
</div> </div>
</div> </div>
</el-form> </el-form>
</el-card> </el-card>
</div> </div>
</div> </div>
</div> </div>
<function-dialog v-model="showFunctionDialog" :functions="currentFunctions"
@update-functions="handleUpdateFunctions" @dialog-closed="handleDialogClosed" />
</div> </div>
</template> </template>
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
import FunctionDialog from "@/components/FunctionDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
export default { export default {
name: 'RoleConfigPage', name: 'RoleConfigPage',
components: { HeaderBar }, components: { HeaderBar, FunctionDialog },
data() { data() {
return { return {
form: { form: {
agentCode: "", agentCode: "",
agentName: "", agentName: "",
ttsVoiceId: "", ttsVoiceId: "",
chatHistoryConf: 0,
systemPrompt: "", systemPrompt: "",
summaryMemory: "",
langCode: "", langCode: "",
language: "", language: "",
sort: "", sort: "",
@@ -121,6 +164,18 @@ export default {
templates: [], templates: [],
loadingTemplate: false, loadingTemplate: false,
voiceOptions: [], voiceOptions: [],
showFunctionDialog: false,
currentFunctions: [],
functionColorMap: [
'#FF6B6B', '#4ECDC4', '#45B7D1',
'#96CEB4', '#FFEEAD', '#D4A5A5', '#A2836E'
],
allFunctions: [
{ name: '天气', params: {} },
{ name: '新闻', params: {} },
{ name: '工具', params: {} },
{ name: '退出', params: {} }
],
} }
}, },
methods: { methods: {
@@ -136,12 +191,15 @@ export default {
llmModelId: this.form.model.llmModelId, llmModelId: this.form.model.llmModelId,
ttsModelId: this.form.model.ttsModelId, ttsModelId: this.form.model.ttsModelId,
ttsVoiceId: this.form.ttsVoiceId, ttsVoiceId: this.form.ttsVoiceId,
chatHistoryConf: this.form.chatHistoryConf,
memModelId: this.form.model.memModelId, memModelId: this.form.model.memModelId,
intentModelId: this.form.model.intentModelId, intentModelId: this.form.model.intentModelId,
systemPrompt: this.form.systemPrompt, systemPrompt: this.form.systemPrompt,
summaryMemory: this.form.summaryMemory,
langCode: this.form.langCode, langCode: this.form.langCode,
language: this.form.language, language: this.form.language,
sort: this.form.sort sort: this.form.sort,
functions: this.currentFunctions
}; };
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => { Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
@@ -167,7 +225,9 @@ export default {
agentCode: "", agentCode: "",
agentName: "", agentName: "",
ttsVoiceId: "", ttsVoiceId: "",
chatHistoryConf: 0,
systemPrompt: "", systemPrompt: "",
summaryMemory: "",
langCode: "", langCode: "",
language: "", language: "",
sort: "", sort: "",
@@ -180,12 +240,12 @@ export default {
intentModelId: "", intentModelId: "",
} }
} }
this.currentFunctions = [];
this.$message.success({ this.$message.success({
message: '配置已重置', message: '配置已重置',
showClose: true showClose: true
}) })
}).catch(() => { }).catch(() => { });
});
}, },
fetchTemplates() { fetchTemplates() {
Api.agent.getAgentTemplate(({ data }) => { Api.agent.getAgentTemplate(({ data }) => {
@@ -220,7 +280,9 @@ export default {
...this.form, ...this.form,
agentName: templateData.agentName || this.form.agentName, agentName: templateData.agentName || this.form.agentName,
ttsVoiceId: templateData.ttsVoiceId || this.form.ttsVoiceId, ttsVoiceId: templateData.ttsVoiceId || this.form.ttsVoiceId,
chatHistoryConf: templateData.chatHistoryConf || this.form.chatHistoryConf,
systemPrompt: templateData.systemPrompt || this.form.systemPrompt, systemPrompt: templateData.systemPrompt || this.form.systemPrompt,
summaryMemory: templateData.summaryMemory || this.form.summaryMemory,
langCode: templateData.langCode || this.form.langCode, langCode: templateData.langCode || this.form.langCode,
model: { model: {
ttsModelId: templateData.ttsModelId || this.form.model.ttsModelId, ttsModelId: templateData.ttsModelId || this.form.model.ttsModelId,
@@ -247,6 +309,7 @@ export default {
intentModelId: data.data.intentModelId intentModelId: data.data.intentModelId
} }
}; };
this.currentFunctions = data.data.functions || [];
} else { } else {
this.$message.error(data.msg || '获取配置失败'); this.$message.error(data.msg || '获取配置失败');
} }
@@ -281,7 +344,56 @@ export default {
this.voiceOptions = []; this.voiceOptions = [];
} }
}); });
} },
getFunctionColor(name) {
const hash = [...name].reduce((acc, char) => acc + char.charCodeAt(0), 0);
return this.functionColorMap[hash % 7];
},
showFunctionIcons(type) {
// TODO 暂时不放出来
return false;
// return type === 'Intent' &&
// this.form.model.intentModelId !== 'Intent_nointent';
},
handleModelChange(type, value) {
if (type === 'Intent' && value !== 'Intent_nointent') {
this.fetchFunctionList();
}
if (type === 'Memory' && value === 'Memory_nomem') {
this.form.chatHistoryConf = 0;
}
if (type === 'Memory' && value !== 'Memory_nomem' && (this.form.chatHistoryConf === 0 || this.form.chatHistoryConf === null)) {
this.form.chatHistoryConf = 2;
}
},
fetchFunctionList() {
// 使用假数据代替API调用
return new Promise(resolve => {
setTimeout(() => {
this.currentFunctions = [
{ name: '天气', params: { city: '北京' } },
{ name: '新闻', params: { type: '科技' } }
];
resolve();
}, 500);
});
},
handleUpdateFunctions(selected) {
this.currentFunctions = selected;
console.log('保存的功能列表:', selected);
this.$message.success('功能配置已保存');
},
handleDialogClosed(saved) {
if (!saved) {
// 如果未保存,恢复原始功能列表
this.currentFunctions = JSON.parse(JSON.stringify(this.originalFunctions));
}
},
updateChatHistoryConf() {
if (this.form.model.memModelId === 'Memory_nomem') {
this.form.chatHistoryConf = 0;
}
},
}, },
watch: { watch: {
'form.model.ttsModelId': { 'form.model.ttsModelId': {
@@ -308,6 +420,9 @@ export default {
const agentId = this.$route.query.agentId; const agentId = this.$route.query.agentId;
if (agentId) { if (agentId) {
this.fetchAgentConfig(agentId); this.fetchAgentConfig(agentId);
this.fetchFunctionList().then(() => {
this.originalFunctions = JSON.parse(JSON.stringify(this.currentFunctions));
});
} }
this.fetchModelOptions(); this.fetchModelOptions();
this.fetchTemplates(); this.fetchTemplates();
@@ -466,51 +581,35 @@ export default {
background-color: #d0d8ff; background-color: #d0d8ff;
} }
.action-bar { .model-select-wrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 2vh;
align-items: center;
}
.action-bar {
.el-button.save-btn {
background: #5778ff;
color: white;
border: none;
border-radius: 18px;
padding: 10px 20px;
width: 100px;
height: 35px;
font-size: 14px;
}
.el-button.reset-btn {
background: #e6ebff;
color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 10px 20px;
}
}
.hint-text {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; width: 100%;
color: #979db1;
font-size: 11px;
margin-left: 16px;
} }
.hint-text img { .function-icons {
width: 19px; display: flex;
height: 19px; align-items: center;
margin-left: auto;
padding-left: 10px;
}
.icon-dot {
width: 25px;
height: 25px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 12px;
margin-right: 8px;
position: relative;
} }
::v-deep .el-form-item__label { ::v-deep .el-form-item__label {
font-size: 10px !important; font-size: 12px !important;
color: #3d4566 !important; color: #3d4566 !important;
font-weight: 400; font-weight: 400;
line-height: 22px; line-height: 22px;
@@ -551,4 +650,73 @@ export default {
color: #409EFF; color: #409EFF;
border-color: #409EFF; border-color: #409EFF;
} }
.edit-function-btn {
background: #e6ebff;
color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 10px 20px;
transition: all 0.3s;
}
.edit-function-btn.active-btn {
background: #5778ff;
color: white;
}
.chat-history-options {
display: flex;
gap: 10px;
min-width: 250px;
justify-content: flex-end;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.header-actions .hint-text {
display: flex;
align-items: center;
gap: 4px;
color: #979db1;
font-size: 12px;
margin-right: 8px;
}
.header-actions .hint-text img {
width: 16px;
height: 16px;
}
.header-actions .save-btn {
background: #5778ff;
color: white;
border: none;
border-radius: 18px;
padding: 8px 16px;
height: 32px;
font-size: 14px;
}
.header-actions .reset-btn {
background: #e6ebff;
color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 8px 16px;
height: 32px;
}
.header-actions .custom-close-btn {
position: static;
transform: none;
width: 32px;
height: 32px;
margin-left: 8px;
}
</style> </style>
+21 -13
View File
@@ -101,7 +101,8 @@ plugins:
# 这个密钥是项目共用的key,用多了可能会被限制 # 这个密钥是项目共用的key,用多了可能会被限制
# 想稳定一点就自行申请替换,每天有1000次免费调用 # 想稳定一点就自行申请替换,每天有1000次免费调用
# 申请地址:https://console.qweather.com/#/apps/create-key/over # 申请地址:https://console.qweather.com/#/apps/create-key/over
get_weather: { "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" } # 申请后通过这个链接可以找到自己的apihosthttps://console.qweather.com/setting?lang=zh
get_weather: {"api_host":"mj7p3y7naa.re.qweatherapi.com", "api_key": "a861d0d5e7bf4ee1a83d9a9e4f96d4da", "default_location": "广州" }
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻 # 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/ # 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
get_news_from_chinanews: get_news_from_chinanews:
@@ -129,7 +130,7 @@ plugins:
# ################################以下是角色模型配置###################################### # ################################以下是角色模型配置######################################
prompt: | prompt: |
是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。 是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
[核心特征] [核心特征]
- 讲话像连珠炮,但会突然冒出超温柔语气 - 讲话像连珠炮,但会突然冒出超温柔语气
- 用梗密度高 - 用梗密度高
@@ -143,6 +144,13 @@ prompt: |
- 长篇大论,叽叽歪歪 - 长篇大论,叽叽歪歪
- 长时间严肃对话 - 长时间严肃对话
# 结束语prompt
end_prompt:
enable: true # 是否开启结束语
# 结束语
prompt: |
请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧!
# 具体处理时选择的模块(The module selected for specific processing) # 具体处理时选择的模块(The module selected for specific processing)
selected_module: selected_module:
# 语音活动检测模块,默认使用SileroVAD模型 # 语音活动检测模块,默认使用SileroVAD模型
@@ -221,7 +229,7 @@ ASR:
FunASRServer: FunASRServer:
# 独立部署FunASR,使用FunASR的API服务,只需要五句话 # 独立部署FunASR,使用FunASR的API服务,只需要五句话
# 第一句:mkdir -p ./funasr-runtime-resources/models # 第一句:mkdir -p ./funasr-runtime-resources/models
# 第二句:sudo docker run -d -p 10096:10095 --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12 # 第二句:sudo docker run -p 10096:10095 -it --privileged=true -v $PWD/funasr-runtime-resources/models:/workspace/models registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.12
# 上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime # 上一句话执行后会进入到容器,继续第三句:cd FunASR/runtime
# 不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 & # 不要退出容器,继续在容器中执行第四句:nohup bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --lm-dir damo/speech_ngram_lm_zh-cn-ai-wesp-fst --itn-dir thuduj12/fst_itn_zh --hotword /workspace/models/hotwords.txt > log.txt 2>&1 &
# 上一句话执行后会进入到容器,继续第五句:tail -f log.txt # 上一句话执行后会进入到容器,继续第五句:tail -f log.txt
@@ -231,6 +239,7 @@ ASR:
host: 127.0.0.1 host: 127.0.0.1
port: 10096 port: 10096
is_ssl: true is_ssl: true
api_key: none
output_dir: tmp/ output_dir: tmp/
SherpaASR: SherpaASR:
type: sherpa_onnx_local type: sherpa_onnx_local
@@ -389,6 +398,12 @@ LLM:
model_name: deepseek-r1-distill-llama-8b@q4_k_m # 使用的模型名称,需要预先在社区下载 model_name: deepseek-r1-distill-llama-8b@q4_k_m # 使用的模型名称,需要预先在社区下载
url: http://localhost:1234/v1 # LM Studio服务地址 url: http://localhost:1234/v1 # LM Studio服务地址
api_key: lm-studio # LM Studio服务的固定API Key api_key: lm-studio # LM Studio服务的固定API Key
HomeAssistant:
# 定义LLM API类型
type: homeassistant
base_url: http://homeassistant.local:8123
agent_id: conversation.chatgpt
api_key: 你的home assistant api访问令牌
FastgptLLM: FastgptLLM:
# 定义LLM API类型 # 定义LLM API类型
type: fastgpt type: fastgpt
@@ -469,20 +484,13 @@ TTS:
speed: 1 speed: 1
output_dir: tmp/ output_dir: tmp/
FishSpeech: FishSpeech:
# 定义TTS API类型 # 参照教程:https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/fish-speech-integration.md
#启动tts方法:
#python -m tools.api_server
#--listen 0.0.0.0:8080
#--llama-checkpoint-path "checkpoints/fish-speech-1.5"
#--decoder-checkpoint-path "checkpoints/fish-speech-1.5/firefly-gan-vq-fsq-8x1024-21hz-generator.pth"
#--decoder-config-name firefly_gan_vq
#--compile
type: fishspeech type: fishspeech
output_dir: tmp/ output_dir: tmp/
response_format: wav response_format: wav
reference_id: null reference_id: null
reference_audio: ["/tmp/test.wav",] reference_audio: ["config/assets/wakeup_words.wav",]
reference_text: ["你弄来这些吟词宴曲来看,还是这些混话来欺负我。",] reference_text: ["哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦",]
normalize: true normalize: true
max_new_tokens: 1024 max_new_tokens: 1024
chunk_length: 200 chunk_length: 200
+1 -1
View File
@@ -4,7 +4,7 @@ from loguru import logger
from config.config_loader import load_config from config.config_loader import load_config
from config.settings import check_config_file from config.settings import check_config_file
SERVER_VERSION = "0.4.2" SERVER_VERSION = "0.4.3"
def get_module_abbreviation(module_name, module_dict): def get_module_abbreviation(module_name, module_dict):
@@ -145,6 +145,20 @@ def get_agent_models(
) )
def save_mem_local_short(mac_address: str, short_momery: str) -> Optional[Dict]:
try:
return ManageApiClient._instance._execute_request(
"PUT",
f"/agent/saveMemory/" + mac_address,
json={
"summaryMemory": short_momery,
},
)
except Exception as e:
print(f"存储短期记忆到服务器失败: {e}")
return None
def report( def report(
mac_address: str, session_id: str, chat_type: int, content: str, audio mac_address: str, session_id: str, chat_type: int, content: str, audio
) -> Optional[Dict]: ) -> Optional[Dict]:
+85 -48
View File
@@ -33,7 +33,7 @@ from core.mcp.manager import MCPManager
from config.config_loader import get_private_config_from_api from config.config_loader import get_private_config_from_api
from config.manage_api_client import DeviceNotFoundException, DeviceBindException from config.manage_api_client import DeviceNotFoundException, DeviceBindException
from core.utils.output_counter import add_device_output from core.utils.output_counter import add_device_output
from core.handle.ttsReportHandle import enqueue_tts_report, report_tts from core.handle.reportHandle import enqueue_tts_report, report
TAG = __name__ TAG = __name__
@@ -75,6 +75,7 @@ class ConnectionHandler:
self.prompt = None self.prompt = None
self.welcome_msg = None self.welcome_msg = None
self.max_output_size = 0 self.max_output_size = 0
self.chat_history_conf = 0
# 客户端状态相关 # 客户端状态相关
self.client_abort = False self.client_abort = False
@@ -88,8 +89,11 @@ class ConnectionHandler:
self.executor = ThreadPoolExecutor(max_workers=10) self.executor = ThreadPoolExecutor(max_workers=10)
# 上报线程 # 上报线程
self.tts_report_queue = queue.Queue() self.report_queue = queue.Queue()
self.tts_report_thread = None self.report_thread = None
# TODO(haotian): 2025/5/12 可以通过修改此处,调节asr的上报和tts的上报
self.report_asr_enable = self.read_config_from_api
self.report_tts_enable = self.read_config_from_api
# 依赖的组件 # 依赖的组件
self.vad = None self.vad = None
@@ -221,10 +225,26 @@ class ConnectionHandler:
"""保存记忆并关闭连接""" """保存记忆并关闭连接"""
try: try:
if self.memory: if self.memory:
await self.memory.save_memory(self.dialogue.dialogue) # 使用线程池异步保存记忆
def save_memory_task():
try:
# 创建新事件循环(避免与主循环冲突)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(
self.memory.save_memory(self.dialogue.dialogue)
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
finally:
loop.close()
# 启动线程保存记忆,不等待完成
threading.Thread(target=save_memory_task, daemon=True).start()
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}") self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
finally: finally:
# 立即关闭连接,不等待记忆保存完成
await self.close(ws) await self.close(ws)
async def reset_timeout(self): async def reset_timeout(self):
@@ -250,11 +270,15 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).info("收到服务器重启指令,准备执行...") self.logger.bind(tag=TAG).info("收到服务器重启指令,准备执行...")
# 发送确认响应 # 发送确认响应
await self.websocket.send(json.dumps({ await self.websocket.send(
"type": "server_response", json.dumps(
"status": "success", {
"message": "服务器重启中..." "type": "server_response",
})) "status": "success",
"message": "服务器重启中...",
}
)
)
# 异步执行重启操作 # 异步执行重启操作
def restart_server(): def restart_server():
@@ -266,7 +290,7 @@ class ConnectionHandler:
stdin=sys.stdin, stdin=sys.stdin,
stdout=sys.stdout, stdout=sys.stdout,
stderr=sys.stderr, stderr=sys.stderr,
start_new_session=True start_new_session=True,
) )
os._exit(0) os._exit(0)
@@ -275,11 +299,15 @@ class ConnectionHandler:
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"重启失败: {str(e)}") self.logger.bind(tag=TAG).error(f"重启失败: {str(e)}")
await self.websocket.send(json.dumps({ await self.websocket.send(
"type": "server_response", json.dumps(
"status": "error", {
"message": f"Restart failed: {str(e)}" "type": "server_response",
})) "status": "error",
"message": f"Restart failed: {str(e)}",
}
)
)
def _initialize_components(self): def _initialize_components(self):
"""初始化组件""" """初始化组件"""
@@ -306,11 +334,13 @@ class ConnectionHandler:
"""初始化ASR和TTS上报线程""" """初始化ASR和TTS上报线程"""
if not self.read_config_from_api or self.need_bind: if not self.read_config_from_api or self.need_bind:
return return
if self.tts_report_thread is None or not self.tts_report_thread.is_alive(): if self.chat_history_conf == 0:
self.tts_report_thread = threading.Thread( return
target=self._tts_report_worker, daemon=True if self.report_thread is None or not self.report_thread.is_alive():
self.report_thread = threading.Thread(
target=self._report_worker, daemon=True
) )
self.tts_report_thread.start() self.report_thread.start()
self.logger.bind(tag=TAG).info("TTS上报线程已启动") self.logger.bind(tag=TAG).info("TTS上报线程已启动")
def _initialize_private_config(self): def _initialize_private_config(self):
@@ -377,9 +407,12 @@ class ConnectionHandler:
]["Intent"] ]["Intent"]
if private_config.get("prompt", None) is not None: if private_config.get("prompt", None) is not None:
self.config["prompt"] = private_config["prompt"] self.config["prompt"] = private_config["prompt"]
if private_config.get("summaryMemory", None) is not None:
self.config["summaryMemory"] = private_config["summaryMemory"]
if private_config.get("device_max_output_size", None) is not None: if private_config.get("device_max_output_size", None) is not None:
self.max_output_size = int(private_config["device_max_output_size"]) self.max_output_size = int(private_config["device_max_output_size"])
if private_config.get("chat_history_conf", None) is not None:
self.chat_history_conf = int(private_config["chat_history_conf"])
try: try:
modules = initialize_modules( modules = initialize_modules(
self.logger, self.logger,
@@ -409,7 +442,12 @@ class ConnectionHandler:
def _initialize_memory(self): def _initialize_memory(self):
"""初始化记忆模块""" """初始化记忆模块"""
self.memory.init_memory(self.device_id, self.llm) self.memory.init_memory(
role_id=self.device_id,
llm=self.llm,
summary_memory=self.config.get("summaryMemory", None),
save_to_file=not self.read_config_from_api,
)
def _initialize_intent(self): def _initialize_intent(self):
self.intent_type = self.config["Intent"][ self.intent_type = self.config["Intent"][
@@ -838,7 +876,7 @@ class ConnectionHandler:
if future is None: if future is None:
continue continue
text = None text = None
opus_datas, tts_file = [], None audio_datas, tts_file = [], None
try: try:
self.logger.bind(tag=TAG).debug("正在处理TTS任务...") self.logger.bind(tag=TAG).debug("正在处理TTS任务...")
tts_timeout = int(self.config.get("tts_timeout", 10)) tts_timeout = int(self.config.get("tts_timeout", 10))
@@ -860,8 +898,8 @@ class ConnectionHandler:
audio_datas, _ = self.tts.audio_to_pcm_data(tts_file) audio_datas, _ = self.tts.audio_to_pcm_data(tts_file)
else: else:
audio_datas, _ = self.tts.audio_to_opus_data(tts_file) audio_datas, _ = self.tts.audio_to_opus_data(tts_file)
# 在这里上报TTS数据(使用文件路径) # 在这里上报TTS数据
enqueue_tts_report(self, 2, text, audio_datas) enqueue_tts_report(self, text, audio_datas)
else: else:
self.logger.bind(tag=TAG).error( self.logger.bind(tag=TAG).error(
f"TTS出错:文件不存在{tts_file}" f"TTS出错:文件不存在{tts_file}"
@@ -917,13 +955,12 @@ class ConnectionHandler:
f"audio_play_priority priority_thread: {text} {e}" f"audio_play_priority priority_thread: {text} {e}"
) )
def _tts_report_worker(self): def _report_worker(self):
"""TTS上报工作线程""" """聊天记录上报工作线程"""
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
# 从队列获取数据,设置超时以便定期检查停止事件 # 从队列获取数据,设置超时以便定期检查停止事件
item = self.tts_report_queue.get(timeout=1) item = self.report_queue.get(timeout=1)
if item is None: # 检测毒丸对象 if item is None: # 检测毒丸对象
break break
@@ -931,18 +968,18 @@ class ConnectionHandler:
try: try:
# 执行上报(传入二进制数据) # 执行上报(传入二进制数据)
report_tts(self, type, text, audio_data) report(self, type, text, audio_data)
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"TTS上报线程异常: {e}") self.logger.bind(tag=TAG).error(f"聊天记录上报线程异常: {e}")
finally: finally:
# 标记任务完成 # 标记任务完成
self.tts_report_queue.task_done() self.report_queue.task_done()
except queue.Empty: except queue.Empty:
continue continue
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"TTS上报工作线程异常: {e}") self.logger.bind(tag=TAG).error(f"聊天记录上报工作线程异常: {e}")
self.logger.bind(tag=TAG).info("TTS上报线程已退出") self.logger.bind(tag=TAG).info("聊天记录上报线程已退出")
def speak_and_play(self, text, text_index=0): def speak_and_play(self, text, text_index=0):
if text is None or len(text) <= 0: if text is None or len(text) <= 0:
@@ -971,6 +1008,7 @@ class ConnectionHandler:
async def close(self, ws=None): async def close(self, ws=None):
"""资源清理方法""" """资源清理方法"""
# 取消超时任务 # 取消超时任务
if self.timeout_task: if self.timeout_task:
self.timeout_task.cancel() self.timeout_task.cancel()
@@ -980,43 +1018,42 @@ class ConnectionHandler:
if hasattr(self, "mcp_manager") and self.mcp_manager: if hasattr(self, "mcp_manager") and self.mcp_manager:
await self.mcp_manager.cleanup_all() await self.mcp_manager.cleanup_all()
# 触发停止事件并清理资源 # 触发停止事件
if self.stop_event: if self.stop_event:
self.stop_event.set() self.stop_event.set()
# 立即关闭线程池
if self.executor:
self.executor.shutdown(wait=False, cancel_futures=True)
self.executor = None
# 添加毒丸对象到上报队列确保线程退出
self.tts_report_queue.put(None)
# 清空任务队列 # 清空任务队列
self.clear_queues() self.clear_queues()
# 关闭WebSocket连接
if ws: if ws:
await ws.close() await ws.close()
elif self.websocket: elif self.websocket:
await self.websocket.close() await self.websocket.close()
# 最后关闭线程池(避免阻塞)
if self.executor:
self.executor.shutdown(wait=False)
self.executor = None
self.logger.bind(tag=TAG).info("连接资源已释放") self.logger.bind(tag=TAG).info("连接资源已释放")
def clear_queues(self): def clear_queues(self):
# 清空所有任务队列 """清空所有任务队列"""
self.logger.bind(tag=TAG).debug( self.logger.bind(tag=TAG).debug(
f"开始清理: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}" f"开始清理: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
) )
# 使用非阻塞方式清空队列
for q in [self.tts_queue, self.audio_play_queue]: for q in [self.tts_queue, self.audio_play_queue]:
if not q: if not q:
continue continue
while not q.empty(): while True:
try: try:
q.get_nowait() q.get_nowait()
except queue.Empty: except queue.Empty:
continue break
q.queue.clear()
# 添加毒丸信号到队列,确保线程退出
# q.queue.put(None)
self.logger.bind(tag=TAG).debug( self.logger.bind(tag=TAG).debug(
f"清理结束: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}" f"清理结束: TTS队列大小={self.tts_queue.qsize()}, 音频队列大小={self.audio_play_queue.qsize()}"
) )
@@ -4,7 +4,7 @@ from core.utils.util import remove_punctuation_and_length
from core.handle.sendAudioHandle import send_stt_message from core.handle.sendAudioHandle import send_stt_message
from core.handle.intentHandler import handle_user_intent from core.handle.intentHandler import handle_user_intent
from core.utils.output_counter import check_device_output_limit from core.utils.output_counter import check_device_output_limit
from core.handle.ttsReportHandle import enqueue_tts_report from core.handle.reportHandle import enqueue_asr_report
from core.utils.util import audio_to_data from core.utils.util import audio_to_data
TAG = __name__ TAG = __name__
@@ -44,7 +44,7 @@ async def handleAudioMessage(conn, audio):
text_len, _ = remove_punctuation_and_length(text) text_len, _ = remove_punctuation_and_length(text)
if text_len > 0: if text_len > 0:
# 使用自定义模块进行上报 # 使用自定义模块进行上报
enqueue_tts_report(conn, 1, text, copy.deepcopy(conn.asr_audio)) enqueue_asr_report(conn, text, copy.deepcopy(conn.asr_audio))
await startToChat(conn, text) await startToChat(conn, text)
else: else:
@@ -98,9 +98,14 @@ async def no_voice_close_connect(conn):
conn.close_after_chat = True conn.close_after_chat = True
conn.client_abort = False conn.client_abort = False
conn.asr_server_receive = False conn.asr_server_receive = False
prompt = ( end_prompt = conn.config.get("end_prompt", {})
"请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。" if end_prompt and end_prompt.get("enable", True) is False:
) conn.logger.bind(tag=TAG).info("结束对话,无需发送结束提示语")
await conn.close()
return
prompt = end_prompt.get("prompt")
if not prompt:
prompt = "请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。!"
await startToChat(conn, prompt) await startToChat(conn, prompt)
@@ -11,13 +11,13 @@ TTS上报功能已集成到ConnectionHandler类中。
import opuslib_next import opuslib_next
from config.manage_api_client import report from config.manage_api_client import report as manage_report
TAG = __name__ TAG = __name__
def report_tts(conn, type, text, opus_data): def report(conn, type, text, opus_data):
"""执行TTS上报操作 """执行聊天记录上报操作
Args: Args:
conn: 连接对象 conn: 连接对象
@@ -31,7 +31,7 @@ def report_tts(conn, type, text, opus_data):
else: else:
audio_data = None audio_data = None
# 执行上报 # 执行上报
report( manage_report(
mac_address=conn.device_id, mac_address=conn.device_id,
session_id=conn.session_id, session_id=conn.session_id,
chat_type=type, chat_type=type,
@@ -39,7 +39,7 @@ def report_tts(conn, type, text, opus_data):
audio=audio_data, audio=audio_data,
) )
except Exception as e: except Exception as e:
conn.logger.bind(tag=TAG).error(f"TTS上报失败: {e}") conn.logger.bind(tag=TAG).error(f"聊天记录上报失败: {e}")
def opus_to_wav(conn, opus_data): def opus_to_wav(conn, opus_data):
@@ -89,8 +89,10 @@ def opus_to_wav(conn, opus_data):
return bytes(wav_header) + pcm_data_bytes return bytes(wav_header) + pcm_data_bytes
def enqueue_tts_report(conn, type, text, opus_data): def enqueue_tts_report(conn, text, opus_data):
if not conn.read_config_from_api or conn.need_bind: if not conn.read_config_from_api or conn.need_bind or not conn.report_tts_enable:
return
if conn.chat_history_conf == 0:
return return
"""将TTS数据加入上报队列 """将TTS数据加入上报队列
@@ -101,10 +103,43 @@ def enqueue_tts_report(conn, type, text, opus_data):
""" """
try: try:
# 使用连接对象的队列,传入文本和二进制数据而非文件路径 # 使用连接对象的队列,传入文本和二进制数据而非文件路径
conn.tts_report_queue.put((type, text, opus_data)) if conn.chat_history_conf == 2:
conn.report_queue.put((2, text, opus_data))
conn.logger.bind(tag=TAG).debug( conn.logger.bind(tag=TAG).debug(
f"TTS数据已加入上报队列: {conn.device_id}, 音频大小: {len(opus_data)} " f"TTS数据已加入上报队列: {conn.device_id}, 音频大小: {len(opus_data)} "
) )
else:
conn.report_queue.put((2, text, None))
conn.logger.bind(tag=TAG).debug(
f"TTS数据已加入上报队列: {conn.device_id}, 不上报音频"
)
except Exception as e: except Exception as e:
conn.logger.bind(tag=TAG).error(f"加入TTS上报队列失败: {text}, {e}") conn.logger.bind(tag=TAG).error(f"加入TTS上报队列失败: {text}, {e}")
def enqueue_asr_report(conn, text, opus_data):
if not conn.read_config_from_api or conn.need_bind or not conn.report_asr_enable:
return
if conn.chat_history_conf == 0:
return
"""将ASR数据加入上报队列
Args:
conn: 连接对象
text: 合成文本
opus_data: opus音频数据
"""
try:
# 使用连接对象的队列,传入文本和二进制数据而非文件路径
if conn.chat_history_conf == 2:
conn.report_queue.put((1, text, opus_data))
conn.logger.bind(tag=TAG).debug(
f"ASR数据已加入上报队列: {conn.device_id}, 音频大小: {len(opus_data)} "
)
else:
conn.report_queue.put((1, text, None))
conn.logger.bind(tag=TAG).debug(
f"ASR数据已加入上报队列: {conn.device_id}, 不上报音频"
)
except Exception as e:
conn.logger.bind(tag=TAG).error(f"加入ASR上报队列失败: {text}, {e}")
@@ -5,7 +5,7 @@ from core.utils.util import remove_punctuation_and_length
from core.handle.receiveAudioHandle import startToChat, handleAudioMessage from core.handle.receiveAudioHandle import startToChat, handleAudioMessage
from core.handle.sendAudioHandle import send_stt_message, send_tts_message from core.handle.sendAudioHandle import send_stt_message, send_tts_message
from core.handle.iotHandle import handleIotDescriptors, handleIotStatus from core.handle.iotHandle import handleIotDescriptors, handleIotStatus
from core.handle.ttsReportHandle import enqueue_tts_report from core.handle.reportHandle import enqueue_asr_report
import asyncio import asyncio
TAG = __name__ TAG = __name__
@@ -56,11 +56,11 @@ async def handleTextMessage(conn, message):
await send_tts_message(conn, "stop", None) await send_tts_message(conn, "stop", None)
elif is_wakeup_words: elif is_wakeup_words:
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据) # 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_tts_report(conn, 1, "嘿,你好呀", []) enqueue_asr_report(conn, "嘿,你好呀", [])
await startToChat(conn, "嘿,你好呀") await startToChat(conn, "嘿,你好呀")
else: else:
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据) # 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_tts_report(conn, 1, text, []) enqueue_asr_report(conn, text, [])
# 否则需要LLM对文字内容进行答复 # 否则需要LLM对文字内容进行答复
await startToChat(conn, text) await startToChat(conn, text)
elif msg_json["type"] == "iot": elif msg_json["type"] == "iot":
@@ -55,7 +55,7 @@ class ASRProvider(ASRProviderBase):
) -> Tuple[Optional[str], Optional[str]]: ) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本""" """将语音数据转换为文本"""
if not opus_data: if not opus_data:
logger.bind(tag=TAG).warn("音频数据为空!") logger.bind(tag=TAG).warning("音频数据为空!")
return None, None return None, None
file_path = None file_path = None
@@ -9,6 +9,7 @@ import wave
import websockets import websockets
from config.logger import setup_logging from config.logger import setup_logging
import asyncio import asyncio
import re
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
@@ -24,7 +25,12 @@ class ASRProvider(ASRProviderBase):
super().__init__() super().__init__()
self.host = config.get("host", "localhost") self.host = config.get("host", "localhost")
self.port = config.get("port", 10095) self.port = config.get("port", 10095)
self.is_ssl = config.get("is_ssl", True) self.api_key = config.get("api_key", "none")
self.is_ssl = str(config.get("is_ssl", True)).lower() in (
"true",
"1",
"yes",
)
self.output_dir = config.get("output_dir") self.output_dir = config.get("output_dir")
self.delete_audio_file = delete_audio_file self.delete_audio_file = delete_audio_file
self.uri = ( self.uri = (
@@ -130,9 +136,13 @@ class ASRProvider(ASRProviderBase):
pass pass
else: else:
file_path = self.save_audio_to_file(pcm_data, session_id) file_path = self.save_audio_to_file(pcm_data, session_id)
auth_header = {"Authorization": "Bearer; {}".format(self.api_key)}
async with websockets.connect( async with websockets.connect(
self.uri, subprotocols=["binary"], ping_interval=None, ssl=self.ssl_context self.uri,
additional_headers=auth_header,
subprotocols=["binary"],
ping_interval=None,
ssl=self.ssl_context,
) as ws: ) as ws:
try: try:
# Use asyncio to handle WebSocket communication # Use asyncio to handle WebSocket communication
@@ -157,6 +167,9 @@ class ASRProvider(ASRProviderBase):
# Get the result from the receive task # Get the result from the receive task
result = receive_task.result() result = receive_task.result()
match = re.match(r"<\|(.*?)\|><\|(.*?)\|><\|(.*?)\|>(.*)", result)
if match:
result = match.group(4).strip()
return ( return (
result, result,
file_path, file_path,
@@ -52,7 +52,7 @@ class ASRProvider(ASRProviderBase):
) -> Tuple[Optional[str], Optional[str]]: ) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本""" """将语音数据转换为文本"""
if not opus_data: if not opus_data:
logger.bind(tag=TAG).warn("音频数据为空!") logger.bind(tag=TAG).warning("音频数据为空!")
return None, None return None, None
file_path = None file_path = None
@@ -230,7 +230,7 @@ class ASRProvider(ASRProviderBase):
if "Response" in response_json and "Result" in response_json["Response"]: if "Response" in response_json and "Result" in response_json["Response"]:
return response_json["Response"]["Result"] return response_json["Response"]["Result"]
else: else:
logger.bind(tag=TAG).warn(f"响应中没有识别结果: {response_json}") logger.bind(tag=TAG).warning(f"响应中没有识别结果: {response_json}")
return "" return ""
except Exception as e: except Exception as e:
@@ -215,7 +215,7 @@ class IntentProvider(IntentProviderBase):
# 记录识别到的function call # 记录识别到的function call
logger.bind(tag=TAG).info( logger.bind(tag=TAG).info(
f"识别到function call: {function_name}, 参数: {function_args}" f"llm 识别到意图: {function_name}, 参数: {function_args}"
) )
# 添加到缓存 # 添加到缓存
@@ -61,5 +61,6 @@ class LLMProvider(LLMProviderBase):
yield "【LLM服务响应异常】" yield "【LLM服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"阿里百练暂未实现完整的工具调用(function call") logger.bind(tag=TAG).error(
return self.response(session_id, dialogue) f"阿里百练暂未实现完整的工具调用(function call),建议使用其他意图识别"
)
@@ -66,5 +66,6 @@ class LLMProvider(LLMProviderBase):
yield "【服务响应异常】" yield "【服务响应异常】"
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"fastgpt暂未实现完整的工具调用(function call") logger.bind(tag=TAG).error(
return self.response(session_id, dialogue) f"fastgpt暂未实现完整的工具调用(function call),建议使用其他意图识别"
)
@@ -1,140 +1,205 @@
import google.generativeai as genai import os, json, uuid
from core.utils.util import check_model_key from types import SimpleNamespace
from core.providers.llm.base import LLMProviderBase from typing import Any, Dict, List
from config.logger import setup_logging
import requests
import json
import requests
from google import generativeai as genai
from google.generativeai import types, GenerationConfig
from core.providers.llm.base import LLMProviderBase
from core.utils.util import check_model_key
from config.logger import setup_logging
from google.generativeai.types import GenerateContentResponse
from requests import RequestException
log = setup_logging()
TAG = __name__ TAG = __name__
logger = setup_logging()
def test_proxy(proxy_url: str, test_url: str) -> bool:
try:
resp = requests.get(test_url, proxies={"http": proxy_url, "https": proxy_url})
return 200 <= resp.status_code < 400
except RequestException:
return False
def setup_proxy_env(http_proxy: str | None, https_proxy: str | None):
"""
分别测试 HTTP 和 HTTPS 代理是否可用,并设置环境变量。
如果 HTTPS 代理不可用但 HTTP 可用,会将 HTTPS_PROXY 也指向 HTTP。
"""
test_http_url = "http://www.google.com"
test_https_url = "https://www.google.com"
ok_http = ok_https = False
if http_proxy:
ok_http = test_proxy(http_proxy, test_http_url)
if ok_http:
os.environ["HTTP_PROXY"] = http_proxy
log.bind(tag=TAG).info(f"配置提供的Gemini HTTPS代理连通成功: {http_proxy}")
else:
log.bind(tag=TAG).warning(f"配置提供的Gemini HTTP代理不可用: {http_proxy}")
if https_proxy:
ok_https = test_proxy(https_proxy, test_https_url)
if ok_https:
os.environ["HTTPS_PROXY"] = https_proxy
log.bind(tag=TAG).info(f"配置提供的Gemini HTTPS代理连通成功: {https_proxy}")
else:
log.bind(tag=TAG).warning(
f"配置提供的Gemini HTTPS代理不可用: {https_proxy}"
)
# 如果https_proxy不可用,但http_proxy可用且能走通https,则复用http_proxy作为https_proxy
if ok_http and not ok_https:
if test_proxy(http_proxy, test_https_url):
os.environ["HTTPS_PROXY"] = http_proxy
ok_https = True
log.bind(tag=TAG).info(f"复用HTTP代理作为HTTPS代理: {http_proxy}")
if not ok_http and not ok_https:
log.bind(tag=TAG).error(
f"Gemini 代理设置失败: HTTP 和 HTTPS 代理都不可用,请检查配置"
)
raise RuntimeError("HTTP 和 HTTPS 代理都不可用,请检查配置")
class LLMProvider(LLMProviderBase): class LLMProvider(LLMProviderBase):
def __init__(self, config): def __init__(self, cfg: Dict[str, Any]):
"""初始化Gemini LLM Provider""" self.model_name = cfg.get("model_name", "gemini-2.0-flash")
self.model_name = config.get("model_name", "gemini-1.5-pro") self.api_key = cfg["api_key"]
self.api_key = config.get("api_key") http_proxy = cfg.get("http_proxy")
self.http_proxy = config.get("http_proxy") https_proxy = cfg.get("https_proxy")
self.https_proxy = config.get("https_proxy")
have_key = check_model_key("LLM", self.api_key)
if not have_key: if not check_model_key("LLM", self.api_key):
return raise ValueError("无效的Gemini API Key,请检查是否配置正确")
try: if http_proxy or https_proxy:
# 初始化Gemini客户端 log.bind(tag=TAG).info(
# 配置代理(如果提供了代理配置) f"检测到Gemini代理配置,开始测试代理连通性和设置代理环境..."
self.proxies = None )
if self.http_proxy is not "" or self.https_proxy is not "": setup_proxy_env(http_proxy, https_proxy)
log.bind(tag=TAG).info(
f"Gemini 代理设置成功 - HTTP: {http_proxy}, HTTPS: {https_proxy}"
)
genai.configure(api_key=self.api_key)
self.model = genai.GenerativeModel(self.model_name)
self.proxies = { self.gen_cfg = GenerationConfig(
"http": self.http_proxy, temperature=0.7,
"https": self.https_proxy, top_p=0.9,
} top_k=40,
logger.bind(tag=TAG).info(f"Gemini set proxys:{self.proxies}") max_output_tokens=2048,
# 使用猴子补丁修改 google-generativeai 库的请求会话 )
# 使用 session 对象配置 genai @staticmethod
def _build_tools(funcs: List[Dict[str, Any]] | None):
genai.configure(api_key=self.api_key) if not funcs:
self.model = genai.GenerativeModel(self.model_name) return None
return [
# 设置生成参数 types.Tool(
self.generation_config = { function_declarations=[
"temperature": 0.7, types.FunctionDeclaration(
"top_p": 0.9, name=f["function"]["name"],
"top_k": 40, description=f["function"]["description"],
"max_output_tokens": 2048, parameters=f["function"]["parameters"],
} )
self.chat = None for f in funcs
except Exception as e: ]
logger.bind(tag=TAG).error(f"Gemini初始化失败: {e}") )
self.model = None ]
# Gemini文档提到,无需维护session-id,直接用dialogue拼接而成
def response(self, session_id, dialogue): def response(self, session_id, dialogue):
"""生成Gemini对话响应""" yield from self._generate(dialogue, None)
if not self.model:
yield "【Gemini服务未正确初始化】"
return
try:
# 处理对话历史
chat_history = []
for msg in dialogue[:-1]: # 历史对话
role = "model" if msg["role"] == "assistant" else "user"
content = msg["content"].strip()
if content:
chat_history.append({"role": role, "parts": [{"text": content}]})
# 获取当前消息
current_msg = dialogue[-1]["content"]
# 构建请求体
request_body = {
"contents": chat_history
+ [{"role": "user", "parts": [{"text": current_msg}]}],
"generationConfig": self.generation_config,
}
# 构建请求URL
url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model_name}:generateContent?key={self.api_key}"
# 构建请求头
headers = {
"Content-Type": "application/json",
}
# 发送POST请求,经测试手动 request 无法使用 stream 模式
if self.proxies:
response = requests.post(
url,
headers=headers,
json=request_body,
stream=False,
proxies=self.proxies,
)
try:
data = response.json() # 直接解析JSON
if "candidates" in data and data["candidates"]:
yield data["candidates"][0]["content"]["parts"][0]["text"]
else:
yield "未找到候选回复。"
except json.JSONDecodeError as e:
yield f"JSON解码错误:{e}"
except Exception as e:
yield f"发生错误:{e}"
else:
logger.bind(tag=TAG).info(f"Gemini stream mode ")
chat = self.model.start_chat(history=chat_history)
# 发送消息并获取流式响应
response = chat.send_message(
current_msg, stream=True, generation_config=self.generation_config
)
# 处理流式响应
for chunk in response:
if hasattr(chunk, "text") and chunk.text:
yield chunk.text
except Exception as e:
error_msg = str(e)
logger.bind(tag=TAG).error(f"Gemini响应生成错误: {error_msg}")
# 针对不同错误返回友好提示
if "Rate limit" in error_msg:
yield "【Gemini服务请求太频繁,请稍后再试】"
elif "Invalid API key" in error_msg:
yield "【Gemini API key无效】"
else:
yield f"【Gemini服务响应异常: {error_msg}"
except requests.exceptions.RequestException as e:
yield f"请求失败:{e}"
except json.JSONDecodeError as e:
yield f"JSON解码错误:{e}"
except Exception as e:
yield f"发生错误:{e}"
def response_with_functions(self, session_id, dialogue, functions=None): def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).info(f"gemini暂未实现完整的工具调用(function call") yield from self._generate(dialogue, self._build_tools(functions))
return self.response(session_id, dialogue)
def _generate(self, dialogue, tools):
role_map = {"assistant": "model", "user": "user"}
contents: list = []
# 拼接对话
for m in dialogue:
r = m["role"]
if r == "assistant" and "tool_calls" in m:
tc = m["tool_calls"][0]
contents.append(
{
"role": "model",
"parts": [
{
"function_call": {
"name": tc["function"]["name"],
"args": json.loads(tc["function"]["arguments"]),
}
}
],
}
)
continue
if r == "tool":
contents.append(
{
"role": "model",
"parts": [{"text": str(m.get("content", ""))}],
}
)
continue
contents.append(
{
"role": role_map.get(r, "user"),
"parts": [{"text": str(m.get("content", ""))}],
}
)
stream: GenerateContentResponse = self.model.generate_content(
contents=contents,
generation_config=self.gen_cfg,
tools=tools,
stream=True,
)
try:
for chunk in stream:
cand = chunk.candidates[0]
for part in cand.content.parts:
# a) 函数调用-通常是最后一段话才是函数调用
if getattr(part, "function_call", None):
fc = part.function_call
yield None, [
SimpleNamespace(
id=uuid.uuid4().hex,
type="function",
function=SimpleNamespace(
name=fc.name,
arguments=json.dumps(
dict(fc.args), ensure_ascii=False
),
),
)
]
return
# b) 普通文本
if getattr(part, "text", None):
yield part.text if tools is None else (part.text, None)
finally:
if tools is not None:
yield None, None # functionmode 结束,返回哑包
# 关闭stream,预留后续打断对话功能的功能方法,官方文档推荐打断对话要关闭上一个流,可以有效减少配额计费和资源占用
@staticmethod
def _safe_finish_stream(stream: GenerateContentResponse):
if hasattr(stream, "resolve"):
stream.resolve() # Gemini SDK version ≥ 0.5.0
elif hasattr(stream, "close"):
stream.close() # Gemini SDK version < 0.5.0
else:
for _ in stream: # 兜底耗尽
pass
@@ -0,0 +1,71 @@
import requests
from requests.exceptions import RequestException
from config.logger import setup_logging
from core.providers.llm.base import LLMProviderBase
TAG = __name__
logger = setup_logging()
class LLMProvider(LLMProviderBase):
def __init__(self, config):
self.agent_id = config.get("agent_id") # 对应 agent_id
self.api_key = config.get("api_key")
self.base_url = config.get("base_url", config.get("url")) # 默认使用 base_url
self.api_url = f"{self.base_url}/api/conversation/process" # 拼接完整的 API URL
def response(self, session_id, dialogue):
try:
# home assistant语音助手自带意图,无需使用xiaozhi ai自带的,只需要把用户说的话传递给home assistant即可
# 提取最后一个 role 为 'user' 的 content
input_text = None
if isinstance(dialogue, list): # 确保 dialogue 是一个列表
# 逆序遍历,找到最后一个 role 为 'user' 的消息
for message in reversed(dialogue):
if message.get("role") == "user": # 找到 role 为 'user' 的消息
input_text = message.get("content", "")
break # 找到后立即退出循环
# 构造请求数据
payload = {
"text": input_text,
"agent_id": self.agent_id,
"conversation_id": session_id, # 使用 session_id 作为 conversation_id
}
# 设置请求头
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
# 发起 POST 请求
response = requests.post(self.api_url, json=payload, headers=headers)
# 检查请求是否成功
response.raise_for_status()
# 解析返回数据
data = response.json()
speech = (
data.get("response", {})
.get("speech", {})
.get("plain", {})
.get("speech", "")
)
# 返回生成的内容
if speech:
yield speech
else:
logger.bind(tag=TAG).warning("API 返回数据中没有 speech 内容")
except RequestException as e:
logger.bind(tag=TAG).error(f"HTTP 请求错误: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"生成响应时出错: {e}")
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).error(
f"homeassistant不支持(function call),建议使用其他意图识别"
)
@@ -4,6 +4,7 @@ from config.logger import setup_logging
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
class MemoryProviderBase(ABC): class MemoryProviderBase(ABC):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
@@ -20,6 +21,6 @@ class MemoryProviderBase(ABC):
"""Query memories for specific role based on similarity""" """Query memories for specific role based on similarity"""
return "please implement query method" return "please implement query method"
def init_memory(self, role_id, llm): def init_memory(self, role_id, llm, **kwargs):
self.role_id = role_id self.role_id = role_id
self.llm = llm self.llm = llm
@@ -8,7 +8,7 @@ TAG = __name__
class MemoryProvider(MemoryProviderBase): class MemoryProvider(MemoryProviderBase):
def __init__(self, config): def __init__(self, config, summary_memory=None):
super().__init__(config) super().__init__(config)
self.api_key = config.get("api_key", "") self.api_key = config.get("api_key", "")
self.api_version = config.get("api_version", "v1.1") self.api_version = config.get("api_version", "v1.1")
@@ -4,6 +4,7 @@ import json
import os import os
import yaml import yaml
from config.config_loader import get_project_dir from config.config_loader import get_project_dir
from config.manage_api_client import save_mem_local_short
short_term_memory_prompt = """ short_term_memory_prompt = """
@@ -72,6 +73,17 @@ short_term_memory_prompt = """
``` ```
""" """
short_term_memory_prompt_only_content = """
你是一个经验丰富的记忆总结者,擅长将对话内容进行总结摘要,遵循以下规则:
1、总结user的重要信息,以便在未来的对话中提供更个性化的服务
2、不要重复总结,不要遗忘之前记忆,除非原来的记忆超过了1800字内,否则不要遗忘、不要压缩用户的历史记忆
3、用户操控的设备音量、播放音乐、天气、退出、不想对话等和用户本身无关的内容,这些信息不需要加入到总结中
4、不要把设备操控的成果结果和失败结果加入到总结中,也不要把用户的一些废话加入到总结中
5、不要为了总结而总结,如果用户的聊天没有意义,请返回原来的历史记录也是可以的
6、只需要返回总结摘要,严格控制在1800字内
7、不要包含代码、xml,不需要解释、注释和说明,保存记忆时仅从对话提取信息,不要混入示例内容
"""
def extract_json_data(json_code): def extract_json_data(json_code):
start = json_code.find("```json") start = json_code.find("```json")
@@ -93,17 +105,26 @@ TAG = __name__
class MemoryProvider(MemoryProviderBase): class MemoryProvider(MemoryProviderBase):
def __init__(self, config): def __init__(self, config, summary_memory):
super().__init__(config) super().__init__(config)
self.short_momery = "" self.short_momery = ""
self.save_to_file = True
self.memory_path = get_project_dir() + "data/.memory.yaml" self.memory_path = get_project_dir() + "data/.memory.yaml"
self.load_memory() self.load_memory(summary_memory)
def init_memory(self, role_id, llm): def init_memory(
super().init_memory(role_id, llm) self, role_id, llm, summary_memory=None, save_to_file=True, **kwargs
self.load_memory() ):
super().init_memory(role_id, llm, **kwargs)
self.save_to_file = save_to_file
self.load_memory(summary_memory)
def load_memory(self, summary_memory):
# api获取到总结记忆后直接返回
if summary_memory or not self.save_to_file:
self.short_momery = summary_memory
return
def load_memory(self):
all_memory = {} all_memory = {}
if os.path.exists(self.memory_path): if os.path.exists(self.memory_path):
with open(self.memory_path, "r", encoding="utf-8") as f: with open(self.memory_path, "r", encoding="utf-8") as f:
@@ -134,7 +155,7 @@ class MemoryProvider(MemoryProviderBase):
msgStr += f"User: {msg.content}\n" msgStr += f"User: {msg.content}\n"
elif msg.role == "assistant": elif msg.role == "assistant":
msgStr += f"Assistant: {msg.content}\n" msgStr += f"Assistant: {msg.content}\n"
if len(self.short_momery) > 0: if self.short_momery and len(self.short_momery) > 0:
msgStr += "历史记忆:\n" msgStr += "历史记忆:\n"
msgStr += self.short_momery msgStr += self.short_momery
@@ -142,16 +163,20 @@ class MemoryProvider(MemoryProviderBase):
time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
msgStr += f"当前时间:{time_str}" msgStr += f"当前时间:{time_str}"
result = self.llm.response_no_stream(short_term_memory_prompt, msgStr) if self.save_to_file:
result = self.llm.response_no_stream(short_term_memory_prompt, msgStr)
json_str = extract_json_data(result) json_str = extract_json_data(result)
try: try:
json_data = json.loads(json_str) # 检查json格式是否正确 json.loads(json_str) # 检查json格式是否正确
self.short_momery = json_str self.short_momery = json_str
except Exception as e: self.save_memory_to_file()
print("Error:", e) except Exception as e:
print("Error:", e)
self.save_memory_to_file() else:
result = self.llm.response_no_stream(
short_term_memory_prompt_only_content, msgStr
)
save_mem_local_short(self.role_id, result)
logger.bind(tag=TAG).info(f"Save memory successful - Role: {self.role_id}") logger.bind(tag=TAG).info(f"Save memory successful - Role: {self.role_id}")
return self.short_momery return self.short_momery
@@ -1,18 +1,20 @@
''' """
不使用记忆,可以选择此模块 不使用记忆,可以选择此模块
''' """
from ..base import MemoryProviderBase, logger from ..base import MemoryProviderBase, logger
TAG = __name__ TAG = __name__
class MemoryProvider(MemoryProviderBase): class MemoryProvider(MemoryProviderBase):
def __init__(self, config): def __init__(self, config, summary_memory=None):
super().__init__(config) super().__init__(config)
async def save_memory(self, msgs): async def save_memory(self, msgs):
logger.bind(tag=TAG).debug("nomem mode: No memory saving is performed.") logger.bind(tag=TAG).debug("nomem mode: No memory saving is performed.")
return None return None
async def query_memory(self, query: str)-> str: async def query_memory(self, query: str) -> str:
logger.bind(tag=TAG).debug("nomem mode: No memory query is performed.") logger.bind(tag=TAG).debug("nomem mode: No memory query is performed.")
return "" return ""
@@ -85,16 +85,22 @@ class TTSProvider(TTSProviderBase):
def __init__(self, config, delete_audio_file): def __init__(self, config, delete_audio_file):
super().__init__(config, delete_audio_file) super().__init__(config, delete_audio_file)
self.reference_id = config.get("reference_id") self.reference_id = (
None if not config.get("reference_id") else config.get("reference_id")
)
self.reference_audio = parse_string_to_list(config.get("reference_audio")) self.reference_audio = parse_string_to_list(config.get("reference_audio"))
self.reference_text = parse_string_to_list(config.get("reference_text")) self.reference_text = parse_string_to_list(config.get("reference_text"))
self.format = config.get("format", "wav") self.format = config.get("response_format", "wav")
self.api_key = config.get("api_key", "YOUR_API_KEY") self.api_key = config.get("api_key", "YOUR_API_KEY")
have_key = check_model_key("FishSpeech TTS", self.api_key) have_key = check_model_key("FishSpeech TTS", self.api_key)
if not have_key: if not have_key:
return return
self.normalize = config.get("normalize", True) self.normalize = str(config.get("normalize", True)).lower() in (
"true",
"1",
"yes",
)
# 处理空字符串的情况 # 处理空字符串的情况
channels = config.get("channels", "1") channels = config.get("channels", "1")
+2 -1
View File
@@ -75,7 +75,8 @@ class Dialogue:
if system_message: if system_message:
enhanced_system_prompt = ( enhanced_system_prompt = (
f"{system_message.content}\n\n" f"相关记忆:\n{memory_str}" f"{system_message.content}\n\n"
f"以下是用户的历史记忆:\n```\n{memory_str}\n```"
) )
dialogue.append({"role": "system", "content": enhanced_system_prompt}) dialogue.append({"role": "system", "content": enhanced_system_prompt})
+1 -2
View File
@@ -319,6 +319,7 @@ def initialize_modules(
modules["memory"] = memory.create_instance( modules["memory"] = memory.create_instance(
memory_type, memory_type,
config["Memory"][select_memory_module], config["Memory"][select_memory_module],
config.get('summaryMemory', None),
) )
logger.bind(tag=TAG).info(f"初始化组件: memory成功 {select_memory_module}") logger.bind(tag=TAG).info(f"初始化组件: memory成功 {select_memory_module}")
@@ -930,7 +931,6 @@ def check_vad_update(before_config, new_config):
if "type" not in new_config["VAD"][new_vad_module] if "type" not in new_config["VAD"][new_vad_module]
else new_config["VAD"][new_vad_module]["type"] else new_config["VAD"][new_vad_module]["type"]
) )
print(f"前vad:{current_vad_type},后vad:{new_vad_type}")
update_vad = current_vad_type != new_vad_type update_vad = current_vad_type != new_vad_type
return update_vad return update_vad
@@ -954,6 +954,5 @@ def check_asr_update(before_config, new_config):
if "type" not in new_config["ASR"][new_asr_module] if "type" not in new_config["ASR"][new_asr_module]
else new_config["ASR"][new_asr_module]["type"] else new_config["ASR"][new_asr_module]["type"]
) )
print(f"前asr:{current_asr_type},后asr:{new_asr_type}")
update_asr = current_asr_type != new_asr_type update_asr = current_asr_type != new_asr_type
return update_asr return update_asr
@@ -7,6 +7,15 @@
"当前支持stdio/sse两种模式。" "当前支持stdio/sse两种模式。"
], ],
"mcpServers": { "mcpServers": {
"Home Assistant": {
"command": "mcp-proxy",
"args": [
"http://YOUR_HA_HOST/mcp_server/sse"
],
"env": {
"API_ACCESS_TOKEN": "YOUR_API_ACCESS_TOKEN"
}
},
"filesystem": { "filesystem": {
"command": "npx", "command": "npx",
"args": [ "args": [
@@ -107,8 +107,8 @@ WEATHER_CODE_MAP = {
} }
def fetch_city_info(location, api_key): def fetch_city_info(location, api_key, api_host):
url = f"https://geoapi.qweather.com/v2/city/lookup?key={api_key}&location={location}&lang=zh" url = f"https://{api_host}/geo/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
@@ -151,7 +151,8 @@ def parse_weather_info(soup):
@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_host = conn.config["plugins"]["get_weather"].get("api_host", "mj7p3y7naa.re.qweatherapi.com")
api_key = conn.config["plugins"]["get_weather"].get("api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da")
default_location = conn.config["plugins"]["get_weather"]["default_location"] default_location = conn.config["plugins"]["get_weather"]["default_location"]
client_ip = conn.client_ip client_ip = conn.client_ip
# 优先使用用户提供的location参数 # 优先使用用户提供的location参数
@@ -164,8 +165,7 @@ def get_weather(conn, location: str = None, lang: str = "zh_CN"):
else: else:
# 若IP解析失败或无IP,使用默认位置 # 若IP解析失败或无IP,使用默认位置
location = default_location location = default_location
city_info = fetch_city_info(location, api_key, api_host)
city_info = fetch_city_info(location, api_key)
if not city_info: if not city_info:
return ActionResponse( return ActionResponse(
Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None