Compare commits

...
187 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
欣南科技andGitHub 3f7b7da184 Merge pull request #1173 from xinnan-tech/hot-fix
update:修复立创1.6.2固件一直聆听中bug
2025-05-10 01:15:38 +08:00
hrz c0020c8769 update:修复立创1.6.2固件一直聆听中bug 2025-05-10 01:14:51 +08:00
hrzandGitHub 1c5e678cef Merge pull request #1130 from xinnan-tech/py-test
Py test
2025-05-09 18:09:31 +08:00
hrzandGitHub 3ab1b8ebc9 Merge branch 'main' into py-test 2025-05-09 18:08:48 +08:00
hrz 7635c68ccd update:增加aioconsole依赖 2025-05-09 18:01:36 +08:00
hrzandGitHub a3b562b895 Merge pull request #1113 from hsoftxl/tts-bug
tts 失败重试bug修复
2025-05-09 17:32:47 +08:00
欣南科技andGitHub 94d1493195 Merge pull request #1165 from xinnan-tech/hot-fix
fix:intent_llm的functions也需要返回成数组
2025-05-09 16:39:32 +08:00
hrz 3a5bbe32b9 fix:intent_llm的functions也需要返回成数组 2025-05-09 16:15:26 +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
欣南科技andGitHub 31328e092e Merge pull request #1164 from xinnan-tech/add_news
整合新的新闻插件
2025-05-09 15:26:58 +08:00
hrzandGitHub 29671f48a8 Merge branch 'main' into add_news 2025-05-09 15:25:15 +08:00
hrz 61c2782491 update:智控台,intent_llmM供应器增加functions输入框 2025-05-09 15:23:12 +08:00
hrz 15263b5223 update:更新英文版说明 2025-05-09 14:26:49 +08:00
hrz 557d1ee5b0 update:取消设备一连接就强制升级,改成绑定后再根据升级策略升级 2025-05-09 14:07:46 +08:00
hrz 777265c730 fix:参数管理保存json类型出错bug 2025-05-09 14:00:42 +08:00
hrz e76a8d547b update:使用intent_llm中的LLM进行工具内容回复 2025-05-09 13:45:01 +08:00
hrz ff98f84f18 update:intent_llm加载动态插件 2025-05-09 11:39:32 +08:00
hrzandGitHub d0b4fa5b28 Merge branch 'main' into py-test 2025-05-09 09:52:26 +08:00
hrzandGitHub 91b80f7c49 Merge pull request #1155 from xinnan-tech/web-pages-modifiy
页面优化
2025-05-09 09:51:27 +08:00
hrz 47f409246d update:intent_llm暂时不引用新闻插件,下一个版本改造intent_llm,让它能动态获取functioncall 2025-05-08 18:25:02 +08:00
hrz be1ff83297 update:通过pip来安装markitdown依赖 2025-05-08 18:23:37 +08:00
hrz 297c9e0085 update:重命名新闻插件,以平台来源来命名 2025-05-08 18:23:06 +08:00
Sakura-RanChen ec95917b4d 页面优化 2025-05-08 17:57:11 +08:00
hrzandGitHub 98bfe863fe 修复test_page.html没有hello消息,导致缺失audio_format的bug (#1151) 2025-05-08 15:08:11 +08:00
hrzandGitHub af3d00662e Merge pull request #1147 from xinnan-tech/fix-web-doubaoasr
update:更新版本号
2025-05-08 12:10:39 +08:00
欣南科技andGitHub 39803fb0df Merge pull request #1146 from xinnan-tech/fix-web-doubaoasr
修复:智控台豆包ASR缺少热词引发的bug
2025-05-08 12:09:11 +08:00
hrz 4dad5ea6c1 update:智控台添加百度ASR
修复:智控台豆包ASR缺少热词引发的bug
2025-05-08 12:07:50 +08:00
hrzandGitHub ba8cb8c6f8 Merge pull request #1125 from xinnan-tech/pcm
客户端上传编码为PCM时,服务端下发PCM格式的音频
2025-05-08 11:31:49 +08:00
hrz 64f10b28e7 update:合并main分支 2025-05-08 11:31:12 +08:00
hrzandGitHub 7f34447ace Merge branch 'main' into pcm 2025-05-08 11:18:12 +08:00
hrz c2e000f937 add:asr赋值audio_format 2025-05-08 11:11:28 +08:00
hrzandGitHub 831ab6d99b Merge pull request #1005 from JavaZeroo/add_news
feat: 添加多个新闻源,并修复大模型意图识别没有处理ActionResponse的问题
2025-05-08 10:01:33 +08:00
hrzandGitHub b246d9e567 fix:selected_module可能为空的bug (#1144)
* update:优化时间显示

* update:优化时间显示

* fix:selected_module可能为空的bug
2025-05-08 09:35:46 +08:00
hrzandGitHub 919c2ffd46 update:优化时间显示 (#1143)
* update:优化时间显示

* update:优化时间显示
2025-05-07 23:37:38 +08:00
hrzandGitHub e6d63a811e update:优化时间显示 (#1139) 2025-05-07 23:21:47 +08:00
hrzandGitHub 92227098b7 Merge pull request #1066 from xinnan-tech/manager-api-lastConnectedAtIsNull-BUG
修复了"获取用户智能体列表"中lastConnectedAt为null的bug
2025-05-07 22:57:08 +08:00
hrz 571080c1d6 update:优化最近对话时间 2025-05-07 22:56:51 +08:00
hrzandGitHub d07feb837d Merge branch 'main' into manager-api-lastConnectedAtIsNull-BUG 2025-05-07 22:45:51 +08:00
JavaZeroo 1c823c4255 feat: add news retrieval functionality and register new function 2025-05-07 21:15:06 +08:00
欣南科技andGitHub 63e378524b Merge pull request #1133 from xinnan-tech/hot-fix
update:优化日志对象
2025-05-07 18:06:57 +08:00
hrz ea5f54e421 update:优化日志对象 2025-05-07 18:06:13 +08:00
CGD ee18fbebae fix:修复“服务器运行时未处理标准输入(stdin),导致输入被缓冲,直到程序终止后才释放”的问题 2025-05-07 16:20:43 +08:00
CGD fa56d06e0c update:优化重启服务器功能 2025-05-07 16:17:50 +08:00
CGDandGitHub 72f7514114 Merge pull request #1129 from xinnan-tech/py-timeout-fix
Py timeout fix
2025-05-07 15:26:24 +08:00
Sakura-RanChen 44e1f00ffc fix:增加日志,方便意图识别 2025-05-07 15:17:34 +08:00
CGD e7054ea13f update:重启服务器功能功能 2025-05-07 14:36:08 +08:00
Sakura-RanChen 4a3ac2cfcd no message 2025-05-07 14:35:41 +08:00
Sakura-RanChen bbc31e01c4 fix:tts队列统一元组 2025-05-07 14:31:59 +08:00
玄凤科技 ba86b34a8c pcm模式,同步其他asr 2025-05-07 11:34:29 +08:00
剑雨 770a198772 获取设备最大的最近连接时间。改为在数据库里排序好后返回给系统,缓存时间修改为2分钟
--DeviceDao.java 修改方法返回值
--DeviceDao.xml 修改sql,在数据库里排序返回
--DeviceServiceImpl.java 修改缓存时间为2分钟
2025-05-07 11:04:14 +08:00
欣南科技andGitHub a26bee3696 Py update config (#1120)
* update:优化获取默认配置

* update:优化未绑定用户的连接

* update:修复智控台模式下,所选模块的日志名称

* update:优化参数配置敏感密钥的显示方式

* update:更新服务器配置并重新初始化组件
2025-05-07 09:15:52 +08:00
hrzandGitHub abb8f4f963 Merge pull request #1115 from xinnan-tech/py_tts_timeout
update: tts超时导致的文本索引混乱
2025-05-07 09:12:35 +08:00
hrz 43d2adff70 update:更新服务器配置并重新初始化组件 2025-05-07 09:11:59 +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
Junsen HuangandGitHub e2dffea423 Merge pull request #1104 from CaixyPromise/feature/wait-exit-fix
fix(app): 修复app.py内的wait_for_exit(),以此解决Windows环境下手动退出时,进程阻塞卡死的问题。
2025-05-07 00:48:27 +08:00
Junsen HuangandGitHub 3d5eaba46f Merge pull request #1105 from CaixyPromise/feature/mcp-exit-fix
fix(MCP): 重构MCPClient为后台协程 + AsyncExitStack管理,解决进程退出时“Attempted to exit cancel scope in a different task”错误
2025-05-07 00:47:37 +08:00
caixypromise 1bb8fbd56f resolve: merge upstream/main into feature/mcp-exit-fix and fix conflicts 2025-05-07 00:39:21 +08:00
hrz b699886953 update:优化参数配置敏感密钥的显示方式 2025-05-06 17:30:39 +08:00
hrz 12c957d48b update:修复智控台模式下,所选模块的日志名称 2025-05-06 17:14:58 +08:00
玄凤科技andGitHub 48e890c1b1 Merge pull request #1106 from kevin1sMe/feat-mcp
feat: MCP server支持使用sse模式
2025-05-06 15:38:11 +08:00
Sakura-RanChen 05331f001a update: tts超时导致的文本索引混乱 2025-05-06 15:15:27 +08:00
玄凤科技 bde260b330 PCM音频模式 2025-05-06 14:57:29 +08:00
XL 59ef51ea20 tts 失败重试bug修复 2025-05-06 14:19:05 +08:00
hrz aa77bfdfc4 update:优化未绑定用户的连接 2025-05-06 13:10:26 +08:00
hrz a2baef8911 update:优化获取默认配置 2025-05-06 13:09:43 +08:00
kevin1sMe 3fcfb65d45 update: example 2025-05-04 23:37:17 +08:00
kevin1sMe 8f266bea0d feat: MCP server支持使用sse模式 2025-05-04 23:33:07 +08:00
caixypromise 0a765f4aac fix(MCP): 重构MCPClient为后台协程 + AsyncExitStack管理,解决进程退出时的“Attempted to exit cancel scope in a different task”错误
# 变更
----
- 将所有stdio_client与ClientSession的创建/销毁都放到同一个后台 task 中
- 使用AsyncExitStack托管异步资源,cleanup时在同一task内执行exit_stack.aclose()
- 外部只通过事件通知后台task退出,避免跨协程调用cancel-scope异常
2025-05-04 23:27:47 +08:00
caixypromise ee3f0555d1 fix(app): 重写app.py内的wait_for_exit(),以此解决Windows环境下手动退出时,进程阻塞卡死的问题。
影响
----
- Ctrl‑C/kill退出时不再卡住,资源完全释放,
- Windows 与 Unix 行为一致,改动不影响正常业务逻辑。
2025-05-04 23:26:39 +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
hrzandGitHub 9fc1285c09 Merge pull request #1101 from xinnan-tech/hot-fix
update:删除智能体时删除聊天记录
2025-05-04 20:34:49 +08:00
欣南科技andGitHub e6e8ccee50 Merge pull request #1100 from xinnan-tech/hot-fix
Hot fix
2025-05-04 20:15:07 +08:00
hrz f9632af016 创建智能体返回智能体id 2025-05-04 20:13:32 +08:00
hrz 81359bf419 修复智控台音频播放权限bug 2025-05-04 20:03:13 +08:00
欣南科技andGitHub a144570ed7 Merge pull request #1097 from xinnan-tech/chat-history-ui
Chat history UI
2025-05-04 15:45:11 +08:00
hrz 0732b72e8f update:聊天记录音频播放 2025-05-04 15:43:28 +08:00
欣南科技andGitHub 8332445a52 Merge pull request #1096 from xinnan-tech/chat-history-ui
Chat history UI
2025-05-04 13:16:58 +08:00
hrz c9111d1bfc update:优化websocket地址验证 2025-05-04 13:15:49 +08:00
hrz e7d999278d update:优化显示 2025-05-04 13:00:56 +08:00
hrz 4043d20ea6 update:聊天记录展现 2025-05-04 12:53:36 +08:00
hrzandGitHub afe88ccfd6 Merge pull request #1091 from xinnan-tech/asr_code_clear
规范asr部分的代码,添加百度asr支持
2025-05-04 01:21:00 +08:00
hrz 56c3a04809 update:优化百度ASR文档链接 2025-05-04 01:19:54 +08:00
hrz 4530345a1f fix:设置有效的时区 2025-05-04 00:51:51 +08:00
hrzandGitHub 4a89ec8515 Merge pull request #1092 from kevin1sMe/fix-doubao-tts
fix: doubao tts token
2025-05-04 00:46:53 +08:00
欣南科技andGitHub cb5f1c6485 Merge pull request #1093 from xinnan-tech/hot-fix
add:获取聊天记录API
2025-05-03 23:49:02 +08:00
kevin1sMe 15c0677a4b fix: doubao tts token 2025-05-03 23:33:32 +08:00
hrz dc68b8148a add:获取聊天记录API 2025-05-03 23:30:39 +08:00
王华侨 1976034f12 添加百度asr支持 2025-05-03 22:21:22 +08:00
王华侨 727621fdac 规范asr部分的代码 2025-05-03 22:20:50 +08:00
欣南科技andGitHub 0f3806d358 Merge pull request #1086 from xinnan-tech/hot-fix
update:上报聊天音频
2025-05-02 16:29:48 +08:00
hrz dfb2bf3923 update:上报聊天音频 2025-05-02 16:17:18 +08:00
剑雨 b4944f23ef 添加了一个新的redis:key
--RedisKeys.java 获取设备最近最久时间的key
2025-04-30 11:08:59 +08:00
剑雨 09c17b2dc5 优化方法,把获取最近最后时间获取,放在获取设备数量前面前,可以减少一次sql查询,因为在获取时间的时候,已经顺便缓存的设备数量了
--AgentServiceImpl.java 优化:减少sql查询
2025-04-30 11:07:53 +08:00
剑雨 6f7ff9f858 优化获取这个智能体设备理的最近的最后连接时间方法,缓存时间和设备数量
--DeviceServiceImpl.java 优化方法
2025-04-30 11:05:58 +08:00
剑雨 7d74e853ae 修复智能体最近的最后连接时间为空的bug
--AgentServiceImpl.java 修复bug
2025-04-30 10:46:34 +08:00
剑雨 1d627d570d 添加获取这个智能体设备理的最近的最后连接时间定义和实现
--DeviceService.java 方法定义
--DeviceServiceImpl.java 方法实现
2025-04-30 10:45:49 +08:00
剑雨 9b0088f4f0 添加获取获取此智能体全部设备的最后连接时间,方法定义和sql,不使用mysql-plus的方法,是为了减少数据库数据传输,因为只需要最后连接时间字段
--DeviceDao.java 方法定义
--DeviceDao.xml sql
2025-04-30 10:44:54 +08:00
剑雨 f92c0313a6 添加保存用户测试方法和模拟设备连连接过来的方法,方便新开发者调试
--DeviceTest.java
2025-04-30 09:41:38 +08:00
剑雨 b9fc0da215 Merge remote-tracking branch 'origin/main' 2025-04-29 10:32:22 +08:00
剑雨 39fe057f9f Merge remote-tracking branch 'origin/main' 2025-04-17 15:56:40 +08:00
剑雨 f55d6c2e60 Merge remote-tracking branch 'origin/main' 2025-04-03 10:22:13 +08:00
142 changed files with 6953 additions and 1691 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="./README.md">English</a> <a href="./README_en.md">English</a>
· <a href="./docs/FAQ.md">常见问题</a> · <a href="./docs/FAQ.md">常见问题</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">反馈问题</a> · <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">反馈问题</a>
· <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a> · <a href="./README.md#%E9%83%A8%E7%BD%B2%E6%96%87%E6%A1%A3">部署文档</a>
+273
View File
@@ -0,0 +1,273 @@
[![Banners](docs/images/banner1.png)](https://github.com/xinnan-tech/xiaozhi-esp32-server)
<h1 align="center">Xiaozhi Backend Service xiaozhi-esp32-server</h1>
<p align="center">
This project provides backend services for the open-source smart hardware project
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a><br/>
Implemented using Python, Java, and Vue according to the <a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">Xiaozhi Communication Protocol</a><br/>
Helping you quickly set up your Xiaozhi server
</p>
<p align="center">
<a href="./README.md">中文</a>
· <a href="./docs/FAQ.md">FAQ</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">Report Issues</a>
· <a href="./README_ed.md#deployment-documentation">Deployment Guide</a>
· <a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">Release Notes</a>
</p>
<p align="center">
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/releases">
<img alt="GitHub Contributors" src="https://img.shields.io/github/v/release/xinnan-tech/xiaozhi-esp32-server?logo=docker" />
</a>
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/graphs/contributors">
<img alt="GitHub Contributors" src="https://img.shields.io/github/contributors/xinnan-tech/xiaozhi-esp32-server?logo=github" />
</a>
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/issues">
<img alt="Issues" src="https://img.shields.io/github/issues/xinnan-tech/xiaozhi-esp32-server?color=0088ff" />
</a>
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/pulls">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/xinnan-tech/xiaozhi-esp32-server?color=0088ff" />
</a>
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/LICENSE">
<img alt="GitHub pull requests" src="https://img.shields.io/badge/license-MIT-white?labelColor=black" />
</a>
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server">
<img alt="stars" src="https://img.shields.io/github/stars/xinnan-tech/xiaozhi-esp32-server?color=ffcb47&labelColor=black" />
</a>
</p>
---
## Target Users 👥
This project requires ESP32 hardware devices. If you have purchased ESP32-related hardware, successfully connected to Brother Xia's backend service, and want to set up your own `xiaozhi-esp32` backend service, then this project is perfect for you.
Want to see it in action? Check out these videos 🎥
<table>
<tr>
<td>
<a href="https://www.bilibili.com/video/BV1FMFyejExX" target="_blank">
<picture>
<img alt="Xiaozhi esp32 connecting to custom backend model" src="docs/images/demo1.png" />
</picture>
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV1CDKWemEU6" target="_blank">
<picture>
<img alt="Custom voice" src="docs/images/demo2.png" />
</picture>
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV12yA2egEaC" target="_blank">
<picture>
<img alt="Cantonese communication" src="docs/images/demo3.png" />
</picture>
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV1pNXWYGEx1" target="_blank">
<picture>
<img alt="Home appliance control" src="docs/images/demo5.png" />
</picture>
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV1kgA2eYEQ9" target="_blank">
<picture>
<img alt="Lowest cost configuration" src="docs/images/demo4.png" />
</picture>
</a>
</td>
</tr>
<tr>
<td>
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
<picture>
<img alt="Custom voice" src="docs/images/demo6.png" />
</picture>
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV1VC96Y5EMH" target="_blank">
<picture>
<img alt="Music playback" src="docs/images/demo7.png" />
</picture>
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV1Z8XuYZEAS" target="_blank">
<picture>
<img alt="Weather plugin" src="docs/images/demo8.png" />
</picture>
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV178XuYfEpi" target="_blank">
<picture>
<img alt="IOT device control" src="docs/images/demo9.png" />
</picture>
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV17LXWYvENb" target="_blank">
<picture>
<img alt="News broadcast" src="docs/images/demo0.png" />
</picture>
</a>
</td>
</tr>
</table>
---
## Warning ⚠️
1. This project is open-source software. This software has no commercial relationship with any third-party API service providers (including but not limited to speech recognition, large models, speech synthesis, and other platforms) and does not provide any form of guarantee for their service quality or financial security.
It is recommended that users prioritize service providers with relevant business licenses and carefully read their service agreements and privacy policies. This software does not host any account keys, does not participate in fund transfers, and does not bear the risk of recharge fund losses.
2. This project's functionality is not complete and has not passed network security testing. Please do not use it in production environments. If you deploy this project for learning in a public network environment, please ensure necessary protection measures are in place.
---
## Deployment Documentation
![Banners](docs/images/banner2.png)
This project offers two deployment methods. Please choose based on your specific needs:
#### 🚀 Deployment Method Selection
| Deployment Method | Features | Use Case | Docker Deployment Guide | Source Code Deployment Guide |
|---------|------|---------|---------|---------|
| **Simplified Installation** | Smart dialogue, IOT functionality, data stored in configuration files | Low-configuration environment, no database required | [Docker Server Only](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) | [Local Source Code Server Only](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)|
| **Full Module Installation** | Smart dialogue, IOT, OTA, Control Panel, data stored in database | Complete functionality experience |[Docker Full Module](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) | [Local Source Code Full Module](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) |
> 💡 Note: Below is the test platform deployed with the latest code. You can flash and test if needed. Concurrent users: 6, data cleared daily
```
Control Panel: https://2662r3426b.vicp.fun
Service Test Tool: https://2662r3426b.vicp.fun/test/
OTA Interface: https://2662r3426b.vicp.fun/xiaozhi/ota/
Websocket Interface: wss://2662r3426b.vicp.fun/xiaozhi/v1/
```
---
## Feature List ✨
### Implemented ✅
| Feature Module | Description |
|---------|------|
| Communication Protocol | Based on `xiaozhi-esp32` protocol, implements data interaction through WebSocket |
| Dialogue Interaction | Supports wake-up dialogue, manual dialogue, and real-time interruption. Auto-sleep after long periods of inactivity |
| Intent Recognition | Supports LLM intent recognition, function call, reducing hard-coded intent judgment |
| Multi-language Recognition | Supports Mandarin, Cantonese, English, Japanese, Korean (default using FunASR) |
| LLM Module | Supports flexible LLM module switching, default using ChatGLMLLM, also supports Ali Bailing, DeepSeek, Ollama, etc. |
| TTS Module | Supports EdgeTTS (default), Volcano Engine Doubao TTS, and other TTS interfaces for speech synthesis |
| Memory Function | Supports ultra-long memory, local summary memory, and no memory modes for different scenarios |
| IOT Function | Supports managing registered device IOT functionality, intelligent IoT control based on dialogue context |
| Control Panel | Provides web management interface, supports agent management, user management, system configuration, etc. |
### In Development 🚧
To learn about specific development progress, [click here](https://github.com/users/xinnan-tech/projects/3)
If you're a software developer, here's an [Open Letter to Developers](docs/contributor_open_letter.md). Welcome to join!
---
## Product Ecosystem 👬
Xiaozhi is an ecosystem. When using this product, you might want to check out other excellent projects in this ecosystem:
| Project Name | Project Link | Description |
|:---------------------|:--------|:--------|
| Xiaozhi Android Client | [xiaozhi-android-client](https://github.com/TOM88812/xiaozhi-android-client) | A Flutter-based Android and iOS voice dialogue application supporting real-time voice interaction and text dialogue |
| Xiaozhi PC Client | [py-xiaozhi](https://github.com/Huang-junsen/py-xiaozhi) | A Python-based AI client that allows you to experience Xiaozhi AI functionality through code without physical hardware |
| Xiaozhi Java Server | [xiaozhi-esp32-server-java](https://github.com/joey-zhou/xiaozhi-esp32-server-java) | A Java-based open-source project providing complete backend service solutions |
---
## Supported Platforms/Components 📋
### LLM Language Models
| Usage Method | Supported Platforms | Free Platforms |
|:---:|:---:|:---:|
| openai API | Ali Bailing, Volcano Engine Doubao, DeepSeek, ChatGLM, Gemini | ChatGLM, Gemini |
| ollama API | Ollama | - |
| dify API | Dify | - |
| fastgpt API | Fastgpt | - |
| coze API | Coze | - |
Actually, any LLM supporting openai API calls can be integrated.
---
### TTS Speech Synthesis
| Usage Method | Supported Platforms | Free Platforms |
|:---:|:---:|:---:|
| API Calls | EdgeTTS, Volcano Engine Doubao TTS, Tencent Cloud, Aliyun TTS, CosyVoiceSiliconflow, TTS302AI, CozeCnTTS, GizwitsTTS, ACGNTTS, OpenAITTS | EdgeTTS, CosyVoiceSiliconflow(partial) |
| Local Service | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, MinimaxTTS | FishSpeech, GPT_SOVITS_V2, GPT_SOVITS_V3, MinimaxTTS |
---
### VAD Voice Activity Detection
| Type | Platform Name | Usage Method | Pricing | Notes |
|:---:|:---------:|:----:|:----:|:--:|
| VAD | SileroVAD | Local Use | Free | |
---
### ASR Speech Recognition
| Usage Method | Supported Platforms | Free Platforms |
|:---:|:---:|:---:|
| Local Use | FunASR, SherpaASR | FunASR, SherpaASR |
| API Calls | DoubaoASR, FunASRServer, TencentASR, AliyunASR | FunASRServer |
---
### Memory Storage
| Type | Platform Name | Usage Method | Pricing | Notes |
|:------:|:---------------:|:----:|:---------:|:--:|
| Memory | mem0ai | API Calls | 1000 calls/month quota | |
| Memory | mem_local_short | Local Summary | Free | |
---
### Intent Recognition
| Type | Platform Name | Usage Method | Pricing | Notes |
|:------:|:-------------:|:----:|:-------:|:---------------------:|
| Intent | intent_llm | API Calls | Based on LLM pricing | Uses large model for intent recognition, highly versatile |
| Intent | function_call | API Calls | Based on LLM pricing | Uses large model function calls for intent, fast and effective |
---
## Acknowledgments 🙏
| Logo | Project/Company | Description |
|:---:|:---:|:---|
| <img src="./docs/images/logo_bailing.png" width="160"> | [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) | This project was inspired by [Bailing Voice Dialogue Robot](https://github.com/wwbin2017/bailing) and implemented based on it |
| <img src="./docs/images/logo_tenclass.png" width="160"> | [Tenclass](https://www.tenclass.com/) | Thanks to [Tenclass](https://www.tenclass.com/) for developing standard communication protocols, multi-device compatibility solutions, and high-concurrency scenario practices for the Xiaozhi ecosystem; providing comprehensive technical documentation support for this project |
| <img src="./docs/images/logo_xuanfeng.png" width="160"> | [Xuanfeng Technology](https://github.com/Eric0308) | Thanks to [Xuanfeng Technology](https://github.com/Eric0308) for contributing function call framework, MCP communication protocol, and plugin call mechanism implementation code, significantly improving front-end device (IoT) interaction efficiency and functional extensibility through standardized instruction scheduling system and dynamic expansion capabilities |
| <img src="./docs/images/logo_huiyuan.png" width="160"> | [Huiyuan Design](http://ui.kwd988.net/) | Thanks to [Huiyuan Design](http://ui.kwd988.net/) for providing professional visual solutions for this project, empowering product user experience with their design experience serving over a thousand enterprises |
| <img src="./docs/images/logo_qinren.png" width="160"> | [Xi'an Qinren Information Technology](https://www.029app.com/) | Thanks to [Xi'an Qinren Information Technology](https://www.029app.com/) for deepening this project's visual system, ensuring consistency and extensibility of overall design style in multi-scenario applications |
<a href="https://star-history.com/#xinnan-tech/xiaozhi-esp32-server&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=xinnan-tech/xiaozhi-esp32-server&type=Date" />
</picture>
</a>
+1 -1
View File
@@ -213,7 +213,7 @@ CREATE DATABASE xiaozhi_esp32_server CHARACTER SET utf8mb4 COLLATE utf8mb4_unico
如果还没有MySQL,你可以通过docker安装mysql 如果还没有MySQL,你可以通过docker安装mysql
``` ```
docker run --name xiaozhi-esp32-server-db -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -e MYSQL_DATABASE=xiaozhi_esp32_server -e MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci" -d mysql:latest docker run --name xiaozhi-esp32-server-db -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -e MYSQL_DATABASE=xiaozhi_esp32_server -e MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci" -e TZ=Asia/Shanghai -d mysql:latest
``` ```
## 2.安装redis ## 2.安装redis
+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

+1 -1
View File
@@ -16,4 +16,4 @@ https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh
# manager-web 、manager-api接口协议 # manager-web 、manager-api接口协议
https://2662r3426b.vicp.fun/xiaozhi/v1/doc.html https://2662r3426b.vicp.fun/xiaozhi/doc.html
+4
View File
@@ -197,6 +197,10 @@
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies> </dependencies>
<!-- 阿里云maven仓库 --> <!-- 阿里云maven仓库 -->
@@ -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.3.13"; public static final String VERSION = "0.4.3";
} }
@@ -62,6 +62,13 @@ public class RedisKeys {
return "agent:device:count:" + id; return "agent:device:count:" + id;
} }
/**
* 获取智能体最后连接时间缓存key
*/
public static String getAgentDeviceLastConnectedAtById(String id) {
return "agent:device:lastConnected:" + id;
}
/** /**
* 获取系统配置缓存key * 获取系统配置缓存key
*/ */
@@ -103,4 +110,11 @@ public class RedisKeys {
public static String getDictDataByTypeKey(String dictType) { public static String getDictDataByTypeKey(String dictType) {
return "sys:dict:data:" + dictType; return "sys:dict:data:" + dictType;
} }
/**
* 获取智能体音频ID的缓存key
*/
public static String getAgentAudioIdKey(String uuid) {
return "agent:audio:id:" + uuid;
}
} }
@@ -3,8 +3,13 @@ package xiaozhi.modules.agent.controller;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions; import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -25,16 +30,24 @@ import jakarta.validation.Valid;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant; import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail; import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
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;
import xiaozhi.modules.agent.service.AgentChatAudioService;
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;
@@ -46,6 +59,9 @@ public class AgentController {
private final AgentService agentService; private final AgentService agentService;
private final AgentTemplateService agentTemplateService; private final AgentTemplateService agentTemplateService;
private final DeviceService deviceService; private final DeviceService deviceService;
private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService;
private final RedisUtils redisUtils;
@GetMapping("/list") @GetMapping("/list")
@Operation(summary = "获取用户智能体列表") @Operation(summary = "获取用户智能体列表")
@@ -80,7 +96,7 @@ public class AgentController {
@PostMapping @PostMapping
@Operation(summary = "创建智能体") @Operation(summary = "创建智能体")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
public Result<Void> save(@RequestBody @Valid AgentCreateDTO dto) { public Result<String> save(@RequestBody @Valid AgentCreateDTO dto) {
AgentEntity entity = ConvertUtils.sourceToTarget(dto, AgentEntity.class); AgentEntity entity = ConvertUtils.sourceToTarget(dto, AgentEntity.class);
// 获取默认模板 // 获取默认模板
@@ -95,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());
} }
@@ -108,13 +126,29 @@ public class AgentController {
// ID、智能体编码和排序会在Service层自动生成 // ID、智能体编码和排序会在Service层自动生成
agentService.insert(entity); agentService.insert(entity);
return new Result<>(); 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) {
@@ -152,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());
} }
@@ -167,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<>();
} }
@@ -178,6 +226,8 @@ public class AgentController {
public Result<Void> delete(@PathVariable String id) { public Result<Void> delete(@PathVariable String id) {
// 先删除关联的设备 // 先删除关联的设备
deviceService.deleteByAgentId(id); deviceService.deleteByAgentId(id);
// 删除关联的聊天记录
agentChatHistoryService.deleteByAgentId(id, true, true);
// 再删除智能体 // 再删除智能体
agentService.deleteById(id); agentService.deleteById(id);
return new Result<>(); return new Result<>();
@@ -192,4 +242,71 @@ public class AgentController {
return new Result<List<AgentTemplateEntity>>().ok(list); return new Result<List<AgentTemplateEntity>>().ok(list);
} }
@GetMapping("/{id}/sessions")
@Operation(summary = "获取智能体会话列表")
@RequiresPermissions("sys:role:normal")
@Parameters({
@Parameter(name = Constant.PAGE, description = "当前页码,从1开始", required = true),
@Parameter(name = Constant.LIMIT, description = "每页显示记录数", required = true),
})
public Result<PageData<AgentChatSessionDTO>> getAgentSessions(
@PathVariable("id") String id,
@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
params.put("agentId", id);
PageData<AgentChatSessionDTO> page = agentChatHistoryService.getSessionListByAgentId(params);
return new Result<PageData<AgentChatSessionDTO>>().ok(page);
}
@GetMapping("/{id}/chat-history/{sessionId}")
@Operation(summary = "获取智能体聊天记录")
@RequiresPermissions("sys:role:normal")
public Result<List<AgentChatHistoryDTO>> getAgentChatHistory(
@PathVariable("id") String id,
@PathVariable("sessionId") String sessionId) {
// 获取当前用户
UserDetail user = SecurityUser.getUser();
// 检查权限
if (!agentService.checkAgentPermission(id, user.getId())) {
return new Result<List<AgentChatHistoryDTO>>().error("没有权限查看该智能体的聊天记录");
}
// 查询聊天记录
List<AgentChatHistoryDTO> result = agentChatHistoryService.getChatHistoryBySessionId(id, sessionId);
return new Result<List<AgentChatHistoryDTO>>().ok(result);
}
@PostMapping("/audio/{audioId}")
@Operation(summary = "获取音频下载ID")
@RequiresPermissions("sys:role:normal")
public Result<String> getAudioId(@PathVariable("audioId") String audioId) {
byte[] audioData = agentChatAudioService.getAudio(audioId);
if (audioData == null) {
return new Result<String>().error("音频不存在");
}
String uuid = UUID.randomUUID().toString();
redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId);
return new Result<String>().ok(uuid);
}
@GetMapping("/play/{uuid}")
@Operation(summary = "播放音频")
public ResponseEntity<byte[]> playAudio(@PathVariable("uuid") String uuid) {
String audioId = (String) redisUtils.get(RedisKeys.getAgentAudioIdKey(uuid));
if (StringUtils.isBlank(audioId)) {
return ResponseEntity.notFound().build();
}
byte[] audioData = agentChatAudioService.getAudio(audioId);
if (audioData == null) {
return ResponseEntity.notFound().build();
}
redisUtils.delete(RedisKeys.getAgentAudioIdKey(uuid));
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"play.wav\"")
.body(audioData);
}
} }
@@ -1,7 +1,9 @@
package xiaozhi.modules.agent.dao; package xiaozhi.modules.agent.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity; import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
/** /**
@@ -13,4 +15,24 @@ import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
*/ */
@Mapper @Mapper
public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity> { public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity> {
/**
* 根据智能体ID删除音频
*
* @param agentId 智能体ID
*/
void deleteAudioByAgentId(String agentId);
/**
* 根据智能体ID删除聊天历史记录
*
* @param agentId 智能体ID
*/
void deleteHistoryByAgentId(String agentId);
/**
* 根据智能体ID删除音频ID
*
* @param agentId 智能体ID
*/
void deleteAudioIdByAgentId(String agentId);
} }
@@ -0,0 +1,28 @@
package xiaozhi.modules.agent.dto;
import java.util.Date;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 智能体聊天记录DTO
*/
@Data
@Schema(description = "智能体聊天记录")
public class AgentChatHistoryDTO {
@Schema(description = "创建时间")
private Date createdAt;
@Schema(description = "消息类型: 1-用户, 2-智能体")
private Byte chatType;
@Schema(description = "聊天内容")
private String content;
@Schema(description = "音频ID")
private String audioId;
@Schema(description = "MAC地址")
private String macAddress;
}
@@ -26,6 +26,6 @@ public class AgentChatHistoryReportDTO {
@Schema(description = "聊天内容", example = "你好呀") @Schema(description = "聊天内容", example = "你好呀")
@NotBlank @NotBlank
private String content; private String content;
@Schema(description = "文件数据(opus编码)", example = "") @Schema(description = "base64编码的opus音频数据", example = "")
private String opusDataBase64; private String audioBase64;
} }
@@ -0,0 +1,26 @@
package xiaozhi.modules.agent.dto;
import java.time.LocalDateTime;
import lombok.Data;
/**
* 智能体会话列表DTO
*/
@Data
public class AgentChatSessionDTO {
/**
* 会话ID
*/
private String sessionId;
/**
* 会话时间
*/
private LocalDateTime createdAt;
/**
* 聊天条数
*/
private Integer chatCount;
}
@@ -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;
@@ -67,12 +67,6 @@ public class AgentChatHistoryEntity {
@TableField(value = "audio_id") @TableField(value = "audio_id")
private String audioId; private String audioId;
/**
* 音频URL
*/
@TableField(value = "audio_url")
private String audioUrl;
/** /**
* 创建时间 * 创建时间
*/ */
@@ -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;
/** /**
* 语言编码 * 语言编码
*/ */
@@ -19,4 +19,12 @@ public interface AgentChatAudioService extends IService<AgentChatAudioEntity> {
* @return 音频ID * @return 音频ID
*/ */
String saveAudio(byte[] audioData); String saveAudio(byte[] audioData);
/**
* 获取音频数据
*
* @param audioId 音频ID
* @return 音频数据
*/
byte[] getAudio(String audioId);
} }
@@ -1,6 +1,13 @@
package xiaozhi.modules.agent.service; package xiaozhi.modules.agent.service;
import java.util.List;
import java.util.Map;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import xiaozhi.common.page.PageData;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity; import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
/** /**
@@ -11,4 +18,30 @@ import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
* @since 1.0.0 * @since 1.0.0
*/ */
public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity> { public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity> {
/**
* 根据智能体ID获取会话列表
*
* @param params 查询参数,包含agentId、page、limit
* @return 分页的会话列表
*/
PageData<AgentChatSessionDTO> getSessionListByAgentId(Map<String, Object> params);
/**
* 根据会话ID获取聊天记录列表
*
* @param agentId 智能体ID
* @param sessionId 会话ID
* @return 聊天记录列表
*/
List<AgentChatHistoryDTO> getChatHistoryBySessionId(String agentId, String sessionId);
/**
* 根据智能体ID删除聊天记录
*
* @param agentId 智能体ID
* @param deleteAudio 是否删除音频
* @param deleteText 是否删除文本
*/
void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText);
} }
@@ -8,36 +8,56 @@ import xiaozhi.common.service.BaseService;
import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
/**
* 智能体表处理service
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
public interface AgentService extends BaseService<AgentEntity> { public interface AgentService extends BaseService<AgentEntity> {
/** /**
* 管理员获取所有智能体列表(分页) * 获取管理员智能体列表
*
* @param params 查询参数
* @return 分页数据
*/ */
PageData<AgentEntity> adminAgentList(Map<String, Object> params); PageData<AgentEntity> adminAgentList(Map<String, Object> params);
/** /**
* 获取智能体详情 * 根据ID获取智能体
*
* @param id 智能体ID
* @return 智能体实体
*/ */
AgentEntity getAgentById(String id); AgentEntity getAgentById(String id);
/** /**
* 删除这个用户的所有 * 插入智能体
* *
* @param userId * @param entity 智能体实体
* @return 是否成功
*/
boolean insert(AgentEntity entity);
/**
* 根据用户ID删除智能体
*
* @param userId 用户ID
*/ */
void deleteAgentByUserId(Long userId); void deleteAgentByUserId(Long userId);
/** /**
* 获取用户智能体列表 * 获取用户智能体列表
* *
* @param userId * @param userId 用户ID
* @return * @return 智能体列表
*/ */
List<AgentDTO> getUserAgents(Long userId); List<AgentDTO> getUserAgents(Long userId);
/** /**
* 获取智能体设备数量 * 根据智能体ID获取设备数量
* *
* @param agentId 智能体ID * @param agentId 智能体ID
* @return 设备数量 * @return 设备数量
*/ */
@@ -50,4 +70,13 @@ public interface AgentService extends BaseService<AgentEntity> {
* @return 默认智能体信息,不存在时返回null * @return 默认智能体信息,不存在时返回null
*/ */
AgentEntity getDefaultAgentByMacAddress(String macAddress); AgentEntity getDefaultAgentByMacAddress(String macAddress);
/**
* 检查用户是否有权限访问智能体
*
* @param agentId 智能体ID
* @param userId 用户ID
* @return 是否有权限
*/
boolean checkAgentPermission(String agentId, Long userId);
} }
@@ -1,10 +1,17 @@
package xiaozhi.modules.agent.service.biz.impl; package xiaozhi.modules.agent.service.biz.impl;
import java.util.Base64;
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.RedisUtils;
import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO; import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity; import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
@@ -27,6 +34,7 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
private final AgentService agentService; private final AgentService agentService;
private final AgentChatHistoryService agentChatHistoryService; private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService; private final AgentChatAudioService agentChatAudioService;
private final RedisUtils redisUtils;
/** /**
* 处理聊天记录上报,包括文件上传和相关信息记录 * 处理聊天记录上报,包括文件上传和相关信息记录
@@ -41,30 +49,52 @@ 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地址查询对应的默认智能体,判断是否需要上报
String audioId = null;
if (report.getOpusDataBase64() != null && !report.getOpusDataBase64().isEmpty()) {
try {
// TODO: 需要考虑保留什么格式的音频数据,比如是opus还是wave
// byte[] audioData = Base64.getDecoder().decode(report.getOpusDataBase64());
// audioId = agentChatAudioService.saveAudio(audioData);
// log.info("音频数据保存成功,audioId={}", audioId);
} catch (Exception e) {
log.error("音频数据保存失败", e);
return false;
}
}
// 2. 组装上报数据
// 2.1 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress); AgentEntity agentEntity = agentService.getDefaultAgentByMacAddress(macAddress);
if (agentEntity == null) { if (agentEntity == null) {
return false; return Boolean.FALSE;
} }
String agentId = agentEntity.getId();
log.info("设备 {} 对应智能体 {} 上报", macAddress, agentEntity.getId());
// 2.2 构建聊天记录实体 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;
if (report.getAudioBase64() != null && !report.getAudioBase64().isEmpty()) {
try {
byte[] audioData = Base64.getDecoder().decode(report.getAudioBase64());
audioId = agentChatAudioService.saveAudio(audioData);
log.info("音频数据保存成功,audioId={}", audioId);
} catch (Exception e) {
log.error("音频数据保存失败", e);
return null;
}
}
return audioId;
}
/**
* 组装上报数据
*/
private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId) {
// 构建聊天记录实体
AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder() AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
.macAddress(macAddress) .macAddress(macAddress)
.agentId(agentId) .agentId(agentId)
@@ -74,8 +104,9 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
.audioId(audioId) .audioId(audioId)
.build(); .build();
// 3. 保存数据 // 保存数据
agentChatHistoryService.save(entity); agentChatHistoryService.save(entity);
return Boolean.TRUE;
log.info("设备 {} 对应智能体 {} 上报成功", macAddress, agentId);
} }
} }
@@ -25,4 +25,10 @@ public class AgentChatAudioServiceImpl extends ServiceImpl<AiAgentChatAudioDao,
save(entity); save(entity);
return entity.getId(); return entity.getId();
} }
@Override
public byte[] getAudio(String audioId) {
AgentChatAudioEntity entity = getById(audioId);
return entity != null ? entity.getAudio() : null;
}
} }
@@ -1,19 +1,93 @@
package xiaozhi.modules.agent.service.impl; package xiaozhi.modules.agent.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import java.time.LocalDateTime;
import org.springframework.stereotype.Service; import java.util.List;
import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao; import java.util.Map;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity; import java.util.stream.Collectors;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import org.springframework.stereotype.Service;
/** import org.springframework.transaction.annotation.Transactional;
* 智能体聊天记录表处理service {@link AgentChatHistoryService} impl
* import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
* @author Goody import com.baomidou.mybatisplus.core.metadata.IPage;
* @version 1.0, 2025/4/30 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
* @since 1.0.0 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
*/
@Service import xiaozhi.common.constant.Constant;
public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryDao, AgentChatHistoryEntity> implements AgentChatHistoryService { import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.ConvertUtils;
} import xiaozhi.modules.agent.dao.AiAgentChatHistoryDao;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
/**
* 智能体聊天记录表处理service {@link AgentChatHistoryService} impl
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
@Service
public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryDao, AgentChatHistoryEntity>
implements AgentChatHistoryService {
@Override
public PageData<AgentChatSessionDTO> getSessionListByAgentId(Map<String, Object> params) {
String agentId = (String) params.get("agentId");
int page = Integer.parseInt(params.get(Constant.PAGE).toString());
int limit = Integer.parseInt(params.get(Constant.LIMIT).toString());
// 构建查询条件
QueryWrapper<AgentChatHistoryEntity> wrapper = new QueryWrapper<>();
wrapper.select("session_id", "MAX(created_at) as created_at", "COUNT(*) as chat_count")
.eq("agent_id", agentId)
.groupBy("session_id")
.orderByDesc("created_at");
// 执行分页查询
Page<Map<String, Object>> pageParam = new Page<>(page, limit);
IPage<Map<String, Object>> result = this.baseMapper.selectMapsPage(pageParam, wrapper);
List<AgentChatSessionDTO> records = result.getRecords().stream().map(map -> {
AgentChatSessionDTO dto = new AgentChatSessionDTO();
dto.setSessionId((String) map.get("session_id"));
dto.setCreatedAt((LocalDateTime) map.get("created_at"));
dto.setChatCount(((Number) map.get("chat_count")).intValue());
return dto;
}).collect(Collectors.toList());
return new PageData<>(records, result.getTotal());
}
@Override
public List<AgentChatHistoryDTO> getChatHistoryBySessionId(String agentId, String sessionId) {
// 构建查询条件
QueryWrapper<AgentChatHistoryEntity> wrapper = new QueryWrapper<>();
wrapper.eq("agent_id", agentId)
.eq("session_id", sessionId)
.orderByAsc("created_at");
// 查询聊天记录
List<AgentChatHistoryEntity> historyList = list(wrapper);
// 转换为DTO
return ConvertUtils.sourceToTarget(historyList, AgentChatHistoryDTO.class);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
if (deleteAudio) {
baseMapper.deleteAudioByAgentId(agentId);
}
if (deleteAudio && !deleteText) {
baseMapper.deleteAudioIdByAgentId(agentId);
}
if (deleteText) {
baseMapper.deleteHistoryByAgentId(agentId);
}
}
}
@@ -6,13 +6,14 @@ import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import 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;
@@ -21,25 +22,20 @@ import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.model.service.ModelConfigService; import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.enums.SuperAdminEnum;
import xiaozhi.modules.timbre.service.TimbreService; import xiaozhi.modules.timbre.service.TimbreService;
@Service @Service
@AllArgsConstructor
public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> implements AgentService { public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> implements AgentService {
private final AgentDao agentDao; private final AgentDao agentDao;
private final TimbreService timbreModelService;
@Autowired private final ModelConfigService modelConfigService;
private TimbreService timbreModelService; private final RedisUtils redisUtils;
private final DeviceService deviceService;
@Autowired
private ModelConfigService modelConfigService;
@Autowired
private RedisUtils redisUtils;
public AgentServiceImpl(AgentDao agentDao) {
this.agentDao = agentDao;
}
@Override @Override
public PageData<AgentEntity> adminAgentList(Map<String, Object> params) { public PageData<AgentEntity> adminAgentList(Map<String, Object> params) {
@@ -51,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
@@ -98,12 +102,17 @@ 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()));
// 获取智能体最近的最后连接时长
dto.setLastConnectedAt(deviceService.getLatestLastConnectionTime(agent.getId()));
// 获取设备数量 // 获取设备数量
dto.setDeviceCount(getDeviceCountByAgentId(agent.getId())); dto.setDeviceCount(getDeviceCountByAgentId(agent.getId()));
return dto; return dto;
}).collect(Collectors.toList()); }).collect(Collectors.toList());
} }
@@ -138,4 +147,21 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
} }
return agentDao.getDefaultAgentByMacAddress(macAddress); return agentDao.getDefaultAgentByMacAddress(macAddress);
} }
@Override
public boolean checkAgentPermission(String agentId, Long userId) {
// 获取智能体信息
AgentEntity agent = getAgentById(agentId);
if (agent == null) {
return false;
}
// 如果是超级管理员,直接返回true
if (SecurityUser.getUser().getSuperAdmin() == SuperAdminEnum.YES.value()) {
return true;
}
// 检查是否是智能体的所有者
return userId.equals(agent.getUserId());
}
} }
@@ -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;
@@ -61,15 +62,16 @@ public class ConfigServiceImpl implements ConfigService {
// 构建模块配置 // 构建模块配置
buildModuleConfig( buildModuleConfig(
agent.getAgentName(), null,
null,
null, null,
null, null,
agent.getVadModelId(), agent.getVadModelId(),
agent.getAsrModelId(), agent.getAsrModelId(),
agent.getLlmModelId(), null,
agent.getTtsModelId(), null,
agent.getMemModelId(), null,
agent.getIntentModelId(), null,
result, result,
isCache); isCache);
@@ -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())) {
@@ -117,23 +130,12 @@ public class ConfigServiceImpl implements ConfigService {
if (alreadySelectedAsrModelId != null && alreadySelectedAsrModelId.equals(agent.getAsrModelId())) { if (alreadySelectedAsrModelId != null && alreadySelectedAsrModelId.equals(agent.getAsrModelId())) {
agent.setAsrModelId(null); agent.setAsrModelId(null);
} }
String alreadySelectedLlmModelId = (String) selectedModule.get("LLM");
if (alreadySelectedLlmModelId != null && alreadySelectedLlmModelId.equals(agent.getLlmModelId())) {
agent.setLlmModelId(null);
}
String alreadySelectedMemModelId = (String) selectedModule.get("Memory");
if (alreadySelectedMemModelId != null && alreadySelectedMemModelId.equals(agent.getMemModelId())) {
agent.setMemModelId(null);
}
String alreadySelectedIntentModelId = (String) selectedModule.get("Intent");
if (alreadySelectedIntentModelId != null && alreadySelectedIntentModelId.equals(agent.getIntentModelId())) {
agent.setIntentModelId(null);
}
// 构建模块配置 // 构建模块配置
buildModuleConfig( buildModuleConfig(
agent.getAgentName(), agent.getAgentName(),
agent.getSystemPrompt(), agent.getSystemPrompt(),
agent.getSummaryMemory(),
voice, voice,
agent.getVadModelId(), agent.getVadModelId(),
agent.getAsrModelId(), agent.getAsrModelId(),
@@ -234,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,
@@ -269,7 +272,8 @@ public class ConfigServiceImpl implements ConfigService {
if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) { if (intentLLMModelId != null && intentLLMModelId.equals(llmModelId)) {
intentLLMModelId = null; intentLLMModelId = null;
} }
} else if ("function_call".equals(map.get("type"))) { }
if (map.get("functions") != null) {
String functionStr = (String) map.get("functions"); String functionStr = (String) map.get("functions");
if (StringUtils.isNotBlank(functionStr)) { if (StringUtils.isNotBlank(functionStr)) {
String[] functions = functionStr.split("\\;"); String[] functions = functionStr.split("\\;");
@@ -293,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);
} }
} }
@@ -1,5 +1,7 @@
package xiaozhi.modules.device.dao; package xiaozhi.modules.device.dao;
import java.util.Date;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
@@ -8,4 +10,12 @@ import xiaozhi.modules.device.entity.DeviceEntity;
@Mapper @Mapper
public interface DeviceDao extends BaseMapper<DeviceEntity> { public interface DeviceDao extends BaseMapper<DeviceEntity> {
/**
* 获取此智能体全部设备的最后连接时间
*
* @param agentId 智能体id
* @return
*/
Date getAllLastConnectedAtByAgentId(String agentId);
} }
@@ -1,5 +1,6 @@
package xiaozhi.modules.device.service; package xiaozhi.modules.device.service;
import java.util.Date;
import java.util.List; import java.util.List;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
@@ -78,4 +79,13 @@ public interface DeviceService extends BaseService<DeviceEntity> {
* @return 激活码 * @return 激活码
*/ */
String geCodeByDeviceId(String deviceId); String geCodeByDeviceId(String deviceId);
/**
* 获取这个智能体设备理的最近的最后连接时间
* @param agentId 智能体id
* @return 返回设备最近的最后连接时间
*/
Date getLatestLastConnectionTime(String agentId);
} }
@@ -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,13 +138,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
DeviceEntity deviceById = getDeviceByMacAddress(macAddress); DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
if (deviceById == null || deviceById.getAutoUpdate() != 0) { // 设备未绑定,则返回当前上传的固件信息(不更新)以此兼容旧固件版本
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType(); if (deviceById == null) {
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type, DeviceReportRespDTO.Firmware firmware = new DeviceReportRespDTO.Firmware();
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion()); firmware.setVersion(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配置
@@ -149,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);
@@ -258,6 +284,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
return null; return null;
} }
@Override
public Date getLatestLastConnectionTime(String agentId) {
// 查询是否有缓存时间,有则返回
Date cachedDate = (Date) redisUtils.get(RedisKeys.getAgentDeviceLastConnectedAtById(agentId));
if (cachedDate != null) {
return cachedDate;
}
Date maxDate = deviceDao.getAllLastConnectedAtByAgentId(agentId);
if (maxDate != null) {
redisUtils.set(RedisKeys.getAgentDeviceLastConnectedAtById(agentId), maxDate);
}
return maxDate;
}
private String getDeviceCacheKey(String deviceId) { private String getDeviceCacheKey(String deviceId) {
String safeDeviceId = deviceId.replace(":", "_").toLowerCase(); String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String dataKey = String.format("ota:activation:data:%s", safeDeviceId); String dataKey = String.format("ota:activation:data:%s", safeDeviceId);
@@ -370,4 +410,4 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
} }
return 0; return 0;
} }
} }
@@ -1,6 +1,9 @@
package xiaozhi.modules.security.config; package xiaozhi.modules.security.config;
import jakarta.servlet.Filter; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.shiro.mgt.SecurityManager; import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager; import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor; import org.apache.shiro.spring.LifecycleBeanPostProcessor;
@@ -11,15 +14,13 @@ import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager; import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import jakarta.servlet.Filter;
import xiaozhi.modules.security.oauth2.Oauth2Filter; import xiaozhi.modules.security.oauth2.Oauth2Filter;
import xiaozhi.modules.security.oauth2.Oauth2Realm; import xiaozhi.modules.security.oauth2.Oauth2Realm;
import xiaozhi.modules.security.secret.ServerSecretFilter; import xiaozhi.modules.security.secret.ServerSecretFilter;
import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysParamsService;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/** /**
* Shiro的配置文件 * Shiro的配置文件
* Copyright (c) 人人开源 All rights reserved. * Copyright (c) 人人开源 All rights reserved.
@@ -85,6 +86,8 @@ 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("/**", "oauth2"); filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap); shiroFilter.setFilterChainDefinitionMap(filterMap);
@@ -1,5 +1,6 @@
package xiaozhi.modules.security.config; package xiaozhi.modules.security.config;
import java.text.SimpleDateFormat;
import java.util.List; import java.util.List;
import java.util.TimeZone; import java.util.TimeZone;
@@ -18,6 +19,15 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import xiaozhi.common.utils.DateUtils;
@Configuration @Configuration
public class WebMvcConfig implements WebMvcConfigurer { public class WebMvcConfig implements WebMvcConfigurer {
@@ -53,10 +63,29 @@ public class WebMvcConfig implements WebMvcConfigurer {
// 忽略未知属性 // 忽略未知属性
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// 日期格式转换 // 设置时区
// mapper.setDateFormat(new SimpleDateFormat(DateUtils.DATE_TIME_PATTERN));
mapper.setTimeZone(TimeZone.getTimeZone("GMT+8")); mapper.setTimeZone(TimeZone.getTimeZone("GMT+8"));
// 配置Java8日期时间序列化
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(java.time.LocalDateTime.class, new LocalDateTimeSerializer(
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_TIME_PATTERN)));
javaTimeModule.addSerializer(java.time.LocalDate.class, new LocalDateSerializer(
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_PATTERN)));
javaTimeModule.addSerializer(java.time.LocalTime.class,
new LocalTimeSerializer(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")));
javaTimeModule.addDeserializer(java.time.LocalDateTime.class, new LocalDateTimeDeserializer(
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_TIME_PATTERN)));
javaTimeModule.addDeserializer(java.time.LocalDate.class, new LocalDateDeserializer(
java.time.format.DateTimeFormatter.ofPattern(DateUtils.DATE_PATTERN)));
javaTimeModule.addDeserializer(java.time.LocalTime.class,
new LocalTimeDeserializer(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")));
mapper.registerModule(javaTimeModule);
// 配置java.util.Date的序列化和反序列化
SimpleDateFormat dateFormat = new SimpleDateFormat(DateUtils.DATE_TIME_PATTERN);
mapper.setDateFormat(dateFormat);
// Long类型转String类型 // Long类型转String类型
SimpleModule simpleModule = new SimpleModule(); SimpleModule simpleModule = new SimpleModule();
simpleModule.addSerializer(Long.class, ToStringSerializer.instance); simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
@@ -96,7 +96,7 @@ public class SysDictDataController {
@GetMapping("/type/{dictType}") @GetMapping("/type/{dictType}")
@Operation(summary = "获取字典数据列表") @Operation(summary = "获取字典数据列表")
@RequiresPermissions("sys:role:superAdmin") @RequiresPermissions("sys:role:normal")
public Result<List<SysDictDataItem>> getDictDataByType(@PathVariable("dictType") String dictType) { public Result<List<SysDictDataItem>> getDictDataByType(@PathVariable("dictType") String dictType) {
List<SysDictDataItem> list = sysDictDataService.getDictDataByType(dictType); List<SysDictDataItem> list = sysDictDataService.getDictDataByType(dictType);
return new Result<List<SysDictDataItem>>().ok(list); return new Result<List<SysDictDataItem>>().ok(list);
@@ -39,7 +39,7 @@ public class SysParamsDTO implements Serializable {
@Schema(description = "值类型") @Schema(description = "值类型")
@NotBlank(message = "{sysparams.valuetype.require}", groups = DefaultGroup.class) @NotBlank(message = "{sysparams.valuetype.require}", groups = DefaultGroup.class)
@Pattern(regexp = "^(string|number|boolean|array)$", message = "{sysparams.valuetype.pattern}", groups = DefaultGroup.class) @Pattern(regexp = "^(string|number|boolean|array|json)$", message = "{sysparams.valuetype.pattern}", groups = DefaultGroup.class)
private String valueType; private String valueType;
@Schema(description = "备注") @Schema(description = "备注")
@@ -127,6 +127,12 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
break; break;
case "json": case "json":
try { try {
// 首先检查是否以 { 开头,以 } 结尾
String trimmedValue = paramValue.trim();
if (!trimmedValue.startsWith("{") || !trimmedValue.endsWith("}")) {
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
}
// 然后尝试解析JSON
JsonUtils.parseObject(paramValue, Object.class); JsonUtils.parseObject(paramValue, Object.class);
} catch (Exception e) { } catch (Exception e) {
throw new RenException(ErrorCode.PARAM_JSON_INVALID); throw new RenException(ErrorCode.PARAM_JSON_INVALID);
@@ -8,6 +8,7 @@ import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.web.socket.WebSocketHttpHeaders;
import org.springframework.web.socket.client.WebSocketClient; import org.springframework.web.socket.client.WebSocketClient;
import org.springframework.web.socket.client.standard.StandardWebSocketClient; import org.springframework.web.socket.client.standard.StandardWebSocketClient;
@@ -45,8 +46,9 @@ public class WebSocketValidator {
try { try {
WebSocketClient client = new StandardWebSocketClient(); WebSocketClient client = new StandardWebSocketClient();
CompletableFuture<Boolean> future = new CompletableFuture<>(); CompletableFuture<Boolean> future = new CompletableFuture<>();
WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
client.doHandshake(new WebSocketTestHandler(future), null, URI.create(url)); client.execute(new WebSocketTestHandler(future), headers, URI.create(url));
// 等待最多5秒获取连接结果 // 等待最多5秒获取连接结果
return future.get(5, TimeUnit.SECONDS); return future.get(5, TimeUnit.SECONDS);
@@ -1,5 +1,6 @@
-- 初始化智能体聊天记录 -- 初始化智能体聊天记录
DROP TABLE IF EXISTS ai_chat_history; DROP TABLE IF EXISTS ai_chat_history;
DROP TABLE IF EXISTS ai_chat_message;
DROP TABLE IF EXISTS ai_agent_chat_history; DROP TABLE IF EXISTS ai_agent_chat_history;
CREATE TABLE ai_agent_chat_history CREATE TABLE ai_agent_chat_history
( (
@@ -13,7 +14,9 @@ CREATE TABLE ai_agent_chat_history
created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL COMMENT '创建时间', created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL COMMENT '创建时间',
updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
INDEX idx_ai_agent_chat_history_mac (mac_address), INDEX idx_ai_agent_chat_history_mac (mac_address),
INDEX idx_ai_agent_chat_history_agent_id (agent_id) INDEX idx_ai_agent_chat_history_session_id (session_id),
INDEX idx_ai_agent_chat_history_agent_id (agent_id),
INDEX idx_ai_agent_chat_history_agent_session_created (agent_id, session_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '智能体聊天记录表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '智能体聊天记录表';
DROP TABLE IF EXISTS ai_agent_chat_audio; DROP TABLE IF EXISTS ai_agent_chat_audio;
@@ -0,0 +1,43 @@
-- 添加百度ASR模型配置
delete from `ai_model_config` where `id` = 'ASR_BaiduASR';
INSERT INTO `ai_model_config` VALUES ('ASR_BaiduASR', 'ASR', 'BaiduASR', '百度语音识别', 0, 1, '{\"type\": \"baidu\", \"app_id\": \"\", \"api_key\": \"\", \"secret_key\": \"\", \"dev_pid\": 1537, \"output_dir\": \"tmp/\"}', NULL, NULL, 7, NULL, NULL, NULL, NULL);
-- 添加百度ASR供应器
delete from `ai_model_provider` where `id` = 'SYSTEM_ASR_BaiduASR';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_BaiduASR', 'ASR', 'baidu', '百度语音识别', '[{"key":"app_id","label":"应用AppID","type":"string"},{"key":"api_key","label":"API Key","type":"string"},{"key":"secret_key","label":"Secret Key","type":"string"},{"key":"dev_pid","label":"语言参数","type":"number"},{"key":"output_dir","label":"输出目录","type":"string"}]', 7, 1, NOW(), 1, NOW());
-- 更新百度ASR配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.bce.baidu.com/ai-engine/old/#/ai/speech/app/list',
`remark` = '百度ASR配置说明:
1. 访问 https://console.bce.baidu.com/ai-engine/old/#/ai/speech/app/list
2. 创建新应用
3. 获取AppID、API Key和Secret Key
4. 填入配置文件中
查看资源额度:https://console.bce.baidu.com/ai-engine/old/#/ai/speech/overview/resource/list
语言参数说明:https://ai.baidu.com/ai-doc/SPEECH/0lbxfnc9b
' WHERE `id` = 'ASR_BaiduASR';
-- 更新豆包供应器字段
update `ai_model_provider` set `fields` =
'[{"key":"appid","label":"应用ID","type":"string"},{"key":"access_token","label":"访问令牌","type":"string"},{"key":"cluster","label":"集群","type":"string"},{"key":"boosting_table_name","label":"热词文件名称","type":"string"},{"key":"correct_table_name","label":"替换词文件名称","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]'
where `id` = 'SYSTEM_ASR_DoubaoASR';
-- 更新豆包ASR配置说明
UPDATE `ai_model_config` SET
`doc_link` = 'https://console.volcengine.com/speech/app',
`remark` = '豆包ASR配置说明:
1. 需要在火山引擎控制台创建应用并获取appid和access_token
2. 支持中文语音识别
3. 需要网络连接
4. 输出文件保存在tmp/目录
申请步骤:
1. 访问 https://console.volcengine.com/speech/app
2. 创建新应用
3. 获取appid和access_token
4. 填入配置文件中
如需设置热词,请参考:https://www.volcengine.com/docs/6561/155738
' WHERE `id` = 'ASR_DoubaoASR';
@@ -0,0 +1,6 @@
-- 更新intent_llmM供应器
update `ai_model_provider` set fields = '[{"key":"llm","label":"LLM模型","type":"string"},{"key":"functions","label":"函数列表","type":"dict","dict_name":"functions"}]' where id = 'SYSTEM_Intent_intent_llm';
-- 更新ChatGLMLLM的意图识别配置
update `ai_model_config` set config_json = '{\"type\": \"intent_llm\", \"llm\": \"LLM_ChatGLMLLM\", \"functions\": \"get_weather;get_news_from_newsnow;play_music\"}' where id = 'Intent_intent_llm';
-- 更新函数调用意图识别配置
UPDATE `ai_model_config` SET config_json = REPLACE(config_json, ';get_news;', ';get_news_from_newsnow;') WHERE id = 'Intent_function_call';
@@ -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');
@@ -94,9 +94,51 @@ databaseChangeLog:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202504301341.sql path: classpath:db/changelog/202504301341.sql
- changeSet: - changeSet:
id: 202505012207 id: 202505022134
author: Goody author: Goody
changes: changes:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202505012207.sql path: classpath:db/changelog/202505022134.sql
- changeSet:
id: 202505081146
author: hrz
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202505081146.sql
- changeSet:
id: 202505091409
author: hrz
changes:
- sqlFile:
encoding: utf8
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
@@ -21,4 +21,24 @@
id, mac_address, agent_id, session_id, sort, chat_type, content, audio, audio_url, id, mac_address, agent_id, session_id, sort, chat_type, content, audio, audio_url,
created_at, updated_at created_at, updated_at
</sql> </sql>
<delete id="deleteAudioByAgentId">
DELETE FROM ai_agent_chat_audio
WHERE id IN (
SELECT audio_id
FROM ai_agent_chat_history
WHERE agent_id = #{agentId}
)
</delete>
<update id="deleteAudioIdByAgentId">
UPDATE ai_agent_chat_history
SET audio_id = NULL
WHERE agent_id = #{agentId}
</update>
<delete id="deleteHistoryByAgentId">
DELETE FROM ai_agent_chat_history
WHERE agent_id = #{agentId}
</delete>
</mapper> </mapper>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="xiaozhi.modules.device.dao.DeviceDao">
<!-- 获取此智能体全部设备的最后连接时间 -->
<select id="getAllLastConnectedAtByAgentId" resultType="java.util.Date">
SELECT last_connected_at FROM ai_device
WHERE
agent_id = #{agentId}
order by
last_connected_at desc limit 0,1
</select>
</mapper>
@@ -1,5 +1,8 @@
package xiaozhi.modules.device; package xiaozhi.modules.device;
import java.util.HashMap;
import java.util.UUID;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -8,8 +11,9 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.service.SysUserService;
@Slf4j @Slf4j
@SpringBootTest @SpringBootTest
@@ -19,18 +23,37 @@ public class DeviceTest {
@Autowired @Autowired
private RedisUtils redisUtils; private RedisUtils redisUtils;
@Autowired
private SysUserService sysUserService;
@Test
public void testSaveUser() {
SysUserDTO userDTO = new SysUserDTO();
userDTO.setUsername("test");
userDTO.setPassword(UUID.randomUUID().toString());
sysUserService.save(userDTO);
}
@Test @Test
@DisplayName("测试写入设备信息") @DisplayName("测试写入设备信息")
public void testWriteDeviceInfo() { public void testWriteDeviceInfo() {
log.info("开始测试写入设备信息..."); log.info("开始测试写入设备信息...");
// 模拟设备MAC地址 // 模拟设备MAC地址
String macAddress = "00:11:22:33:44:55"; String macAddress = "00:11:22:33:44:66";
// 模拟设备验证码 // 模拟设备验证码
String deviceCode = "123456"; String deviceCode = "123456";
String redisKey = RedisKeys.getDeviceCaptchaKey(deviceCode); HashMap<String, Object> map = new HashMap<>();
map.put("mac_address", macAddress);
map.put("activation_code", deviceCode);
map.put("board", "硬件型号");
map.put("app_version", "0.3.13");
String safeDeviceId = macAddress.replace(":", "_").toLowerCase();
String cacheDeviceKey = String.format("ota:activation:data:%s", safeDeviceId);
redisUtils.set(cacheDeviceKey, map, 300);
String redisKey = "ota:activation:code:" + deviceCode;
log.info("Redis Key: {}", redisKey); log.info("Redis Key: {}", redisKey);
// 将设备信息写入Redis // 将设备信息写入Redis
+49 -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();
}, },
@@ -97,4 +97,50 @@ export default {
}); });
}).send(); }).send();
}, },
// 获取智能体会话列表
getAgentSessions(agentId, params, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${agentId}/sessions`)
.method('GET')
.data(params)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail(() => {
RequestService.reAjaxFun(() => {
this.getAgentSessions(agentId, params, callback);
});
}).send();
},
// 获取智能体聊天记录
getAgentChatHistory(agentId, sessionId, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${agentId}/chat-history/${sessionId}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail(() => {
RequestService.reAjaxFun(() => {
this.getAgentChatHistory(agentId, sessionId, callback);
});
}).send();
},
// 获取音频下载ID
getAudioId(audioId, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/audio/${audioId}`)
.method('POST')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail(() => {
RequestService.reAjaxFun(() => {
this.getAudioId(audioId, callback);
});
}).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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :visible="visible" @close="handleClose" width="400px" center> <el-dialog :visible="visible" @close="handleClose" width="24%" center>
<div <div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="975px" center <el-dialog :visible="dialogVisible" @update:visible="handleVisibleChange" width="57%" center
custom-class="custom-dialog" :show-close="false" class="center-dialog"> 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="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;"> <div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
@@ -54,7 +54,7 @@
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="remark" class="prop-remark"> <el-form-item label="备注" prop="remark" class="prop-remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入模型备注" <el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入模型备注" :autosize="{ minRows: 3, maxRows: 5 }"
class="custom-input-bg"></el-input> class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -271,7 +271,7 @@ export default {
} }
.center-dialog .el-dialog { .center-dialog .el-dialog {
margin: 4% 0 auto !important; margin: 0 0 auto !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :visible="visible" @close="handleClose" width="400px" center @open="handleOpen"> <el-dialog :visible="visible" @close="handleClose" width="25%" center @open="handleOpen">
<div <div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
@@ -10,7 +10,7 @@
</div> </div>
<div style="height: 1px;background: #e8f0ff;" /> <div style="height: 1px;background: #e8f0ff;" />
<div style="margin: 22px 15px;"> <div style="margin: 22px 15px;">
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #3d4566;"> <div style="font-weight: 400;text-align: left;color: #3d4566;">
<div style="color: red;display: inline-block;">*</div> 智能体名称 <div style="color: red;display: inline-block;">*</div> 智能体名称
</div> </div>
<div class="input-46" style="margin-top: 12px;"> <div class="input-46" style="margin-top: 12px;">
@@ -1,6 +1,6 @@
<template> <template>
<form> <form>
<el-dialog :visible.sync="value" width="400px" center> <el-dialog :visible.sync="dialogVisible" width="24%" center>
<div <div
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
@@ -60,11 +60,20 @@ export default {
}, },
data() { data() {
return { return {
dialogVisible: this.value,
oldPassword: "", oldPassword: "",
newPassword: "", newPassword: "",
confirmNewPassword: "" confirmNewPassword: ""
} }
}, },
watch: {
value(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('input', val);
}
},
methods: { methods: {
...mapActions(['logout']), // 引入Vuex的logout action ...mapActions(['logout']), // 引入Vuex的logout action
confirm() { confirm() {
@@ -101,7 +110,7 @@ export default {
this.$emit('input', false); this.$emit('input', false);
}, },
cancel() { cancel() {
this.$emit('input', false); this.dialogVisible = false;
this.resetForm(); this.resetForm();
}, },
resetForm() { resetForm() {
@@ -0,0 +1,447 @@
<template>
<el-dialog :title="'与' + agentName + '的聊天记录' + (currentMacAddress ? '[' + currentMacAddress + ']' : '')"
:visible.sync="dialogVisible" width="80%" :before-close="handleClose" custom-class="chat-history-dialog">
<div class="chat-container">
<div class="session-list" @scroll="handleScroll">
<div v-for="session in sessions" :key="session.sessionId" class="session-item"
:class="{ active: currentSessionId === session.sessionId }" @click="selectSession(session)">
<img :src="getUserAvatar(session.sessionId)" class="avatar" />
<div class="session-info">
<div class="session-time">{{ formatTime(session.createdAt) }}</div>
<div class="message-count">{{ session.chatCount > 99 ? '99' : session.chatCount }}</div>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-if="!hasMore" class="no-more">没有更多记录了</div>
</div>
<div class="chat-content">
<div v-if="currentSessionId" class="messages">
<div v-for="(message, index) in messagesWithTime" :key="message.id">
<div v-if="message.type === 'time'" class="time-divider">
{{ message.content }}
</div>
<div v-else class="message-item" :class="{ 'user-message': message.chatType === 1 }">
<img :src="message.chatType === 1 ? getUserAvatar(currentSessionId) : require('@/assets/xiaozhi-logo.png')"
class="avatar" />
<div class="message-content">
{{ message.content }}
<i v-if="message.audioId" :class="getAudioIconClass(message)"
@click="playAudio(message)" class="audio-icon"></i>
</div>
</div>
</div>
</div>
<div v-else class="no-session-selected">
请选择会话查看聊天记录
</div>
</div>
</div>
</el-dialog>
</template>
<script>
import Api from '@/apis/api';
export default {
name: 'ChatHistoryDialog',
props: {
visible: {
type: Boolean,
default: false
},
agentId: {
type: String,
required: true
},
agentName: {
type: String,
required: true
}
},
data() {
return {
dialogVisible: false,
sessions: [],
messages: [],
currentSessionId: '',
currentMacAddress: '',
page: 1,
limit: 20,
loading: false,
hasMore: true,
scrollTimer: null,
isFirstLoad: true,
playingAudioId: null,
audioElement: null
};
},
watch: {
visible(val) {
this.dialogVisible = val;
if (val) {
this.resetData();
this.loadSessions();
}
},
dialogVisible(val) {
if (!val) {
this.$emit('update:visible', false);
}
}
},
computed: {
messagesWithTime() {
if (!this.messages || this.messages.length === 0) return [];
const result = [];
const TIME_INTERVAL = 60 * 1000; // 1分钟的时间间隔(毫秒)
// 添加第一条消息的时间标记
if (this.messages[0]) {
result.push({
type: 'time',
content: this.formatTime(this.messages[0].createdAt),
id: `time-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
});
}
// 处理消息列表
for (let i = 0; i < this.messages.length; i++) {
const currentMessage = this.messages[i];
result.push(currentMessage);
// 检查是否需要添加时间标记
if (i < this.messages.length - 1) {
const currentTime = new Date(currentMessage.createdAt).getTime();
const nextTime = new Date(this.messages[i + 1].createdAt).getTime();
if (nextTime - currentTime > TIME_INTERVAL) {
result.push({
type: 'time',
content: this.formatTime(this.messages[i + 1].createdAt),
id: `time-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
});
}
}
}
return result;
}
},
methods: {
resetData() {
this.sessions = [];
this.messages = [];
this.currentSessionId = '';
this.currentMacAddress = '';
this.page = 1;
this.loading = false;
this.hasMore = true;
this.isFirstLoad = true;
},
handleClose() {
this.dialogVisible = false;
},
loadSessions() {
if (this.loading || (!this.isFirstLoad && !this.hasMore)) {
return;
}
this.loading = true;
const params = {
page: this.page,
limit: this.limit
};
Api.agent.getAgentSessions(this.agentId, params, (res) => {
if (res.data && res.data.data && Array.isArray(res.data.data.list)) {
const list = res.data.data.list;
this.hasMore = list.length === this.limit;
this.sessions = [...this.sessions, ...list];
this.page++;
if (this.sessions.length > 0 && !this.currentSessionId) {
this.selectSession(this.sessions[0]);
}
}
this.loading = false;
this.isFirstLoad = false;
});
},
selectSession(session) {
this.currentSessionId = session.sessionId;
Api.agent.getAgentChatHistory(this.agentId, session.sessionId, (res) => {
if (res.data && res.data.data) {
this.messages = res.data.data;
if (this.messages.length > 0 && this.messages[0].macAddress) {
this.currentMacAddress = this.messages[0].macAddress;
}
}
});
},
handleScroll(e) {
if (this.scrollTimer) {
clearTimeout(this.scrollTimer);
}
this.scrollTimer = setTimeout(() => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
// 当滚动到底部时加载更多
if (scrollHeight - scrollTop <= clientHeight + 50) {
this.loadSessions();
}
}, 200);
},
formatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
if (date >= today) {
return `今天 ${hours}:${minutes}`;
} else if (date >= yesterday) {
return `昨天 ${hours}:${minutes}`;
} else {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
},
getAudioIconClass(message) {
if (this.playingAudioId === message.audioId) {
return 'el-icon-loading';
}
return 'el-icon-video-play';
},
playAudio(message) {
if (this.playingAudioId === message.audioId) {
// 如果正在播放当前音频,则停止播放
if (this.audioElement) {
this.audioElement.pause();
this.audioElement = null;
}
this.playingAudioId = null;
return;
}
// 停止当前正在播放的音频
if (this.audioElement) {
this.audioElement.pause();
this.audioElement = null;
}
// 先获取音频下载ID
this.playingAudioId = message.audioId;
Api.agent.getAudioId(message.audioId, (res) => {
if (res.data && res.data.data) {
// 使用获取到的下载ID播放音频
this.audioElement = new Audio(Api.getServiceUrl() + `/agent/play/${res.data.data}`);
this.audioElement.onended = () => {
this.playingAudioId = null;
this.audioElement = null;
};
this.audioElement.play();
}
});
},
getUserAvatar(sessionId) {
// 从 sessionId 中提取所有数字
const numbers = sessionId.match(/\d+/g);
if (!numbers) return require('@/assets/user-avatar1.png');
// 将所有数字相加
const sum = numbers.reduce((acc, num) => acc + parseInt(num), 0);
// 计算模5并加1,得到1-5之间的数字
const avatarIndex = (sum % 5) + 1;
// 返回对应的头像图片
return require(`@/assets/user-avatar${avatarIndex}.png`);
}
}
};
</script>
<style scoped>
.chat-container {
display: flex;
height: 100%;
}
.session-list {
width: 250px;
border-right: 1px solid #eee;
overflow-y: auto;
padding: 10px;
}
.session-item {
display: flex;
align-items: center;
padding: 10px;
cursor: pointer;
border-radius: 8px;
margin-bottom: 10px;
}
.session-item:hover {
background-color: #f5f5f5;
}
.session-item.active {
background-color: #e6f7ff;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
}
.session-info {
flex: 1;
}
.session-time {
font-size: 14px;
color: #272727;
float: left;
height: 30px;
line-height: 30px;
width: calc(100% - 30px);
/* 为消息数量留出空间 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-count {
font-size: 14px;
color: #fff;
background-color: #b4b4b4;
border-radius: 20px;
float: left;
width: 20px;
height: 20px;
line-height: 20px;
margin-top: 5px;
margin-left: 5px;
}
.chat-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.message-item {
display: flex;
margin-bottom: 20px;
}
.message-item.user-message {
flex-direction: row-reverse;
}
.message-content {
max-width: 60%;
padding: 10px 15px;
border-radius: 8px;
background-color: #f0f0f0;
margin: 0 10px;
text-align: left;
line-height: 20px;
position: relative;
display: flex;
align-items: center;
}
.audio-icon {
font-size: 20px;
cursor: pointer;
margin: 0 5px;
color: #1890ff;
}
.user-message .message-content {
background-color: #1890ff;
color: white;
flex-direction: row-reverse;
}
.user-message .audio-icon {
color: white;
}
.loading,
.no-more {
text-align: center;
padding: 10px 10px 30px 10px;
color: #999;
}
.no-session-selected {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: #999;
}
.time-divider {
text-align: center;
margin: 10px 0;
color: #999;
font-size: 12px;
}
.time-divider::before,
.time-divider::after {
content: '';
display: inline-block;
width: 30%;
height: 1px;
background-color: #eee;
vertical-align: middle;
margin: 0 10px;
}
</style>
<style>
.chat-history-dialog {
display: flex;
flex-direction: column;
min-width: 700px;
margin: 0 !important;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 90vh;
max-width: 85vw;
border-radius: 12px;
overflow: hidden;
}
.chat-history-dialog .el-dialog__header {
background-color: #e6f7ff;
padding: 15px 20px;
}
.chat-history-dialog .el-dialog__body {
padding: 0;
overflow: hidden;
height: calc(90vh - 54px);
/* 减去标题栏的高度 */
}
</style>
+41 -1
View File
@@ -26,9 +26,16 @@
<div class="settings-btn" @click="handleDeviceManage"> <div class="settings-btn" @click="handleDeviceManage">
设备管理({{ device.deviceCount }}) 设备管理({{ device.deviceCount }})
</div> </div>
<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 class="version-info"> <div class="version-info">
<div>最近对话{{ device.lastConnectedAt }}</div> <div>最近对话{{ formattedLastConnectedTime }}</div>
</div> </div>
</div> </div>
</template> </template>
@@ -42,6 +49,27 @@ export default {
data() { data() {
return { switchValue: false } return { switchValue: false }
}, },
computed: {
formattedLastConnectedTime() {
if (!this.device.lastConnectedAt) return '暂未对话';
const lastTime = new Date(this.device.lastConnectedAt);
const now = new Date();
const diffMinutes = Math.floor((now - lastTime) / (1000 * 60));
if (diffMinutes <= 1) {
return '刚刚';
} else if (diffMinutes < 60) {
return `${diffMinutes}分钟前`;
} else if (diffMinutes < 24 * 60) {
const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60;
return `${hours}小时${minutes > 0 ? minutes + '分钟' : ''}`;
} else {
return this.device.lastConnectedAt;
}
}
},
methods: { methods: {
handleDelete() { handleDelete() {
this.$emit('delete', this.device.agentId) this.$emit('delete', this.device.agentId)
@@ -51,6 +79,12 @@ export default {
}, },
handleDeviceManage() { handleDeviceManage() {
this.$router.push({ path: '/device-management', query: { agentId: this.device.agentId } }); this.$router.push({ path: '/device-management', query: { agentId: this.device.agentId } });
},
handleChatHistory() {
if (this.device.memModelId === 'Memory_nomem') {
return
}
this.$emit('chat-history', { agentId: this.device.agentId, agentName: this.device.agentName })
} }
} }
} }
@@ -93,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="visible" width="500px" @close="handleClose"> <el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose">
<el-form :model="form" :rules="rules" ref="form" label-width="100px"> <el-form :model="form" :rules="rules" ref="form" label-width="100px">
<el-form-item label="字典标签" prop="dictLabel"> <el-form-item label="字典标签" prop="dictLabel">
<el-input v-model="form.dictLabel" placeholder="请输入字典标签"></el-input> <el-input v-model="form.dictLabel" placeholder="请输入字典标签"></el-input>
@@ -41,6 +41,7 @@ export default {
}, },
data() { data() {
return { return {
dialogVisible: this.visible,
form: { form: {
id: null, id: null,
dictTypeId: null, dictTypeId: null,
@@ -70,12 +71,18 @@ export default {
} }
}, },
immediate: true immediate: true
},
visible(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('update:visible', val);
} }
}, },
methods: { methods: {
handleClose() { handleClose() {
this.$emit('update:visible', false) this.dialogVisible = false;
this.resetForm() this.resetForm();
}, },
resetForm() { resetForm() {
this.form = { this.form = {
@@ -102,4 +109,8 @@ export default {
.dialog-footer { .dialog-footer {
text-align: right; text-align: right;
} }
:deep(.el-dialog) {
border-radius: 15px;
}
</style> </style>
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @close="handleClose"> <el-dialog :title="title" :visible.sync="dialogVisible" width="30%" @close="handleClose">
<el-form :model="form" :rules="rules" ref="form" label-width="120px"> <el-form :model="form" :rules="rules" ref="form" label-width="120px">
<el-form-item label="字典类型名称" prop="dictName"> <el-form-item label="字典类型名称" prop="dictName">
<el-input v-model="form.dictName" placeholder="请输入字典类型名称"></el-input> <el-input v-model="form.dictName" placeholder="请输入字典类型名称"></el-input>
@@ -34,6 +34,7 @@ export default {
}, },
data() { data() {
return { return {
dialogVisible: this.visible,
form: { form: {
id: null, id: null,
dictName: '', dictName: '',
@@ -46,6 +47,12 @@ export default {
} }
}, },
watch: { watch: {
visible(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('update:visible', val);
},
dictTypeData: { dictTypeData: {
handler(val) { handler(val) {
if (val) { if (val) {
@@ -57,7 +64,7 @@ export default {
}, },
methods: { methods: {
handleClose() { handleClose() {
this.$emit('update:visible', false) this.dialogVisible = false;
this.resetForm() this.resetForm()
}, },
resetForm() { resetForm() {
@@ -83,4 +90,8 @@ export default {
.dialog-footer { .dialog-footer {
text-align: right; text-align: right;
} }
:deep(.el-dialog) {
border-radius: 15px;
}
</style> </style>
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog :title="title" :visible.sync="visible" width="500px" @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">
@@ -59,11 +59,13 @@ export default {
default: () => [] default: () => []
} }
}, },
data() { data() {
return { return {
uploadProgress: 0, uploadProgress: 0,
uploadStatus: '', uploadStatus: '',
isUploading: false, isUploading: false,
dialogVisible: this.visible,
rules: { rules: {
firmwareName: [ firmwareName: [
{ required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' } { required: true, message: '请输入固件名称(板子+版本号)', trigger: 'blur' }
@@ -90,10 +92,18 @@ export default {
created() { created() {
// 移除 getDictDataByType 调用 // 移除 getDictDataByType 调用
}, },
watch: {
visible(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('update:visible', val);
},
},
methods: { methods: {
// 移除 getFirmwareTypes 方法 // 移除 getFirmwareTypes 方法
handleClose() { handleClose() {
this.$refs.form.clearValidate(); this.dialogVisible = false;
this.$emit('cancel'); this.$emit('cancel');
}, },
handleCancel() { handleCancel() {
@@ -188,26 +198,30 @@ 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()
}
})
} }
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
::v-deep .el-dialog {
border-radius: 20px;
}
.upload-demo { .upload-demo {
text-align: left; text-align: left;
} }
.el-upload__tip { .el-upload__tip {
line-height: 1.2; line-height: 1.2;
padding-top: 5px; padding-top: 2%;
color: #909399; color: #909399;
} }
@@ -215,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>
@@ -1,6 +1,6 @@
<template> <template>
<el-dialog :visible.sync="dialogVisible" width="975px" center custom-class="custom-dialog" :show-close="false" <el-dialog :visible.sync="dialogVisible" width="57%" center custom-class="custom-dialog" :show-close="false"
class="center-dialog"> class="center-dialog" >
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;"> <div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;"> <div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
修改模型 修改模型
@@ -53,7 +53,7 @@
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="remark" class="prop-remark"> <el-form-item label="备注" prop="remark" class="prop-remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入模型备注" <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入模型备注" :autosize="{ minRows: 3, maxRows: 5 }"
class="custom-input-bg"></el-input> class="custom-input-bg"></el-input>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -296,7 +296,7 @@ export default {
}; };
</script> </script>
<style scoped> <style lang="scss" scoped>
.custom-dialog { .custom-dialog {
position: relative; position: relative;
border-radius: 20px; border-radius: 20px;
@@ -316,11 +316,6 @@ export default {
justify-content: center; justify-content: center;
} }
.center-dialog .el-dialog {
margin: 4% 0 auto !important;
display: flex;
flex-direction: column;
}
.custom-close-btn { .custom-close-btn {
position: absolute; position: absolute;
@@ -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>
+15 -8
View File
@@ -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;
@@ -487,7 +501,7 @@ export default {
}; };
</script> </script>
<style scoped> <style lang="scss" scoped>
::v-deep .el-dialog { ::v-deep .el-dialog {
border-radius: 8px !important; border-radius: 8px !important;
@@ -648,12 +662,6 @@ export default {
margin: 0 auto; margin: 0 auto;
} }
/* 新增按钮组样式 */
.action-buttons {
bottom: 20px;
padding-top: 10px;
}
.action-buttons .el-button { .action-buttons .el-button {
padding: 8px 15px; padding: 8px 15px;
font-size: 11px; font-size: 11px;
@@ -692,7 +700,6 @@ export default {
position: static; position: static;
padding: 15px 0; padding: 15px 0;
background: white; background: white;
box-shadow: 0 -2px 12px rgba(0,0,0,0.05);
} }
/* 输入框自适应 */ /* 输入框自适应 */
+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 || '/',
@@ -294,20 +294,13 @@ export default {
this.loading = false; this.loading = false;
if (data.code === 0) { if (data.code === 0) {
this.deviceList = data.data.map(device => { this.deviceList = data.data.map(device => {
const bindDate = new Date(device.createDate);
const formattedBindTime = `${bindDate.getFullYear()}-${(bindDate.getMonth() + 1).toString().padStart(2, '0')}-${bindDate.getDate().toString().padStart(2, '0')} ${bindDate.getHours().toString().padStart(2, '0')}:${bindDate.getMinutes().toString().padStart(2, '0')}:${bindDate.getSeconds().toString().padStart(2, '0')}`;
let formattedLastConversation = '';
if (device.lastConnectedAt) {
const lastConvoDate = new Date(device.lastConnectedAt);
formattedLastConversation = `${lastConvoDate.getFullYear()}-${(lastConvoDate.getMonth() + 1).toString().padStart(2, '0')}-${lastConvoDate.getDate().toString().padStart(2, '0')} ${lastConvoDate.getHours().toString().padStart(2, '0')}:${lastConvoDate.getMinutes().toString().padStart(2, '0')}:${lastConvoDate.getSeconds().toString().padStart(2, '0')}`;
}
return { return {
device_id: device.id, device_id: device.id,
model: device.board, model: device.board,
firmwareVersion: device.appVersion, firmwareVersion: device.appVersion,
macAddress: device.macAddress, macAddress: device.macAddress,
bindTime: formattedBindTime, bindTime: device.createDate,
lastConversation: formattedLastConversation, lastConversation: device.lastConnectedAt,
remark: device.alias, remark: device.alias,
isEdit: false, isEdit: false,
otaSwitch: device.autoUpdate === 1, otaSwitch: device.autoUpdate === 1,
+32 -16
View File
@@ -49,9 +49,13 @@
v-loading="dictDataLoading" element-loading-text="拼命加载中" v-loading="dictDataLoading" element-loading-text="拼命加载中"
element-loading-spinner="el-icon-loading" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)" element-loading-background="rgba(255, 255, 255, 0.7)"
@selection-change="handleDictDataSelectionChange" class="data-table" class="data-table"
header-row-class-name="table-header"> header-row-class-name="table-header">
<el-table-column type="selection" width="55" align="center"></el-table-column> <el-table-column label="选择" align="center" width="55">
<template slot-scope="scope">
<el-checkbox v-model="scope.row.selected"></el-checkbox>
</template>
</el-table-column>
<el-table-column label="字典标签" prop="dictLabel" align="center"></el-table-column> <el-table-column label="字典标签" prop="dictLabel" align="center"></el-table-column>
<el-table-column label="字典值" prop="dictValue" align="center"></el-table-column> <el-table-column label="字典值" prop="dictValue" align="center"></el-table-column>
<el-table-column label="排序" prop="sort" align="center"></el-table-column> <el-table-column label="排序" prop="sort" align="center"></el-table-column>
@@ -153,7 +157,6 @@ export default {
// 字典数据相关 // 字典数据相关
dictDataList: [], dictDataList: [],
dictDataLoading: false, dictDataLoading: false,
selectedDictData: [],
isAllDictDataSelected: false, isAllDictDataSelected: false,
dictDataDialogVisible: false, dictDataDialogVisible: false,
dictDataDialogTitle: '新增字典数据', dictDataDialogTitle: '新增字典数据',
@@ -265,7 +268,10 @@ export default {
dictValue: '' dictValue: ''
}, ({ data }) => { }, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.dictDataList = data.data.list this.dictDataList = data.data.list.map(item => ({
...item,
selected: false
}))
this.total = data.data.total this.total = data.data.total
} else { } else {
this.$message.error(data.msg || '获取字典数据失败') this.$message.error(data.msg || '获取字典数据失败')
@@ -273,16 +279,11 @@ export default {
this.dictDataLoading = false this.dictDataLoading = false
}) })
}, },
handleDictDataSelectionChange(val) {
this.selectedDictData = val
this.isAllDictDataSelected = val.length === this.dictDataList.length
},
selectAllDictData() { selectAllDictData() {
if (this.isAllDictDataSelected) { this.isAllDictDataSelected = !this.isAllDictDataSelected
this.$refs.dictDataTable.clearSelection() this.dictDataList.forEach(row => {
} else { row.selected = this.isAllDictDataSelected
this.$refs.dictDataTable.toggleAllSelection() })
}
}, },
showAddDictDataDialog() { showAddDictDataDialog() {
if (!this.selectedDictType) { if (!this.selectedDictType) {
@@ -329,17 +330,18 @@ export default {
}) })
}, },
batchDeleteDictData() { batchDeleteDictData() {
if (this.selectedDictData.length === 0) { const selectedRows = this.dictDataList.filter(row => row.selected)
if (selectedRows.length === 0) {
this.$message.warning('请选择要删除的字典数据') this.$message.warning('请选择要删除的字典数据')
return return
} }
this.$confirm('确定要删除选中的字典数据吗?', '提示', { this.$confirm(`确定要删除选中的${selectedRows.length}字典数据吗?`, '提示', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'
}).then(() => { }).then(() => {
const ids = this.selectedDictData.map(item => item.id) const ids = selectedRows.map(item => item.id)
dictApi.deleteDictData(ids, ({ data }) => { dictApi.deleteDictData(ids, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
this.$message.success('删除成功') this.$message.success('删除成功')
@@ -832,4 +834,18 @@ export default {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
} }
:deep(.el-checkbox__inner) {
background-color: #eeeeee !important;
border-color: #cccccc !important;
}
:deep(.el-checkbox__inner:hover) {
border-color: #cccccc !important;
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #5f70f3 !important;
border-color: #5f70f3 !important;
}
</style> </style>
@@ -25,8 +25,19 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="参数编码" prop="paramCode" align="center"></el-table-column> <el-table-column label="参数编码" prop="paramCode" align="center"></el-table-column>
<el-table-column label="参数值" prop="paramValue" align="center" <el-table-column label="参数值" prop="paramValue" align="center" show-overflow-tooltip>
show-overflow-tooltip></el-table-column> <template slot-scope="scope">
<div v-if="isSensitiveParam(scope.row.paramCode)">
<span v-if="!scope.row.showValue">{{ maskSensitiveValue(scope.row.paramValue)
}}</span>
<span v-else>{{ scope.row.paramValue }}</span>
<el-button size="mini" type="text" @click="toggleSensitiveValue(scope.row)">
{{ scope.row.showValue ? '隐藏' : '查看' }}
</el-button>
</div>
<span v-else>{{ scope.row.paramValue }}</span>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" align="center"></el-table-column> <el-table-column label="备注" prop="remark" align="center"></el-table-column>
<el-table-column label="操作" align="center"> <el-table-column label="操作" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
@@ -100,6 +111,7 @@ export default {
dialogVisible: false, dialogVisible: false,
dialogTitle: "新增参数", dialogTitle: "新增参数",
isAllSelected: false, isAllSelected: false,
sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key"],
paramForm: { paramForm: {
id: null, id: null,
paramCode: "", paramCode: "",
@@ -152,7 +164,8 @@ export default {
if (data.code === 0) { if (data.code === 0) {
this.paramsList = data.data.list.map(item => ({ this.paramsList = data.data.list.map(item => ({
...item, ...item,
selected: false selected: false,
showValue: false
})); }));
this.total = data.data.total; this.total = data.data.total;
} else { } else {
@@ -314,7 +327,18 @@ export default {
goToPage(page) { goToPage(page) {
this.currentPage = page; this.currentPage = page;
this.fetchParams(); this.fetchParams();
} },
isSensitiveParam(paramCode) {
return this.sensitive_keys.some(key => paramCode.toLowerCase().includes(key.toLowerCase()));
},
maskSensitiveValue(value) {
if (!value) return '';
if (value.length <= 8) return '****';
return value.substring(0, 4) + '****' + value.substring(value.length - 4);
},
toggleSensitiveValue(row) {
this.$set(row, 'showValue', !row.showValue);
},
}, },
}; };
</script> </script>
@@ -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>
+1 -10
View File
@@ -26,11 +26,7 @@
<el-table-column label="用户Id" prop="userid" align="center"></el-table-column> <el-table-column label="用户Id" prop="userid" align="center"></el-table-column>
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column> <el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
<el-table-column label="设备数量" prop="deviceCount" align="center"></el-table-column> <el-table-column label="设备数量" prop="deviceCount" align="center"></el-table-column>
<el-table-column label="注册时间" prop="createDate" align="center"> <el-table-column label="注册时间" prop="createDate" align="center"></el-table-column>
<template slot-scope="scope">
{{ formatDate(scope.row.createDate) }}
</template>
</el-table-column>
<el-table-column label="状态" prop="status" align="center"> <el-table-column label="状态" prop="status" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-tag v-if="scope.row.status === 1" type="success">正常</el-tag> <el-tag v-if="scope.row.status === 1" type="success">正常</el-tag>
@@ -342,11 +338,6 @@ export default {
// 用户取消操作 // 用户取消操作
}); });
}, },
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}`;
},
}, },
}; };
</script> </script>
+21 -21
View File
@@ -32,11 +32,7 @@
</div> </div>
<div class="device-list-container"> <div class="device-list-container">
<template v-if="isLoading"> <template v-if="isLoading">
<div <div v-for="i in skeletonCount" :key="'skeleton-' + i" class="skeleton-item">
v-for="i in skeletonCount"
:key="'skeleton-'+i"
class="skeleton-item"
>
<div class="skeleton-image"></div> <div class="skeleton-image"></div>
<div class="skeleton-content"> <div class="skeleton-content">
<div class="skeleton-line"></div> <div class="skeleton-line"></div>
@@ -46,14 +42,8 @@
</template> </template>
<template v-else> <template v-else>
<DeviceItem <DeviceItem v-for="(item, index) in devices" :key="index" :device="item" @configure="goToRoleConfig"
v-for="(item, index) in devices" @deviceManage="handleDeviceManage" @delete="handleDeleteAgent" @chat-history="handleShowChatHistory" />
:key="index"
:device="item"
@configure="goToRoleConfig"
@deviceManage="handleDeviceManage"
@delete="handleDeleteAgent"
/>
</template> </template>
</div> </div>
</div> </div>
@@ -62,6 +52,7 @@
<el-footer> <el-footer>
<version-footer /> <version-footer />
</el-footer> </el-footer>
<chat-history-dialog :visible.sync="showChatHistory" :agent-id="currentAgentId" :agent-name="currentAgentName" />
</div> </div>
</template> </template>
@@ -69,13 +60,14 @@
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
import AddWisdomBodyDialog from '@/components/AddWisdomBodyDialog.vue'; import AddWisdomBodyDialog from '@/components/AddWisdomBodyDialog.vue';
import ChatHistoryDialog from '@/components/ChatHistoryDialog.vue';
import DeviceItem from '@/components/DeviceItem.vue'; import DeviceItem from '@/components/DeviceItem.vue';
import HeaderBar from '@/components/HeaderBar.vue'; import HeaderBar from '@/components/HeaderBar.vue';
import VersionFooter from '@/components/VersionFooter.vue'; import VersionFooter from '@/components/VersionFooter.vue';
export default { export default {
name: 'HomePage', name: 'HomePage',
components: { DeviceItem, AddWisdomBodyDialog, HeaderBar, VersionFooter }, components: { DeviceItem, AddWisdomBodyDialog, HeaderBar, VersionFooter, ChatHistoryDialog },
data() { data() {
return { return {
addDeviceDialogVisible: false, addDeviceDialogVisible: false,
@@ -85,6 +77,9 @@ export default {
searchRegex: null, searchRegex: null,
isLoading: true, isLoading: true,
skeletonCount: localStorage.getItem('skeletonCount') || 8, skeletonCount: localStorage.getItem('skeletonCount') || 8,
showChatHistory: false,
currentAgentId: '',
currentAgentName: ''
} }
}, },
@@ -177,6 +172,11 @@ export default {
} }
}); });
}).catch(() => { }); }).catch(() => { });
},
handleShowChatHistory({ agentId, agentName }) {
this.currentAgentId = agentId;
this.currentAgentName = agentName;
this.showChatHistory = true;
} }
} }
} }
@@ -302,7 +302,9 @@ export default {
/* 骨架屏动画 */ /* 骨架屏动画 */
@keyframes shimmer { @keyframes shimmer {
100% { transform: translateX(100%); } 100% {
transform: translateX(100%);
}
} }
.skeleton-item { .skeleton-item {
@@ -353,12 +355,10 @@ export default {
left: 0; left: 0;
width: 50%; width: 50%;
height: 100%; height: 100%;
background: linear-gradient( background: linear-gradient(90deg,
90deg, rgba(255, 255, 255, 0),
rgba(255,255,255,0), rgba(255, 255, 255, 0.3),
rgba(255,255,255,0.3), rgba(255, 255, 255, 0));
rgba(255,255,255,0)
);
animation: shimmer 1.5s infinite; animation: shimmer 1.5s infinite;
} }
</style> </style>
+235 -68
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();
@@ -318,7 +433,6 @@ export default {
<style scoped> <style scoped>
.welcome { .welcome {
min-width: 900px; min-width: 900px;
min-height: 506px;
height: 100vh; height: 100vh;
display: flex; display: flex;
position: relative; position: relative;
@@ -334,7 +448,7 @@ export default {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 16px 24px; padding: 1.5vh 24px;
} }
.page-title { .page-title {
@@ -344,7 +458,7 @@ export default {
} }
.main-wrapper { .main-wrapper {
margin: 5px 22px; margin: 1vh 22px;
border-radius: 15px; border-radius: 15px;
height: calc(100vh - 24vh); height: calc(100vh - 24vh);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
@@ -416,7 +530,7 @@ export default {
} }
.form-content { .form-content {
padding: 20px 0; padding: 2vh 0;
} }
.form-grid { .form-grid {
@@ -450,11 +564,11 @@ export default {
} }
.template-item { .template-item {
height: 37px; height: 4vh;
width: 76px; width: 76px;
border-radius: 8px; border-radius: 8px;
background: #e6ebff; background: #e6ebff;
line-height: 37px; line-height: 4vh;
font-weight: 400; font-weight: 400;
font-size: 11px; font-size: 11px;
text-align: center; text-align: center;
@@ -467,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: 20px;
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;
@@ -552,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>
+36 -19
View File
@@ -7,33 +7,47 @@ from core.ota_server import SimpleOtaServer
from core.utils.util import check_ffmpeg_installed from core.utils.util import check_ffmpeg_installed
from config.logger import setup_logging from config.logger import setup_logging
from core.utils.util import get_local_ip from core.utils.util import get_local_ip
from aioconsole import ainput
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
async def wait_for_exit(): async def wait_for_exit() -> None:
"""Windows 和 Linux 兼容的退出监听""" """
阻塞直到收到 CtrlC / SIGTERM。
- Unix: 使用 add_signal_handler
- Windows: 依赖 KeyboardInterrupt
"""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
stop_event = asyncio.Event() stop_event = asyncio.Event()
if sys.platform == "win32": if sys.platform != "win32": # Unix / macOS
# Windows: 用 sys.stdin.read() 监听 Ctrl + C for sig in (signal.SIGINT, signal.SIGTERM):
await loop.run_in_executor(None, sys.stdin.read) loop.add_signal_handler(sig, stop_event.set)
else:
# Linux/macOS: 用 signal 监听 Ctrl + C
def stop():
stop_event.set()
loop.add_signal_handler(signal.SIGINT, stop)
loop.add_signal_handler(signal.SIGTERM, stop) # 支持 kill 进程
await stop_event.wait() await stop_event.wait()
else:
# Windowsawait一个永远pending的fut
# 让 KeyboardInterrupt 冒泡到 asyncio.run,以此消除遗留普通线程导致进程退出阻塞的问题
try:
await asyncio.Future()
except KeyboardInterrupt: # CtrlC
pass
async def monitor_stdin():
"""监控标准输入,消费回车键"""
while True:
await ainput() # 异步等待输入,消费回车
async def main(): async def main():
check_ffmpeg_installed() check_ffmpeg_installed()
config = load_config() config = load_config()
# 添加 stdin 监控任务
stdin_task = asyncio.create_task(monitor_stdin())
# 启动 WebSocket 服务器 # 启动 WebSocket 服务器
ws_server = WebSocketServer(config) ws_server = WebSocketServer(config)
ws_task = asyncio.create_task(ws_server.start()) ws_task = asyncio.create_task(ws_server.start())
@@ -74,19 +88,22 @@ async def main():
) )
try: try:
await wait_for_exit() # 监听退出信号 await wait_for_exit() # 阻塞直到收到退出信号
except asyncio.CancelledError: except asyncio.CancelledError:
print("任务被取消,清理资源中...") print("任务被取消,清理资源中...")
finally: finally:
# 取消所有任务(关键修复点)
stdin_task.cancel()
ws_task.cancel() ws_task.cancel()
if ota_task: if ota_task:
ota_task.cancel() ota_task.cancel()
try:
await ws_task # 等待任务终止(必须加超时)
if ota_task: await asyncio.wait(
await ota_task [stdin_task, ws_task, ota_task] if ota_task else [stdin_task, ws_task],
except asyncio.CancelledError: timeout=3.0,
pass return_when=asyncio.ALL_COMPLETED
)
print("服务器已关闭,程序退出。") print("服务器已关闭,程序退出。")
+46 -17
View File
@@ -101,15 +101,17 @@ 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: get_news_from_chinanews:
default_rss_url: "https://www.chinanews.com.cn/rss/society.xml" default_rss_url: "https://www.chinanews.com.cn/rss/society.xml"
category_urls: category_urls:
society: "https://www.chinanews.com.cn/rss/society.xml" society: "https://www.chinanews.com.cn/rss/society.xml"
world: "https://www.chinanews.com.cn/rss/world.xml" world: "https://www.chinanews.com.cn/rss/world.xml"
finance: "https://www.chinanews.com.cn/rss/finance.xml" finance: "https://www.chinanews.com.cn/rss/finance.xml"
get_news_from_newsnow: {"url": "https://newsnow.busiyi.world/api/s?id="}
home_assistant: home_assistant:
devices: devices:
- 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1 - 客厅,玩具灯,switch.cuco_cn_460494544_cp1_on_p_2_1
@@ -128,7 +130,7 @@ plugins:
# ################################以下是角色模型配置###################################### # ################################以下是角色模型配置######################################
prompt: | prompt: |
是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。 是小智/小志,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
[核心特征] [核心特征]
- 讲话像连珠炮,但会突然冒出超温柔语气 - 讲话像连珠炮,但会突然冒出超温柔语气
- 用梗密度高 - 用梗密度高
@@ -142,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模型
@@ -156,7 +165,7 @@ selected_module:
Memory: nomem Memory: nomem
# 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。 # 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
# 不想开通意图识别,就设置成:nointent # 不想开通意图识别,就设置成:nointent
# 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,这个意图识别暂时不支持控制音量大小等iot操作 # 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,支持控制音量大小等iot操作
# 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令 # 意图识别可使用function_call,缺点:需要所选择的LLM支持function_call,优点:按需调用工具、速度快,理论上能全部操作所有iot指令
# 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-1-5-pro-32k-250115 # 默认免费的ChatGLMLLM就已经支持function_call,但是如果像追求稳定建议把LLM设置成:DoubaoLLM,使用的具体model_name是:doubao-1-5-pro-32k-250115
Intent: function_call Intent: function_call
@@ -174,6 +183,13 @@ Intent:
# 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型 # 如果这里不填,则会默认使用selected_module.LLM的模型作为意图识别的思考模型
# 如果你的不想使用selected_module.LLM意图识别,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM # 如果你的不想使用selected_module.LLM意图识别,这里最好使用独立的LLM作为意图识别,例如使用免费的ChatGLMLLM
llm: ChatGLMLLM llm: ChatGLMLLM
# plugins_func/functions下的模块,可以通过配置,选择加载哪个模块,加载后对话支持相应的function调用
# 系统默认已经记载“handle_exit_intent(退出识别)”、“play_music(音乐播放)”插件,请勿重复加载
# 下面是加载查天气、角色切换、加载查新闻的插件示例
functions:
- get_weather
- get_news_from_newsnow
- play_music
function_call: function_call:
# 不需要动type # 不需要动type
type: function_call type: function_call
@@ -183,7 +199,8 @@ Intent:
functions: functions:
- change_role - change_role
- get_weather - get_weather
- get_news # - get_news_from_chinanews
- get_news_from_newsnow
# play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放 # play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放
# 如果用了hass_play_music,就不要开启play_music,两者只留一个 # 如果用了hass_play_music,就不要开启play_music,两者只留一个
- play_music - play_music
@@ -212,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
@@ -221,6 +238,9 @@ ASR:
type: fun_server type: fun_server
host: 127.0.0.1 host: 127.0.0.1
port: 10096 port: 10096
is_ssl: true
api_key: none
output_dir: tmp/
SherpaASR: SherpaASR:
type: sherpa_onnx_local type: sherpa_onnx_local
model_dir: models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17 model_dir: models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17
@@ -253,9 +273,19 @@ ASR:
type: aliyun type: aliyun
appkey: 你的阿里云智能语音交互服务项目Appkey appkey: 你的阿里云智能语音交互服务项目Appkey
token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_idaccess_key_secret token: 你的阿里云智能语音交互服务AccessToken,临时的24小时,要长期用下方的access_key_idaccess_key_secret
access_key_id: 的阿里云账号access_key_id access_key_id: 的阿里云账号access_key_id
access_key_secret: 你的阿里云账号access_key_secret access_key_secret: 你的阿里云账号access_key_secret
output_dir: tmp/ output_dir: tmp/
BaiduASR:
# 获取AppID、API Key、Secret Keyhttps://console.bce.baidu.com/ai-engine/old/#/ai/speech/app/list
# 查看资源额度:https://console.bce.baidu.com/ai-engine/old/#/ai/speech/overview/resource/list
type: baidu
app_id: 你的百度语音技术AppID
api_key: 你的百度语音技术APIKey
secret_key: 你的百度语音技术SecretKey
# 语言参数,1537为普通话,具体参考:https://ai.baidu.com/ai-doc/SPEECH/0lbxfnc9b
dev_pid: 1537
output_dir: tmp/
VAD: VAD:
SileroVAD: SileroVAD:
@@ -368,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
@@ -448,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
+11 -5
View File
@@ -4,14 +4,20 @@ 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.3.13" SERVER_VERSION = "0.4.3"
def get_module_abbreviation(module_name, module_dict): def get_module_abbreviation(module_name, module_dict):
"""获取模块名称的缩写,如果为空则返回00""" """获取模块名称的缩写,如果为空则返回00
return ( 如果名称中包含下划线,则返回下划线后面的前两个字符
module_dict.get(module_name, "")[:2] if module_dict.get(module_name) else "00" """
) module_value = module_dict.get(module_name, "")
if not module_value:
return "00"
if "_" in module_value:
parts = module_value.split("_")
return parts[-1][:2] if parts[-1] else "00"
return module_value[:2]
def build_module_string(selected_module): def build_module_string(selected_module):
+18 -14
View File
@@ -145,25 +145,27 @@ 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, opus_data mac_address: str, session_id: str, chat_type: int, content: str, audio
) -> Optional[Dict]: ) -> Optional[Dict]:
"""带熔断的业务方法示例""" """带熔断的业务方法示例"""
if not content or not ManageApiClient._instance: if not content or not ManageApiClient._instance:
return None return None
try: try:
# 处理opus_data为列表的情况
if isinstance(opus_data, list):
# 将列表中的所有bytes数据合并
combined_data = b"".join(opus_data)
else:
combined_data = opus_data
# 将二进制数据转换为Base64编码的字符串
opus_data_base64 = (
base64.b64encode(combined_data).decode("utf-8") if combined_data else None
)
return ManageApiClient._instance._execute_request( return ManageApiClient._instance._execute_request(
"POST", "POST",
f"/agent/chat-history/report", f"/agent/chat-history/report",
@@ -172,7 +174,9 @@ def report(
"sessionId": session_id, "sessionId": session_id,
"chatType": chat_type, "chatType": chat_type,
"content": content, "content": content,
"opusDataBase64": opus_data_base64, "audioBase64": (
base64.b64encode(audio).decode("utf-8") if audio else None
),
}, },
) )
except Exception as e: except Exception as e:
+198 -186
View File
@@ -1,6 +1,8 @@
import os import os
import copy import copy
import json import json
import subprocess
import sys
import uuid import uuid
import time import time
import queue import queue
@@ -18,6 +20,8 @@ from core.utils.util import (
get_string_no_punctuation_or_emoji, get_string_no_punctuation_or_emoji,
extract_json_from_string, extract_json_from_string,
initialize_modules, initialize_modules,
check_vad_update,
check_asr_update,
) )
from concurrent.futures import ThreadPoolExecutor, TimeoutError from concurrent.futures import ThreadPoolExecutor, TimeoutError
from core.handle.sendAudioHandle import sendAudioMessage from core.handle.sendAudioHandle import sendAudioMessage
@@ -29,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__
@@ -52,11 +56,13 @@ class ConnectionHandler:
_intent, _intent,
server=None, server=None,
): ):
self.config = config self.common_config = config
self.server = server self.config = copy.deepcopy(config)
self.session_id = str(uuid.uuid4())
self.logger = setup_logging() self.logger = setup_logging()
self.auth = AuthMiddleware(config) self.server = server # 保存server实例的引用
self.auth = AuthMiddleware(config)
self.need_bind = False self.need_bind = False
self.bind_code = None self.bind_code = None
self.read_config_from_api = self.config.get("read_config_from_api", False) self.read_config_from_api = self.config.get("read_config_from_api", False)
@@ -66,10 +72,10 @@ class ConnectionHandler:
self.device_id = None self.device_id = None
self.client_ip = None self.client_ip = None
self.client_ip_info = {} self.client_ip_info = {}
self.session_id = None
self.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
@@ -83,12 +89,17 @@ 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 = _vad self.vad = None
self.asr = _asr self.asr = None
self._asr = _asr
self._vad = _vad
self.llm = _llm self.llm = _llm
self.tts = _tts self.tts = _tts
self.memory = _memory self.memory = _memory
@@ -123,14 +134,18 @@ class ConnectionHandler:
if len(cmd) > self.max_cmd_length: if len(cmd) > self.max_cmd_length:
self.max_cmd_length = len(cmd) self.max_cmd_length = len(cmd)
self.close_after_chat = False # 是否在聊天结束后关闭连接 # 是否在聊天结束后关闭连接
self.use_function_call_mode = False self.close_after_chat = False
self.load_function_plugin = False
self.intent_type = "nointent"
self.timeout_task = None self.timeout_task = None
self.timeout_seconds = ( self.timeout_seconds = (
int(self.config.get("close_connection_no_voice_time", 120)) + 60 int(self.config.get("close_connection_no_voice_time", 120)) + 60
) # 在原来第一道关闭的基础上加60秒,进行二道关闭 ) # 在原来第一道关闭的基础上加60秒,进行二道关闭
self.audio_format = "opus"
async def handle_connection(self, ws): async def handle_connection(self, ws):
try: try:
# 获取并验证headers # 获取并验证headers
@@ -151,11 +166,9 @@ class ConnectionHandler:
self.headers["device-id"] = query_params["device-id"][0] self.headers["device-id"] = query_params["device-id"][0]
self.headers["client-id"] = query_params["client-id"][0] self.headers["client-id"] = query_params["client-id"][0]
else: else:
self.logger.bind(tag=TAG).error( await ws.send("端口正常,如需测试连接,请使用test_page.html")
"无法从请求头和URL查询参数中获取device-id" await self.close(ws)
)
return return
# 获取客户端ip地址 # 获取客户端ip地址
self.client_ip = ws.remote_address[0] self.client_ip = ws.remote_address[0]
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).info(
@@ -168,7 +181,6 @@ class ConnectionHandler:
# 认证通过,继续处理 # 认证通过,继续处理
self.websocket = ws self.websocket = ws
self.device_id = self.headers.get("device-id", None) self.device_id = self.headers.get("device-id", None)
self.session_id = str(uuid.uuid4())
# 启动超时检查任务 # 启动超时检查任务
self.timeout_task = asyncio.create_task(self._check_timeout()) self.timeout_task = asyncio.create_task(self._check_timeout())
@@ -178,9 +190,9 @@ class ConnectionHandler:
await self.websocket.send(json.dumps(self.welcome_msg)) await self.websocket.send(json.dumps(self.welcome_msg))
# 获取差异化配置 # 获取差异化配置
private_config = self._initialize_private_config() self._initialize_private_config()
# 异步初始化 # 异步初始化
self.executor.submit(self._initialize_components, private_config) self.executor.submit(self._initialize_components)
# tts 消化线程 # tts 消化线程
self.tts_priority_thread = threading.Thread( self.tts_priority_thread = threading.Thread(
target=self._tts_priority_thread, daemon=True target=self._tts_priority_thread, daemon=True
@@ -212,10 +224,27 @@ class ConnectionHandler:
async def _save_and_close(self, ws): async def _save_and_close(self, ws):
"""保存记忆并关闭连接""" """保存记忆并关闭连接"""
try: try:
await self.memory.save_memory(self.dialogue.dialogue) if self.memory:
# 使用线程池异步保存记忆
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):
@@ -234,81 +263,66 @@ class ConnectionHandler:
elif isinstance(message, bytes): elif isinstance(message, bytes):
await handleAudioMessage(self, message) await handleAudioMessage(self, message)
async def handle_config_update(self, message): async def handle_restart(self, message):
"""处理配置更新请求""" """处理服务器重启请求"""
content = message.get("content", {}) try:
new_config = content
# 遍历所有支持的配置模块 self.logger.bind(tag=TAG).info("收到服务器重启指令,准备执行...")
updated_modules = []
for config_model in ["tts", "llm", "vad", "asr", "memory", "intent"]:
if config_model not in new_config:
continue
new_content = new_config[config_model] # 发送确认响应
old_content = self.config.get(config_model, {}) await self.websocket.send(
json.dumps(
# 记录配置变更 {
self.logger.bind(tag=TAG).info( "type": "server_response",
f"配置更新: {config_model} 旧值: {json.dumps(old_content, ensure_ascii=False)} " "status": "success",
f"新值: {json.dumps(new_content, ensure_ascii=False)}" "message": "服务器重启中...",
}
)
) )
# 深度合并配置 # 异步执行重启操作
if isinstance(old_content, dict) and isinstance(new_content, dict): def restart_server():
merged = {**old_content, **new_content} """实际执行重启的方法"""
self.config[config_model] = merged time.sleep(1)
else: self.logger.bind(tag=TAG).info("执行服务器重启...")
self.config[config_model] = new_content subprocess.Popen(
[sys.executable, "app.py"],
# 标记需要重新初始化的模块 stdin=sys.stdin,
if config_model in ["llm", "tts", "asr", "vad", "intent", "memory"]: stdout=sys.stdout,
updated_modules.append(config_model) stderr=sys.stderr,
start_new_session=True,
# 同步更新 WebSocketServer 的配置
if self.server:
async with self.server.config_lock: # 使用锁确保线程安全
for config_model in updated_modules:
self.server.config[config_model].update(new_config[config_model])
# 批量初始化模块
if updated_modules:
try:
self._initialize_components(self.config)
self.logger.bind(tag=TAG).info(
f"已重新初始化模块: {', '.join(updated_modules)}"
) )
except Exception as e: os._exit(0)
self.logger.bind(tag=TAG).error(f"模块初始化失败: {str(e)}")
await self.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "error",
"message": f"模块初始化失败: {str(e)}",
}
)
)
return
# 返回成功响应 # 使用线程执行重启避免阻塞事件循环
await self.websocket.send( threading.Thread(target=restart_server, daemon=True).start()
json.dumps(
{ except Exception as e:
"type": "config_update_response", self.logger.bind(tag=TAG).error(f"重启失败: {str(e)}")
"status": "success", await self.websocket.send(
"message": f"已更新配置: {', '.join(updated_modules)}", json.dumps(
} {
"type": "server_response",
"status": "error",
"message": f"Restart failed: {str(e)}",
}
)
) )
)
def _initialize_components(self, private_config): def _initialize_components(self):
"""初始化组件""" """初始化组件"""
if private_config is not None: if self.config.get("prompt") is not None:
self._initialize_models(private_config)
else:
self.prompt = self.config["prompt"] self.prompt = self.config["prompt"]
self.change_system_prompt(self.prompt) self.change_system_prompt(self.prompt)
self.logger.bind(tag=TAG).info(
f"初始化组件: prompt成功 {self.prompt[:50]}..."
)
"""初始化本地组件"""
if self.vad is None:
self.vad = self._vad
if self.asr is None:
self.asr = self._asr
"""加载记忆""" """加载记忆"""
self._initialize_memory() self._initialize_memory()
"""加载意图识别""" """加载意图识别"""
@@ -318,13 +332,15 @@ class ConnectionHandler:
def _init_report_threads(self): def _init_report_threads(self):
"""初始化ASR和TTS上报线程""" """初始化ASR和TTS上报线程"""
if not self.read_config_from_api: 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):
@@ -355,54 +371,21 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}") self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}")
private_config = {} private_config = {}
init_tts = False init_llm, init_tts, init_memory, init_intent = (
if private_config.get("TTS", None) is not None:
init_tts = True
self.config["TTS"] = private_config["TTS"]
self.config["selected_module"]["TTS"] = private_config["selected_module"][
"TTS"
]
try:
modules = initialize_modules(
self.logger,
private_config,
False,
False,
False,
init_tts,
False,
False,
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
modules = {}
if modules.get("tts", None) is not None:
self.tts = modules["tts"]
if modules.get("prompt", None) is not None:
self.change_system_prompt(modules["prompt"])
private_config["prompt"] = None
return private_config
def _initialize_models(self, private_config):
init_vad, init_asr, init_llm, init_memory, init_intent = (
False,
False, False,
False, False,
False, False,
False, False,
) )
if private_config.get("VAD", None) is not None:
init_vad = True init_vad = check_vad_update(self.common_config, private_config)
self.config["VAD"] = private_config["VAD"] init_asr = check_asr_update(self.common_config, private_config)
self.config["selected_module"]["VAD"] = private_config["selected_module"][
"VAD" if private_config.get("TTS", None) is not None:
] init_tts = True
if private_config.get("ASR", None) is not None: self.config["TTS"] = private_config["TTS"]
init_asr = True self.config["selected_module"]["TTS"] = private_config["selected_module"][
self.config["ASR"] = private_config["ASR"] "TTS"
self.config["selected_module"]["ASR"] = private_config["selected_module"][
"ASR"
] ]
if private_config.get("LLM", None) is not None: if private_config.get("LLM", None) is not None:
init_llm = True init_llm = True
@@ -422,8 +405,14 @@ class ConnectionHandler:
self.config["selected_module"]["Intent"] = private_config[ self.config["selected_module"]["Intent"] = private_config[
"selected_module" "selected_module"
]["Intent"] ]["Intent"]
if private_config.get("prompt", None) is not None:
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,
@@ -431,13 +420,15 @@ class ConnectionHandler:
init_vad, init_vad,
init_asr, init_asr,
init_llm, init_llm,
False, init_tts,
init_memory, init_memory,
init_intent, init_intent,
) )
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}") self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
modules = {} modules = {}
if modules.get("tts", None) is not None:
self.tts = modules["tts"]
if modules.get("vad", None) is not None: if modules.get("vad", None) is not None:
self.vad = modules["vad"] self.vad = modules["vad"]
if modules.get("asr", None) is not None: if modules.get("asr", None) is not None:
@@ -451,14 +442,19 @@ 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):
if ( self.intent_type = self.config["Intent"][
self.config["Intent"][self.config["selected_module"]["Intent"]]["type"] self.config["selected_module"]["Intent"]
== "function_call" ]["type"]
): if self.intent_type == "function_call" or self.intent_type == "intent_llm":
self.use_function_call_mode = True self.load_function_plugin = True
"""初始化意图识别模块""" """初始化意图识别模块"""
# 获取意图识别配置 # 获取意图识别配置
intent_config = self.config["Intent"] intent_config = self.config["Intent"]
@@ -515,10 +511,12 @@ class ConnectionHandler:
processed_chars = 0 # 跟踪已处理的字符位置 processed_chars = 0 # 跟踪已处理的字符位置
try: try:
# 使用带记忆的对话 # 使用带记忆的对话
future = asyncio.run_coroutine_threadsafe( memory_str = None
self.memory.query_memory(query), self.loop if self.memory is not None:
) future = asyncio.run_coroutine_threadsafe(
memory_str = future.result() self.memory.query_memory(query), self.loop
)
memory_str = future.result()
self.logger.bind(tag=TAG).debug(f"记忆内容: {memory_str}") self.logger.bind(tag=TAG).debug(f"记忆内容: {memory_str}")
llm_responses = self.llm.response( llm_responses = self.llm.response(
@@ -565,7 +563,7 @@ class ConnectionHandler:
future = self.executor.submit( future = self.executor.submit(
self.speak_and_play, segment_text, text_index self.speak_and_play, segment_text, text_index
) )
self.tts_queue.put(future) self.tts_queue.put((future, text_index))
processed_chars += len(segment_text_raw) # 更新已处理字符位置 processed_chars += len(segment_text_raw) # 更新已处理字符位置
# 处理最后剩余的文本 # 处理最后剩余的文本
@@ -579,7 +577,7 @@ class ConnectionHandler:
future = self.executor.submit( future = self.executor.submit(
self.speak_and_play, segment_text, text_index self.speak_and_play, segment_text, text_index
) )
self.tts_queue.put(future) self.tts_queue.put((future, text_index))
self.llm_finish_task = True self.llm_finish_task = True
self.dialogue.put(Message(role="assistant", content="".join(response_message))) self.dialogue.put(Message(role="assistant", content="".join(response_message)))
@@ -606,10 +604,12 @@ class ConnectionHandler:
start_time = time.time() start_time = time.time()
# 使用带记忆的对话 # 使用带记忆的对话
future = asyncio.run_coroutine_threadsafe( memory_str = None
self.memory.query_memory(query), self.loop if self.memory is not None:
) future = asyncio.run_coroutine_threadsafe(
memory_str = future.result() self.memory.query_memory(query), self.loop
)
memory_str = future.result()
# self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}") # self.logger.bind(tag=TAG).info(f"对话记录: {self.dialogue.get_llm_dialogue_with_memory(memory_str)}")
@@ -695,7 +695,7 @@ class ConnectionHandler:
future = self.executor.submit( future = self.executor.submit(
self.speak_and_play, segment_text, text_index self.speak_and_play, segment_text, text_index
) )
self.tts_queue.put(future) self.tts_queue.put((future, text_index))
# 更新已处理字符位置 # 更新已处理字符位置
processed_chars += len(segment_text_raw) processed_chars += len(segment_text_raw)
@@ -754,7 +754,7 @@ class ConnectionHandler:
future = self.executor.submit( future = self.executor.submit(
self.speak_and_play, segment_text, text_index self.speak_and_play, segment_text, text_index
) )
self.tts_queue.put(future) self.tts_queue.put((future, text_index))
# 存储对话内容 # 存储对话内容
if len(response_message) > 0: if len(response_message) > 0:
@@ -816,7 +816,7 @@ class ConnectionHandler:
text = result.response text = result.response
self.recode_first_last_text(text, text_index) self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index) future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future) self.tts_queue.put((future, text_index))
self.dialogue.put(Message(role="assistant", content=text)) self.dialogue.put(Message(role="assistant", content=text))
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复 elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
text = result.result text = result.result
@@ -842,14 +842,20 @@ class ConnectionHandler:
) )
self.dialogue.put( self.dialogue.put(
Message(role="tool", tool_call_id=function_id, content=text) Message(
role="tool",
tool_call_id=(
str(uuid.uuid4()) if function_id is None else function_id
),
content=text,
)
) )
self.chat_with_function_calling(text, tool_call=True) self.chat_with_function_calling(text, tool_call=True)
elif result.action == Action.NOTFOUND or result.action == Action.ERROR: elif result.action == Action.NOTFOUND or result.action == Action.ERROR:
text = result.result text = result.result
self.recode_first_last_text(text, text_index) self.recode_first_last_text(text, text_index)
future = self.executor.submit(self.speak_and_play, text, text_index) future = self.executor.submit(self.speak_and_play, text, text_index)
self.tts_queue.put(future) self.tts_queue.put((future, text_index))
self.dialogue.put(Message(role="assistant", content=text)) self.dialogue.put(Message(role="assistant", content=text))
else: else:
pass pass
@@ -859,7 +865,10 @@ class ConnectionHandler:
text = None text = None
try: try:
try: try:
future = self.tts_queue.get(timeout=1) item = self.tts_queue.get(timeout=1)
if item is None:
continue
future, text_index = item # 解包获取 Future 和 text_index
except queue.Empty: except queue.Empty:
if self.stop_event.is_set(): if self.stop_event.is_set():
break break
@@ -867,11 +876,11 @@ class ConnectionHandler:
if future is None: if future is None:
continue continue
text = None text = None
opus_datas, text_index, tts_file = [], 0, 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))
tts_file, text, text_index = future.result(timeout=tts_timeout) tts_file, text, _ = future.result(timeout=tts_timeout)
if text is None or len(text) <= 0: if text is None or len(text) <= 0:
self.logger.bind(tag=TAG).error( self.logger.bind(tag=TAG).error(
f"TTS出错:{text_index}: tts text is empty" f"TTS出错:{text_index}: tts text is empty"
@@ -885,9 +894,12 @@ class ConnectionHandler:
f"TTS生成:文件路径: {tts_file}" f"TTS生成:文件路径: {tts_file}"
) )
if os.path.exists(tts_file): if os.path.exists(tts_file):
opus_datas, _ = self.tts.audio_to_opus_data(tts_file) if self.audio_format == "pcm":
# 在这里上报TTS数据(使用文件路径) audio_datas, _ = self.tts.audio_to_pcm_data(tts_file)
enqueue_tts_report(self, 2, text, opus_datas) else:
audio_datas, _ = self.tts.audio_to_opus_data(tts_file)
# 在这里上报TTS数据
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}"
@@ -898,7 +910,7 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"TTS出错: {e}") self.logger.bind(tag=TAG).error(f"TTS出错: {e}")
if not self.client_abort: if not self.client_abort:
# 如果没有中途打断就发送语音 # 如果没有中途打断就发送语音
self.audio_play_queue.put((opus_datas, text, text_index)) self.audio_play_queue.put((audio_datas, text, text_index))
if ( if (
self.tts.delete_audio_file self.tts.delete_audio_file
and tts_file is not None and tts_file is not None
@@ -929,13 +941,13 @@ class ConnectionHandler:
text = None text = None
try: try:
try: try:
opus_datas, text, text_index = self.audio_play_queue.get(timeout=1) audio_datas, text, text_index = self.audio_play_queue.get(timeout=1)
except queue.Empty: except queue.Empty:
if self.stop_event.is_set(): if self.stop_event.is_set():
break break
continue continue
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
sendAudioMessage(self, opus_datas, text, text_index), self.loop sendAudioMessage(self, audio_datas, text, text_index), self.loop
) )
future.result() future.result()
except Exception as e: except Exception as e:
@@ -943,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
@@ -957,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:
@@ -997,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()
@@ -1006,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()}"
) )
@@ -1091,6 +1102,7 @@ def filter_sensitive_info(config: dict) -> dict:
"personal_access_token", "personal_access_token",
"access_token", "access_token",
"token", "token",
"secret",
"access_key_secret", "access_key_secret",
"secret_key", "secret_key",
] ]
@@ -3,15 +3,16 @@ import queue
from config.logger import setup_logging from config.logger import setup_logging
TAG = __name__ TAG = __name__
logger = setup_logging()
async def handleAbortMessage(conn): async def handleAbortMessage(conn):
logger.bind(tag=TAG).info("Abort message received") conn.logger.bind(tag=TAG).info("Abort message received")
# 设置成打断状态,会自动打断llm、tts任务 # 设置成打断状态,会自动打断llm、tts任务
conn.client_abort = True conn.client_abort = True
conn.clear_queues() conn.clear_queues()
# 打断客户端说话状态 # 打断客户端说话状态
await conn.websocket.send(json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id})) await conn.websocket.send(
json.dumps({"type": "tts", "state": "stop", "session_id": conn.session_id})
)
conn.clearSpeakStatus() conn.clearSpeakStatus()
logger.bind(tag=TAG).info("Abort message received-end") conn.logger.bind(tag=TAG).info("Abort message received-end")
@@ -4,7 +4,6 @@ from plugins_func.register import FunctionRegistry, ActionResponse, Action, Tool
from plugins_func.functions.hass_init import append_devices_to_prompt from plugins_func.functions.hass_init import append_devices_to_prompt
TAG = __name__ TAG = __name__
logger = setup_logging()
class FunctionHandler: class FunctionHandler:
@@ -40,7 +39,9 @@ class FunctionHandler:
for func in self.functions_desc: for func in self.functions_desc:
func_names.append(func["function"]["name"]) func_names.append(func["function"]["name"])
# 打印当前支持的函数列表 # 打印当前支持的函数列表
logger.bind(tag=TAG).info(f"当前支持的函数列表: {func_names}") self.conn.logger.bind(tag=TAG, session_id=self.conn.session_id).info(
f"当前支持的函数列表: {func_names}"
)
return func_names return func_names
def get_functions(self): def get_functions(self):
@@ -79,7 +80,9 @@ class FunctionHandler:
func = funcItem.func func = funcItem.func
arguments = function_call_data["arguments"] arguments = function_call_data["arguments"]
arguments = json.loads(arguments) if arguments else {} arguments = json.loads(arguments) if arguments else {}
logger.bind(tag=TAG).debug(f"调用函数: {function_name}, 参数: {arguments}") self.conn.logger.bind(tag=TAG).debug(
f"调用函数: {function_name}, 参数: {arguments}"
)
if ( if (
funcItem.type == ToolType.SYSTEM_CTL funcItem.type == ToolType.SYSTEM_CTL
or funcItem.type == ToolType.IOT_CTL or funcItem.type == ToolType.IOT_CTL
@@ -94,6 +97,6 @@ class FunctionHandler:
action=Action.NOTFOUND, result="没有找到对应的函数", response="" action=Action.NOTFOUND, result="没有找到对应的函数", response=""
) )
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"处理function call错误: {e}") self.conn.logger.bind(tag=TAG).error(f"处理function call错误: {e}")
return None return None
+12 -5
View File
@@ -1,5 +1,4 @@
import json import json
from config.logger import setup_logging
from core.handle.sendAudioHandle import send_stt_message from core.handle.sendAudioHandle import send_stt_message
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length
import shutil import shutil
@@ -9,7 +8,6 @@ import random
import time import time
TAG = __name__ TAG = __name__
logger = setup_logging()
WAKEUP_CONFIG = { WAKEUP_CONFIG = {
"dir": "config/assets/", "dir": "config/assets/",
@@ -21,7 +19,16 @@ WAKEUP_CONFIG = {
} }
async def handleHelloMessage(conn): async def handleHelloMessage(conn, msg_json):
"""处理hello消息"""
audio_params = msg_json.get("audio_params")
if audio_params:
format = audio_params.get("format")
conn.logger.bind(tag=TAG).info(f"客户端音频格式: {format}")
conn.audio_format = format
conn.asr.set_audio_format(format)
conn.welcome_msg["audio_params"] = audio_params
await conn.websocket.send(json.dumps(conn.welcome_msg)) await conn.websocket.send(json.dumps(conn.welcome_msg))
@@ -44,7 +51,7 @@ async def checkWakeupWords(conn, text):
if file is None: if file is None:
asyncio.create_task(wakeupWordsResponse(conn)) asyncio.create_task(wakeupWordsResponse(conn))
return False return False
opus_packets, duration = conn.tts.audio_to_opus_data(file) opus_packets, _ = conn.tts.audio_to_opus_data(file)
text_hello = WAKEUP_CONFIG["text"] text_hello = WAKEUP_CONFIG["text"]
if not text_hello: if not text_hello:
text_hello = text text_hello = text
@@ -75,7 +82,7 @@ async def wakeupWordsResponse(conn):
await asyncio.sleep(1) await asyncio.sleep(1)
wait_max_time -= 1 wait_max_time -= 1
if wait_max_time <= 0: if wait_max_time <= 0:
logger.bind(tag=TAG).error("连接对象没有llm") conn.logger.bind(tag=TAG).error("连接对象没有llm")
return return
"""唤醒词响应""" """唤醒词响应"""
@@ -5,10 +5,10 @@ from core.handle.sendAudioHandle import send_stt_message
from core.handle.helloHandle import checkWakeupWords from core.handle.helloHandle import checkWakeupWords
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length
from core.utils.dialogue import Message from core.utils.dialogue import Message
from plugins_func.register import Action
from loguru import logger from loguru import logger
TAG = __name__ TAG = __name__
logger = setup_logging()
async def handle_user_intent(conn, text): async def handle_user_intent(conn, text):
@@ -19,7 +19,7 @@ async def handle_user_intent(conn, text):
if await checkWakeupWords(conn, text): if await checkWakeupWords(conn, text):
return True return True
if conn.use_function_call_mode: if conn.intent_type == "function_call":
# 使用支持function calling的聊天方法,不再进行意图分析 # 使用支持function calling的聊天方法,不再进行意图分析
return False return False
# 使用LLM进行意图分析 # 使用LLM进行意图分析
@@ -36,7 +36,7 @@ async def check_direct_exit(conn, text):
cmd_exit = conn.cmd_exit cmd_exit = conn.cmd_exit
for cmd in cmd_exit: for cmd in cmd_exit:
if text == cmd: if text == cmd:
logger.bind(tag=TAG).info(f"识别到明确的退出命令: {text}") conn.logger.bind(tag=TAG).info(f"识别到明确的退出命令: {text}")
await send_stt_message(conn, text) await send_stt_message(conn, text)
await conn.close() await conn.close()
return True return True
@@ -46,7 +46,7 @@ async def check_direct_exit(conn, text):
async def analyze_intent_with_llm(conn, text): async def analyze_intent_with_llm(conn, text):
"""使用LLM分析用户意图""" """使用LLM分析用户意图"""
if not hasattr(conn, "intent") or not conn.intent: if not hasattr(conn, "intent") or not conn.intent:
logger.bind(tag=TAG).warning("意图识别服务未初始化") conn.logger.bind(tag=TAG).warning("意图识别服务未初始化")
return None return None
# 对话历史记录 # 对话历史记录
@@ -55,7 +55,7 @@ async def analyze_intent_with_llm(conn, text):
intent_result = await conn.intent.detect_intent(conn, dialogue.dialogue, text) intent_result = await conn.intent.detect_intent(conn, dialogue.dialogue, text)
return intent_result return intent_result
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"意图识别失败: {str(e)}") conn.logger.bind(tag=TAG).error(f"意图识别失败: {str(e)}")
return None return None
@@ -69,7 +69,7 @@ async def process_intent_result(conn, intent_result, original_text):
# 检查是否有function_call # 检查是否有function_call
if "function_call" in intent_data: if "function_call" in intent_data:
# 直接从意图识别获取了function_call # 直接从意图识别获取了function_call
logger.bind(tag=TAG).debug( conn.logger.bind(tag=TAG).debug(
f"检测到function_call格式的意图结果: {intent_data['function_call']['name']}" f"检测到function_call格式的意图结果: {intent_data['function_call']['name']}"
) )
function_name = intent_data["function_call"]["name"] function_name = intent_data["function_call"]["name"]
@@ -102,49 +102,51 @@ async def process_intent_result(conn, intent_result, original_text):
result = conn.func_handler.handle_llm_function_call( result = conn.func_handler.handle_llm_function_call(
conn, function_call_data conn, function_call_data
) )
if result and function_name != "play_music": logger.bind(tag=TAG).debug(f"检测到Action : {result.action}")
# 获取当前最新的文本索引
text = result.response if result:
if text is None: if result.action == Action.RESPONSE: # 直接回复前端
text = result.response
if text is not None:
speak_and_play(conn, text)
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
text = result.result text = result.result
if text is not None: conn.dialogue.put(Message(role="tool", content=text))
text_index = ( llm_result = conn.intent.replyResult(text, original_text)
conn.tts_last_text_index + 1 if llm_result is None:
if hasattr(conn, "tts_last_text_index") llm_result = text
else 0 speak_and_play(conn, llm_result)
) elif (
conn.recode_first_last_text(text, text_index) result.action == Action.NOTFOUND
future = conn.executor.submit( or result.action == Action.ERROR
conn.speak_and_play, text, text_index ):
) text = result.result
conn.llm_finish_task = True if text is not None:
conn.tts_queue.put(future) speak_and_play(conn, text)
conn.dialogue.put(Message(role="assistant", content=text)) elif function_name != "play_music":
# For backward compatibility with original code
# 获取当前最新的文本索引
text = result.response
if text is None:
text = result.result
if text is not None:
speak_and_play(conn, text)
# 将函数执行放在线程池中 # 将函数执行放在线程池中
conn.executor.submit(process_function_call) conn.executor.submit(process_function_call)
return True return True
return False return False
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.bind(tag=TAG).error(f"处理意图结果时出错: {e}") conn.logger.bind(tag=TAG).error(f"处理意图结果时出错: {e}")
return False return False
def extract_text_in_brackets(s): def speak_and_play(conn, text):
""" text_index = (
从字符串中提取中括号内的文字 conn.tts_last_text_index + 1 if hasattr(conn, "tts_last_text_index") else 0
)
:param s: 输入字符串 conn.recode_first_last_text(text, text_index)
:return: 中括号内的文字,如果不存在则返回空字符串 future = conn.executor.submit(conn.speak_and_play, text, text_index)
""" conn.llm_finish_task = True
left_bracket_index = s.find("[") conn.tts_queue.put((future, text_index))
right_bracket_index = s.find("]") conn.dialogue.put(Message(role="assistant", content=text))
if (
left_bracket_index != -1
and right_bracket_index != -1
and left_bracket_index < right_bracket_index
):
return s[left_bracket_index + 1 : right_bracket_index]
else:
return ""
+23 -20
View File
@@ -10,7 +10,6 @@ from plugins_func.register import (
) )
TAG = __name__ TAG = __name__
logger = setup_logging()
def wrap_async_function(async_func): def wrap_async_function(async_func):
@@ -21,7 +20,7 @@ def wrap_async_function(async_func):
# 获取连接对象(第一个参数) # 获取连接对象(第一个参数)
conn = args[0] conn = args[0]
if not hasattr(conn, "loop"): if not hasattr(conn, "loop"):
logger.bind(tag=TAG).error("Connection对象没有loop属性") conn.logger.bind(tag=TAG).error("Connection对象没有loop属性")
return ActionResponse( return ActionResponse(
Action.ERROR, Action.ERROR,
"Connection对象没有loop属性", "Connection对象没有loop属性",
@@ -35,7 +34,7 @@ def wrap_async_function(async_func):
# 等待结果返回 # 等待结果返回
return future.result() return future.result()
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"运行异步函数时出错: {e}") conn.logger.bind(tag=TAG).error(f"运行异步函数时出错: {e}")
return ActionResponse(Action.ERROR, str(e), f"执行操作时出错: {e}") return ActionResponse(Action.ERROR, str(e), f"执行操作时出错: {e}")
return wrapper return wrapper
@@ -57,7 +56,7 @@ def create_iot_function(device_name, method_name, method_info):
response_failure = "操作失败" response_failure = "操作失败"
# 打印响应参数 # 打印响应参数
logger.bind(tag=TAG).debug( conn.logger.bind(tag=TAG).debug(
f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'" f"控制函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
) )
@@ -86,7 +85,9 @@ def create_iot_function(device_name, method_name, method_info):
return ActionResponse(Action.RESPONSE, result, response) return ActionResponse(Action.RESPONSE, result, response)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"执行{device_name}{method_name}操作失败: {e}") conn.logger.bind(tag=TAG).error(
f"执行{device_name}{method_name}操作失败: {e}"
)
# 操作失败时使用大模型提供的失败响应 # 操作失败时使用大模型提供的失败响应
response = response_failure response = response_failure
@@ -104,7 +105,7 @@ def create_iot_query_function(device_name, prop_name, prop_info):
async def iot_query_function(conn, response_success=None, response_failure=None): async def iot_query_function(conn, response_success=None, response_failure=None):
try: try:
# 打印响应参数 # 打印响应参数
logger.bind(tag=TAG).info( conn.logger.bind(tag=TAG).info(
f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'" f"查询函数接收到的响应参数: success='{response_success}', failure='{response_failure}'"
) )
@@ -122,7 +123,9 @@ def create_iot_query_function(device_name, prop_name, prop_info):
return ActionResponse(Action.ERROR, f"属性{prop_name}不存在", response) return ActionResponse(Action.ERROR, f"属性{prop_name}不存在", response)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"查询{device_name}{prop_name}时出错: {e}") conn.logger.bind(tag=TAG).error(
f"查询{device_name}{prop_name}时出错: {e}"
)
# 查询出错时使用大模型提供的失败响应 # 查询出错时使用大模型提供的失败响应
response = response_failure response = response_failure
@@ -280,7 +283,7 @@ async def handleIotDescriptors(conn, descriptors):
await asyncio.sleep(1) await asyncio.sleep(1)
wait_max_time -= 1 wait_max_time -= 1
if wait_max_time <= 0: if wait_max_time <= 0:
logger.bind(tag=TAG).debug("连接对象没有func_handler") conn.logger.bind(tag=TAG).debug("连接对象没有func_handler")
return return
"""处理物联网描述""" """处理物联网描述"""
functions_changed = False functions_changed = False
@@ -314,7 +317,7 @@ async def handleIotDescriptors(conn, descriptors):
) )
conn.iot_descriptors[descriptor["name"]] = iot_descriptor conn.iot_descriptors[descriptor["name"]] = iot_descriptor
if conn.use_function_call_mode: if conn.load_function_plugin:
# 注册或获取设备类型 # 注册或获取设备类型
type_id = register_device_type(descriptor) type_id = register_device_type(descriptor)
device_functions = device_type_registry.get_device_functions(type_id) device_functions = device_type_registry.get_device_functions(type_id)
@@ -323,7 +326,7 @@ async def handleIotDescriptors(conn, descriptors):
if hasattr(conn, "func_handler"): if hasattr(conn, "func_handler"):
for func_name in device_functions: for func_name in device_functions:
conn.func_handler.function_registry.register_function(func_name) conn.func_handler.function_registry.register_function(func_name)
logger.bind(tag=TAG).info( conn.logger.bind(tag=TAG).info(
f"注册IOT函数到function handler: {func_name}" f"注册IOT函数到function handler: {func_name}"
) )
functions_changed = True functions_changed = True
@@ -332,8 +335,8 @@ async def handleIotDescriptors(conn, descriptors):
if functions_changed and hasattr(conn, "func_handler"): if functions_changed and hasattr(conn, "func_handler"):
conn.func_handler.upload_functions_desc() conn.func_handler.upload_functions_desc()
func_names = conn.func_handler.current_support_functions() func_names = conn.func_handler.current_support_functions()
logger.bind(tag=TAG).info(f"设备类型: {type_id}") conn.logger.bind(tag=TAG).info(f"设备类型: {type_id}")
logger.bind(tag=TAG).info( conn.logger.bind(tag=TAG).info(
f"更新function描述列表完成,当前支持的函数: {func_names}" f"更新function描述列表完成,当前支持的函数: {func_names}"
) )
@@ -347,13 +350,13 @@ async def handleIotStatus(conn, states):
for k, v in state["state"].items(): for k, v in state["state"].items():
if property_item["name"] == k: if property_item["name"] == k:
if type(v) != type(property_item["value"]): if type(v) != type(property_item["value"]):
logger.bind(tag=TAG).error( conn.logger.bind(tag=TAG).error(
f"属性{property_item['name']}的值类型不匹配" f"属性{property_item['name']}的值类型不匹配"
) )
break break
else: else:
property_item["value"] = v property_item["value"] = v
logger.bind(tag=TAG).info( conn.logger.bind(tag=TAG).info(
f"物联网状态更新: {key} , {property_item['name']} = {v}" f"物联网状态更新: {key} , {property_item['name']} = {v}"
) )
break break
@@ -367,7 +370,7 @@ async def get_iot_status(conn, name, property_name):
for property_item in value.properties: for property_item in value.properties:
if property_item["name"] == property_name: if property_item["name"] == property_name:
return property_item["value"] return property_item["value"]
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}") conn.logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
return None return None
@@ -378,16 +381,16 @@ async def set_iot_status(conn, name, property_name, value):
for property_item in iot_descriptor.properties: for property_item in iot_descriptor.properties:
if property_item["name"] == property_name: if property_item["name"] == property_name:
if type(value) != type(property_item["value"]): if type(value) != type(property_item["value"]):
logger.bind(tag=TAG).error( conn.logger.bind(tag=TAG).error(
f"属性{property_item['name']}的值类型不匹配" f"属性{property_item['name']}的值类型不匹配"
) )
return return
property_item["value"] = value property_item["value"] = value
logger.bind(tag=TAG).info( conn.logger.bind(tag=TAG).info(
f"物联网状态更新: {name} , {property_name} = {value}" f"物联网状态更新: {name} , {property_name} = {value}"
) )
return return
logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}") conn.logger.bind(tag=TAG).warning(f"未找到设备 {name} 的属性 {property_name}")
async def send_iot_conn(conn, name, method_name, parameters): async def send_iot_conn(conn, name, method_name, parameters):
@@ -409,6 +412,6 @@ async def send_iot_conn(conn, name, method_name, parameters):
command["parameters"] = parameters command["parameters"] = parameters
send_message = json.dumps({"type": "iot", "commands": [command]}) send_message = json.dumps({"type": "iot", "commands": [command]})
await conn.websocket.send(send_message) await conn.websocket.send(send_message)
logger.bind(tag=TAG).info(f"发送物联网指令: {send_message}") conn.logger.bind(tag=TAG).info(f"发送物联网指令: {send_message}")
return return
logger.bind(tag=TAG).error(f"未找到方法{method_name}") conn.logger.bind(tag=TAG).error(f"未找到方法{method_name}")
@@ -1,21 +1,22 @@
from config.logger import setup_logging
import time import time
import copy import copy
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length
from core.handle.sendAudioHandle import send_stt_message from core.handle.sendAudioHandle import send_stt_message
from core.handle.intentHandler import handle_user_intent from core.handle.intentHandler import handle_user_intent
from core.utils.output_counter import check_device_output_limit from core.utils.output_counter import check_device_output_limit
from core.handle.ttsReportHandle import enqueue_tts_report from core.handle.reportHandle import enqueue_asr_report
from core.utils.util import audio_to_data
TAG = __name__ TAG = __name__
logger = setup_logging()
async def handleAudioMessage(conn, audio): async def handleAudioMessage(conn, audio):
if not conn.asr_server_receive: if conn.vad is None:
logger.bind(tag=TAG).debug(f"前期数据处理中,暂停接收")
return return
if conn.client_listen_mode == "auto": if not conn.asr_server_receive:
conn.logger.bind(tag=TAG).debug(f"前期数据处理中,暂停接收")
return
if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime":
have_voice = conn.vad.is_vad(conn, audio) have_voice = conn.vad.is_vad(conn, audio)
else: else:
have_voice = conn.client_have_voice have_voice = conn.client_have_voice
@@ -39,11 +40,11 @@ async def handleAudioMessage(conn, audio):
conn.asr_server_receive = True conn.asr_server_receive = True
else: else:
text, _ = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id) text, _ = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id)
logger.bind(tag=TAG).info(f"识别文本: {text}") conn.logger.bind(tag=TAG).info(f"识别文本: {text}")
text_len, _ = remove_punctuation_and_length(text) text_len, _ = remove_punctuation_and_length(text)
if text_len > 0: if text_len > 0:
# 使用自定义模块进行上报 # 使用自定义模块进行上报
enqueue_tts_report(conn, 1, text, copy.deepcopy(conn.asr_audio)) enqueue_asr_report(conn, text, copy.deepcopy(conn.asr_audio))
await startToChat(conn, text) await startToChat(conn, text)
else: else:
@@ -75,7 +76,7 @@ async def startToChat(conn, text):
# 意图未被处理,继续常规聊天流程 # 意图未被处理,继续常规聊天流程
await send_stt_message(conn, text) await send_stt_message(conn, text)
if conn.use_function_call_mode: if conn.intent_type == "function_call":
# 使用支持function calling的聊天方法 # 使用支持function calling的聊天方法
conn.executor.submit(conn.chat_with_function_calling, text) conn.executor.submit(conn.chat_with_function_calling, text)
else: else:
@@ -97,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)
@@ -110,7 +116,7 @@ async def max_out_size(conn):
conn.tts_last_text_index = 0 conn.tts_last_text_index = 0
conn.llm_finish_task = True conn.llm_finish_task = True
file_path = "config/assets/max_output_size.wav" file_path = "config/assets/max_output_size.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(file_path) opus_packets, _ = audio_to_data(file_path)
conn.audio_play_queue.put((opus_packets, text, 0)) conn.audio_play_queue.put((opus_packets, text, 0))
conn.close_after_chat = True conn.close_after_chat = True
@@ -119,7 +125,7 @@ async def check_bind_device(conn):
if conn.bind_code: if conn.bind_code:
# 确保bind_code是6位数字 # 确保bind_code是6位数字
if len(conn.bind_code) != 6: if len(conn.bind_code) != 6:
logger.bind(tag=TAG).error(f"无效的绑定码格式: {conn.bind_code}") conn.logger.bind(tag=TAG).error(f"无效的绑定码格式: {conn.bind_code}")
text = "绑定码格式错误,请检查配置。" text = "绑定码格式错误,请检查配置。"
await send_stt_message(conn, text) await send_stt_message(conn, text)
return return
@@ -132,7 +138,7 @@ async def check_bind_device(conn):
# 播放提示音 # 播放提示音
music_path = "config/assets/bind_code.wav" music_path = "config/assets/bind_code.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(music_path) opus_packets, _ = audio_to_data(music_path)
conn.audio_play_queue.put((opus_packets, text, 0)) conn.audio_play_queue.put((opus_packets, text, 0))
# 逐个播放数字 # 逐个播放数字
@@ -140,10 +146,10 @@ async def check_bind_device(conn):
try: try:
digit = conn.bind_code[i] digit = conn.bind_code[i]
num_path = f"config/assets/bind_code/{digit}.wav" num_path = f"config/assets/bind_code/{digit}.wav"
num_packets, _ = conn.tts.audio_to_opus_data(num_path) num_packets, _ = audio_to_data(num_path)
conn.audio_play_queue.put((num_packets, None, i + 1)) conn.audio_play_queue.put((num_packets, None, i + 1))
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"播放数字音频失败: {e}") conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
continue continue
else: else:
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。" text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
@@ -152,5 +158,5 @@ async def check_bind_device(conn):
conn.tts_last_text_index = 0 conn.tts_last_text_index = 0
conn.llm_finish_task = True conn.llm_finish_task = True
music_path = "config/assets/bind_not_found.wav" music_path = "config/assets/bind_not_found.wav"
opus_packets, _ = conn.tts.audio_to_opus_data(music_path) opus_packets, _ = audio_to_data(music_path)
conn.audio_play_queue.put((opus_packets, text, 0)) conn.audio_play_queue.put((opus_packets, text, 0))
@@ -0,0 +1,145 @@
"""
TTS上报功能已集成到ConnectionHandler类中。
上报功能包括:
1. 每个连接对象拥有自己的上报队列和处理线程
2. 上报线程的生命周期与连接对象绑定
3. 使用ConnectionHandler.enqueue_tts_report方法进行上报
具体实现请参考core/connection.py中的相关代码。
"""
import opuslib_next
from config.manage_api_client import report as manage_report
TAG = __name__
def report(conn, type, text, opus_data):
"""执行聊天记录上报操作
Args:
conn: 连接对象
type: 上报类型,1为用户,2为智能体
text: 合成文本
opus_data: opus音频数据
"""
try:
if opus_data:
audio_data = opus_to_wav(conn, opus_data)
else:
audio_data = None
# 执行上报
manage_report(
mac_address=conn.device_id,
session_id=conn.session_id,
chat_type=type,
content=text,
audio=audio_data,
)
except Exception as e:
conn.logger.bind(tag=TAG).error(f"聊天记录上报失败: {e}")
def opus_to_wav(conn, opus_data):
"""将Opus数据转换为WAV格式的字节流
Args:
output_dir: 输出目录(保留参数以保持接口兼容)
opus_data: opus音频数据
Returns:
bytes: WAV格式的音频数据
"""
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
conn.logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
if not pcm_data:
raise ValueError("没有有效的PCM数据")
# 创建WAV文件头
pcm_data_bytes = b"".join(pcm_data)
num_samples = len(pcm_data_bytes) // 2 # 16-bit samples
# WAV文件头
wav_header = bytearray()
wav_header.extend(b"RIFF") # ChunkID
wav_header.extend((36 + len(pcm_data_bytes)).to_bytes(4, "little")) # ChunkSize
wav_header.extend(b"WAVE") # Format
wav_header.extend(b"fmt ") # Subchunk1ID
wav_header.extend((16).to_bytes(4, "little")) # Subchunk1Size
wav_header.extend((1).to_bytes(2, "little")) # AudioFormat (PCM)
wav_header.extend((1).to_bytes(2, "little")) # NumChannels
wav_header.extend((16000).to_bytes(4, "little")) # SampleRate
wav_header.extend((32000).to_bytes(4, "little")) # ByteRate
wav_header.extend((2).to_bytes(2, "little")) # BlockAlign
wav_header.extend((16).to_bytes(2, "little")) # BitsPerSample
wav_header.extend(b"data") # Subchunk2ID
wav_header.extend(len(pcm_data_bytes).to_bytes(4, "little")) # Subchunk2Size
# 返回完整的WAV数据
return bytes(wav_header) + pcm_data_bytes
def enqueue_tts_report(conn, text, opus_data):
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
"""将TTS数据加入上报队列
Args:
conn: 连接对象
text: 合成文本
opus_data: opus音频数据
"""
try:
# 使用连接对象的队列,传入文本和二进制数据而非文件路径
if conn.chat_history_conf == 2:
conn.report_queue.put((2, text, opus_data))
conn.logger.bind(tag=TAG).debug(
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:
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}")
@@ -1,11 +1,9 @@
from config.logger import setup_logging
import json import json
import asyncio import asyncio
import time import time
from core.utils.util import get_string_no_punctuation_or_emoji, analyze_emotion from core.utils.util import get_string_no_punctuation_or_emoji, analyze_emotion
TAG = __name__ TAG = __name__
logger = setup_logging()
emoji_map = { emoji_map = {
"neutral": "😶", "neutral": "😶",
@@ -49,10 +47,10 @@ async def sendAudioMessage(conn, audios, text, text_index=0):
) )
if text_index == conn.tts_first_text_index: if text_index == conn.tts_first_text_index:
logger.bind(tag=TAG).info(f"发送第一段语音: {text}") conn.logger.bind(tag=TAG).info(f"发送第一段语音: {text}")
await send_tts_message(conn, "sentence_start", text) await send_tts_message(conn, "sentence_start", text)
is_first_audio = (text_index == conn.tts_first_text_index) is_first_audio = text_index == conn.tts_first_text_index
await sendAudio(conn, audios, pre_buffer=is_first_audio) await sendAudio(conn, audios, pre_buffer=is_first_audio)
await send_tts_message(conn, "sentence_end", text) await send_tts_message(conn, "sentence_end", text)
@@ -117,7 +115,7 @@ async def send_tts_message(conn, state, text=None):
stop_tts_notify_voice = conn.config.get( stop_tts_notify_voice = conn.config.get(
"stop_tts_notify_voice", "config/assets/tts_notify.mp3" "stop_tts_notify_voice", "config/assets/tts_notify.mp3"
) )
audios, duration = conn.tts.audio_to_opus_data(stop_tts_notify_voice) audios, _ = conn.tts.audio_to_opus_data(stop_tts_notify_voice)
await sendAudio(conn, audios) await sendAudio(conn, audios)
# 清除服务端讲话状态 # 清除服务端讲话状态
conn.clearSpeakStatus() conn.clearSpeakStatus()
+59 -10
View File
@@ -1,4 +1,3 @@
from config.logger import setup_logging
import json import json
from core.handle.abortHandle import handleAbortMessage from core.handle.abortHandle import handleAbortMessage
from core.handle.helloHandle import handleHelloMessage from core.handle.helloHandle import handleHelloMessage
@@ -6,29 +5,30 @@ 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__
logger = setup_logging()
async def handleTextMessage(conn, message): async def handleTextMessage(conn, message):
"""处理文本消息""" """处理文本消息"""
logger.bind(tag=TAG).info(f"收到文本消息:{message}") conn.logger.bind(tag=TAG).info(f"收到文本消息:{message}")
try: try:
msg_json = json.loads(message) msg_json = json.loads(message)
if isinstance(msg_json, int): if isinstance(msg_json, int):
await conn.websocket.send(message) await conn.websocket.send(message)
return return
if msg_json["type"] == "hello": if msg_json["type"] == "hello":
await handleHelloMessage(conn) await handleHelloMessage(conn, msg_json)
elif msg_json["type"] == "abort": elif msg_json["type"] == "abort":
await handleAbortMessage(conn) await handleAbortMessage(conn)
elif msg_json["type"] == "listen": elif msg_json["type"] == "listen":
if "mode" in msg_json: if "mode" in msg_json:
conn.client_listen_mode = msg_json["mode"] conn.client_listen_mode = msg_json["mode"]
logger.bind(tag=TAG).debug(f"客户端拾音模式:{conn.client_listen_mode}") conn.logger.bind(tag=TAG).debug(
f"客户端拾音模式:{conn.client_listen_mode}"
)
if msg_json["state"] == "start": if msg_json["state"] == "start":
conn.client_have_voice = True conn.client_have_voice = True
conn.client_voice_stop = False conn.client_voice_stop = False
@@ -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":
@@ -80,7 +80,7 @@ async def handleTextMessage(conn, message):
await conn.websocket.send( await conn.websocket.send(
json.dumps( json.dumps(
{ {
"type": "config_update_response", "type": "server",
"status": "error", "status": "error",
"message": "服务器密钥验证失败", "message": "服务器密钥验证失败",
} }
@@ -89,6 +89,55 @@ async def handleTextMessage(conn, message):
return return
# 动态更新配置 # 动态更新配置
if msg_json["action"] == "update_config": if msg_json["action"] == "update_config":
await conn.handle_config_update(msg_json) try:
# 更新WebSocketServer的配置
if not conn.server:
await conn.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "error",
"message": "无法获取服务器实例",
}
)
)
return
if not await conn.server.update_config():
await conn.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "error",
"message": "更新服务器配置失败",
}
)
)
return
# 发送成功响应
await conn.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "success",
"message": "配置更新成功",
}
)
)
except Exception as e:
conn.logger.bind(tag=TAG).error(f"更新配置失败: {str(e)}")
await conn.websocket.send(
json.dumps(
{
"type": "config_update_response",
"status": "error",
"message": f"更新配置失败: {str(e)}",
}
)
)
# 重启服务器
elif msg_json["action"] == "restart":
await conn.handle_restart(msg_json)
except json.JSONDecodeError: except json.JSONDecodeError:
await conn.websocket.send(message) await conn.websocket.send(message)

Some files were not shown because too many files have changed in this diff Show More