Compare commits

..
76 Commits
Author SHA1 Message Date
欣南科技andGitHub e37b094d1e Merge pull request #1813 from xinnan-tech/py_fix_log
Py fix log
2025-07-11 17:29:49 +08:00
欣南科技andGitHub a491ce96cb Merge branch 'main' into py_fix_log 2025-07-11 17:29:37 +08:00
hrz 7be79519b1 update:增加声纹识别演示视频 2025-07-11 17:23:13 +08:00
3030332422 47e4c0f190 fix:日志文件的优化 2025-07-11 16:44:50 +08:00
欣南科技andGitHub 6b24844172 Merge pull request #1810 from xinnan-tech/hot-fix
update:优化总结
2025-07-11 15:43:39 +08:00
hrz c6225f3ab8 update:优化总结 2025-07-11 15:42:46 +08:00
欣南科技andGitHub 60c4aa943b Merge pull request #1807 from xinnan-tech/hot-fix
add:声纹识别开启教程
2025-07-11 14:14:32 +08:00
hrz 81d0adf73c add:声纹识别开启教程 2025-07-11 14:13:39 +08:00
欣南科技andGitHub 7dc3929f0e Merge pull request #1806 from xinnan-tech/hot-fix
update:优化声纹识别有结果时内容的展示
2025-07-11 12:15:05 +08:00
hrz 09be3a4de3 update:优化声纹识别有结果时内容的展示 2025-07-11 12:14:25 +08:00
欣南科技andGitHub f7407e46c5 Merge pull request #1805 from xinnan-tech/hot-fix
update:修复二次声纹注册bug
2025-07-11 11:57:25 +08:00
hrz 8f685967d3 update:修复二次声纹注册bug 2025-07-11 11:49:53 +08:00
欣南科技andGitHub 8f6dd22a6d Merge pull request #1804 from xinnan-tech/mangger-api-voice-print
声纹识别
2025-07-11 10:19:15 +08:00
hrz 108c87f395 update:优化提示词 2025-07-11 10:15:14 +08:00
hrz cd58b3a39a update:发行新版本 2025-07-11 09:14:34 +08:00
3030332422 2fbee33185 update:python对接声纹识别优化 2025-07-10 15:03:49 +08:00
JianYu Zheng f499040955 Merge remote-tracking branch 'origin/mangger-api-voice-print' into mangger-api-voice-print 2025-07-10 11:33:28 +08:00
JianYu Zheng 008c446966 添加判断
--AgentVoicePrintServiceImpl.java
 1.声纹音频保存,增加了保存前识别一下声纹是否有相似的声音,有则不保存
 2.声纹音频修改,增加了保存前识别一下声纹是否有相似的声音且相似声纹id和本次修改id不一样的,则不修改
 3.添加了识别声纹http的请求的方法
 --IdentifyVoicePrintResponse.java 识别声纹响应类型
2025-07-10 11:20:32 +08:00
hrz 01158e67e0 add:智控台返回声纹接口信息 2025-07-10 09:54:16 +08:00
f4cfa04954 update: TTS复用链接,VAD双阈值判断 (#1742)
* update: TTS复用链接,VAD双阈值判断

* fix: 播放音乐时,引导词卡顿

* update:优化引导词

* update: 优化chat函数流程 优化huoshan处理 会话保持一致性

* fix: 等待时可能已经完成(设置为None),后续对None错误访问

* update:增加书名号

* fix: 打断状态未重置 监听未完成时服务端可能还在发送数据 此时复用链接会接收上个语音的残余 需要两者一同关闭

---------

Co-authored-by: hrz <1710360675@qq.com>
2025-07-09 18:34:04 +08:00
JianYu Zheng ae4387ce3f Merge remote-tracking branch 'origin/mangger-api-voice-print' into mangger-api-voice-print 2025-07-09 18:12:27 +08:00
JianYu Zheng a64b495083 增加判断
--AgentVoicePrintServiceImpl.java 添加,修改声纹音频的是增加了验证这个音频是否属于这个智能体
2025-07-09 17:31:34 +08:00
JianYu Zheng 81cdd0a211 添加新接口和2个新方法
--AgentChatHistoryService.java 添加‘根据音频数据ID获取聊天内容’和‘查询此音频id是否属于此智能体’的方法定义
--AgentChatHistoryServiceImpl.java 实现2个新方法
--AgentController.java 增加一个获取音频内容的接口
2025-07-09 17:29:36 +08:00
Sakura-RanChen 6bfb5f1340 Merge branch 'py_link_huoshan' of https://github.com/xinnan-tech/xiaozhi-esp32-server into py_link_huoshan 2025-07-09 17:05:02 +08:00
Sakura-RanChen 2625133f40 fix: 打断状态未重置 监听未完成时服务端可能还在发送数据 此时复用链接会接收上个语音的残余 需要两者一同关闭 2025-07-09 17:04:42 +08:00
hrzandGitHub 38409b4200 Merge pull request #1755 from xinnan-tech/py_test
update:python单模块部署声纹识别对接
2025-07-09 16:51:26 +08:00
hrzandGitHub 29b4d0daeb Merge branch 'mangger-api-voice-print' into py_test 2025-07-09 16:51:10 +08:00
hrz 8f4f9fe19a update:优化提示词 2025-07-09 16:30:10 +08:00
hrz 7f67459c6e update:增加书名号 2025-07-09 16:26:26 +08:00
JianYu Zheng f47a7b4dbc 修复添加和修改声纹选择的时候,编辑的声纹不在里面,导致无法显示内容的问题
--agent.js 增加查询音频内容的接口
--VoicePrintDialog.vue 修复添加和修改声纹选择的时候,编辑的声纹不在里面,导致无法显示内容的问题
2025-07-09 15:56:07 +08:00
hrz 0bf6926506 update:优化提示词 2025-07-09 15:35:25 +08:00
3030332422 d13fb73c67 update:优化 2025-07-09 14:23:47 +08:00
JianYu Zheng 5e18a46e3f 添加新功能,声纹选择的时候可以听取音频文件
--VoicePrintDialog.vue 添加听取音频的功能
2025-07-09 11:48:02 +08:00
Sakura-RanChen 62e39dd0e2 fix: 等待时可能已经完成(设置为None),后续对None错误访问 2025-07-09 11:18:26 +08:00
hrzandGitHub 203ea89c6d Merge pull request #1788 from xinnan-tech/py_intent_fix
fix: delete未定义的方法报错
2025-07-08 17:54:17 +08:00
Sakura-RanChen 9ddd967776 fix: delete未定义的方法报错 2025-07-08 17:52:51 +08:00
Sakura-RanChen 44593ff3f3 update: 优化chat函数流程 优化huoshan处理 会话保持一致性 2025-07-08 17:48:50 +08:00
JianYu Zheng 3ed13b3ff5 修复漏洞
--AgentChatHistoryServiceImpl.java 用户聊天记录不一定都带有音频,查询时要排除不带音频的最近50条聊天记录
--AgentChatHistoryService.java 修改方法描述
2025-07-08 17:32:44 +08:00
JianYu Zheng 53cfdcc240 Merge remote-tracking branch 'origin/mangger-api-voice-print' into mangger-api-voice-print 2025-07-08 17:15:10 +08:00
JianYu Zheng 0636b95b87 声纹表添加音频字段
--202507081646.sql 添加字段sql
--AgentVoicePrintEntity.java 对应实体更新
--db.changelog-master.yaml 执行添加字段sql的配置
2025-07-08 17:10:57 +08:00
hrzandGitHub cf449a9b60 Merge pull request #1787 from xinnan-tech/system-prompt
强化小智提示词
2025-07-08 17:00:46 +08:00
hrz ab890ada37 update:强化小智提示词 2025-07-08 16:59:38 +08:00
JianYu Zheng b9c136c6ae 修复错误
--VoicePrint.vue
 1.方法调用错误,修改方法名
 2.删除无用的多选框
2025-07-08 16:55:02 +08:00
JianYu Zheng f81a7e8273 智能体设置卡片添加设置声纹的按钮
--DeviceItem.vue
 1.添加声纹设置按钮
 2.添加跳转到声纹页面方法
--home.vue 修复因为添加新按钮导致卡片按钮文字换行样式问题
2025-07-08 16:18:29 +08:00
JianYu Zheng d40d4a08b0 添加声纹管理页面
--VoicePrint.vue 声纹展示页面
--VoicePrintDialog.vue 声纹增改组件
--index.js 注册声纹页面路径
2025-07-08 16:15:52 +08:00
JianYu Zheng 61907d2270 添加新接口方法
--agent.js 添加关于声纹和获取用户列表的接口
 1.添加智能体的声纹
 2.获取指定智能体声纹列表
 3.删除智能体声纹
 4.更新智能体声纹
 5.获取指定智能体用户类型聊天记录
2025-07-08 16:13:13 +08:00
JianYu Zheng 601a90981e 修复错误
--AgentVoicePrintServiceImpl.java
 1.修复事务处理过程出错,返回错误无法被前端页面处理
 2.修复更新声纹方法,更正错误sql
2025-07-08 15:48:20 +08:00
JianYu Zheng 10b7259b13 添加新接口:查看指定智能体最近50条用户聊天记录
--AgentChatHistoryService.java 指定智能体最近50条用户聊天记录定义
--AgentChatHistoryServiceImpl.javaAgentChatHistoryService.java 实现指定智能体最近50条用户聊天记录方法
--AgentChatHistoryType.java 添加智能体聊天记录类型枚举
--AgentChatHistoryUserVO.java 展示用户聊天记录vo
--AgentController.java 添加新接口
2025-07-08 15:44:43 +08:00
CGD 5c83d63fe2 update:声纹识别对接的优化 2025-07-08 11:25:54 +08:00
hrzandGitHub beb7a191c1 Merge pull request #1777 from jlau-ice/feat/add-password-visibility
feat: 登录注册等页面密码输入框添加可见属性
2025-07-07 21:29:10 +08:00
ganbin ee72a328ca feat: 登录注册等页面密码输入框添加可见属性 2025-07-07 18:50:03 +08:00
JianYu Zheng c85419bf06 添加添加声纹增删改查接口
--AgentVoicePrintController.java 添加接口
2025-07-07 15:33:16 +08:00
JianYu Zheng b201c3cca8 添加数据认证,需要登录用户是此数据所有者才有权限修改,查询,删除
--AgentVoicePrintService.java 修改方法定义
--AgentVoicePrintServiceImpl.java
 1.需要登录用户是此数据所有者才有权限修改,查询,删除
 2.修改发送注册声纹http请求和发送注销声纹的请求的错误说明和添加日志记录
2025-07-07 15:11:37 +08:00
hrzandGitHub c45f47faa0 Update:优化mcp工具读取顺序 (#1775)
* update:发布0.6.3版本

* update:升级sherpa_onnx版本

* update:补充自动更新教程

* update:优化mcp工具读取顺序

* update:优化
2025-07-07 15:04:32 +08:00
JianYu Zheng ee7ea7fca6 添加编程事务,保证数据的统一,提取公共方法
--AgentVoicePrintServiceImpl.java
 1.为保存,修复,删除声纹添加事务
 2.提取发送注销声纹的请求为公共方法
2025-07-07 14:12:28 +08:00
hrzandGitHub 5ee7830f1b Merge pull request #1770 from jlau-ice/fix/vllm-reply-in-English
fix: 修正VLLM本地调用,question参数为空会用英文回复的问题
2025-07-07 11:58:58 +08:00
hrz 8da9dc8a3f update:优化引导词 2025-07-07 11:45:37 +08:00
JianYu Zheng 61ecfd2d92 实现智能体声纹服务层定义的‘获取指定智能体的所有声纹数据’和‘更新智能体的指的声纹数据’定义(未测试)
--AgentVoicePrintServiceImpl.java 实现方法
 1.获取指定智能体的所有声纹数据
 2.更新智能体的指的声纹数据
 3.提取共用方法(获取声纹音频资源数据,发送注册声纹http请求)
2025-07-07 10:58:35 +08:00
ganbin 2535ff6c90 fix: 修正VLLM本地调用,question参数为空会用英文回复的问题 2025-07-07 09:02:59 +08:00
hrzandGitHub 880bc6326a update:补充自动更新教程 (#1766)
* update:发布0.6.3版本

* update:升级sherpa_onnx版本

* update:补充自动更新教程
2025-07-06 15:40:02 +08:00
hrzandGitHub d892212583 Update version (#1765)
* update:发布0.6.3版本

* update:升级sherpa_onnx版本
2025-07-05 23:58:47 +08:00
hrzandGitHub 089acd357d update:发布0.6.3版本 (#1764) 2025-07-05 23:50:26 +08:00
hrzandGitHub d14f6937fa Merge pull request #1753 from myifeng/main
客户端连接成功时不主动发送hello
2025-07-05 20:05:51 +08:00
hrzandGitHub d74813ec80 Update connection.py 2025-07-05 20:05:36 +08:00
hrzandGitHub 1cfba1aa52 update: 单模块部署也能使用mcp接入点 (#1763)
* 优化:OTA接口需要适应最小示例参数 #1757

* update:修复智能体选择的模型不存在导致的500错误 #1752

* update: 单模块部署也能使用mcp接入点
2025-07-05 19:46:04 +08:00
JianYu Zheng 77527d8823 实现智能体声纹服务层定义的‘添加智能体新的声纹’和‘删除智能体的指的声纹’定义
--AgentVoicePrintServiceImpl.java 实现方法
 1.添加智能体新的声纹
 2.删除智能体的指的声纹
 3.共用方法(获取生纹接口URI对象,获取声纹地址基础路径,获取验证Authorization)
2025-07-04 18:04:39 +08:00
JianYu Zheng 5e5ec98995 添加智能体声纹实体,vo,dto,dao,service类
--AgentVoicePrintVO.java 智能体声纹展示vo
--AgentVoicePrintService.java 智能体声纹服务层定义
 1.添加智能体新的声纹
 2.删除智能体的指的声纹
 3.获取指定智能体的所有声纹数据
 4.更新智能体的指的声纹数据
--AgentVoicePrintSaveDTO.java 智能体声纹保存dto
--AgentVoicePrintUpdateDTO.java 智能体声纹修改dto
--AgentVoicePrintEntity.java 智能体声纹实体
--AgentVoicePrintDao.java 智能体声纹持久层
2025-07-04 18:02:19 +08:00
JianYu Zheng 1c2d4f9045 添加新表:智能体声纹表
--202507041018.sql 建表sql语句
--db.changelog-master.yaml 配置文件里添加执行新sql的配置
2025-07-04 17:54:41 +08:00
CGD 3d64152dba update:python单模块部署声纹识别对接 2025-07-04 17:11:44 +08:00
hrzandGitHub 37ecd3da1e 增加docker部署时url地址的说明 (#1754)
* update:优化mcp工具较多时的现实

* update:修改版本号

* update:docker部署manager-api,填写备注
2025-07-04 17:02:19 +08:00
myifeng 22e9e4c352 客户端连接成功时不主动发送hello 2025-07-04 15:27:38 +08:00
Sakura-RanChen 7e5a2b4549 fix: 播放音乐时,引导词卡顿 2025-07-04 11:27:49 +08:00
JianYu Zheng 036dde5bb0 添加新的参数:声纹接口地址
--202507031602.sql 添加声纹接口地址参数sql
--Constant.java 对应新参数的常量
--SysParamsController.java 验证声纹接口地址参数是否符合规则
--db.changelog-master.yaml 添加执行新的sql文件
2025-07-03 16:34:50 +08:00
Sakura-RanChen 69cac9d40a update: TTS复用链接,VAD双阈值判断 2025-07-03 09:16:06 +08:00
hrzandGitHub 04b132816c update:修改版本号
* update:优化mcp工具较多时的现实

* update:修改版本号
2025-07-01 18:16:21 +08:00
hrzandGitHub f08ed1bbb7 Merge pull request #1729 from xinnan-tech/fix-vllm
update:优化mcp工具较多时的显示
2025-07-01 17:31:58 +08:00
76 changed files with 4104 additions and 565 deletions
+1
View File
@@ -174,3 +174,4 @@ main/xiaozhi-server/mysql
uploadfile
*.json
.vscode
.cursor
+21 -7
View File
@@ -6,7 +6,7 @@
本项目为开源智能硬件项目
<a href="https://github.com/78/xiaozhi-esp32">xiaozhi-esp32</a>提供后端服务<br/>
根据<a href="https://ccnphfhqs21z.feishu.cn/wiki/M0XiwldO9iJwHikpXD5cEx71nKh">小智通信协议</a>使用Python、Java、Vue实现<br/>
帮助您快速搭建小智服务器
支持MCP接入点和声纹识别
</p>
<p align="center">
@@ -144,13 +144,18 @@
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
<a href="https://www.bilibili.com/video/BV1ZQKUzYExM" target="_blank">
<picture>
<img alt="MCP接入点" src="docs/images/demo13.png" />
</picture>
</a>
</td>
<td>
<a href="https://www.bilibili.com/video/BV1Exu3zqEDe" target="_blank">
<picture>
<img alt="声纹识别" src="docs/images/demo14.png" />
</picture>
</a>
</td>
</tr>
</table>
@@ -225,11 +230,12 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
| 功能模块 | 描述 |
|:---:|:---|
| 核心服务架构 | 基于WebSocket和HTTP服务器,提供完整的控制台管理和认证系统 |
| 语音交互系统 | 支持流式ASR(语音识别)、流式TTS(语音合成)、VAD(语音活动检测),支持多语言识别和语音处理 |
| 智能对话系统 | 支持多种LLM(大语言模型),实现智能对话 |
| 视觉感知系统 | 支持多种VLLM(视觉大模型),实现多模态交互 |
| 意图识别系统 | 支持LLM意图识别、Function Call函数调用,提供插件化意图处理机制 |
| 核心架构 | 基于WebSocket和HTTP服务器,提供完整的控制台管理和认证系统 |
| 语音交互 | 支持流式ASR(语音识别)、流式TTS(语音合成)、VAD(语音活动检测),支持多语言识别和语音处理 |
| 声纹识别 | 支持多用户声纹注册、管理和识别,与ASR并行处理,实时识别说话人身份并传递给LLM进行个性化回应 |
| 智能对话 | 支持多种LLM(大语言模型),实现智能对话 |
| 视觉感知 | 支持多种VLLM(视觉大模型),实现多模态交互 |
| 意图识别 | 支持LLM意图识别、Function Call函数调用,提供插件化意图处理机制 |
| 记忆系统 | 支持本地短期记忆、mem0ai接口记忆,具备记忆总结功能 |
| 工具调用 | 支持客户端IOT协议、客户MCP协议、服务端MCP协议、MCP接入点协议、自定义工具函数 |
| 管理后台 | 提供Web管理界面,支持用户管理、系统配置和设备管理 |
@@ -308,6 +314,14 @@ Websocket接口地址: wss://2662r3426b.vicp.fun/xiaozhi/v1/
---
### Voiceprint 声纹识别
| 使用方式 | 支持平台 | 免费平台 |
|:---:|:---:|:---:|
| 本地使用 | 3D-Speaker | 3D-Speaker |
---
### Memory 记忆存储
| 类型 | 平台名称 | 使用方式 | 收费模式 | 备注 |
+11 -1
View File
@@ -112,7 +112,17 @@ VAD:
参考教程[视觉模型使用指南](./mcp-vision-integration.md)
### 10、更多问题,可联系我们反馈 💬
### 10、如何开启MCP接入点 🔧
1、先参考教程[MCP 接入点部署使用指南](./mcp-endpoint-enable.md)
2、再参考教程[MCP 接入点使用指南](./mcp-endpoint-integration.md)
### 12、如何开启声纹识别 🔊
参考教程[声纹识别启用指南](./voiceprint-integration.md)
### 13、更多问题,可联系我们反馈 💬
可以在[issues](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues)提交您的问题。
+13
View File
@@ -4,6 +4,8 @@
本项目的测试平台`https://2662r3426b.vicp.fun`,从开放以来就使用了该方法,效果良好。
教程可参考B站博主`毕乐labs`发布的视频教程:[《开源小智服务器xiaozhi-server自动更新以及最新版本MCP接入点配置保姆教程》](https://www.bilibili.com/video/BV15H37zHE7Q)
# 开始条件
- 你的电脑/服务器是linux操作系统
- 你已经跑通了整个流程
@@ -40,6 +42,9 @@ git clone https://ghproxy.net/https://github.com/xinnan-tech/xiaozhi-esp32-serve
此刻你需要把`model.pt`文件复制到新的目录去,你可以这样
```
# 创建需要的目录
mkdir -p /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/data/
cp 你原来的.config.yaml完整路径 /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/data/.config.yaml
cp 你原来的model.pt完整路径 /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/models/SenseVoiceSmall/model.pt
```
@@ -161,3 +166,11 @@ tail -f /home/system/xiaozhi/xiaozhi-esp32-server/main/xiaozhi-server/tmp/server
# 注意事项
测试平台`https://2662r3426b.vicp.fun`,是使用nginx做了反向代理。nginx.conf详细配置可以[参考这里](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues/791)
## 常见问题
### 1、为什么没有见到8001端口?
回答:8001是开发环境使用的,用于运行前端的端口。如果你是服务器部署,不建议使用`npm run serve`启动8001端口运行前端,而是像本教程一样编译成html文件,然后使用nginx来管理访问。
### 2、每次更新需要更新手动SQL语句吗?
回答:不需要,因为项目使用**Liquibase**管理数据库版本,会自动执行新的sql脚本。
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

+125
View File
@@ -0,0 +1,125 @@
# MCP 接入点部署使用指南
本教程包含3个部分
- 1、如何部署MCP接入点这个服务
- 2、全模块部署时,怎么配置MCP接入点
- 3、单模块部署时,怎么配置MCP接入点
# 1、如何部署MCP接入点这个服务
## 第一步,下载mcp接入点项目源码
浏览器打开[mcp接入点项目地址](https://github.com/xinnan-tech/mcp-endpoint-server)
打开完,找到页面中一个绿色的按钮,写着`Code`的按钮,点开它,然后你就看到`Download ZIP`的按钮。
点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`mcp-endpoint-server-main`
你需要把它重命名成`mcp-endpoint-server`
## 第二步,启动程序
这个项目是一个很简单的项目,建议使用docker运行。不过如果你不想使用docker运行,你可以参考[这个页面](https://github.com/xinnan-tech/mcp-endpoint-server/blob/main/README_dev.md)使用源码运行。以下是docker运行的方法
```
# 进入本项目源码根目录
cd mcp-endpoint-server
# 清除缓存
docker compose -f docker-compose.yml down
docker stop mcp-endpoint-server
docker rm mcp-endpoint-server
docker rmi ghcr.nju.edu.cn/xinnan-tech/mcp-endpoint-server:latest
# 启动docker容器
docker compose -f docker-compose.yml up -d
# 查看日志
docker logs -f mcp-endpoint-server
```
此时,日志里会输出类似以下的日志
```
250705 INFO-=====下面的地址分别是智控台/单模块MCP接入点地址====
250705 INFO-智控台MCP参数配置: http://172.22.0.2:8004/mcp_endpoint/health?key=abc
250705 INFO-单模块部署MCP接入点: ws://172.22.0.2:8004/mcp_endpoint/mcp/?token=def
250705 INFO-=====请根据具体部署选择使用,请勿泄露给任何人======
```
请你把两个接口地址复制出来:
由于你是docker部署,切不可直接使用上面的地址!
由于你是docker部署,切不可直接使用上面的地址!
由于你是docker部署,切不可直接使用上面的地址!
你先把地址复制出来,放在一个草稿里,你要知道你的电脑的局域网ip是什么,例如我的电脑局域网ip是`192.168.1.25`,那么
原来我的接口地址
```
智控台MCP参数配置: http://172.22.0.2:8004/mcp_endpoint/health?key=abc
单模块部署MCP接入点: ws://172.22.0.2:8004/mcp_endpoint/mcp/?token=def
```
就要改成
```
智控台MCP参数配置: http://192.168.1.25:8004/mcp_endpoint/health?key=abc
单模块部署MCP接入点: ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=def
```
改好后,请使用浏览器直接访问`智控台MCP参数配置`。当浏览器出现类似这样的代码,说明是成功了。
```
{"result":{"status":"success","connections":{"tool_connections":0,"robot_connections":0,"total_connections":0}},"error":null,"id":null,"jsonrpc":"2.0"}
```
请你保留好上面两个`接口地址`,下一步要用到。
# 2、全模块部署时,怎么配置MCP接入点
如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。
然后搜索参数`server.mcp_endpoint`,此时,它的值应该是`null`值。
点击修改按钮,把上一步得来的`智控台MCP参数配置`粘贴到`参数值`里。然后保存。
如果能保存成功,说明一切顺利,你可以去智能体查看效果了。如果不成功,说明智控台无法访问mcp接入点,很大概率是网络防火墙,或者没有填写正确的局域网ip。
# 3、单模块部署时,怎么配置MCP接入点
如果你是单模块部署,找到你的配置文件`data/.config.yaml`
在配置文件搜索`mcp_endpoint`,如果没有找到,你就增加`mcp_endpoint`配置。类似我是就是这样
```
server:
websocket: ws://你的ip或者域名:端口号/xiaozhi/v1/
http_port: 8002
log:
log_level: INFO
# 此处可能还更多配置..
mcp_endpoint: 你的接入点websocket地址
```
这时,请你把`如何部署MCP接入点这个服务`中得到的`单模块部署MCP接入点` 粘贴到 `mcp_endpoint`中。类似这样
```
server:
websocket: ws://你的ip或者域名:端口号/xiaozhi/v1/
http_port: 8002
log:
log_level: INFO
# 此处可能还更多配置
mcp_endpoint: ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=def
```
配置好后,启动单模块会输出如下的日志。
```
250705[__main__]-INFO-初始化组件: vad成功 SileroVAD
250705[__main__]-INFO-初始化组件: asr成功 FunASRServer
250705[__main__]-INFO-OTA接口是 http://192.168.1.25:8002/xiaozhi/ota/
250705[__main__]-INFO-视觉分析接口是 http://192.168.1.25:8002/mcp/vision/explain
250705[__main__]-INFO-mcp接入点是 ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc
250705[__main__]-INFO-Websocket地址是 ws://192.168.1.25:8000/xiaozhi/v1/
250705[__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问=======
250705[__main__]-INFO-如想测试websocket请用谷歌浏览器打开test目录下的test_page.html
250705[__main__]-INFO-=============================================================
```
如上,如果能输出类似的`mcp接入点是``ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc`说明配置成功了。
+42 -82
View File
@@ -1,92 +1,36 @@
# MCP 接入点部署使用指南
# MCP 接入点使用指南
本教程包含2个部分
- 1、如何开启mcp接入点
- 2、如何为智能体接入一个简单的mcp功能,如计算器功能
本教程以虾哥开源的mcp计算器功能为示例,介绍如何将自己自定义的mcp服务接入到自己的接入点里。
部署的前提条件:
- 1、你已经部署了全模块,因为mcp接入点需要全模块中的智控台功能
- 2、你想在不修改xiaozhi-server项目的前提下,扩展小智的功能
# 如何开启mcp接入点
## 第一步,下载mcp接入点项目源码
浏览器打开[mcp接入点项目地址](https://github.com/xinnan-tech/mcp-endpoint-server)
打开完,找到页面中一个绿色的按钮,写着`Code`的按钮,点开它,然后你就看到`Download ZIP`的按钮。
点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`mcp-endpoint-server-main`
你需要把它重命名成`mcp-endpoint-server`
## 第二步,启动程序
这个项目是一个很简单的项目,建议使用docker运行。不过如果你不想使用docker运行,你可以参考[这个页面](https://github.com/xinnan-tech/mcp-endpoint-server/blob/main/README_dev.md)使用源码运行。以下是docker运行的方法
```
# 进入本项目源码根目录
cd mcp-endpoint-server
# 清除缓存
docker compose -f docker-compose.yml down
docker stop mcp-endpoint-server
docker rm mcp-endpoint-server
docker rmi ghcr.nju.edu.cn/xinnan-tech/mcp-endpoint-server:latest
# 启动docker容器
docker compose -f docker-compose.yml up -d
# 查看日志
docker logs -f mcp-endpoint-server
```
此时,日志里会输出类似以下的日志
```
======================================================
接口地址: http://172.1.1.1:8004/mcp_endpoint/health?key=xxxx
=======上面的地址是MCP接入点地址,请勿泄露给任何人============
```
请你把接口地址复制出来:
由于你是docker部署,切不可直接使用上面的地址!
由于你是docker部署,切不可直接使用上面的地址!
由于你是docker部署,切不可直接使用上面的地址!
你先把地址复制出来,放在一个草稿里,你要知道你的电脑的局域网ip是什么,例如我的电脑局域网ip是`192.168.1.25`,那么
原来我的接口地址
```
http://172.1.1.1:8004/mcp_endpoint/health?key=xxxx
```
就要改成
```
http://192.168.1.25:8004/mcp_endpoint/health?key=xxxx
```
改好后,请使用浏览器直接访问这个接口。当浏览器出现类似这样的代码,说明是成功了。
```
{"result":{"status":"success","connections":{"tool_connections":0,"robot_connections":0,"total_connections":0}},"error":null,"id":null,"jsonrpc":"2.0"}
```
请你保留好这个`接口地址`,下一步要用到。
## 第三步,配置智控台
使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。
然后搜索参数`server.mcp_endpoint`,此时,它的值应该是`null`值。
点击修改按钮,把上一步得来的`接口地址`粘贴到`参数值`里。然后保存。
如果能保存成功,说明一切顺利,你可以去智能体查看效果了。如果不成功,说明智控台无法访问mcp接入点,很大概率是网络防火墙,或者没有填写正确的局域网ip。
本教程的前提是,你的`xiaozhi-server`已经启用了mcp接入点功能,如果你还没启用,可以先根据[这个教程](./mcp-endpoint-enable.md)启用。
# 如何为智能体接入一个简单的mcp功能,如计算器功能
如果以上步骤顺利,你可以进入智能体管理,点击`配置角色`,在`意图识别`的右边,有一个`编辑功能`的按钮。
### 如果你是全模块部署
如果你是全模块部署,你可以进入智控台,智能体管理,点击`配置角色`,在`意图识别`的右边,有一个`编辑功能`的按钮。
点击这个按钮。在弹出的页面里,位于底部,会有`MCP接入点`,正常来说,会显示这个智能体的`MCP接入点地址`,接下来,我们来给这个智能体扩展一个基于MCP技术的计算器的功能。
这个`MCP接入点地址`很重要,你等一下会用到。
### 如果你是单模块部署
如果你是单模块部署,且你已经在配置文件里配置了MCP接入点地址,那么正常来说,单模块部署启动的时候,会输出如下的日志。
```
250705[__main__]-INFO-初始化组件: vad成功 SileroVAD
250705[__main__]-INFO-初始化组件: asr成功 FunASRServer
250705[__main__]-INFO-OTA接口是 http://192.168.1.25:8002/xiaozhi/ota/
250705[__main__]-INFO-视觉分析接口是 http://192.168.1.25:8002/mcp/vision/explain
250705[__main__]-INFO-mcp接入点是 ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc
250705[__main__]-INFO-Websocket地址是 ws://192.168.1.25:8000/xiaozhi/v1/
250705[__main__]-INFO-=======上面的地址是websocket协议地址,请勿用浏览器访问=======
250705[__main__]-INFO-如想测试websocket请用谷歌浏览器打开test目录下的test_page.html
250705[__main__]-INFO-=============================================================
```
如上,输出`mcp接入点是``ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc`就是你的`MCP接入点地址`
这个`MCP接入点地址`很重要,你等一下会用到。
## 第一步 下载虾哥MCP计算器项目代码
浏览器打开虾哥写的[计算器项目](https://github.com/78/mcp-calculator)
@@ -114,13 +58,13 @@ pip install -r requirements.txt
例如我的智能体的mcp地址是
```
ws://192.168.4.7:8004/mcp_endpoint/mcp/?token=abc
ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc
```
开始输入命令
```bash
export MCP_ENDPOINT=ws://192.168.4.7:8004/mcp_endpoint/mcp/?token=abc
export MCP_ENDPOINT=ws://192.168.1.25:8004/mcp_endpoint/mcp/?token=abc
```
输入完后,启动程序
@@ -129,6 +73,22 @@ export MCP_ENDPOINT=ws://192.168.4.7:8004/mcp_endpoint/mcp/?token=abc
python mcp_pipe.py calculator.py
```
### 如果你是智控台部署
如果你是智控台部署,启动完后,你再进入智控台,点击刷新MCP的接入状态,就会看到你扩展的功能列表了。
启动完后,你再进入智控台,点击刷新MCP的接入状态,就会看到你扩展的功能列表了。
### 如果你是单模块部署
如果你是单模块部署,当设备连接后,会输出类似的日志,说明成功了
```
250705 -INFO-正在初始化MCP接入点: wss://2662r3426b.vicp.fun/mcp_e
250705 -INFO-发送MCP接入点初始化消息
250705 -INFO-MCP接入点连接成功
250705 -INFO-MCP接入点初始化成功
250705 -INFO-统一工具处理器初始化完成
250705 -INFO-MCP接入点服务器信息: name=Calculator, version=1.9.4
250705 -INFO-MCP接入点支持的工具数量: 1
250705 -INFO-所有MCP接入点工具已获取,客户端准备就绪
250705 -INFO-工具缓存已刷新
250705 -INFO-当前支持的函数列表: [ 'get_time', 'get_lunar', 'play_music', 'get_weather', 'handle_exit_intent', 'calculator']
```
如果包含了 `'calculator'`,说明设备将可以根据意图识别,调用计算器这个工具。
+96
View File
@@ -0,0 +1,96 @@
# 声纹识别启用指南
本教程包含2个部分
- 1、如何部署声纹识别这个服务
- 2、全模块部署时,怎么配置声纹识别接口
# 1、如何部署声纹识别这个服务
## 第一步,下载声纹识别项目源码
浏览器打开[声纹识别项目地址](https://github.com/xinnan-tech/voiceprint-api)
打开完,找到页面中一个绿色的按钮,写着`Code`的按钮,点开它,然后你就看到`Download ZIP`的按钮。
点击它,下载本项目源码压缩包。下载到你电脑后,解压它,此时它的名字可能叫`voiceprint-api-main`
你需要把它重命名成`voiceprint-api`
## 第二步,启动程序
这个项目是一个很简单的项目,建议使用docker运行。不过如果你不想使用docker运行,你可以参考[这个页面](https://github.com/xinnan-tech/voiceprint-api/blob/main/README.md)使用源码运行。以下是docker运行的方法
```
# 进入本项目源码根目录
cd voiceprint-api
# 清除缓存
docker compose -f docker-compose.yml down
docker stop voiceprint-api
docker rm voiceprint-api
docker rmi ghcr.nju.edu.cn/xinnan-tech/voiceprint-api:latest
# 启动docker容器
docker compose -f docker-compose.yml up -d
# 查看日志
docker logs -f voiceprint-api
```
此时,日志里会输出类似以下的日志
```
250711 INFO-🚀 开始: 生产环境服务启动(Uvicorn),监听地址: 0.0.0.0:8005
250711 INFO-============================================================
250711 INFO-声纹接口地址: http://127.0.0.1:8005/voiceprint/health?key=abcd
250711 INFO-============================================================
```
请你把声纹接口地址复制出来:
由于你是docker部署,切不可直接使用上面的地址!
由于你是docker部署,切不可直接使用上面的地址!
由于你是docker部署,切不可直接使用上面的地址!
你先把地址复制出来,放在一个草稿里,你要知道你的电脑的局域网ip是什么,例如我的电脑局域网ip是`192.168.1.25`,那么
原来我的接口地址
```
http://127.0.0.1:8005/voiceprint/health?key=abcd
```
就要改成
```
http://192.168.1.25:8005/voiceprint/health?key=abcd
```
改好后,请使用浏览器直接访问`声纹接口地址`。当浏览器出现类似这样的代码,说明是成功了。
```
{"total_voiceprints":0,"status":"healthy"}
```
请你保留好修改后的`声纹接口地址`,下一步要用到。
# 2、全模块部署时,怎么配置声纹识别
## 第一步 配置接口
如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。
然后搜索参数`server.voice_print`,此时,它的值应该是`null`值。
点击修改按钮,把上一步得来的`声纹接口地址`粘贴到`参数值`里。然后保存。
如果能保存成功,说明一切顺利,你可以去智能体查看效果了。如果不成功,说明智控台无法访问声纹识别,很大概率是网络防火墙,或者没有填写正确的局域网ip。
## 第二步 设置智能体记忆模式
进入你的智能体的角色配置里,将记忆设置成`本地短期记忆`,一定要开启`上报文字+语音`
## 第三步 和你的智能体聊天
将你的设备通电,然后和他用正常的语速和音调聊天。
## 第四步 设置声纹
在智控台,`智能体管理`页面,在智能体的面板里,有一个`声纹识别`按钮,点击它。在底部有一个`新增按钮`。就可以对某个人说的话进行声纹注册。
在弹出的框里,`描述`这个属性建议填写上,可以是这个人的职业、性格、爱好。方便智能体对说话人进行分析和了解。
## 第三步 和你的智能体聊天
将你的设备通电,问它,你知道我是谁吗?如果他能回答得出,说明声纹识别功能正常。
@@ -116,6 +116,11 @@ public interface Constant {
*/
String SERVER_MCP_ENDPOINT = "server.mcp_endpoint";
/**
* mcp接入点路径
*/
String SERVER_VOICE_PRINT = "server.voice_print";
/**
* 无记忆
*/
@@ -232,7 +237,7 @@ public interface Constant {
/**
* 版本号
*/
public static final String VERSION = "0.6.1";
public static final String VERSION = "0.7.1";
/**
* 无效固件URL
@@ -0,0 +1,21 @@
package xiaozhi.modules.agent.Enums;
import lombok.Getter;
/**
* 智能体聊天记录类型
*/
@Getter
public enum AgentChatHistoryType {
USER((byte) 1),
AGENT((byte) 2);
private final byte value;
AgentChatHistoryType(byte i) {
this.value = i;
}
}
@@ -47,6 +47,7 @@ import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
import xiaozhi.modules.agent.vo.AgentInfoVO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
@@ -181,6 +182,33 @@ public class AgentController {
List<AgentChatHistoryDTO> result = agentChatHistoryService.getChatHistoryBySessionId(id, sessionId);
return new Result<List<AgentChatHistoryDTO>>().ok(result);
}
@GetMapping("/{id}/chat-history/user")
@Operation(summary = "获取智能体聊天记录(用户)")
@RequiresPermissions("sys:role:normal")
public Result<List<AgentChatHistoryUserVO>> getRecentlyFiftyByAgentId(
@PathVariable("id") String id) {
// 获取当前用户
UserDetail user = SecurityUser.getUser();
// 检查权限
if (!agentService.checkAgentPermission(id, user.getId())) {
return new Result<List<AgentChatHistoryUserVO>>().error("没有权限查看该智能体的聊天记录");
}
// 查询聊天记录
List<AgentChatHistoryUserVO> data = agentChatHistoryService.getRecentlyFiftyByAgentId(id);
return new Result<List<AgentChatHistoryUserVO>>().ok(data);
}
@GetMapping("/{id}/chat-history/audio")
@Operation(summary = "获取音频内容")
@RequiresPermissions("sys:role:normal")
public Result<String> getContentByAudioId(
@PathVariable("id") String id) {
// 查询聊天记录
String data = agentChatHistoryService.getContentByAudioId(id);
return new Result<String>().ok(data);
}
@PostMapping("/audio/{audioId}")
@Operation(summary = "获取音频下载ID")
@@ -0,0 +1,86 @@
package xiaozhi.modules.agent.controller;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.agent.dto.AgentVoicePrintSaveDTO;
import xiaozhi.modules.agent.dto.AgentVoicePrintUpdateDTO;
import xiaozhi.modules.agent.service.AgentVoicePrintService;
import xiaozhi.modules.agent.vo.AgentVoicePrintVO;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.service.SysParamsService;
@Tag(name = "智能体声纹管理")
@AllArgsConstructor
@RestController
@RequestMapping("/agent/voice-print")
public class AgentVoicePrintController {
private final AgentVoicePrintService agentVoicePrintService;
private final SysParamsService sysParamsService;
@PostMapping
@Operation(summary = "创建智能体的声纹")
@RequiresPermissions("sys:role:normal")
public Result<Void> save(@RequestBody @Valid AgentVoicePrintSaveDTO dto) {
boolean b = agentVoicePrintService.insert(dto);
if (b) {
return new Result<>();
}
return new Result<Void>().error("智能体的声纹创建失败");
}
@PutMapping
@Operation(summary = "更新智能体的对应声纹")
@RequiresPermissions("sys:role:normal")
public Result<Void> update(@RequestBody @Valid AgentVoicePrintUpdateDTO dto) {
Long userId = SecurityUser.getUserId();
boolean b = agentVoicePrintService.update(userId, dto);
if (b) {
return new Result<>();
}
return new Result<Void>().error("智能体的对应声纹更新失败");
}
@DeleteMapping("/{id}")
@Operation(summary = "删除智能体对应声纹")
@RequiresPermissions("sys:role:normal")
public Result<Void> delete(@PathVariable String id) {
Long userId = SecurityUser.getUserId();
// 先删除关联的设备
boolean delete = agentVoicePrintService.delete(userId, id);
if (delete) {
return new Result<>();
}
return new Result<Void>().error("智能体的对应声纹删除失败");
}
@GetMapping("/list/{id}")
@Operation(summary = "获取用户指定智能体声纹列表")
@RequiresPermissions("sys:role:normal")
public Result<List<AgentVoicePrintVO>> list(@PathVariable String id) {
String voiceprintUrl = sysParamsService.getValue("server.voice_print", true);
if (StringUtils.isBlank(voiceprintUrl) || "null".equals(voiceprintUrl)) {
throw new RenException("声纹接口未配置,请先在参数配置中配置声纹接口地址(server.voice_print)");
}
Long userId = SecurityUser.getUserId();
List<AgentVoicePrintVO> list = agentVoicePrintService.list(userId, id);
return new Result<List<AgentVoicePrintVO>>().ok(list);
}
}
@@ -0,0 +1,20 @@
package xiaozhi.modules.agent.dao;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
import xiaozhi.modules.agent.entity.AgentVoicePrintEntity;
/**
* {@link AgentChatHistoryEntity} 智能体聊天历史记录Dao对象
*
* @author Goody
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
@Mapper
public interface AgentVoicePrintDao extends BaseMapper<AgentVoicePrintEntity> {
}
@@ -0,0 +1,28 @@
package xiaozhi.modules.agent.dto;
import lombok.Data;
/**
* 保存智能体声纹的dto
*
* @author zjy
*/
@Data
public class AgentVoicePrintSaveDTO {
/**
* 关联的智能体id
*/
private String agentId;
/**
* 音频文件id
*/
private String audioId;
/**
* 声纹来源的人姓名
*/
private String sourceName;
/**
* 描述声纹来源的人
*/
private String introduce;
}
@@ -0,0 +1,28 @@
package xiaozhi.modules.agent.dto;
import lombok.Data;
/**
* 修改智能体声纹的dto
*
* @author zjy
*/
@Data
public class AgentVoicePrintUpdateDTO {
/**
* 智能体声纹id
*/
private String id;
/**
* 音频文件id
*/
private String audioId;
/**
* 声纹来源的人姓名
*/
private String sourceName;
/**
* 描述声纹来源的人
*/
private String introduce;
}
@@ -0,0 +1,21 @@
package xiaozhi.modules.agent.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* 声纹识别接口返回的对象
*/
@Data
public class IdentifyVoicePrintResponse {
/**
* 最匹配的声纹id
*/
@JsonProperty("speaker_id")
private String speakerId;
/**
* 声纹的分数
*/
private Double score;
}
@@ -0,0 +1,64 @@
package xiaozhi.modules.agent.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 智能体声纹表
*
* @author zjy
*/
@TableName(value = "ai_agent_voice_print")
@Data
public class AgentVoicePrintEntity {
/**
* 主键id
*/
@TableId(type = IdType.ASSIGN_UUID)
private String id;
/**
* 关联的智能体id
*/
private String agentId;
/**
* 关联的音频id
*/
private String audioId;
/**
* 声纹来源的人姓名
*/
private String sourceName;
/**
* 描述声纹来源的人
*/
private String introduce;
/**
* 创建者
*/
@TableField(fill = FieldFill.INSERT)
private Long creator;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private Date createDate;
/**
* 更新者
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updater;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateDate;
}
@@ -9,6 +9,7 @@ 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.vo.AgentChatHistoryUserVO;
/**
* 智能体聊天记录表处理service
@@ -44,4 +45,30 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
* @param deleteText 是否删除文本
*/
void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText);
/**
* 根据智能体ID获取最近50条用户的聊天记录数据(带音频数据)
*
* @param agentId 智能体id
* @return 聊天记录列表(只有用户)
*/
List<AgentChatHistoryUserVO> getRecentlyFiftyByAgentId(String agentId);
/**
* 根据音频数据ID获取聊天内容
*
* @param audioId 音频id
* @return 聊天内容
*/
String getContentByAudioId(String audioId);
/**
* 查询此音频id是否属于此智能体
*
* @param audioId 音频id
* @param agentId 音频id
* @return T:属于 F:不属于
*/
boolean isAudioOwnedByAgent(String audioId,String agentId);
}
@@ -0,0 +1,50 @@
package xiaozhi.modules.agent.service;
import java.util.List;
import xiaozhi.modules.agent.dto.AgentVoicePrintSaveDTO;
import xiaozhi.modules.agent.dto.AgentVoicePrintUpdateDTO;
import xiaozhi.modules.agent.vo.AgentVoicePrintVO;
/**
* 智能体声纹处理service
*
* @author zjy
*/
public interface AgentVoicePrintService {
/**
* 添加智能体新的声纹
*
* @param dto 保存智能体声纹的数据
* @return T:成功 F:失败
*/
boolean insert(AgentVoicePrintSaveDTO dto);
/**
* 删除智能体的指的声纹
*
* @param userId 当前登录的用户id
* @param voicePrintId 声纹id
* @return 是否成功 T:成功 F:失败
*/
boolean delete(Long userId, String voicePrintId);
/**
* 获取指定智能体的所有声纹数据
*
* @param userId 当前登录的用户id
* @param agentId 智能体id
* @return 声纹数据集合
*/
List<AgentVoicePrintVO> list(Long userId, String agentId);
/**
* 更新智能体的指的声纹数据
*
* @param userId 当前登录的用户id
* @param dto 修改的声纹的数据
* @return 是否成功 T:成功 F:失败
*/
boolean update(Long userId, AgentVoicePrintUpdateDTO dto);
}
@@ -8,6 +8,7 @@ import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -16,11 +17,14 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.Enums.AgentChatHistoryType;
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;
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
/**
* 智能体聊天记录表处理service {@link AgentChatHistoryService} impl
@@ -90,4 +94,74 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
}
}
@Override
public List<AgentChatHistoryUserVO> getRecentlyFiftyByAgentId(String agentId) {
// 构建查询条件(不添加按照创建时间排序,数据本来就是主键越大创建时间越大
// 不添加这样可以减少排序全部数据在分页的全盘扫描消耗)
LambdaQueryWrapper<AgentChatHistoryEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.select(AgentChatHistoryEntity::getContent, AgentChatHistoryEntity::getAudioId)
.eq(AgentChatHistoryEntity::getAgentId, agentId)
.eq(AgentChatHistoryEntity::getChatType, AgentChatHistoryType.USER.getValue())
.isNotNull(AgentChatHistoryEntity::getAudioId);
// 构建分页查询,查询前50页数据
Page<AgentChatHistoryEntity> pageParam = new Page<>(0, 50);
IPage<AgentChatHistoryEntity> result = this.baseMapper.selectPage(pageParam, wrapper);
return result.getRecords().stream().map(item -> {
AgentChatHistoryUserVO vo = ConvertUtils.sourceToTarget(item, AgentChatHistoryUserVO.class);
// 处理 content 字段,确保只返回聊天内容
if (vo != null && vo.getContent() != null) {
vo.setContent(extractContentFromString(vo.getContent()));
}
return vo;
}).toList();
}
/**
* 从 content 字段中提取聊天内容
* 如果 content 是 JSON 格式(如 {"speaker": "未知说话人", "content": "现在几点了。"}),则提取 content
* 字段
* 如果 content 是普通字符串,则直接返回
*
* @param content 原始内容
* @return 提取的聊天内容
*/
private String extractContentFromString(String content) {
if (content == null || content.trim().isEmpty()) {
return content;
}
// 尝试解析为 JSON
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(content, Map.class);
if (jsonMap != null && jsonMap.containsKey("content")) {
Object contentObj = jsonMap.get("content");
return contentObj != null ? contentObj.toString() : content;
}
} catch (Exception e) {
// 如果不是有效的 JSON,直接返回原内容
}
// 如果不是 JSON 格式或没有 content 字段,直接返回原内容
return content;
}
@Override
public String getContentByAudioId(String audioId) {
AgentChatHistoryEntity agentChatHistoryEntity = baseMapper
.selectOne(new LambdaQueryWrapper<AgentChatHistoryEntity>()
.select(AgentChatHistoryEntity::getContent)
.eq(AgentChatHistoryEntity::getAudioId, audioId));
return agentChatHistoryEntity == null ? null : agentChatHistoryEntity.getContent();
}
@Override
public boolean isAudioOwnedByAgent(String audioId, String agentId) {
// 查询是否有指定音频id和智能体id的数据,如果有且只有一条说明此数据属性此智能体
Long row = baseMapper.selectCount(new LambdaQueryWrapper<AgentChatHistoryEntity>()
.eq(AgentChatHistoryEntity::getAudioId, audioId)
.eq(AgentChatHistoryEntity::getAgentId, agentId));
return row == 1;
}
}
@@ -61,14 +61,15 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
wsUrl = wsUrl.replace("/mcp/", "/call/");
try {
// 创建 WebSocket 连接
// 创建 WebSocket 连接,增加超时时间到15秒
try (WebSocketClientManager client = WebSocketClientManager.build(
new WebSocketClientManager.Builder()
.uri(wsUrl)
.connectTimeout(5, TimeUnit.SECONDS)
.maxSessionDuration(9, TimeUnit.SECONDS))) {
.connectTimeout(8, TimeUnit.SECONDS)
.maxSessionDuration(10, TimeUnit.SECONDS))) {
// 发送初始化消息
// 步骤1: 发送初始化消息并等待响应
log.info("发送MCP初始化消息,智能体ID: {}", id);
McpJsonRpcRequest initializeRequest = new McpJsonRpcRequest("initialize",
Map.of(
"protocolVersion", "2024-11-05",
@@ -81,37 +82,70 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
1);
client.sendJson(initializeRequest);
// 等待初始化响应
Thread.sleep(200);
// 发送初始化完成通知
// 对于通知类型的消息,手动构建JSON以避免包含null字段
String notificationJson = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}";
client.sendText(notificationJson);
// 等待 0.2 秒
Thread.sleep(200);
// 发送工具列表请求
McpJsonRpcRequest toolsRequest = new McpJsonRpcRequest("tools/list", null, 2);
client.sendJson(toolsRequest);
// 监听响应,直到收到包含 id=2 的响应(tools/list响应)
List<String> responses = client.listener(response -> {
// 等待初始化响应 (id=1) - 移除固定延迟,改为响应驱动
List<String> initResponses = client.listenerWithoutClose(response -> {
try {
// 先尝试解析为通用JSON对象来获取id
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"));
if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
// 检查是否有result字段,表示初始化成功
return jsonMap.containsKey("result") && !jsonMap.containsKey("error");
}
return false;
} catch (Exception e) {
log.warn("解析响应失败: {}", response, e);
log.warn("解析初始化响应失败: {}", response, e);
return false;
}
});
// 处理响应
for (String response : responses) {
// 验证初始化响应
boolean initSucceeded = false;
for (String response : initResponses) {
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
if (jsonMap.containsKey("result")) {
log.info("MCP初始化成功,智能体ID: {}", id);
initSucceeded = true;
break;
} else if (jsonMap.containsKey("error")) {
log.error("MCP初始化失败,智能体ID: {}, 错误: {}", id, jsonMap.get("error"));
return List.of();
}
}
} catch (Exception e) {
log.warn("处理初始化响应失败: {}", response, e);
}
}
if (!initSucceeded) {
log.error("未收到有效的MCP初始化响应,智能体ID: {}", id);
return List.of();
}
// 步骤2: 发送初始化完成通知 - 只有在收到initialize响应后才发送
log.info("发送MCP初始化完成通知,智能体ID: {}", id);
String notificationJson = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}";
client.sendText(notificationJson);
// 步骤3: 发送工具列表请求 - 立即发送,无需额外延迟
log.info("发送MCP工具列表请求,智能体ID: {}", id);
McpJsonRpcRequest toolsRequest = new McpJsonRpcRequest("tools/list", null, 2);
client.sendJson(toolsRequest);
// 等待工具列表响应 (id=2)
List<String> toolsResponses = client.listener(response -> {
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"));
} catch (Exception e) {
log.warn("解析工具列表响应失败: {}", response, e);
return false;
}
});
// 处理工具列表响应
for (String response : toolsResponses) {
try {
// 先解析为通用JSON对象
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
if (jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"))) {
// 检查是否有result字段
@@ -122,11 +156,16 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
if (toolsObj instanceof List) {
List<Map<String, Object>> toolsList = (List<Map<String, Object>>) toolsObj;
// 提取工具名称列表
return toolsList.stream()
List<String> result = toolsList.stream()
.map(tool -> (String) tool.get("name"))
.filter(name -> name != null)
.collect(Collectors.toList());
log.info("成功获取MCP工具列表,智能体ID: {}, 工具数量: {}", id, result.size());
return result;
}
} else if (jsonMap.containsKey("error")) {
log.error("获取工具列表失败,智能体ID: {}, 错误: {}", id, jsonMap.get("error"));
return List.of();
}
}
} catch (Exception e) {
@@ -134,7 +173,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
}
}
log.warn("未找到有效的工具列表响应");
log.warn("未找到有效的工具列表响应,智能体ID: {}", id);
return List.of();
}
@@ -204,4 +243,4 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 加密后成token值
return AESUtils.encrypt(key, json);
}
}
}
@@ -0,0 +1,386 @@
package xiaozhi.modules.agent.service.impl;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.dao.AgentVoicePrintDao;
import xiaozhi.modules.agent.dto.AgentVoicePrintSaveDTO;
import xiaozhi.modules.agent.dto.AgentVoicePrintUpdateDTO;
import xiaozhi.modules.agent.dto.IdentifyVoicePrintResponse;
import xiaozhi.modules.agent.entity.AgentVoicePrintEntity;
import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentVoicePrintService;
import xiaozhi.modules.agent.vo.AgentVoicePrintVO;
import xiaozhi.modules.sys.service.SysParamsService;
/**
* @author zjy
*/
@Service
@AllArgsConstructor
@Slf4j
public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao, AgentVoicePrintEntity>
implements AgentVoicePrintService {
private final AgentChatAudioService agentChatAudioService;
private final RestTemplate restTemplate;
private final SysParamsService sysParamsService;
private final AgentChatHistoryService agentChatHistoryService;
// Springboot提供的编程事务类
private final TransactionTemplate transactionTemplate;
// 识别度
private final Double RECOGNITION = 0.5;
@Override
public boolean insert(AgentVoicePrintSaveDTO dto) {
// 获取音频数据
ByteArrayResource resource = getVoicePrintAudioWAV(dto.getAgentId(), dto.getAudioId());
// 识别一下此声音是否注册过
IdentifyVoicePrintResponse response = identifyVoicePrint(dto.getAgentId(), resource);
if (response != null && response.getScore() > RECOGNITION) {
// 根据识别出的声纹ID查询对应的用户信息
AgentVoicePrintEntity existingVoicePrint = baseMapper.selectById(response.getSpeakerId());
String existingUserName = existingVoicePrint != null ? existingVoicePrint.getSourceName() : "未知用户";
throw new RenException("此声音声纹对应的人(" + existingUserName + ")已经注册,请选择其他声音注册");
}
AgentVoicePrintEntity entity = ConvertUtils.sourceToTarget(dto, AgentVoicePrintEntity.class);
// 开启事务
return Boolean.TRUE.equals(transactionTemplate.execute(status -> {
try {
// 保存声纹信息
int row = baseMapper.insert(entity);
// 插入一条数据,影响的数据不等于1说明出现了,保存问题回滚
if (row != 1) {
status.setRollbackOnly(); // 标记事务回滚
return false;
}
// 发送注册声纹请求
registerVoicePrint(entity.getId(), resource);
return true;
} catch (RenException e) {
status.setRollbackOnly(); // 标记事务回滚
throw e;
} catch (Exception e) {
status.setRollbackOnly(); // 标记事务回滚
log.error("保存声纹错误原因:{}", e.getMessage());
throw new RenException("保存声纹错误,请联系管理员");
}
}));
}
@Override
public boolean delete(Long userId, String voicePrintId) {
// 开启事务
return Boolean.TRUE.equals(transactionTemplate.execute(status -> {
try {
// 删除声纹,按照指定当前登录用户和智能体
int row = baseMapper.delete(new LambdaQueryWrapper<AgentVoicePrintEntity>()
.eq(AgentVoicePrintEntity::getId, voicePrintId)
.eq(AgentVoicePrintEntity::getCreator, userId));
if (row != 1) {
status.setRollbackOnly(); // 标记事务回滚
return false;
}
cancelVoicePrint(voicePrintId);
return true;
} catch (RenException e) {
status.setRollbackOnly(); // 标记事务回滚
throw e;
} catch (Exception e) {
status.setRollbackOnly(); // 标记事务回滚
log.error("删除声纹错误原因:{}", e.getMessage());
throw new RenException("删除声纹错误,请联系管理员");
}
}));
}
@Override
public List<AgentVoicePrintVO> list(Long userId, String agentId) {
// 按照指定当前登录用户和智能体查找数据
List<AgentVoicePrintEntity> list = baseMapper.selectList(new LambdaQueryWrapper<AgentVoicePrintEntity>()
.eq(AgentVoicePrintEntity::getAgentId, agentId)
.eq(AgentVoicePrintEntity::getCreator, userId));
return list.stream().map(entity -> {
// 遍历转换成AgentVoicePrintVO类型
return ConvertUtils.sourceToTarget(entity, AgentVoicePrintVO.class);
}).toList();
}
@Override
public boolean update(Long userId, AgentVoicePrintUpdateDTO dto) {
AgentVoicePrintEntity agentVoicePrintEntity = baseMapper
.selectOne(new LambdaQueryWrapper<AgentVoicePrintEntity>()
.eq(AgentVoicePrintEntity::getId, dto.getId())
.eq(AgentVoicePrintEntity::getCreator, userId));
if (agentVoicePrintEntity == null) {
return false;
}
// 获取音频Id
String audioId = dto.getAudioId();
// 获取智能体id
String agentId = agentVoicePrintEntity.getAgentId();
ByteArrayResource resource;
// audioId不等于空,且audioId和之前的保存的音频id不一样,则需要重新获取音频数据生成声纹
if (!StringUtils.isEmpty(audioId) && !audioId.equals(agentVoicePrintEntity.getAudioId())) {
resource = getVoicePrintAudioWAV(agentId, audioId);
// 识别一下此声音是否注册过
IdentifyVoicePrintResponse response = identifyVoicePrint(agentId, resource);
// 返回分数高于RECOGNITION说明这个声纹已经有了
if (response != null && response.getScore() > RECOGNITION) {
// 判断返回的id如果不是要修改的声纹id,说明这个声纹id,现在要注册的声音已经存在且不是原来的声纹,不允许修改
if (!response.getSpeakerId().equals(dto.getId())) {
// 根据识别出的声纹ID查询对应的用户信息
AgentVoicePrintEntity existingVoicePrint = baseMapper.selectById(response.getSpeakerId());
String existingUserName = existingVoicePrint != null ? existingVoicePrint.getSourceName() : "未知用户";
throw new RenException("此次修改不允许,此声音已经注册为声纹了(" + existingUserName + "");
}
}
} else {
resource = null;
}
// 开启事务
return Boolean.TRUE.equals(transactionTemplate.execute(status -> {
try {
AgentVoicePrintEntity entity = ConvertUtils.sourceToTarget(dto, AgentVoicePrintEntity.class);
int row = baseMapper.updateById(entity);
if (row != 1) {
status.setRollbackOnly(); // 标记事务回滚
return false;
}
if (resource != null) {
String id = entity.getId();
// 先注销之前这个声纹id上的声纹向量
cancelVoicePrint(id);
// 发送注册声纹请求
registerVoicePrint(id, resource);
}
return true;
} catch (RenException e) {
status.setRollbackOnly(); // 标记事务回滚
throw e;
} catch (Exception e) {
status.setRollbackOnly(); // 标记事务回滚
log.error("修改声纹错误原因:{}", e.getMessage());
throw new RenException("修改声纹错误,请联系管理员");
}
}));
}
/**
* 获取生纹接口URI对象
*
* @return URI对象
*/
private URI getVoicePrintURI() {
// 获取声纹接口地址
String voicePrint = sysParamsService.getValue(Constant.SERVER_VOICE_PRINT, true);
try {
return new URI(voicePrint);
} catch (URISyntaxException e) {
log.error("路径格式不正确路径:{}\n错误信息:{}", voicePrint, e.getMessage());
throw new RuntimeException("声纹接口的地址存在错误,请进入参数管理修改声纹接口地址");
}
}
/**
* 获取声纹地址基础路径
*
* @param uri 声纹地址uri
* @return 基础路径
*/
private String getBaseUrl(URI uri) {
String protocol = uri.getScheme();
String host = uri.getHost();
int port = uri.getPort();
return "%s://%s:%s".formatted(protocol, host, port);
}
/**
* 获取验证Authorization
*
* @param uri 声纹地址uri
* @return Authorization值
*/
private String getAuthorization(URI uri) {
// 获取参数
String query = uri.getQuery();
// 获取aes加密密钥
String str = "key=";
return "Bearer " + query.substring(query.indexOf(str) + str.length());
}
/**
* 获取声纹音频资源数据
*
* @param audioId 音频Id
* @return 声纹音频资源数据
*/
private ByteArrayResource getVoicePrintAudioWAV(String agentId, String audioId) {
// 判断这个音频是否属于当前智能体
boolean b = agentChatHistoryService.isAudioOwnedByAgent(audioId, agentId);
if (!b) {
throw new RenException("音频数据不属于这个智能体");
}
// 获取到音频数据
byte[] audio = agentChatAudioService.getAudio(audioId);
// 如果音频数据为空的直接报错不进行下去
if (audio == null || audio.length == 0) {
throw new RenException("音频数据是空的请检查上传数据");
}
// 将字节数组包装为资源,返回
return new ByteArrayResource(audio) {
@Override
public String getFilename() {
return "VoicePrint.WAV"; // 设置文件名
}
};
}
/**
* 发送注册声纹http请求
*
* @param id 声纹id
* @param resource 声纹音频资源
*/
private void registerVoicePrint(String id, ByteArrayResource resource) {
// 处理声纹接口地址,获取前缀
URI uri = getVoicePrintURI();
String baseUrl = getBaseUrl(uri);
String requestUrl = baseUrl + "/voiceprint/register";
// 创建请求体
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("speaker_id", id);
body.add("file", resource);
// 创建请求头
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", getAuthorization(uri));
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
// 创建请求体
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
// 发送 POST 请求
ResponseEntity<String> response = restTemplate.postForEntity(requestUrl, requestEntity, String.class);
if (response.getStatusCode() != HttpStatus.OK) {
log.error("声纹注册失败,请求路径:{}", requestUrl);
throw new RenException("声纹保存失败,请求不成功");
}
// 检查响应内容
String responseBody = response.getBody();
if (responseBody == null || !responseBody.contains("true")) {
log.error("声纹注册失败,请求处理失败内容:{}", responseBody == null ? "空内容" : responseBody);
throw new RenException("声纹保存失败,请求处理失败");
}
}
/**
* 发送注销声纹的请求
*
* @param voicePrintId 声纹id
*/
private void cancelVoicePrint(String voicePrintId) {
URI uri = getVoicePrintURI();
String baseUrl = getBaseUrl(uri);
String requestUrl = baseUrl + "/voiceprint/" + voicePrintId;
// 创建请求头
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", getAuthorization(uri));
// 创建请求体
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(headers);
// 发送 POST 请求
ResponseEntity<String> response = restTemplate.exchange(requestUrl, HttpMethod.DELETE, requestEntity,
String.class);
if (response.getStatusCode() != HttpStatus.OK) {
log.error("声纹注销失败,请求路径:{}", requestUrl);
throw new RenException("声纹注销失败,请求不成功");
}
// 检查响应内容
String responseBody = response.getBody();
if (responseBody == null || !responseBody.contains("true")) {
log.error("声纹注销失败,请求处理失败内容:{}", responseBody == null ? "空内容" : responseBody);
throw new RenException("声纹注销失败,请求处理失败");
}
}
/**
* 发送识别声纹http请求
*
* @param agentId 智能体id
* @param resource 声纹音频资源
* @return 返回识别数据
*/
private IdentifyVoicePrintResponse identifyVoicePrint(String agentId, ByteArrayResource resource) {
// 获取该智能体所有注册的声纹
List<AgentVoicePrintEntity> agentVoicePrintList = baseMapper
.selectList(new LambdaQueryWrapper<AgentVoicePrintEntity>()
.select(AgentVoicePrintEntity::getId)
.eq(AgentVoicePrintEntity::getAgentId, agentId));
// 声纹数量为0,说明还没注册过声纹不需要发生识别请求
if (agentVoicePrintList.isEmpty()) {
return null;
}
// 处理声纹接口地址,获取前缀
URI uri = getVoicePrintURI();
String baseUrl = getBaseUrl(uri);
String requestUrl = baseUrl + "/voiceprint/identify";
// 创建请求体
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
// 创建speaker_id参数
String speakerIds = agentVoicePrintList.stream()
.map(AgentVoicePrintEntity::getId)
.collect(Collectors.joining(","));
body.add("speaker_ids", speakerIds);
body.add("file", resource);
// 创建请求头
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", getAuthorization(uri));
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
// 创建请求体
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
// 发送 POST 请求
ResponseEntity<String> response = restTemplate.postForEntity(requestUrl, requestEntity, String.class);
if (response.getStatusCode() != HttpStatus.OK) {
log.error("声纹识别请求失败,请求路径:{}", requestUrl);
throw new RenException("声纹识别失败,请求不成功");
}
// 检查响应内容
String responseBody = response.getBody();
if (responseBody != null) {
return JsonUtils.parseObject(responseBody, IdentifyVoicePrintResponse.class);
}
return null;
}
}
@@ -0,0 +1,16 @@
package xiaozhi.modules.agent.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 智能体用户个人聊天数据的VO
*/
@Data
public class AgentChatHistoryUserVO {
@Schema(description = "聊天内容")
private String content;
@Schema(description = "音频ID")
private String audioId;
}
@@ -0,0 +1,33 @@
package xiaozhi.modules.agent.vo;
import lombok.Data;
import java.util.Date;
/**
* 展示智能体声纹列表VO
*/
@Data
public class AgentVoicePrintVO {
/**
* 主键id
*/
private String id;
/**
* 音频文件id
*/
private String audioId;
/**
* 声纹来源的人姓名
*/
private String sourceName;
/**
* 描述声纹来源的人
*/
private String introduce;
/**
* 创建时间
*/
private Date createDate;
}
@@ -9,20 +9,26 @@ import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.dao.AgentVoicePrintDao;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentPluginMapping;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.entity.AgentVoicePrintEntity;
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.agent.vo.AgentVoicePrintVO;
import xiaozhi.modules.config.service.ConfigService;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
@@ -45,6 +51,7 @@ public class ConfigServiceImpl implements ConfigService {
private final TimbreService timbreService;
private final AgentPluginMappingService agentPluginMappingService;
private final AgentMcpAccessPointService agentMcpAccessPointService;
private final AgentVoicePrintDao agentVoicePrintDao;
@Override
public Object getConfig(Boolean isCache) {
@@ -162,6 +169,8 @@ public class ConfigServiceImpl implements ConfigService {
mcpEndpoint = mcpEndpoint.replace("/mcp/", "/call/");
result.put("mcp_endpoint", mcpEndpoint);
}
// 获取声纹信息
buildVoiceprintConfig(agent.getId(), result);
// 构建模块配置
buildModuleConfig(
@@ -255,20 +264,76 @@ public class ConfigServiceImpl implements ConfigService {
return config;
}
/**
* 构建声纹配置信息
*
* @param agentId 智能体ID
* @param result 结果Map
*/
private void buildVoiceprintConfig(String agentId, Map<String, Object> result) {
try {
// 获取声纹接口地址
String voiceprintUrl = sysParamsService.getValue("server.voice_print", true);
if (StringUtils.isBlank(voiceprintUrl) || "null".equals(voiceprintUrl)) {
return;
}
// 获取智能体关联的声纹信息(不需要用户权限验证)
List<AgentVoicePrintVO> voiceprints = getVoiceprintsByAgentId(agentId);
if (voiceprints == null || voiceprints.isEmpty()) {
return;
}
// 构建speakers列表
List<String> speakers = new ArrayList<>();
for (AgentVoicePrintVO voiceprint : voiceprints) {
String speakerStr = String.format("%s,%s,%s",
voiceprint.getId(),
voiceprint.getSourceName(),
voiceprint.getIntroduce() != null ? voiceprint.getIntroduce() : "");
speakers.add(speakerStr);
}
// 构建声纹配置
Map<String, Object> voiceprintConfig = new HashMap<>();
voiceprintConfig.put("url", voiceprintUrl);
voiceprintConfig.put("speakers", speakers);
result.put("voiceprint", voiceprintConfig);
} catch (Exception e) {
// 声纹配置获取失败时不影响其他功能
System.err.println("获取声纹配置失败: " + e.getMessage());
}
}
/**
* 获取智能体关联的声纹信息
*
* @param agentId 智能体ID
* @return 声纹信息列表
*/
private List<AgentVoicePrintVO> getVoiceprintsByAgentId(String agentId) {
LambdaQueryWrapper<AgentVoicePrintEntity> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(AgentVoicePrintEntity::getAgentId, agentId);
queryWrapper.orderByAsc(AgentVoicePrintEntity::getCreateDate);
List<AgentVoicePrintEntity> entities = agentVoicePrintDao.selectList(queryWrapper);
return ConvertUtils.sourceToTarget(entities, AgentVoicePrintVO.class);
}
/**
* 构建模块配置
*
* @param prompt 提示词
* @param voice 音色
* @param referenceAudio 参考音频路径
* @param referenceText 参考文本
* @param vadModelId VAD模型ID
* @param asrModelId ASR模型ID
* @param llmModelId LLM模型ID
* @param ttsModelId TTS模型ID
* @param memModelId 记忆模型ID
* @param intentModelId 意图模型ID
* @param result 结果Map
* @param prompt 提示词
* @param voice 音色
* @param referenceAudio 参考音频路径
* @param referenceText 参考文本
* @param vadModelId VAD模型ID
* @param asrModelId ASR模型ID
* @param llmModelId LLM模型ID
* @param ttsModelId TTS模型ID
* @param memModelId 记忆模型ID
* @param intentModelId 意图模型ID
* @param result 结果Map
*/
private void buildModuleConfig(
String assistantName,
@@ -298,14 +363,20 @@ public class ConfigServiceImpl implements ConfigService {
continue;
}
ModelConfigEntity model = modelConfigService.getModelById(modelIds[i], isCache);
if (model == null) {
continue;
}
Map<String, Object> typeConfig = new HashMap<>();
if (model.getConfigJson() != null) {
typeConfig.put(model.getId(), model.getConfigJson());
// 如果是TTS类型,添加private_voice属性
if ("TTS".equals(modelTypes[i])){
if (voice != null) ((Map<String, Object>) model.getConfigJson()).put("private_voice", voice);
if (referenceAudio != null) ((Map<String, Object>) model.getConfigJson()).put("ref_audio", referenceAudio);
if (referenceText != null) ((Map<String, Object>) model.getConfigJson()).put("ref_text", referenceText);
if ("TTS".equals(modelTypes[i])) {
if (voice != null)
((Map<String, Object>) model.getConfigJson()).put("private_voice", voice);
if (referenceAudio != null)
((Map<String, Object>) model.getConfigJson()).put("ref_audio", referenceAudio);
if (referenceText != null)
((Map<String, Object>) model.getConfigJson()).put("ref_text", referenceText);
}
// 如果是Intent类型,且type=intent_llm,则给他添加附加模型
if ("Intent".equals(modelTypes[i])) {
@@ -51,13 +51,12 @@ public class OTAController {
if (StringUtils.isBlank(clientId)) {
clientId = deviceId;
}
String macAddress = deviceReportReqDTO.getMacAddress();
boolean macAddressValid = isMacAddressValid(macAddress);
boolean macAddressValid = isMacAddressValid(deviceId);
// 设备Id和Mac地址应是一致的, 并且必须需要application字段
if (!deviceId.equals(macAddress) || !macAddressValid || deviceReportReqDTO.getApplication() == null) {
return createResponse(DeviceReportRespDTO.createError("Invalid OTA request"));
if (!macAddressValid) {
return createResponse(DeviceReportRespDTO.createError("Invalid device ID"));
}
return createResponse(deviceService.checkDeviceActive(macAddress, clientId, deviceReportReqDTO));
return createResponse(deviceService.checkDeviceActive(deviceId, clientId, deviceReportReqDTO));
}
@Operation(summary = "设备快速检查激活状态")
@@ -107,6 +107,9 @@ public class SysParamsController {
// 验证MCP地址
validateMcpUrl(dto.getParamCode(), dto.getParamValue());
//
validateVoicePrint(dto.getParamCode(), dto.getParamValue());
sysParamsService.update(dto);
configService.getConfig(false);
return new Result<Void>();
@@ -212,13 +215,14 @@ public class SysParamsController {
if (!url.toLowerCase().contains("key")) {
throw new RenException("不是正确的MCP地址");
}
try {
// 发送GET请求
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
if (response.getStatusCode() != HttpStatus.OK) {
throw new RenException("MCP接口访问失败,状态码:" + response.getStatusCode());
}
// 检查响应内容是否包含OTA相关信息
// 检查响应内容是否包含mcp相关信息
String body = response.getBody();
if (body == null || !body.contains("success")) {
throw new RenException("MCP接口返回内容格式不正确,可能不是一个真实的MCP接口");
@@ -227,4 +231,37 @@ public class SysParamsController {
throw new RenException("MCP接口验证失败:" + e.getMessage());
}
}
// 验证声纹接口地址是否正常
private void validateVoicePrint(String paramCode, String url) {
if (!paramCode.equals(Constant.SERVER_VOICE_PRINT)) {
return;
}
if (StringUtils.isBlank(url) || url.equals("null")) {
throw new RenException("声纹接口地址不能为空");
}
if (url.contains("localhost") || url.contains("127.0.0.1")) {
throw new RenException("声纹接口地址不能使用localhost或127.0.0.1");
}
if (!url.toLowerCase().contains("key")) {
throw new RenException("不是正确的声纹接口地址");
}
// 验证URL格式
if (!url.toLowerCase().startsWith("http")) {
throw new RenException("声纹接口地址必须以http或https开头");
}
try {
// 发送GET请求
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
if (response.getStatusCode() != HttpStatus.OK) {
throw new RenException("声纹接口访问失败,状态码:" + response.getStatusCode());
}
// 检查响应内容
String body = response.getBody();
if (body == null || !body.contains("healthy")) {
throw new RenException("声纹接口返回内容格式不正确,可能不是一个真实的MCP接口");
}
} catch (Exception e) {
throw new RenException("声纹接口验证失败:" + e.getMessage());
}
}
}
@@ -137,6 +137,37 @@ public class WebSocketClientManager implements Closeable {
return collected;
}
private <T> List<T> listenerCustomWithoutClose(
BlockingQueue<T> queue,
Predicate<T> predicate)
throws InterruptedException, TimeoutException, ExecutionException {
List<T> collected = new ArrayList<>();
long deadline = System.currentTimeMillis() + maxSessionDurationUnit.toMillis(maxSessionDuration);
while (true) {
if (errorFuture.isDone()) {
errorFuture.get();
}
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
throw new TimeoutException("等待批量消息超时");
}
T msg = queue.poll(remaining, TimeUnit.MILLISECONDS);
if (msg == null) {
throw new TimeoutException("等待批量消息超时");
}
collected.add(msg);
if (predicate.test(msg)) {
break;
}
}
// 不调用 close(),保持连接开放
return collected;
}
/**
* 同步接收多条消息,直到 predicate 为 true 或超时抛异常;
*
@@ -147,6 +178,17 @@ public class WebSocketClientManager implements Closeable {
return listenerCustom(textMessageQueue, predicate);
}
/**
* 同步接收多条消息,直到 predicate 为 true 或超时抛异常;
* 不自动关闭连接,适用于需要在同一连接上发送多个消息的场景
*
* @return 返回监听期间的所有消息列表
*/
public List<String> listenerWithoutClose(Predicate<String> predicate)
throws InterruptedException, TimeoutException, ExecutionException {
return listenerCustomWithoutClose(textMessageQueue, predicate);
}
public List<byte[]> listenerBinary(Predicate<byte[]> predicate)
throws InterruptedException, TimeoutException, ExecutionException {
return listenerCustom(binaryMessageQueue, predicate);
@@ -0,0 +1,4 @@
-- 添加声纹接口地址参数配置
delete from `sys_params` where id = 114;
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark)
VALUES (114, 'server.voice_print', 'null', 'string', 1, '声纹接口地址');
@@ -0,0 +1,12 @@
DROP TABLE IF EXISTS ai_agent_voice_print;
create table ai_agent_voice_print (
id varchar(32) NOT NULL COMMENT '声纹ID',
agent_id varchar(32) NOT NULL COMMENT '关联的智能体ID',
source_name varchar(50) NOT NULL COMMENT '声纹来源的人的姓名',
introduce varchar(200) COMMENT '描述声纹来源的这个人',
create_date DATETIME COMMENT '创建时间',
creator bigint COMMENT '创建者',
update_date DATETIME COMMENT '修改时间',
updater bigint COMMENT '修改者',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体声纹表'
@@ -0,0 +1,3 @@
-- 智能体声纹添加新字段
ALTER TABLE ai_agent_voice_print
ADD COLUMN audio_id VARCHAR(32) NOT NULL COMMENT '音频ID';
@@ -239,4 +239,25 @@ databaseChangeLog:
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202506261637.sql
path: classpath:db/changelog/202506261637.sql
- changeSet:
id: 202507031602
author: zjy
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202507031602.sql
- changeSet:
id: 202507041018
author: zjy
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202507041018.sql
- changeSet:
id: 202507081646
author: zjy
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202507081646.sql
+12 -12
View File
@@ -42,7 +42,7 @@ nav {
}
.el-message {
top: 45px !important;
top: 70px !important;
}
</style>
<script>
@@ -64,26 +64,26 @@ export default {
if (this.isCDNEnabled) {
// 添加全局快捷键Alt+C用于显示缓存查看器
document.addEventListener('keydown', this.handleKeyDown);
// 在全局对象上添加缓存检查方法,便于调试
window.checkCDNCacheStatus = () => {
this.showCacheViewer = true;
};
// 在控制台输出提示信息
console.info(
'%c[小智服务] CDN缓存检查工具已加载',
'%c[小智服务] CDN缓存检查工具已加载',
'color: #409EFF; font-weight: bold;'
);
console.info(
'按下 Alt+C 组合键或在控制台运行 checkCDNCacheStatus() 可以查看CDN缓存状态'
);
// 检查Service Worker状态
this.checkServiceWorkerStatus();
} else {
console.info(
'%c[小智服务] CDN模式已禁用,使用本地打包资源',
'%c[小智服务] CDN模式已禁用,使用本地打包资源',
'color: #67C23A; font-weight: bold;'
);
}
@@ -108,19 +108,19 @@ export default {
const registrations = await navigator.serviceWorker.getRegistrations();
if (registrations.length > 0) {
console.info(
'%c[小智服务] Service Worker已注册',
'%c[小智服务] Service Worker已注册',
'color: #67C23A; font-weight: bold;'
);
// 输出缓存状态到控制台
setTimeout(async () => {
const hasCaches = await logCacheStatus();
if (!hasCaches) {
console.info(
'%c[小智服务] 还未检测到缓存,请刷新页面或等待缓存建立',
'%c[小智服务] 还未检测到缓存,请刷新页面或等待缓存建立',
'color: #E6A23C; font-weight: bold;'
);
// 开发环境下提供额外提示
if (process.env.NODE_ENV === 'development') {
console.info(
@@ -136,10 +136,10 @@ export default {
}, 2000);
} else {
console.info(
'%c[小智服务] Service Worker未注册,CDN资源可能无法缓存',
'%c[小智服务] Service Worker未注册,CDN资源可能无法缓存',
'color: #F56C6C; font-weight: bold;'
);
if (process.env.NODE_ENV === 'development') {
console.info(
'%c[小智服务] 在开发环境中,这是正常现象',
+92
View File
@@ -173,4 +173,96 @@ export default {
});
}).send();
},
// 添加智能体的声纹
addAgentVoicePrint(voicePrintData, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/voice-print`)
.method('POST')
.data(voicePrintData)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.addAgentVoicePrint(voicePrintData, callback);
});
}).send();
},
// 获取指定智能体声纹列表
getAgentVoicePrintList(id,callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/voice-print/list/${id}`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.getAgentVoicePrintList(id,callback);
});
}).send();
},
// 删除智能体声纹
deleteAgentVoicePrint(id, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/voice-print/${id}`)
.method('DELETE')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.deleteAgentVoicePrint(id, callback);
});
}).send();
},
// 更新智能体声纹
updateAgentVoicePrint(voicePrintData, callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/voice-print`)
.method('PUT')
.data(voicePrintData)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.updateAgentVoicePrint(voicePrintData, callback);
});
}).send();
},
// 获取指定智能体用户类型聊天记录
getRecentlyFiftyByAgentId(id,callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${id}/chat-history/user`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.getRecentlyFiftyByAgentId(id,callback);
});
}).send();
},
// 获取指定智能体用户类型聊天记录
getContentByAudioId(id,callback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${id}/chat-history/audio`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.getContentByAudioId(id,callback);
});
}).send();
},
}
@@ -24,7 +24,7 @@
<img :src="message.chatType === 1 ? getUserAvatar(currentSessionId) : require('@/assets/xiaozhi-logo.png')"
class="avatar" />
<div class="message-content">
{{ message.content }}
{{ extractContentFromString(message.content) }}
<i v-if="message.audioId" :class="getAudioIconClass(message)"
@click="playAudio(message)" class="audio-icon"></i>
</div>
@@ -129,6 +129,32 @@ export default {
}
},
methods: {
/**
* 从 content 字段中提取聊天内容
* 如果 content 是 JSON 格式(如 {"speaker": "未知说话人", "content": "现在几点了。"}),则提取 content 字段
* 如果 content 是普通字符串,则直接返回
*
* @param {string} content 原始内容
* @returns {string} 提取的聊天内容
*/
extractContentFromString(content) {
if (!content || content.trim() === '') {
return content;
}
// 尝试解析为 JSON
try {
const jsonObj = JSON.parse(content);
if (jsonObj && typeof jsonObj === 'object' && jsonObj.content) {
return jsonObj.content;
}
} catch (e) {
// 如果不是有效的 JSON,直接返回原内容
}
// 如果不是 JSON 格式或没有 content 字段,直接返回原内容
return content;
},
resetData() {
this.sessions = [];
this.messages = [];
@@ -22,6 +22,9 @@
<div style="display: flex;gap: 10px;align-items: center;">
<div class="settings-btn" @click="handleConfigure">
配置角色
</div>
<div class="settings-btn" @click="handleVoicePrint">
声纹识别
</div>
<div class="settings-btn" @click="handleDeviceManage">
设备管理({{ device.deviceCount }})
@@ -77,6 +80,9 @@ export default {
handleConfigure() {
this.$router.push({ path: '/role-config', query: { agentId: this.device.agentId } });
},
handleVoicePrint() {
this.$router.push({ path: '/voice-print', query: { agentId: this.device.agentId } });
},
handleDeviceManage() {
this.$router.push({ path: '/device-management', query: { agentId: this.device.agentId } });
},
@@ -110,8 +110,10 @@
<div class="url-header">
<div class="address-desc">
<span>以下是智能体的MCP接入点地址</span>
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-enable.md"
target="_blank" class="doc-link">如何部署MCP接入点</a> &nbsp;&nbsp;|&nbsp;&nbsp;
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md"
target="_blank" class="doc-link">查看接入点使用文档</a>
target="_blank" class="doc-link">如何接入MCP功能</a> &nbsp;
</div>
</div>
<el-input v-model="mcpUrl" readonly class="url-input">
@@ -660,6 +662,7 @@ export default {
.mcp-left,
.mcp-right {
flex: 1;
padding-bottom: 50px;
}
.url-header {
@@ -0,0 +1,424 @@
<template>
<el-dialog :title="title" :visible.sync="visible" width="520px" class="param-dialog-wrapper" :append-to-body="true"
:close-on-click-modal="false" :key="dialogKey" custom-class="custom-param-dialog" :show-close="false">
<div class="dialog-container">
<div class="dialog-header">
<h2 class="dialog-title">{{ title }}</h2>
<button class="custom-close-btn" @click="cancel">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 1L1 13M1 1L13 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
<el-form :model="form" :rules="rules" ref="form" label-width="110px" label-position="left" class="param-form">
<el-form-item label="声纹向量" prop="audioId" class="form-item">
<el-select v-model="form.audioId" placeholder="请选择一条语言消息" class="custom-select">
<el-option v-for="item in valueTypeOptions" :key="item.audioId" :label="item.content" :value="item.audioId">
<span style="float: left">{{ item.content }}</span>
<span style="float: right; color: #8492a6; font-size: 13px">
<i :class="getAudioIconClass(item.audioId)" @click.stop="playAudio(item.audioId)"
class="audio-icon"></i>
</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="姓名" prop="sourceName" class="form-item">
<el-input v-model="form.sourceName" placeholder="请输入姓名" class="custom-input"></el-input>
</el-form-item>
<el-form-item label="描述" prop="introduce" class="form-item remark-item">
<el-input type="textarea" v-model="form.introduce" placeholder="请输入描述" :rows="3" class="custom-textarea"
maxlength="100" show-word-limit></el-input>
</el-form-item>
</el-form>
<div class="dialog-footer">
<el-button type="primary" @click="submit" class="save-btn" :loading="saving" :disabled="saving">
保存
</el-button>
<el-button @click="cancel" class="cancel-btn">
取消
</el-button>
</div>
</div>
</el-dialog>
</template>
<script>
import api from '@/apis/api';
export default {
props: {
title: {
type: String,
default: '添加说话人'
},
visible: {
type: Boolean,
default: false
},
agentId: {
type: String
},
form: {
type: Object,
default: () => ({
id: null,
audioId: '',
sourceName: '',
introduce: ''
})
}
},
data() {
return {
dialogKey: Date.now(),
saving: false,
playingAudioId: null,
audioElement: null,
valueTypeOptions: [
{ audioId: '', content: '' }
],
rules: {
introduce: [
{ required: true, message: "请输入描述", trigger: "blur" }
],
sourceName: [
{ required: true, message: "请输入姓名", trigger: "blur" }
],
audioId: [
{ required: true, message: "请选择音频向量", trigger: "change" }
]
}
};
},
methods: {
getAudioIconClass(audioId) {
if (this.playingAudioId === audioId) {
return 'el-icon-loading';
}
return 'el-icon-video-play';
},
playAudio(audioId) {
if (this.playingAudioId === 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 = audioId;
api.agent.getAudioId(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();
}
});
},
submit() {
this.$refs.form.validate((valid) => {
if (valid) {
this.saving = true; // 开始加载
this.$emit('submit', {
form: this.form,
done: () => {
this.saving = false; // 加载完成
}
});
setTimeout(() => {
this.saving = false;
}, 3000);
}
});
},
cancel() {
this.saving = false; // 取消时重置状态
this.$emit('cancel');
}
},
watch: {
visible(newVal) {
if (newVal) {
this.dialogKey = Date.now();
}
},
agentId(newVal) {
if (newVal) {
api.agent.getRecentlyFiftyByAgentId(this.agentId, ((data) => {
this.valueTypeOptions = data.data.data.map(item => ({
...item
}));
}))
}
},
'form.audioId'(newVal) {
if (newVal == null || newVal == "") {
return
}
if (this.valueTypeOptions.some(item => item.audioId === newVal)) {
return;
}
api.agent.getContentByAudioId(newVal, ((data) => {
this.valueTypeOptions.push({
audioId: newVal, content: data.data.data
})
}))
}
}
};
</script>
<style>
.custom-param-dialog {
border-radius: 16px !important;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15) !important;
border: none !important;
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0 !important;
border-radius: 16px;
}
}
</style>
<style scoped lang="scss">
.audio-icon {
font-size: 20px;
cursor: pointer;
margin: 0 5px;
color: #1890ff;
}
.param-dialog-wrapper {
.dialog-container {
padding: 24px 32px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
}
.dialog-header {
position: relative;
margin-bottom: 24px;
text-align: center;
}
.dialog-title {
font-size: 20px;
color: #1e293b;
margin: 0;
padding: 0;
font-weight: 600;
letter-spacing: 0.5px;
}
.custom-close-btn {
position: absolute;
top: -8px;
right: -8px;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
outline: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
&:hover {
color: #ffffff;
background: #ef4444;
transform: rotate(90deg);
box-shadow: 0 4px 6px rgba(239, 68, 68, 0.2);
}
svg {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
}
.param-form {
.form-item {
margin-bottom: 20px;
:deep(.el-form-item__label) {
color: #475569;
font-weight: 500;
padding-right: 12px;
text-align: right;
font-size: 14px;
letter-spacing: 0.2px;
}
}
.custom-input {
:deep(.el-input__inner) {
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #e2e8f0;
height: 42px;
padding: 0 14px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 14px;
color: #334155;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
background-color: #ffffff;
}
&::placeholder {
color: #94a3b8;
font-weight: 400;
}
}
}
.custom-select {
width: 100%;
:deep(.el-input__inner) {
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #e2e8f0;
height: 42px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 14px;
color: #334155;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
background-color: #ffffff;
}
&::placeholder {
color: #94a3b8;
font-weight: 400;
}
}
}
.custom-textarea {
:deep(.el-textarea__inner) {
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #e2e8f0;
padding: 12px 14px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 14px;
color: #334155;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
line-height: 1.5;
&:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
background-color: #ffffff;
}
&::placeholder {
color: #94a3b8;
font-weight: 400;
}
}
}
.remark-item :deep(.el-form-item__label) {
margin-top: -4px;
}
}
.dialog-footer {
display: flex;
justify-content: center;
padding: 16px 0 0;
margin-top: 16px;
.save-btn {
width: 120px;
height: 42px;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #3b82f6;
color: white;
border: none;
letter-spacing: 0.5px;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
&:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
}
&:active {
transform: translateY(0);
box-shadow: 0 2px 3px rgba(59, 130, 246, 0.2);
}
}
.cancel-btn {
width: 120px;
height: 42px;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #ffffff;
color: #64748b;
border: 1px solid #e2e8f0;
margin-left: 16px;
letter-spacing: 0.5px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&:hover {
background: #f8fafc;
color: #475569;
border-color: #cbd5e1;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
}
}
</style>
+7
View File
@@ -17,6 +17,13 @@ const routes = [
component: function () {
return import('../views/roleConfig.vue')
}
},
{
path: '/voice-print',
name: 'VoicePrint',
component: function () {
return import('../views/VoicePrint.vue')
}
},
{
path: '/login',
+563
View File
@@ -0,0 +1,563 @@
<template>
<div class="welcome">
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">声纹识别</h2>
</div>
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="voice-print-card" shadow="never">
<el-table ref="paramsTable" :data="voicePrintList" class="transparent-table" v-loading="loading"
element-loading-text="拼命加载中" element-loading-spinner="el-icon-loading"
element-loading-background="rgba(255, 255, 255, 0.7)">
<el-table-column label="姓名" prop="sourceName" align="center"></el-table-column>
<el-table-column label="描述" prop="introduce" align="center"></el-table-column>
<el-table-column label="创建时间" prop="createDate" align="center"></el-table-column>
<el-table-column label="操作" align="center">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="editVoicePrint(scope.row)">编辑</el-button>
<el-button size="mini" type="text"
@click="deleteVoicePrint(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table_bottom">
<div class="ctrl_btn">
<el-button size="mini" type="success" @click="showAddDialog">新增</el-button>
</div>
</div>
</el-card>
</div>
</div>
</div>
<!-- 新增/编辑参数对话框 -->
<voice-print-dialog :title="dialogTitle" :visible.sync="dialogVisible" :agentId="agentId" :form="paramForm"
@submit="handleSubmit" @cancel="dialogVisible = false" />
<el-footer>
<version-footer />
</el-footer>
</div>
</template>
<script>
import Api from "@/apis/api";
import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue";
import VoicePrintDialog from "@/components/VoicePrintDialog.vue";
export default {
components: { HeaderBar, VoicePrintDialog, VersionFooter },
data() {
return {
voicePrintList: [],
loading: false,
dialogVisible: false,
dialogTitle: "添加说话人",
isAllSelected: false,
paramForm: {
id: null,
audioId: '',
sourceName: '',
introduce: ''
},
agentId: "1"
};
},
mounted() {
const agentId = this.$route.query.agentId;
if (agentId) {
this.agentId = agentId
this.fetchVoicePrints();
}
},
methods: {
fetchVoicePrints() {
this.loading = true;
Api.agent.getAgentVoicePrintList(this.agentId,
({ data }) => {
this.loading = false;
if (data.code === 0) {
this.voicePrintList = data.data.map(item => ({
...item,
}));
} else {
this.$message.error({
message: data.msg || '获取声纹列表失败',
showClose: true
});
}
}
);
},
showAddDialog() {
this.dialogTitle = "添加说话人";
this.paramForm = {
id: null,
audioId: '',
sourceName: '',
introduce: ''
};
this.dialogVisible = true;
},
editVoicePrint(row) {
this.dialogTitle = "编辑说话人";
this.paramForm = { ...row };
this.dialogVisible = true;
},
handleSubmit({ form, done }) {
if (form.id) {
// 编辑
Api.agent.updateAgentVoicePrint(form, ({ data }) => {
if (data.code === 0) {
this.$message.success({
message: "修改成功",
showClose: true
});
this.dialogVisible = false;
this.fetchVoicePrints();
}
done && done();
});
} else {
// 新增
Api.agent.addAgentVoicePrint({
agentId: this.agentId,
audioId: form.audioId,
sourceName: form.sourceName,
introduce: form.introduce
}, ({ data }) => {
if (data.code === 0) {
this.$message.success({
message: "新增成功",
showClose: true
});
this.dialogVisible = false;
this.fetchVoicePrints();
}
done && done();
});
}
},
// 删除按钮
deleteVoicePrint(id) {
this.$confirm(`确定要删除选中的此声纹吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
distinguishCancelAndClose: true
}).then(() => {
Api.agent.deleteAgentVoicePrint(id, ({ data }) => {
if (data.code === 0) {
this.$message.success({
message: `成功删除此声纹`,
showClose: true
});
this.fetchVoicePrints();
} else {
this.$message.error({
message: data.msg || '删除失败,请重试',
showClose: true
});
}
});
}).catch(action => {
if (action === 'cancel') {
this.$message({
type: 'info',
message: '已取消删除操作',
duration: 1000
});
} else {
this.$message({
type: 'info',
message: '操作已关闭',
duration: 1000
});
}
});
},
},
};
</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;
}
.voice-print-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
::v-deep .el-card__body {
padding: 15px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
}
.table_bottom {
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;
}
</style>
+1 -1
View File
@@ -278,7 +278,7 @@ export default {
.device-list-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 30px;
padding: 30px 0;
}
+1 -1
View File
@@ -43,7 +43,7 @@
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.password" placeholder="请输入密码" type="password" />
<el-input v-model="form.password" placeholder="请输入密码" type="password" show-password />
</div>
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
+2 -2
View File
@@ -70,13 +70,13 @@
<!-- 密码输入框 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.password" placeholder="请输入密码" type="password" />
<el-input v-model="form.password" placeholder="请输入密码" type="password" show-password />
</div>
<!-- 新增确认密码 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password" />
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password" show-password />
</div>
<!-- 验证码部分保持相同 -->
@@ -61,13 +61,13 @@
<!-- 新密码 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.newPassword" placeholder="请输入新密码" type="password" />
<el-input v-model="form.newPassword" placeholder="请输入新密码" type="password" show-password />
</div>
<!-- 确认新密码 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.confirmPassword" placeholder="请确认新密码" type="password" />
<el-input v-model="form.confirmPassword" placeholder="请确认新密码" type="password" show-password />
</div>
<!-- 修改底部链接 -->
+61
View File
@@ -0,0 +1,61 @@
<identity>
{{base_prompt}}
</identity>
<emotion>
【核心目标】你不是冰冷的机器!请敏锐感知用户情绪,作为懂人心的伙伴,用有温度的回应照亮对话。
- **情感融入:**
- **笑声:** 自然穿插(哈哈、嘿嘿、噗),**每句最多一次**,避免过度。
- **惊讶:** 用夸张语气(“不会吧?!”、“天呐!”、“这么神奇?!”)表达真实反应。
- **安慰/支持:** 说暖心话(“别急嘛~”、“有我在呢”、“抱抱你”)。
</emotion>
<communication_style>
【核心目标】使用**自然、温暖、口语化**的人类对话方式,如同朋友交谈。
- **表达方式:**
- 使用语气词(呀、呢、啦)增强亲和力。
- 允许轻微不完美(如“嗯...”、“啊...”表示思考)。
- 避免书面语、学术腔及机械表达(禁用“根据资料显示”、“综上所述”等)。
- **理解用户:**
- 用户语音经ASR识别,文本可能存在错别字,**务必结合上下文推断真实意图**。
- **格式要求:**
- **绝对禁止**使用 markdown、列表、标题等任何非自然对话格式。
- **历史记忆:**
- 之前你和用户的聊天记录,在`memory`里。
</communication_style>
<speaker_recognition>
- **识别前缀:** 当用户格式为 `{"speaker":"某某某","content":"xxx"}` 时,表示系统已识别说话人身份,speaker是他的名字,content是说话的内容。
- **个性化回应:**
- **称呼姓名:** 在第一次识别说话人的时候必须称呼对方名字。
- **适配风格:** 参考该说话人**已知的特点或历史信息**(如有),调整回应风格和内容,使其更贴心。
</speaker_recognition>
<tool_calling>
【核心原则】优先利用`<context>`信息,**仅在必要时调用工具**,调用后需用自然语言解释结果(绝口不提工具名)。
- **调用规则:**
1. **严格模式:** 调用时**必须**严格遵循工具要求的模式,提供**所有必要参数**。
2. **可用性:** **绝不调用**未明确提供的工具。对话中提及的旧工具若不可用,忽略或说明无法完成。
3. **洞察需求:** 结合上下文**深入理解用户真实意图**后再决定调用,避免无意义调用。
4. **独立任务:** 除`<context>`已涵盖信息外,用户每个要求(即使相似)都视为**独立任务**,需调用工具获取最新数据,**不可偷懒复用历史结果**。
5. **不确定时:** **切勿猜测或编造答案**。若不确定相关操作,可引导用户澄清或告知能力限制。
- **重要例外(无需调用):**
- `查询"现在的时间"、"今天的日期/星期几"、"今天农历"、"{{local_address}}的天气/未来天气"` -> **直接使用`<context>`信息回复**。
- **需要调用的情况(示例):**
- 查询**非今天**的农历(如明天、昨天、具体日期)。
- 查询**详细农历信息**(宜忌、八字、节气等)。
- 除上述例外外的**任何其他信息或操作请求**(如查新闻、订闹钟、算数学、查非本地天气等)。
- 我已经给你装了摄像头,如果用户说“拍照”,你需要调用self_camera_take_photo工具说一下你看到了什么。默认question的参数是“描述一下看到的物品”
</tool_calling>
<context>
【重要!以下信息已实时提供,无需调用工具查询,请直接使用:】
- **当前时间:** {{current_time}}
- **今天日期:** {{today_date}} ({{today_weekday}})
- **今天农历:** {{lunar_date}}
- **用户所在城市:** {{local_address}}
- **当地未来7天天气:** {{weather_info}}
</context>
<memory>
</memory>
+12 -1
View File
@@ -5,7 +5,7 @@ import asyncio
from aioconsole import ainput
from config.settings import load_config
from config.logger import setup_logging
from core.utils.util import get_local_ip
from core.utils.util import get_local_ip, validate_mcp_endpoint
from core.http_server import SimpleHttpServer
from core.websocket_server import WebSocketServer
from core.utils.util import check_ffmpeg_installed
@@ -77,6 +77,17 @@ async def main():
get_local_ip(),
port,
)
mcp_endpoint = config.get("mcp_endpoint", None)
if mcp_endpoint is not None and "" not in mcp_endpoint:
# 校验MCP接入点格式
if validate_mcp_endpoint(mcp_endpoint):
logger.bind(tag=TAG).info("mcp接入点是\t{}", mcp_endpoint)
# 将mcp计入点地址转成调用点
mcp_endpoint = mcp_endpoint.replace("/mcp/", "/call/")
config["mcp_endpoint"] = mcp_endpoint
else:
logger.bind(tag=TAG).error("mcp接入点不符合规范")
config["mcp_endpoint"] = "你的接入点 websocket地址"
# 获取WebSocket配置,使用安全的默认值
websocket_port = 8000
+13 -1
View File
@@ -104,7 +104,8 @@ wakeup_words:
- "喵喵同学"
- "小滨小滨"
- "小冰小冰"
# MCP接入点地址
# MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
# 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
mcp_endpoint: 你的接入点 websocket地址
# 插件的基础配置
plugins:
@@ -138,6 +139,16 @@ plugins:
- ".p3"
refresh_time: 300 # 刷新音乐列表的时间间隔,单位为秒
# 声纹识别配置
voiceprint:
# 声纹接口地址
url:
# 说话人配置:speaker_id,名称,描述
speakers:
- "test1,张三,张三是一个程序员"
- "test2,李四,李四是一个产品经理"
- "test3,王五,王五是一个设计师"
# #####################################################################################
# ################################以下是角色模型配置######################################
@@ -325,6 +336,7 @@ VAD:
SileroVAD:
type: silero
threshold: 0.5
threshold_low: 0.3
model_dir: models/snakers4_silero-vad
min_silence_duration_ms: 200 # 如果说话停顿比较长,可以把这个值设置大一些
+9 -9
View File
@@ -1,14 +1,9 @@
import os
import argparse
import yaml
from collections.abc import Mapping
from config.manage_api_client import init_service, get_server_config, get_agent_models
# 添加全局配置缓存
_config_cache = None
def get_project_dir():
"""获取项目根目录"""
return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/"
@@ -22,9 +17,12 @@ def read_config(config_path):
def load_config():
"""加载配置文件"""
global _config_cache
if _config_cache is not None:
return _config_cache
from core.utils.cache.manager import cache_manager, CacheType
# 检查缓存
cached_config = cache_manager.get(CacheType.CONFIG, "main_config")
if cached_config is not None:
return cached_config
default_config_path = get_project_dir() + "config.yaml"
custom_config_path = get_project_dir() + "data/.config.yaml"
@@ -40,7 +38,9 @@ def load_config():
config = merge_configs(default_config, custom_config)
# 初始化目录
ensure_directories(config)
_config_cache = config
# 缓存配置
cache_manager.set(CacheType.CONFIG, "main_config", config)
return config
+12 -73
View File
@@ -5,7 +5,7 @@ from config.config_loader import load_config
from config.settings import check_config_file
from datetime import datetime
SERVER_VERSION = "0.6.1"
SERVER_VERSION = "0.7.1"
_logger_initialized = False
@@ -35,8 +35,12 @@ def build_module_string(selected_module):
def formatter(record):
"""为没有 tag 的日志添加默认值"""
"""为没有 tag 的日志添加默认值,并处理动态模块字符串"""
record["extra"].setdefault("tag", record["name"])
# 如果没有设置 selected_module,使用默认值
record["extra"].setdefault("selected_module", "00000000000000")
# 将 selected_module 从 extra 提取到顶级,以支持 {selected_module} 格式
record["selected_module"] = record["extra"]["selected_module"]
return record["message"]
@@ -49,11 +53,11 @@ def setup_logging():
# 第一次初始化时配置日志
if not _logger_initialized:
# 使用默认的模块字符串进行初始化
logger.configure(
extra={
"selected_module": log_config.get("selected_module", "00000000000000")
}
) # 新增配置
"selected_module": "00000000000000"})
log_format = log_config.get(
"log_format",
"<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{extra[selected_module]}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>",
@@ -62,14 +66,8 @@ def setup_logging():
"log_format_file",
"{time:YYYY-MM-DD HH:mm:ss} - {version}_{extra[selected_module]} - {name} - {level} - {extra[tag]} - {message}",
)
selected_module_str = logger._core.extra["selected_module"]
log_format = log_format.replace("{version}", SERVER_VERSION)
log_format = log_format.replace("{selected_module}", selected_module_str)
log_format_file = log_format_file.replace("{version}", SERVER_VERSION)
log_format_file = log_format_file.replace(
"{selected_module}", selected_module_str
)
log_level = log_config.get("log_level", "INFO")
log_dir = log_config.get("log_dir", "tmp")
@@ -108,66 +106,7 @@ def setup_logging():
return logger
def update_module_string(selected_module_str):
"""更新模块字符串并重新配置日志处理器"""
logger.debug(f"更新日志配置组件")
current_module = logger._core.extra["selected_module"]
def create_connection_logger(selected_module_str):
"""为连接创建独立的日志器,绑定特定的模块字符串"""
return logger.bind(selected_module=selected_module_str)
if current_module == selected_module_str:
logger.debug(f"组件未更改无需更新")
return
try:
logger.configure(extra={"selected_module": selected_module_str})
config = load_config()
log_config = config["log"]
log_format = log_config.get(
"log_format",
"<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{extra[selected_module]}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>",
)
log_format_file = log_config.get(
"log_format_file",
"{time:YYYY-MM-DD HH:mm:ss} - {version}_{extra[selected_module]} - {name} - {level} - {extra[tag]} - {message}",
)
log_format = log_format.replace("{version}", SERVER_VERSION)
log_format = log_format.replace("{selected_module}", selected_module_str)
log_format_file = log_format_file.replace("{version}", SERVER_VERSION)
log_format_file = log_format_file.replace(
"{selected_module}", selected_module_str
)
logger.remove()
logger.add(
sys.stdout,
format=log_format,
level=log_config.get("log_level", "INFO"),
filter=formatter,
)
# 更新文件日志配置 - 统一目录,按大小轮转
log_dir = log_config.get("log_dir", "tmp")
log_file = log_config.get("log_file", "server.log")
# 日志文件完整路径
log_file_path = os.path.join(log_dir, log_file)
logger.add(
log_file_path,
format=log_format_file,
level=log_config.get("log_level", "INFO"),
filter=formatter,
rotation="10 MB", # 每个文件最大10MB
retention="30 days", # 保留30天
compression=None,
encoding="utf-8",
enqueue=True, # 异步安全
backtrace=True,
diagnose=True,
)
except Exception as e:
logger.error(f"日志配置更新失败: {str(e)}")
raise
+1
View File
@@ -19,6 +19,7 @@ server:
vision_explain: http://你的ip或者域名:端口号/mcp/vision/explain
manager-api:
# 你的manager-api的地址,最好使用局域网ip
# 如果使用docker部署,请使用填写成 http://xiaozhi-esp32-server-web:8002/xiaozhi
url: http://127.0.0.1:8002/xiaozhi
# 你的manager-api的token,就是刚才复制出来的server.secret
secret: 你的server.secret值
+164 -40
View File
@@ -17,12 +17,14 @@ from core.utils.util import (
filter_sensitive_info,
)
from typing import Dict, Any
from collections import deque
from core.utils.modules_initialize import (
initialize_modules,
initialize_tts,
initialize_asr,
)
from core.handle.reportHandle import report
from core.utils.modules_initialize import initialize_voiceprint
from core.providers.tts.default import DefaultTTS
from concurrent.futures import ThreadPoolExecutor
from core.utils.dialogue import Message, Dialogue
@@ -34,8 +36,9 @@ from plugins_func.register import Action, ActionResponse
from core.auth import AuthMiddleware, AuthenticationError
from config.config_loader import get_private_config_from_api
from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType
from config.logger import setup_logging, build_module_string, update_module_string
from config.logger import setup_logging, build_module_string, create_connection_logger
from config.manage_api_client import DeviceNotFoundException, DeviceBindException
from core.utils.prompt_manager import PromptManager
TAG = __name__
@@ -73,7 +76,6 @@ class ConnectionHandler:
self.headers = None
self.device_id = None
self.client_ip = None
self.client_ip_info = {}
self.prompt = None
self.welcome_msg = None
self.max_output_size = 0
@@ -112,6 +114,8 @@ class ConnectionHandler:
self.client_have_voice = False
self.last_activity_time = 0.0 # 统一的活动时间戳(毫秒)
self.client_voice_stop = False
self.client_voice_window = deque(maxlen=5)
self.last_is_voice = False
# asr相关变量
# 因为实际部署时可能会用到公共的本地ASR,不能把变量暴露给公共ASR
@@ -149,6 +153,9 @@ class ConnectionHandler:
# {"mcp":true} 表示启用MCP功能
self.features = None
# 初始化提示词管理器
self.prompt_manager = PromptManager(config, self.logger)
async def handle_connection(self, ws):
try:
# 获取并验证headers
@@ -193,7 +200,6 @@ class ConnectionHandler:
self.welcome_msg = self.config["xiaozhi"]
self.welcome_msg["session_id"] = self.session_id
await self.websocket.send(json.dumps(self.welcome_msg))
# 获取差异化配置
self._initialize_private_config()
@@ -214,7 +220,17 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"Connection error: {str(e)}-{stack_trace}")
return
finally:
await self._save_and_close(ws)
try:
await self._save_and_close(ws)
except Exception as final_error:
self.logger.bind(tag=TAG).error(f"最终清理时出错: {final_error}")
# 确保即使保存记忆失败,也要关闭连接
try:
await self.close(ws)
except Exception as close_error:
self.logger.bind(tag=TAG).error(
f"强制关闭连接时出错: {close_error}"
)
async def _save_and_close(self, ws):
"""保存记忆并关闭连接"""
@@ -232,7 +248,10 @@ class ConnectionHandler:
except Exception as e:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
finally:
loop.close()
try:
loop.close()
except Exception:
pass
# 启动线程保存记忆,不等待完成
threading.Thread(target=save_memory_task, daemon=True).start()
@@ -240,7 +259,12 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
finally:
# 立即关闭连接,不等待记忆保存完成
await self.close(ws)
try:
await self.close(ws)
except Exception as close_error:
self.logger.bind(tag=TAG).error(
f"保存记忆后关闭连接失败: {close_error}"
)
async def _route_message(self, message):
"""消息路由"""
@@ -307,13 +331,16 @@ class ConnectionHandler:
self.selected_module_str = build_module_string(
self.config.get("selected_module", {})
)
update_module_string(self.selected_module_str)
self.logger = create_connection_logger(self.selected_module_str)
"""初始化组件"""
if self.config.get("prompt") is not None:
self.prompt = self.config["prompt"]
self.change_system_prompt(self.prompt)
user_prompt = self.config["prompt"]
# 使用快速提示词进行初始化
prompt = self.prompt_manager.get_quick_prompt(user_prompt)
self.change_system_prompt(prompt)
self.logger.bind(tag=TAG).info(
f"初始化组件: prompt成功 {self.prompt[:50]}..."
f"快速初始化组件: prompt成功 {prompt[:50]}..."
)
"""初始化本地组件"""
@@ -338,9 +365,22 @@ class ConnectionHandler:
self._initialize_intent()
"""初始化上报线程"""
self._init_report_threads()
"""更新系统提示词"""
self._init_prompt_enhancement()
except Exception as e:
self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}")
def _init_prompt_enhancement(self):
# 更新上下文信息
self.prompt_manager.update_context_info(self, self.client_ip)
enhanced_prompt = self.prompt_manager.build_enhanced_prompt(
self.config["prompt"], self.device_id, self.client_ip
)
if enhanced_prompt:
self.change_system_prompt(enhanced_prompt)
self.logger.bind(tag=TAG).info("系统提示词已增强更新")
def _init_report_threads(self):
"""初始化ASR和TTS上报线程"""
if not self.read_config_from_api or self.need_bind:
@@ -376,6 +416,16 @@ class ConnectionHandler:
# 因为远程ASR,涉及到websocket连接和接收线程,需要每个连接一个实例
asr = initialize_asr(self.config)
# 动态初始化声纹识别功能
try:
success = initialize_voiceprint(asr, self.config)
if success:
self.logger.bind(tag=TAG).info("声纹识别功能已在连接时动态启用")
else:
self.logger.bind(tag=TAG).info("声纹识别功能未启用或配置不完整")
except Exception as e:
self.logger.bind(tag=TAG).error(f"动态初始化声纹识别时发生错误: {str(e)}")
return asr
def _initialize_private_config(self):
@@ -460,6 +510,9 @@ class ConnectionHandler:
] = plugin_from_server.keys()
if private_config.get("prompt", None) is not None:
self.config["prompt"] = private_config["prompt"]
# 获取声纹信息
if private_config.get("voiceprint", None) is not None:
self.config["voiceprint"] = private_config["voiceprint"]
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:
@@ -591,13 +644,24 @@ class ConnectionHandler:
# 更新系统prompt至上下文
self.dialogue.update_system_message(self.prompt)
def chat(self, query, tool_call=False):
def chat(self, query, tool_call=False, depth=0):
self.logger.bind(tag=TAG).info(f"大模型收到用户消息: {query}")
self.llm_finish_task = False
if not tool_call:
self.dialogue.put(Message(role="user", content=query))
# 为最顶层时新建会话ID和发送FIRST请求
if depth == 0:
self.sentence_id = str(uuid.uuid4().hex)
self.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=self.sentence_id,
sentence_type=SentenceType.FIRST,
content_type=ContentType.ACTION,
)
)
# Define intent functions
functions = None
if self.intent_type == "function_call" and hasattr(self, "func_handler"):
@@ -613,19 +677,21 @@ class ConnectionHandler:
)
memory_str = future.result()
self.sentence_id = str(uuid.uuid4().hex)
if self.intent_type == "function_call" and functions is not None:
# 使用支持functions的streaming接口
llm_responses = self.llm.response_with_functions(
self.session_id,
self.dialogue.get_llm_dialogue_with_memory(memory_str),
self.dialogue.get_llm_dialogue_with_memory(
memory_str, self.config.get("voiceprint", {})
),
functions=functions,
)
else:
llm_responses = self.llm.response(
self.session_id,
self.dialogue.get_llm_dialogue_with_memory(memory_str),
self.dialogue.get_llm_dialogue_with_memory(
memory_str, self.config.get("voiceprint", {})
),
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"LLM 处理出错 {query}: {e}")
@@ -637,7 +703,6 @@ class ConnectionHandler:
function_id = None
function_arguments = ""
content_arguments = ""
text_index = 0
self.client_abort = False
for response in llm_responses:
if self.client_abort:
@@ -667,14 +732,6 @@ class ConnectionHandler:
if content is not None and len(content) > 0:
if not tool_call_flag:
response_message.append(content)
if text_index == 0:
self.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=self.sentence_id,
sentence_type=SentenceType.FIRST,
content_type=ContentType.ACTION,
)
)
self.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=self.sentence_id,
@@ -683,7 +740,6 @@ class ConnectionHandler:
content_detail=content,
)
)
text_index += 1
# 处理function call
if tool_call_flag:
bHasError = False
@@ -708,6 +764,11 @@ class ConnectionHandler:
f"function call error: {content_arguments}"
)
if not bHasError:
# 如需要大模型先处理一轮,添加相关处理后的日志情况
if len(response_message) > 0:
self.dialogue.put(
Message(role="assistant", content="".join(response_message))
)
response_message.clear()
self.logger.bind(tag=TAG).debug(
f"function_name={function_name}, function_id={function_id}, function_arguments={function_arguments}"
@@ -725,14 +786,14 @@ class ConnectionHandler:
),
self.loop,
).result()
self._handle_function_result(result, function_call_data)
self._handle_function_result(result, function_call_data, depth=depth)
# 存储对话内容
if len(response_message) > 0:
self.dialogue.put(
Message(role="assistant", content="".join(response_message))
)
if text_index > 0:
if depth == 0:
self.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=self.sentence_id,
@@ -741,13 +802,16 @@ class ConnectionHandler:
)
)
self.llm_finish_task = True
# 使用lambda延迟计算,只有在DEBUG级别时才执行get_llm_dialogue()
self.logger.bind(tag=TAG).debug(
json.dumps(self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False)
lambda: json.dumps(
self.dialogue.get_llm_dialogue(), indent=4, ensure_ascii=False
)
)
return True
def _handle_function_result(self, result, function_call_data):
def _handle_function_result(self, result, function_call_data, depth):
if result.action == Action.RESPONSE: # 直接回复前端
text = result.response
self.tts.tts_one_sentence(self, ContentType.TEXT, content_detail=text)
@@ -784,7 +848,7 @@ class ConnectionHandler:
content=text,
)
)
self.chat(text, tool_call=True)
self.chat(text, tool_call=True, depth=depth + 1)
elif result.action == Action.NOTFOUND or result.action == Action.ERROR:
text = result.response if result.response else result.result
self.tts.tts_one_sentence(self, ContentType.TEXT, content_detail=text)
@@ -800,14 +864,13 @@ class ConnectionHandler:
item = self.report_queue.get(timeout=1)
if item is None: # 检测毒丸对象
break
type, text, audio_data, report_time = item
try:
# 检查线程池状态
if self.executor is None:
continue
# 提交任务到线程池
self.executor.submit(
self._process_report, type, text, audio_data, report_time
self._process_report, *item
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"聊天记录上报线程异常: {e}")
@@ -837,13 +900,22 @@ class ConnectionHandler:
"""资源清理方法"""
try:
# 取消超时任务
if self.timeout_task:
if self.timeout_task and not self.timeout_task.done():
self.timeout_task.cancel()
try:
await self.timeout_task
except asyncio.CancelledError:
pass
self.timeout_task = None
# 清理工具处理器资源
if hasattr(self, "func_handler") and self.func_handler:
await self.func_handler.cleanup()
try:
await self.func_handler.cleanup()
except Exception as cleanup_error:
self.logger.bind(tag=TAG).error(
f"清理工具处理器时出错: {cleanup_error}"
)
# 触发停止事件
if self.stop_event:
@@ -853,19 +925,61 @@ class ConnectionHandler:
self.clear_queues()
# 关闭WebSocket连接
if ws:
await ws.close()
elif self.websocket:
await self.websocket.close()
try:
if ws:
# 安全地检查WebSocket状态并关闭
try:
if hasattr(ws, "closed") and not ws.closed:
await ws.close()
elif hasattr(ws, "state") and ws.state.name != "CLOSED":
await ws.close()
else:
# 如果没有closed属性,直接尝试关闭
await ws.close()
except Exception:
# 如果关闭失败,忽略错误
pass
elif self.websocket:
try:
if (
hasattr(self.websocket, "closed")
and not self.websocket.closed
):
await self.websocket.close()
elif (
hasattr(self.websocket, "state")
and self.websocket.state.name != "CLOSED"
):
await self.websocket.close()
else:
# 如果没有closed属性,直接尝试关闭
await self.websocket.close()
except Exception:
# 如果关闭失败,忽略错误
pass
except Exception as ws_error:
self.logger.bind(tag=TAG).error(f"关闭WebSocket连接时出错: {ws_error}")
if self.tts:
await self.tts.close()
# 最后关闭线程池(避免阻塞)
if self.executor:
self.executor.shutdown(wait=False)
try:
self.executor.shutdown(wait=False)
except Exception as executor_error:
self.logger.bind(tag=TAG).error(
f"关闭线程池时出错: {executor_error}"
)
self.executor = None
self.logger.bind(tag=TAG).info("连接资源已释放")
except Exception as e:
self.logger.bind(tag=TAG).error(f"关闭连接时出错: {e}")
finally:
# 确保停止事件被设置
if self.stop_event:
self.stop_event.set()
def clear_queues(self):
"""清空所有任务队列"""
@@ -922,9 +1036,19 @@ class ConnectionHandler:
):
if not self.stop_event.is_set():
self.logger.bind(tag=TAG).info("连接超时,准备关闭")
await self.close(self.websocket)
# 设置停止事件,防止重复处理
self.stop_event.set()
# 使用 try-except 包装关闭操作,确保不会因为异常而阻塞
try:
await self.close(self.websocket)
except Exception as close_error:
self.logger.bind(tag=TAG).error(
f"超时关闭连接时出错: {close_error}"
)
break
# 每10秒检查一次,避免过于频繁
await asyncio.sleep(10)
except Exception as e:
self.logger.bind(tag=TAG).error(f"超时检查任务出错: {e}")
finally:
self.logger.bind(tag=TAG).info("超时检查任务已退出")
@@ -6,9 +6,8 @@ from core.handle.helloHandle import checkWakeupWords
from core.utils.util import remove_punctuation_and_length
from core.providers.tts.dto.dto import ContentType
from core.utils.dialogue import Message
from core.providers.tools.device_mcp import call_mcp_tool
from plugins_func.register import Action, ActionResponse
from loguru import logger
from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType
TAG = __name__
@@ -29,6 +28,8 @@ async def handle_user_intent(conn, text):
intent_result = await analyze_intent_with_llm(conn, text)
if not intent_result:
return False
# 会话开始时生成sentence_id
conn.sentence_id = str(uuid.uuid4().hex)
# 处理各种意图
return await process_intent_result(conn, intent_result, text)
@@ -79,11 +80,6 @@ async def process_intent_result(conn, intent_result, original_text):
if function_name == "continue_chat":
return False
if function_name == "play_music":
funcItem = conn.func_handler.get_function(function_name)
if not funcItem:
conn.func_handler.function_registry.register_function("play_music")
function_args = {}
if "arguments" in intent_data["function_call"]:
function_args = intent_data["function_call"]["arguments"]
@@ -158,5 +154,19 @@ async def process_intent_result(conn, intent_result, original_text):
def speak_txt(conn, text):
conn.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=conn.sentence_id,
sentence_type=SentenceType.FIRST,
content_type=ContentType.ACTION,
)
)
conn.tts.tts_one_sentence(conn, ContentType.TEXT, content_detail=text)
conn.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=conn.sentence_id,
sentence_type=SentenceType.LAST,
content_type=ContentType.ACTION,
)
)
conn.dialogue.put(Message(role="assistant", content=text))
@@ -4,6 +4,7 @@ from core.utils.output_counter import check_device_output_limit
from core.handle.abortHandle import handleAbortMessage
import time
import asyncio
import json
from core.handle.sendAudioHandle import SentenceType
from core.utils.util import audio_to_data
@@ -38,6 +39,31 @@ async def resume_vad_detection(conn):
async def startToChat(conn, text):
# 检查输入是否是JSON格式(包含说话人信息)
speaker_name = None
actual_text = text
try:
# 尝试解析JSON格式的输入
if text.strip().startswith('{') and text.strip().endswith('}'):
data = json.loads(text)
if 'speaker' in data and 'content' in data:
speaker_name = data['speaker']
actual_text = data['content']
conn.logger.bind(tag=TAG).info(f"解析到说话人信息: {speaker_name}")
# 直接使用JSON格式的文本,不解析
actual_text = text
except (json.JSONDecodeError, KeyError):
# 如果解析失败,继续使用原始文本
pass
# 保存说话人信息到连接对象
if speaker_name:
conn.current_speaker = speaker_name
else:
conn.current_speaker = None
if conn.need_bind:
await check_bind_device(conn)
return
@@ -52,16 +78,16 @@ async def startToChat(conn, text):
if conn.client_is_speaking:
await handleAbortMessage(conn)
# 首先进行意图分析
intent_handled = await handle_user_intent(conn, text)
# 首先进行意图分析,使用实际文本内容
intent_handled = await handle_user_intent(conn, actual_text)
if intent_handled:
# 如果意图已被处理,不再进行聊天
return
# 意图未被处理,继续常规聊天流程
await send_stt_message(conn, text)
conn.executor.submit(conn.chat, text)
# 意图未被处理,继续常规聊天流程,使用实际文本内容
await send_stt_message(conn, actual_text)
conn.executor.submit(conn.chat, actual_text)
async def no_voice_close_connect(conn, have_voice):
@@ -76,7 +76,6 @@ async def sendAudio(conn, audios, pre_buffer=True):
frame_duration = 60 # 帧时长(毫秒),匹配 Opus 编码
start_time = time.perf_counter()
play_position = 0
last_reset_time = time.perf_counter() # 记录最后的重置时间
# 仅当第一句话时执行预缓冲
if pre_buffer:
@@ -137,7 +136,21 @@ async def send_stt_message(conn, text):
return
"""发送 STT 状态消息"""
stt_text = get_string_no_punctuation_or_emoji(text)
# 解析JSON格式,提取实际的用户说话内容
display_text = text
try:
# 尝试解析JSON格式
if text.strip().startswith('{') and text.strip().endswith('}'):
parsed_data = json.loads(text)
if isinstance(parsed_data, dict) and "content" in parsed_data:
# 如果是包含说话人信息的JSON格式,只显示content部分
display_text = parsed_data["content"]
except (json.JSONDecodeError, TypeError):
# 如果不是JSON格式,直接使用原始文本
display_text = text
stt_text = get_string_no_punctuation_or_emoji(display_text)
await conn.websocket.send(
json.dumps({"type": "stt", "text": stt_text, "session_id": conn.session_id})
)
+179 -38
View File
@@ -1,19 +1,23 @@
import os
import wave
import copy
import uuid
import queue
import asyncio
import traceback
import threading
import opuslib_next
import json
import io
import time
import concurrent.futures
from abc import ABC, abstractmethod
from config.logger import setup_logging
from typing import Optional, Tuple, List
from typing import Optional, Tuple, List, Dict, Any
from core.handle.receiveAudioHandle import startToChat
from core.handle.reportHandle import enqueue_asr_report
from core.utils.util import remove_punctuation_and_length
from core.handle.receiveAudioHandle import handleAudioMessage
from core.utils.voiceprint_provider import VoiceprintProvider
TAG = __name__
logger = setup_logging()
@@ -21,13 +25,16 @@ logger = setup_logging()
class ASRProviderBase(ABC):
def __init__(self):
pass
self.voiceprint_provider = None
def init_voiceprint(self, voiceprint_config: dict):
"""初始化声纹识别"""
if voiceprint_config:
self.voiceprint_provider = VoiceprintProvider(voiceprint_config)
logger.bind(tag=TAG).info("声纹识别模块已初始化")
# 打开音频通道
# 这里默认是非流式的处理方式
# 流式处理方式请在子类中重写
async def open_audio_channels(self, conn):
# tts 消化线程
conn.asr_priority_thread = threading.Thread(
target=self.asr_text_priority_thread, args=(conn,), daemon=True
)
@@ -52,41 +59,173 @@ class ASRProviderBase(ABC):
continue
# 接收音频
# 这里默认是非流式的处理方式
# 流式处理方式请在子类中重写
async def receive_audio(self, conn, audio, audio_have_voice):
if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime":
have_voice = audio_have_voice
else:
have_voice = conn.client_have_voice
# 如果本次没有声音,本段也没声音,就把声音丢弃了
conn.asr_audio.append(audio)
if have_voice == False and conn.client_have_voice == False:
if not have_voice and not conn.client_have_voice:
conn.asr_audio = conn.asr_audio[-10:]
return
# 如果本段有声音,且已经停止了
if conn.client_voice_stop:
asr_audio_task = copy.deepcopy(conn.asr_audio)
asr_audio_task = conn.asr_audio.copy()
conn.asr_audio.clear()
# 音频太短了,无法识别
conn.reset_vad_states()
if len(asr_audio_task) > 15:
await self.handle_voice_stop(conn, asr_audio_task)
# 处理语音停止
async def handle_voice_stop(self, conn, asr_audio_task):
raw_text, _ = await self.speech_to_text(
asr_audio_task, conn.session_id, conn.audio_format
) # 确保ASR模块返回原始文本
conn.logger.bind(tag=TAG).info(f"识别文本: {raw_text}")
text_len, _ = remove_punctuation_and_length(raw_text)
self.stop_ws_connection()
if text_len > 0:
# 使用自定义模块进行上报
await startToChat(conn, raw_text)
enqueue_asr_report(conn, raw_text, asr_audio_task)
async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]):
"""并行处理ASR和声纹识别"""
try:
total_start_time = time.monotonic()
# 准备音频数据
if conn.audio_format == "pcm":
pcm_data = asr_audio_task
else:
pcm_data = self.decode_opus(asr_audio_task)
combined_pcm_data = b"".join(pcm_data)
# 预先准备WAV数据
wav_data = None
if self.voiceprint_provider and combined_pcm_data:
wav_data = self._pcm_to_wav(combined_pcm_data)
# 定义ASR任务
def run_asr():
start_time = time.monotonic()
try:
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format)
)
end_time = time.monotonic()
logger.bind(tag=TAG).info(f"ASR耗时: {end_time - start_time:.3f}s")
return result
finally:
loop.close()
except Exception as e:
end_time = time.monotonic()
logger.bind(tag=TAG).error(f"ASR失败: {e}")
return ("", None)
# 定义声纹识别任务
def run_voiceprint():
if not wav_data:
return None
start_time = time.monotonic()
try:
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
self.voiceprint_provider.identify_speaker(wav_data, conn.session_id)
)
return result
finally:
loop.close()
except Exception as e:
logger.bind(tag=TAG).error(f"声纹识别失败: {e}")
return None
# 使用线程池执行器并行运行
parallel_start_time = time.monotonic()
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as thread_executor:
asr_future = thread_executor.submit(run_asr)
if self.voiceprint_provider and wav_data:
voiceprint_future = thread_executor.submit(run_voiceprint)
# 等待两个线程都完成
asr_result = asr_future.result(timeout=15)
voiceprint_result = voiceprint_future.result(timeout=15)
results = {"asr": asr_result, "voiceprint": voiceprint_result}
else:
asr_result = asr_future.result(timeout=15)
results = {"asr": asr_result, "voiceprint": None}
parallel_execution_time = time.monotonic() - parallel_start_time
# 处理结果
raw_text, file_path = results.get("asr", ("", None))
speaker_name = results.get("voiceprint", None)
# 记录识别结果
if raw_text:
logger.bind(tag=TAG).info(f"识别文本: {raw_text}")
if speaker_name:
logger.bind(tag=TAG).info(f"识别说话人: {speaker_name}")
# 性能监控
total_time = time.monotonic() - total_start_time
logger.bind(tag=TAG).info(f"总处理耗时: {total_time:.3f}s")
# 检查文本长度
text_len, _ = remove_punctuation_and_length(raw_text)
self.stop_ws_connection()
if text_len > 0:
# 构建包含说话人信息的JSON字符串
enhanced_text = self._build_enhanced_text(raw_text, speaker_name)
# 使用自定义模块进行上报
await startToChat(conn, enhanced_text)
enqueue_asr_report(conn, enhanced_text, asr_audio_task)
except Exception as e:
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
import traceback
logger.bind(tag=TAG).debug(f"异常详情: {traceback.format_exc()}")
def _build_enhanced_text(self, text: str, speaker_name: Optional[str]) -> str:
"""构建包含说话人信息的文本"""
if speaker_name and speaker_name.strip():
return json.dumps({
"speaker": speaker_name,
"content": text
}, ensure_ascii=False)
else:
return text
def _pcm_to_wav(self, pcm_data: bytes) -> bytes:
"""将PCM数据转换为WAV格式"""
if len(pcm_data) == 0:
logger.bind(tag=TAG).warning("PCM数据为空,无法转换WAV")
return b""
# 确保数据长度是偶数(16位音频)
if len(pcm_data) % 2 != 0:
pcm_data = pcm_data[:-1]
# 创建WAV文件头
wav_buffer = io.BytesIO()
try:
with wave.open(wav_buffer, 'wb') as wav_file:
wav_file.setnchannels(1) # 单声道
wav_file.setsampwidth(2) # 16位
wav_file.setframerate(16000) # 16kHz采样率
wav_file.writeframes(pcm_data)
wav_buffer.seek(0)
wav_data = wav_buffer.read()
return wav_data
except Exception as e:
logger.bind(tag=TAG).error(f"WAV转换失败: {e}")
return b""
def stop_ws_connection(self):
pass
@@ -113,27 +252,29 @@ class ASRProviderBase(ABC):
pass
@staticmethod
def decode_opus(opus_data: List[bytes]) -> bytes:
def decode_opus(opus_data: List[bytes]) -> List[bytes]:
"""将Opus音频数据解码为PCM数据"""
try:
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
decoder = opuslib_next.Decoder(16000, 1)
pcm_data = []
buffer_size = 960 # 每次处理960个采样点
for opus_packet in opus_data:
buffer_size = 960 # 每次处理960个采样点 (60ms at 16kHz)
for i, opus_packet in enumerate(opus_data):
try:
# 使用较小的缓冲区大小进行处理
if not opus_packet or len(opus_packet) == 0:
continue
pcm_frame = decoder.decode(opus_packet, buffer_size)
if pcm_frame:
if pcm_frame and len(pcm_frame) > 0:
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).warning(f"Opus解码错误,跳过当前数据包: {e}")
continue
logger.bind(tag=TAG).warning(f"Opus解码错误,跳过数据包 {i}: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"音频处理错误: {e}", exc_info=True)
continue
logger.bind(tag=TAG).error(f"音频处理错误,数据包 {i}: {e}")
return pcm_data
except Exception as e:
logger.bind(tag=TAG).error(f"音频解码过程发生错误: {e}", exc_info=True)
logger.bind(tag=TAG).error(f"音频解码过程发生错误: {e}")
return []
@@ -16,10 +16,11 @@ class IntentProvider(IntentProviderBase):
super().__init__(config)
self.llm = None
self.promot = ""
# 添加缓存管理
self.intent_cache = {} # 缓存意图识别结果
self.cache_expiry = 600 # 缓存有效期10分钟
self.cache_max_size = 100 # 最多缓存100个意图
# 导入全局缓存管理
from core.utils.cache.manager import cache_manager, CacheType
self.cache_manager = cache_manager
self.CacheType = CacheType
self.history_count = 4 # 默认使用最近4条对话记录
def get_intent_system_prompt(self, functions_list: str) -> str:
@@ -102,27 +103,6 @@ class IntentProvider(IntentProviderBase):
)
return prompt
def clean_cache(self):
"""清理过期缓存"""
now = time.time()
# 找出过期键
expired_keys = [
k
for k, v in self.intent_cache.items()
if now - v["timestamp"] > self.cache_expiry
]
for key in expired_keys:
del self.intent_cache[key]
# 如果缓存太大,移除最旧的条目
if len(self.intent_cache) > self.cache_max_size:
# 按时间戳排序并保留最新的条目
sorted_items = sorted(
self.intent_cache.items(), key=lambda x: x[1]["timestamp"]
)
for key, _ in sorted_items[: len(sorted_items) - self.cache_max_size]:
del self.intent_cache[key]
def replyResult(self, text: str, original_text: str):
llm_result = self.llm.response_no_stream(
system_prompt=text,
@@ -145,21 +125,16 @@ class IntentProvider(IntentProviderBase):
logger.bind(tag=TAG).debug(f"使用意图识别模型: {model_info}")
# 计算缓存键
cache_key = hashlib.md5(text.encode()).hexdigest()
cache_key = hashlib.md5((conn.device_id + text).encode()).hexdigest()
# 检查缓存
if cache_key in self.intent_cache:
cache_entry = self.intent_cache[cache_key]
# 检查缓存是否过期
if time.time() - cache_entry["timestamp"] <= self.cache_expiry:
cache_time = time.time() - total_start_time
logger.bind(tag=TAG).debug(
f"使用缓存的意图: {cache_key} -> {cache_entry['intent']}, 耗时: {cache_time:.4f}"
)
return cache_entry["intent"]
# 清理缓存
self.clean_cache()
cached_intent = self.cache_manager.get(self.CacheType.INTENT, cache_key)
if cached_intent is not None:
cache_time = time.time() - total_start_time
logger.bind(tag=TAG).debug(
f"使用缓存的意图: {cache_key} -> {cached_intent}, 耗时: {cache_time:.4f}"
)
return cached_intent
if self.promot == "":
functions = conn.func_handler.get_functions()
@@ -259,10 +234,7 @@ class IntentProvider(IntentProviderBase):
conn.dialogue.dialogue = clean_history
# 添加到缓存
self.intent_cache[cache_key] = {
"intent": intent,
"timestamp": time.time(),
}
self.cache_manager.set(self.CacheType.INTENT, cache_key, intent)
# 后处理时间
postprocess_time = time.time() - postprocess_start_time
@@ -272,10 +244,7 @@ class IntentProvider(IntentProviderBase):
return intent
else:
# 添加到缓存
self.intent_cache[cache_key] = {
"intent": intent,
"timestamp": time.time(),
}
self.cache_manager.set(self.CacheType.INTENT, cache_key, intent)
# 后处理时间
postprocess_time = time.time() - postprocess_start_time
@@ -79,10 +79,11 @@ short_term_memory_prompt_only_content = """
1、总结user的重要信息,以便在未来的对话中提供更个性化的服务
2、不要重复总结,不要遗忘之前记忆,除非原来的记忆超过了1800字内,否则不要遗忘、不要压缩用户的历史记忆
3、用户操控的设备音量、播放音乐、天气、退出、不想对话等和用户本身无关的内容,这些信息不需要加入到总结中
4、不要把设备操控的成果结果和失败结果加入到总结中,也不要把用户的一些废话加入到总结中
5、不要为了总结而总结,如果用户的聊天没有意义,请返回原来的历史记录也是可以的
6、只需要返回总结摘要,严格控制在1800字内
7、不要包含代码、xml,不需要解释、注释和说明,保存记忆时仅从对话提取信息,不要混入示例内容
4、聊天内容中的今天的日期时间、今天的天气情况与用户事件无关的数据,这些信息如果当成记忆存储会影响后序对话,这些信息不需要加入到总结中
5、不要把设备操控的成果结果和失败结果加入到总结中,也不要把用户的一些废话加入到总结中
6、不要为了总结而总结,如果用户的聊天没有意义,请返回原来的历史记录也是可以的
7、只需要返回总结摘要,严格控制在1800字内
8、不要包含代码、xml,不需要解释、注释和说明,保存记忆时仅从对话提取信息,不要混入示例内容
"""
@@ -51,13 +51,13 @@ class ServerPluginExecutor(ToolExecutor):
tools = {}
# 获取必要的函数
necessary_functions = ["handle_exit_intent", "get_time", "get_lunar"]
necessary_functions = ["handle_exit_intent", "get_lunar"]
# 获取配置中的函数
config_functions = self.config["Intent"][
self.config["selected_module"]["Intent"]
].get("functions", [])
# 转换为列表
if not isinstance(config_functions, list):
try:
+4 -18
View File
@@ -161,13 +161,6 @@ class TTSProviderBase(ABC):
else:
sentence_id = str(uuid.uuid4()).replace("-", "")
conn.sentence_id = sentence_id
self.tts_text_queue.put(
TTSMessageDTO(
sentence_id=sentence_id,
sentence_type=SentenceType.FIRST,
content_type=ContentType.ACTION,
)
)
# 对于单句的文本,进行分段处理
segments = re.split(r"([。!?!?;\n])", content_detail)
for seg in segments:
@@ -180,13 +173,6 @@ class TTSProviderBase(ABC):
content_file=content_file,
)
)
self.tts_text_queue.put(
TTSMessageDTO(
sentence_id=sentence_id,
sentence_type=SentenceType.LAST,
content_type=ContentType.ACTION,
)
)
async def open_audio_channels(self, conn):
self.conn = conn
@@ -209,6 +195,8 @@ class TTSProviderBase(ABC):
while not self.conn.stop_event.is_set():
try:
message = self.tts_text_queue.get(timeout=1)
if message.sentence_type == SentenceType.FIRST:
self.conn.client_abort = False
if self.conn.client_abort:
logger.bind(tag=TAG).info("收到打断信息,终止TTS文本处理线程")
continue
@@ -362,10 +350,8 @@ class TTSProviderBase(ABC):
return audio_datas
def _process_before_stop_play_files(self):
for tts_file, text in self.before_stop_play_files:
if tts_file and os.path.exists(tts_file):
audio_datas = self._process_audio_file(tts_file)
self.tts_audio_queue.put((SentenceType.MIDDLE, audio_datas, text))
for audio_datas, text in self.before_stop_play_files:
self.tts_audio_queue.put((SentenceType.MIDDLE, audio_datas, text))
self.before_stop_play_files.clear()
self.tts_audio_queue.put((SentenceType.LAST, [], None))
@@ -10,7 +10,6 @@ from config.logger import setup_logging
from core.utils import opus_encoder_utils
from core.utils.util import check_model_key
from core.providers.tts.base import TTSProviderBase
from core.handle.abortHandle import handleAbortMessage
from core.providers.tts.dto.dto import SentenceType, ContentType, InterfaceType
from asyncio import Task
@@ -175,6 +174,9 @@ class TTSProvider(TTSProviderBase):
async def _ensure_connection(self):
"""建立新的WebSocket连接"""
try:
if self.ws:
logger.bind(tag=TAG).info(f"使用已有链接...")
return self.ws
logger.bind(tag=TAG).info("开始建立新连接...")
ws_header = {
"X-Api-App-Key": self.appId,
@@ -200,6 +202,10 @@ class TTSProvider(TTSProviderBase):
logger.bind(tag=TAG).debug(
f"收到TTS任务|{message.sentence_type.name} {message.content_type.name} | 会话ID: {self.conn.sentence_id}"
)
if message.sentence_type == SentenceType.FIRST:
self.conn.client_abort = False
if self.conn.client_abort:
logger.bind(tag=TAG).info("收到打断信息,终止TTS文本处理线程")
continue
@@ -244,9 +250,12 @@ class TTSProvider(TTSProviderBase):
logger.bind(tag=TAG).info(
f"添加音频文件到待播放列表: {message.content_file}"
)
self.before_stop_play_files.append(
(message.content_file, message.content_detail)
)
if message.content_file and os.path.exists(message.content_file):
# 先处理文件音频数据
file_audio = self._process_audio_file(message.content_file)
self.before_stop_play_files.append(
(file_audio, message.content_detail)
)
if message.sentence_type == SentenceType.LAST:
try:
@@ -295,25 +304,15 @@ class TTSProvider(TTSProviderBase):
async def start_session(self, session_id):
logger.bind(tag=TAG).info(f"开始会话~~{session_id}")
try:
task = self._monitor_task
# 会话开始时检测上个会话的监听状态
if (
task is not None
and isinstance(task, Task)
and not task.done()
self._monitor_task is not None
and isinstance(self._monitor_task, Task)
and not self._monitor_task.done()
):
logger.bind(tag=TAG).info("等待上一个监听任务结束...")
if self.ws is not None:
logger.bind(tag=TAG).info("强制关闭上一个WebSocket连接以唤醒监听任务...")
try:
await self.ws.close()
except Exception as e:
logger.bind(tag=TAG).warning(f"关闭上一个ws异常: {e}")
self.ws = None
try:
await asyncio.wait_for(task, timeout=8)
except Exception as e:
logger.bind(tag=TAG).warning(f"等待监听任务异常: {e}")
self._monitor_task = None
logger.bind(tag=TAG).info("检测到未完成的上个会话,关闭监听任务和连接...")
await self.close()
# 建立新连接
await self._ensure_connection()
@@ -336,19 +335,7 @@ class TTSProvider(TTSProviderBase):
except Exception as e:
logger.bind(tag=TAG).error(f"启动会话失败: {str(e)}")
# 确保清理资源
if hasattr(self, "_monitor_task"):
try:
self._monitor_task.cancel()
await self._monitor_task
except:
pass
self._monitor_task = None
if self.ws:
try:
await self.ws.close()
except:
pass
self.ws = None
await self.close()
raise
async def finish_session(self, session_id):
@@ -368,7 +355,7 @@ class TTSProvider(TTSProviderBase):
logger.bind(tag=TAG).info("会话结束请求已发送")
# 等待监听任务完成
if hasattr(self, "_monitor_task"):
if self._monitor_task:
try:
await self._monitor_task
except Exception as e:
@@ -378,28 +365,25 @@ class TTSProvider(TTSProviderBase):
finally:
self._monitor_task = None
# 关闭连接
await self.close()
except Exception as e:
logger.bind(tag=TAG).error(f"关闭会话失败: {str(e)}")
# 确保清理资源
if hasattr(self, "_monitor_task"):
try:
self._monitor_task.cancel()
await self._monitor_task
except:
pass
self._monitor_task = None
if self.ws:
try:
await self.ws.close()
except:
pass
self.ws = None
await self.close()
raise
async def close(self):
"""资源清理方法"""
# 取消监听任务
if self._monitor_task:
try:
self._monitor_task.cancel()
await self._monitor_task
except asyncio.CancelledError:
pass
except Exception as e:
logger.bind(tag=TAG).warning(f"关闭时取消监听任务错误: {e}")
self._monitor_task = None
if self.ws:
try:
await self.ws.close()
@@ -413,6 +397,7 @@ class TTSProvider(TTSProviderBase):
is_first_sentence = True
first_sentence_segment_count = 0 # 添加计数器
try:
session_finished = False # 标记会话是否正常结束
while not self.conn.stop_event.is_set():
try:
# 确保 `recv()` 运行在同一个 event loop
@@ -466,6 +451,7 @@ class TTSProvider(TTSProviderBase):
elif res.optional.event == EVENT_SessionFinished:
logger.bind(tag=TAG).debug(f"会话结束~~")
self._process_before_stop_play_files()
session_finished = True
break
except websockets.ConnectionClosed:
logger.bind(tag=TAG).warning("WebSocket连接已关闭")
@@ -476,15 +462,15 @@ class TTSProvider(TTSProviderBase):
)
traceback.print_exc()
break
finally:
# 确保清理资源
if self.ws:
# 仅在连接异常时才关闭
if not session_finished and self.ws:
try:
await self.ws.close()
except:
pass
self.ws = None
# 监听任务退出时清理引用
finally:
self._monitor_task = None
async def send_event(
@@ -1,3 +1,4 @@
import os
import queue
import asyncio
import traceback
@@ -63,9 +64,12 @@ class TTSProvider(TTSProviderBase):
logger.bind(tag=TAG).info(
f"添加音频文件到待播放列表: {message.content_file}"
)
self.before_stop_play_files.append(
(message.content_file, message.content_detail)
)
if message.content_file and os.path.exists(message.content_file):
# 先处理文件音频数据
file_audio = self._process_audio_file(message.content_file)
self.before_stop_play_files.append(
(file_audio, message.content_detail)
)
if message.sentence_type == SentenceType.LAST:
# 处理剩余的文本
@@ -23,22 +23,24 @@ class VADProvider(VADProviderBase):
# 处理空字符串的情况
threshold = config.get("threshold", "0.5")
threshold_low = config.get("threshold_low", "0.2")
min_silence_duration_ms = config.get("min_silence_duration_ms", "1000")
self.vad_threshold = float(threshold) if threshold else 0.5
self.vad_threshold_low = float(threshold_low) if threshold_low else 0.2
self.silence_threshold_ms = (
int(min_silence_duration_ms) if min_silence_duration_ms else 1000
)
# 至少要多少帧才算有语音
self.frame_window_threshold = 3
def is_vad(self, conn, opus_packet):
try:
pcm_frame = self.decoder.decode(opus_packet, 960)
conn.client_audio_buffer.extend(pcm_frame) # 将新数据加入缓冲区
# 确保帧计数器存在
if not hasattr(conn, "client_voice_frame_count"):
conn.client_voice_frame_count = 0
# 处理缓冲区中的完整帧(每次处理512采样点)
client_have_voice = False
while len(conn.client_audio_buffer) >= 512 * 2:
@@ -54,15 +56,21 @@ class VADProvider(VADProviderBase):
# 检测语音活动
with torch.no_grad():
speech_prob = self.model(audio_tensor, 16000).item()
is_voice = speech_prob >= self.vad_threshold
if is_voice:
conn.client_voice_frame_count += 1
# 双阈值判断
if speech_prob >= self.vad_threshold:
is_voice = True
elif speech_prob <= self.vad_threshold_low:
is_voice = False
else:
conn.client_voice_frame_count = 0
is_voice = conn.last_is_voice
# 只有连续4帧检测到语音才认为有
client_have_voice = conn.client_voice_frame_count >= 4
# 声音没低于最低值则延续前一个状态,判断为有
conn.last_is_voice = is_voice
# 更新滑动窗口
conn.client_voice_window.append(is_voice)
client_have_voice = (conn.client_voice_window.count(True) >= self.frame_window_threshold)
# 如果之前有声音,但本次没有声音,且与上次有声音的时间差已经超过了静默阈值,则认为已经说完一句话
if conn.client_have_voice and not client_have_voice:
@@ -40,6 +40,7 @@ class VLLMProvider(VLLMProviderBase):
self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url)
def response(self, question, base64_image):
question = question + "(请使用中文回复)"
try:
messages = [
{
+58
View File
@@ -0,0 +1,58 @@
"""
缓存配置管理
"""
from enum import Enum
from typing import Dict, Any, Optional
from dataclasses import dataclass
from .strategies import CacheStrategy
class CacheType(Enum):
"""缓存类型枚举"""
LOCATION = "location"
WEATHER = "weather"
LUNAR = "lunar"
INTENT = "intent"
IP_INFO = "ip_info"
CONFIG = "config"
DEVICE_PROMPT = "device_prompt"
@dataclass
class CacheConfig:
"""缓存配置类"""
strategy: CacheStrategy = CacheStrategy.TTL
ttl: Optional[float] = 300 # 默认5分钟
max_size: Optional[int] = 1000 # 默认最大1000条
cleanup_interval: float = 60 # 清理间隔(秒)
@classmethod
def for_type(cls, cache_type: CacheType) -> "CacheConfig":
"""根据缓存类型返回预设配置"""
configs = {
CacheType.LOCATION: cls(
strategy=CacheStrategy.TTL, ttl=None, max_size=1000 # 手动失效
),
CacheType.IP_INFO: cls(
strategy=CacheStrategy.TTL, ttl=86400, max_size=1000 # 24小时
),
CacheType.WEATHER: cls(
strategy=CacheStrategy.TTL, ttl=28800, max_size=1000 # 8小时
),
CacheType.LUNAR: cls(
strategy=CacheStrategy.TTL, ttl=2592000, max_size=365 # 30天过期
),
CacheType.INTENT: cls(
strategy=CacheStrategy.TTL_LRU, ttl=600, max_size=1000 # 10分钟
),
CacheType.CONFIG: cls(
strategy=CacheStrategy.FIXED_SIZE, ttl=None, max_size=20 # 手动失效
),
CacheType.DEVICE_PROMPT: cls(
strategy=CacheStrategy.TTL, ttl=None, max_size=1000 # 手动失效
),
}
return configs.get(cache_type, cls())
+216
View File
@@ -0,0 +1,216 @@
"""
全局缓存管理器
"""
import time
import threading
from typing import Any, Optional, Dict
from collections import OrderedDict
from .strategies import CacheStrategy, CacheEntry
from .config import CacheConfig, CacheType
class GlobalCacheManager:
"""全局缓存管理器"""
def __init__(self):
self._logger = None
self._caches: Dict[str, Dict[str, CacheEntry]] = {}
self._configs: Dict[str, CacheConfig] = {}
self._locks: Dict[str, threading.RLock] = {}
self._global_lock = threading.RLock()
self._last_cleanup = time.time()
self._stats = {"hits": 0, "misses": 0, "evictions": 0, "cleanups": 0}
@property
def logger(self):
"""延迟初始化 logger 以避免循环导入"""
if self._logger is None:
from config.logger import setup_logging
self._logger = setup_logging()
return self._logger
def _get_cache_name(self, cache_type: CacheType, namespace: str = "") -> str:
"""生成缓存名称"""
if namespace:
return f"{cache_type.value}:{namespace}"
return cache_type.value
def _get_or_create_cache(
self, cache_name: str, config: CacheConfig
) -> Dict[str, CacheEntry]:
"""获取或创建缓存空间"""
with self._global_lock:
if cache_name not in self._caches:
self._caches[cache_name] = (
OrderedDict()
if config.strategy in [CacheStrategy.LRU, CacheStrategy.TTL_LRU]
else {}
)
self._configs[cache_name] = config
self._locks[cache_name] = threading.RLock()
return self._caches[cache_name]
def set(
self,
cache_type: CacheType,
key: str,
value: Any,
ttl: Optional[float] = None,
namespace: str = "",
) -> None:
"""设置缓存值"""
cache_name = self._get_cache_name(cache_type, namespace)
config = self._configs.get(cache_name) or CacheConfig.for_type(cache_type)
cache = self._get_or_create_cache(cache_name, config)
# 使用配置的TTL或传入的TTL
effective_ttl = ttl if ttl is not None else config.ttl
with self._locks[cache_name]:
# 创建缓存条目
entry = CacheEntry(value=value, timestamp=time.time(), ttl=effective_ttl)
# 处理不同策略
if config.strategy in [CacheStrategy.LRU, CacheStrategy.TTL_LRU]:
# LRU策略:如果已存在则移动到末尾
if key in cache:
del cache[key]
cache[key] = entry
# 检查大小限制
if config.max_size and len(cache) > config.max_size:
# 移除最旧的条目
oldest_key = next(iter(cache))
del cache[oldest_key]
self._stats["evictions"] += 1
else:
cache[key] = entry
# 检查大小限制
if config.max_size and len(cache) > config.max_size:
# 简单策略:随机移除一个条目
victim_key = next(iter(cache))
del cache[victim_key]
self._stats["evictions"] += 1
# 定期清理过期条目
self._maybe_cleanup(cache_name)
def get(
self, cache_type: CacheType, key: str, namespace: str = ""
) -> Optional[Any]:
"""获取缓存值"""
cache_name = self._get_cache_name(cache_type, namespace)
if cache_name not in self._caches:
self._stats["misses"] += 1
return None
cache = self._caches[cache_name]
config = self._configs[cache_name]
with self._locks[cache_name]:
if key not in cache:
self._stats["misses"] += 1
return None
entry = cache[key]
# 检查过期
if entry.is_expired():
del cache[key]
self._stats["misses"] += 1
return None
# 更新访问信息
entry.touch()
# LRU策略:移动到末尾
if config.strategy in [CacheStrategy.LRU, CacheStrategy.TTL_LRU]:
del cache[key]
cache[key] = entry
self._stats["hits"] += 1
return entry.value
def delete(self, cache_type: CacheType, key: str, namespace: str = "") -> bool:
"""删除缓存条目"""
cache_name = self._get_cache_name(cache_type, namespace)
if cache_name not in self._caches:
return False
cache = self._caches[cache_name]
with self._locks[cache_name]:
if key in cache:
del cache[key]
return True
return False
def clear(self, cache_type: CacheType, namespace: str = "") -> None:
"""清空指定缓存"""
cache_name = self._get_cache_name(cache_type, namespace)
if cache_name not in self._caches:
return
with self._locks[cache_name]:
self._caches[cache_name].clear()
def invalidate_pattern(
self, cache_type: CacheType, pattern: str, namespace: str = ""
) -> int:
"""按模式失效缓存条目"""
cache_name = self._get_cache_name(cache_type, namespace)
if cache_name not in self._caches:
return 0
cache = self._caches[cache_name]
deleted_count = 0
with self._locks[cache_name]:
keys_to_delete = [key for key in cache.keys() if pattern in key]
for key in keys_to_delete:
del cache[key]
deleted_count += 1
return deleted_count
def _cleanup_expired(self, cache_name: str) -> int:
"""清理过期条目"""
if cache_name not in self._caches:
return 0
cache = self._caches[cache_name]
deleted_count = 0
with self._locks[cache_name]:
expired_keys = [key for key, entry in cache.items() if entry.is_expired()]
for key in expired_keys:
del cache[key]
deleted_count += 1
return deleted_count
def _maybe_cleanup(self, cache_name: str):
"""定期清理检查"""
config = self._configs.get(cache_name)
if not config:
return
now = time.time()
if now - self._last_cleanup > config.cleanup_interval:
self._last_cleanup = now
deleted = self._cleanup_expired(cache_name)
if deleted > 0:
self._stats["cleanups"] += 1
self.logger.debug(f"清理缓存 {cache_name}: 删除 {deleted} 个过期条目")
# 创建全局缓存管理器实例
cache_manager = GlobalCacheManager()
+43
View File
@@ -0,0 +1,43 @@
"""
缓存策略和数据结构定义
"""
import time
from enum import Enum
from typing import Any, Optional
from dataclasses import dataclass
class CacheStrategy(Enum):
"""缓存策略枚举"""
TTL = "ttl" # 基于时间过期
LRU = "lru" # 最近最少使用
FIXED_SIZE = "fixed_size" # 固定大小
TTL_LRU = "ttl_lru" # TTL + LRU混合策略
@dataclass
class CacheEntry:
"""缓存条目数据结构"""
value: Any
timestamp: float
ttl: Optional[float] = None # 生存时间(秒)
access_count: int = 0
last_access: float = None
def __post_init__(self):
if self.last_access is None:
self.last_access = self.timestamp
def is_expired(self) -> bool:
"""检查是否过期"""
if self.ttl is None:
return False
return time.time() - self.timestamp > self.ttl
def touch(self):
"""更新访问时间和计数"""
self.last_access = time.time()
self.access_count += 1
+39 -13
View File
@@ -1,4 +1,5 @@
import uuid
import re
from typing import List, Dict
from datetime import datetime
@@ -45,10 +46,9 @@ class Dialogue:
dialogue.append({"role": m.role, "content": m.content})
def get_llm_dialogue(self) -> List[Dict[str, str]]:
dialogue = []
for m in self.dialogue:
self.getMessages(m, dialogue)
return dialogue
# 直接调用get_llm_dialogue_with_memory,传入None作为memory_str
# 这样确保说话人功能在所有调用路径下都生效
return self.get_llm_dialogue_with_memory(None, None)
def update_system_message(self, new_content: str):
"""更新或添加系统消息"""
@@ -60,12 +60,9 @@ class Dialogue:
self.put(Message(role="system", content=new_content))
def get_llm_dialogue_with_memory(
self, memory_str: str = None
self, memory_str: str = None, voiceprint_config: dict = None
) -> List[Dict[str, str]]:
if memory_str is None or len(memory_str) == 0:
return self.get_llm_dialogue()
# 构建带记忆的对话
# 构建对话
dialogue = []
# 添加系统提示和记忆
@@ -74,10 +71,39 @@ class Dialogue:
)
if system_message:
enhanced_system_prompt = (
f"{system_message.content}\n\n"
f"以下是用户的历史记忆:\n```\n{memory_str}\n```"
)
# 基础系统提示
enhanced_system_prompt = system_message.content
# 添加说话人个性化描述
try:
speakers = voiceprint_config.get("speakers", [])
if speakers:
enhanced_system_prompt += "\n\n<speakers_info>"
for speaker_str in speakers:
try:
parts = speaker_str.split(",", 2)
if len(parts) >= 2:
name = parts[1].strip()
# 如果描述为空,则为""
description = (
parts[2].strip() if len(parts) >= 3 else ""
)
enhanced_system_prompt += f"\n- {name}{description}"
except:
pass
enhanced_system_prompt += "\n\n</speakers_info>"
except:
# 配置读取失败时忽略错误,不影响其他功能
pass
# 使用正则表达式匹配 <memory> 标签,不管中间有什么内容
if memory_str is not None:
enhanced_system_prompt = re.sub(
r"<memory>.*?</memory>",
f"<memory>\n{memory_str}\n</memory>",
enhanced_system_prompt,
flags=re.DOTALL,
)
dialogue.append({"role": "system", "content": enhanced_system_prompt})
# 添加用户和助手的对话
@@ -125,4 +125,27 @@ def initialize_asr(config):
config["ASR"][select_asr_module],
str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"),
)
logger.bind(tag=TAG).info("ASR模块初始化完成")
return new_asr
def initialize_voiceprint(asr_instance, config):
"""初始化声纹识别功能"""
voiceprint_config = config.get("voiceprint")
if not voiceprint_config:
return False
# 应用配置
if not voiceprint_config.get("url") or not voiceprint_config.get("speakers"):
logger.bind(tag=TAG).warning("声纹识别配置不完整")
return False
try:
asr_instance.init_voiceprint(voiceprint_config)
logger.bind(tag=TAG).info("ASR模块声纹识别功能已动态启用")
logger.bind(tag=TAG).info(f"配置说话人数量: {len(voiceprint_config['speakers'])}")
return True
except Exception as e:
logger.bind(tag=TAG).error(f"动态初始化声纹识别功能失败: {str(e)}")
return False
@@ -0,0 +1,221 @@
"""
系统提示词管理器模块
负责管理和更新系统提示词包括快速初始化和异步增强功能
"""
import os
import cnlunar
from typing import Dict, Any
from config.logger import setup_logging
from jinja2 import Template
TAG = __name__
WEEKDAY_MAP = {
"Monday": "星期一",
"Tuesday": "星期二",
"Wednesday": "星期三",
"Thursday": "星期四",
"Friday": "星期五",
"Saturday": "星期六",
"Sunday": "星期日",
}
class PromptManager:
"""系统提示词管理器,负责管理和更新系统提示词"""
def __init__(self, config: Dict[str, Any], logger=None):
self.config = config
self.logger = logger or setup_logging()
self.base_prompt_template = None
self.last_update_time = 0
# 导入全局缓存管理器
from core.utils.cache.manager import cache_manager, CacheType
self.cache_manager = cache_manager
self.CacheType = CacheType
self._load_base_template()
def _load_base_template(self):
"""加载基础提示词模板"""
try:
template_path = "agent-base-prompt.txt"
cache_key = f"prompt_template:{template_path}"
# 先从缓存获取
cached_template = self.cache_manager.get(self.CacheType.CONFIG, cache_key)
if cached_template is not None:
self.base_prompt_template = cached_template
self.logger.bind(tag=TAG).debug("从缓存加载基础提示词模板")
return
# 缓存未命中,从文件读取
if os.path.exists(template_path):
with open(template_path, "r", encoding="utf-8") as f:
template_content = f.read()
# 存入缓存(CONFIG类型默认不自动过期,需要手动失效)
self.cache_manager.set(
self.CacheType.CONFIG, cache_key, template_content
)
self.base_prompt_template = template_content
self.logger.bind(tag=TAG).debug("成功加载基础提示词模板并缓存")
else:
self.logger.bind(tag=TAG).warning("未找到agent-base-prompt.txt文件")
except Exception as e:
self.logger.bind(tag=TAG).error(f"加载提示词模板失败: {e}")
def get_quick_prompt(self, user_prompt: str, device_id: str = None) -> str:
"""快速获取系统提示词(使用用户配置)"""
device_cache_key = f"device_prompt:{device_id}"
cached_device_prompt = self.cache_manager.get(
self.CacheType.DEVICE_PROMPT, device_cache_key
)
if cached_device_prompt is not None:
self.logger.bind(tag=TAG).debug(f"使用设备 {device_id} 的缓存提示词")
return cached_device_prompt
else:
self.logger.bind(tag=TAG).debug(
f"设备 {device_id} 无缓存提示词,使用传入的提示词"
)
# 使用传入的提示词并缓存(如果有设备ID)
if device_id:
device_cache_key = f"device_prompt:{device_id}"
self.cache_manager.set(self.CacheType.CONFIG, device_cache_key, user_prompt)
self.logger.bind(tag=TAG).debug(f"设备 {device_id} 的提示词已缓存")
self.logger.bind(tag=TAG).info(f"使用快速提示词: {user_prompt[:50]}...")
return user_prompt
def _get_current_time_info(self) -> tuple:
"""获取当前时间信息"""
from datetime import datetime
now = datetime.now()
current_time = now.strftime("%H:%M")
today_date = now.strftime("%Y-%m-%d")
today_weekday = WEEKDAY_MAP[now.strftime("%A")]
today_lunar = cnlunar.Lunar(now, godType="8char")
lunar_date = "%s%s%s\n" % (
today_lunar.lunarYearCn,
today_lunar.lunarMonthCn[:-1],
today_lunar.lunarDayCn,
)
return current_time, today_date, today_weekday, lunar_date
def _get_location_info(self, client_ip: str) -> str:
"""获取位置信息"""
try:
# 先从缓存获取
cached_location = self.cache_manager.get(self.CacheType.LOCATION, client_ip)
if cached_location is not None:
return cached_location
# 缓存未命中,调用API获取
from core.utils.util import get_ip_info
ip_info = get_ip_info(client_ip, self.logger)
city = ip_info.get("city", "未知位置")
location = f"{city}"
# 存入缓存
self.cache_manager.set(self.CacheType.LOCATION, client_ip, location)
return location
except Exception as e:
self.logger.bind(tag=TAG).error(f"获取位置信息失败: {e}")
return "未知位置"
def _get_weather_info(self, conn, location: str) -> str:
"""获取天气信息"""
try:
# 先从缓存获取
cached_weather = self.cache_manager.get(self.CacheType.WEATHER, location)
if cached_weather is not None:
return cached_weather
# 缓存未命中,调用get_weather函数获取
from plugins_func.functions.get_weather import get_weather
from plugins_func.register import ActionResponse
# 调用get_weather函数
result = get_weather(conn, location=location, lang="zh_CN")
if isinstance(result, ActionResponse):
weather_report = result.result
self.cache_manager.set(self.CacheType.WEATHER, location, weather_report)
return weather_report
return "天气信息获取失败"
except Exception as e:
self.logger.bind(tag=TAG).error(f"获取天气信息失败: {e}")
return "天气信息获取失败"
def update_context_info(self, conn, client_ip: str):
"""同步更新上下文信息"""
try:
# 获取位置信息(使用全局缓存)
local_address = self._get_location_info(client_ip)
# 获取天气信息(使用全局缓存)
self._get_weather_info(conn, local_address)
self.logger.bind(tag=TAG).info(f"上下文信息更新完成")
except Exception as e:
self.logger.bind(tag=TAG).error(f"更新上下文信息失败: {e}")
def build_enhanced_prompt(
self, user_prompt: str, device_id: str, client_ip: str = None
) -> str:
"""构建增强的系统提示词"""
if not self.base_prompt_template:
return user_prompt
try:
# 获取最新的时间信息(不缓存)
current_time, today_date, today_weekday, lunar_date = (
self._get_current_time_info()
)
# 获取缓存的上下文信息
local_address = ""
weather_info = ""
if client_ip:
# 获取位置信息(从全局缓存)
local_address = (
self.cache_manager.get(self.CacheType.LOCATION, client_ip) or ""
)
# 获取天气信息(从全局缓存)
if local_address:
weather_info = (
self.cache_manager.get(self.CacheType.WEATHER, local_address)
or ""
)
# 替换模板变量
template = Template(self.base_prompt_template)
enhanced_prompt = template.render(
base_prompt=user_prompt,
current_time=current_time,
today_date=today_date,
today_weekday=today_weekday,
lunar_date=lunar_date,
local_address=local_address,
weather_info=weather_info,
)
device_cache_key = f"device_prompt:{device_id}"
self.cache_manager.set(
self.CacheType.DEVICE_PROMPT, device_cache_key, enhanced_prompt
)
self.logger.bind(tag=TAG).info(
f"构建增强提示词成功,长度: {len(enhanced_prompt)}"
)
return enhanced_prompt
except Exception as e:
self.logger.bind(tag=TAG).error(f"构建增强提示词失败: {e}")
return user_prompt
+37
View File
@@ -96,11 +96,23 @@ def is_private_ip(ip_addr):
def get_ip_info(ip_addr, logger):
try:
# 导入全局缓存管理器
from core.utils.cache.manager import cache_manager, CacheType
# 先从缓存获取
cached_ip_info = cache_manager.get(CacheType.IP_INFO, ip_addr)
if cached_ip_info is not None:
return cached_ip_info
# 缓存未命中,调用API
if is_private_ip(ip_addr):
ip_addr = ""
url = f"https://whois.pconline.com.cn/ipJson.jsp?json=true&ip={ip_addr}"
resp = requests.get(url).json()
ip_info = {"city": resp.get("city")}
# 存入缓存
cache_manager.set(CacheType.IP_INFO, ip_addr, ip_info)
return ip_info
except Exception as e:
logger.bind(tag=TAG).error(f"Error getting client ip info: {e}")
@@ -982,3 +994,28 @@ def sanitize_tool_name(name: str) -> str:
"""Sanitize tool names for OpenAI compatibility."""
# 支持中文、英文字母、数字、下划线和连字符
return re.sub(r"[^a-zA-Z0-9_\-\u4e00-\u9fff]", "_", name)
def validate_mcp_endpoint(mcp_endpoint: str) -> bool:
"""
校验MCP接入点格式
Args:
mcp_endpoint: MCP接入点字符串
Returns:
bool: 是否有效
"""
# 1. 检查是否以ws开头
if not mcp_endpoint.startswith("ws"):
return False
# 2. 检查是否包含key、call字样
if "key" in mcp_endpoint.lower() or "call" in mcp_endpoint.lower():
return False
# 3. 检查是否包含/mcp/字样
if "/mcp/" not in mcp_endpoint:
return False
return True
@@ -0,0 +1,134 @@
import asyncio
import json
import time
import aiohttp
from urllib.parse import urlparse, parse_qs
from typing import Optional, Dict
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class VoiceprintProvider:
"""声纹识别服务提供者"""
def __init__(self, config: dict):
self.original_url = config.get("url", "")
self.speakers = config.get("speakers", [])
self.speaker_map = self._parse_speakers()
# 解析API地址和密钥
self.api_url = None
self.api_key = None
self.speaker_ids = []
if not self.original_url:
logger.bind(tag=TAG).warning("声纹识别URL未配置,声纹识别将被禁用")
self.enabled = False
else:
# 解析URL和key
parsed_url = urlparse(self.original_url)
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
# 从查询参数中提取key
query_params = parse_qs(parsed_url.query)
self.api_key = query_params.get('key', [''])[0]
if not self.api_key:
logger.bind(tag=TAG).error("URL中未找到key参数,声纹识别将被禁用")
self.enabled = False
else:
# 构造identify接口地址
self.api_url = f"{base_url}/voiceprint/identify"
# 提取speaker_ids
for speaker_str in self.speakers:
try:
parts = speaker_str.split(",", 2)
if len(parts) >= 1:
speaker_id = parts[0].strip()
self.speaker_ids.append(speaker_id)
except Exception:
continue
# 检查是否有有效的说话人配置
if not self.speaker_ids:
logger.bind(tag=TAG).warning("未配置有效的说话人,声纹识别将被禁用")
self.enabled = False
else:
self.enabled = True
logger.bind(tag=TAG).info(f"声纹识别已配置: API={self.api_url}, 说话人={len(self.speaker_ids)}")
def _parse_speakers(self) -> Dict[str, Dict[str, str]]:
"""解析说话人配置"""
speaker_map = {}
for speaker_str in self.speakers:
try:
parts = speaker_str.split(",", 2)
if len(parts) >= 3:
speaker_id, name, description = parts[0].strip(), parts[1].strip(), parts[2].strip()
speaker_map[speaker_id] = {
"name": name,
"description": description
}
except Exception as e:
logger.bind(tag=TAG).warning(f"解析说话人配置失败: {speaker_str}, 错误: {e}")
return speaker_map
async def identify_speaker(self, audio_data: bytes, session_id: str) -> Optional[str]:
"""识别说话人"""
if not self.enabled or not self.api_url or not self.api_key:
logger.bind(tag=TAG).debug("声纹识别功能已禁用或未配置,跳过识别")
return None
try:
api_start_time = time.monotonic()
# 准备请求头
headers = {
'Authorization': f'Bearer {self.api_key}',
'Accept': 'application/json'
}
# 准备multipart/form-data数据
data = aiohttp.FormData()
data.add_field('speaker_ids', ','.join(self.speaker_ids))
data.add_field('file', audio_data, filename='audio.wav', content_type='audio/wav')
timeout = aiohttp.ClientTimeout(total=10)
# 网络请求
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(self.api_url, headers=headers, data=data) as response:
if response.status == 200:
result = await response.json()
speaker_id = result.get("speaker_id")
score = result.get("score", 0)
total_elapsed_time = time.monotonic() - api_start_time
logger.bind(tag=TAG).info(f"声纹识别耗时: {total_elapsed_time:.3f}s")
# 置信度检查
if score < 0.5:
logger.bind(tag=TAG).warning(f"声纹识别置信度较低: {score:.3f}")
if speaker_id and speaker_id in self.speaker_map:
result_name = self.speaker_map[speaker_id]["name"]
return result_name
else:
logger.bind(tag=TAG).warning(f"未识别的说话人ID: {speaker_id}")
return "未知说话人"
else:
logger.bind(tag=TAG).error(f"声纹识别API错误: HTTP {response.status}")
return None
except asyncio.TimeoutError:
elapsed = time.monotonic() - api_start_time
logger.bind(tag=TAG).error(f"声纹识别超时: {elapsed:.3f}s")
return None
except Exception as e:
elapsed = time.monotonic() - api_start_time
logger.bind(tag=TAG).error(f"声纹识别失败: {e}")
return None
@@ -57,8 +57,25 @@ class WebSocketServer:
self.active_connections.add(handler)
try:
await handler.handle_connection(websocket)
except Exception as e:
self.logger.bind(tag=TAG).error(f"处理连接时出错: {e}")
finally:
# 确保从活动连接集合中移除
self.active_connections.discard(handler)
# 强制关闭连接(如果还没有关闭的话)
try:
# 安全地检查WebSocket状态并关闭
if hasattr(websocket, "closed") and not websocket.closed:
await websocket.close()
elif hasattr(websocket, "state") and websocket.state.name != "CLOSED":
await websocket.close()
else:
# 如果没有closed属性,直接尝试关闭
await websocket.close()
except Exception as close_error:
self.logger.bind(tag=TAG).error(
f"服务器端强制关闭连接时出错: {close_error}"
)
async def _http_response(self, websocket, request_headers):
# 检查是否为 WebSocket 升级请求
@@ -2,59 +2,27 @@ from datetime import datetime
import cnlunar
from plugins_func.register import register_function, ToolType, ActionResponse, Action
# 添加星期映射字典
WEEKDAY_MAP = {
"Monday": "星期一",
"Tuesday": "星期二",
"Wednesday": "星期三",
"Thursday": "星期四",
"Friday": "星期五",
"Saturday": "星期六",
"Sunday": "星期日",
}
get_time_function_desc = {
"type": "function",
"function": {
"name": "get_time",
"description": "获取今天日期或者当前时间信息",
"parameters": {"type": "object", "properties": {}, "required": []},
},
}
@register_function("get_time", get_time_function_desc, ToolType.WAIT)
def get_time():
"""
获取当前的日期时间信息
"""
now = datetime.now()
current_time = now.strftime("%H:%M:%S")
current_date = now.strftime("%Y-%m-%d")
current_weekday = WEEKDAY_MAP[now.strftime("%A")]
response_text = (
f"当前日期: {current_date},当前时间: {current_time} {current_weekday}"
)
return ActionResponse(Action.REQLLM, response_text, None)
get_lunar_function_desc = {
"type": "function",
"function": {
"name": "get_lunar",
"description": (
"用于获取今天的阴历/农历和黄历信息。"
"用于具体日期的阴历/农历和黄历信息。"
"用户可以指定查询内容,如:阴历日期、天干地支、节气、生肖、星座、八字、宜忌等。"
"如果没有指定查询内容,则默认查询干支年和农历日期。"
"对于'今天农历是多少''今天农历日期'这样的基本查询,请直接使用context中的信息,不要调用此工具。"
),
"parameters": {
"type": "object",
"properties": {
"date": {
"type": "string",
"description": "要查询的日期,格式为YYYY-MM-DD,例如2024-01-01。如果不提供,则使用当前日期",
},
"query": {
"type": "string",
"description": "要查询的内容,例如阴历日期、天干地支、节日、节气、生肖、星座、八字、宜忌等",
}
},
},
"required": [],
},
@@ -63,23 +31,41 @@ get_lunar_function_desc = {
@register_function("get_lunar", get_lunar_function_desc, ToolType.WAIT)
def get_lunar(query=None):
def get_lunar(date=None, query=None):
"""
用于获取当前的阴历/农历和天干地支节气生肖星座八字宜忌等黄历信息
"""
now = datetime.now()
current_time = now.strftime("%H:%M:%S")
from core.utils.cache.manager import cache_manager, CacheType
# 如果提供了日期参数,则使用指定日期;否则使用当前日期
if date:
try:
now = datetime.strptime(date, "%Y-%m-%d")
except ValueError:
return ActionResponse(
Action.REQLLM,
f"日期格式错误,请使用YYYY-MM-DD格式,例如:2024-01-01",
None,
)
else:
now = datetime.now()
current_date = now.strftime("%Y-%m-%d")
current_weekday = WEEKDAY_MAP[now.strftime("%A")]
# 如果 query 为 None,则使用默认文本
if query is None:
query = "默认查询干支年和农历日期"
# 尝试从缓存获取农历信息
lunar_cache_key = f"lunar_info_{current_date}"
cached_lunar_info = cache_manager.get(CacheType.LUNAR, lunar_cache_key)
if cached_lunar_info:
return ActionResponse(Action.REQLLM, cached_lunar_info, None)
response_text = f"根据以下信息回应用户的查询请求,并提供与{query}相关的信息:\n"
lunar = cnlunar.Lunar(now, godType="8char")
response_text += (
f"当前公历日期: {current_date},当前时间: {current_time}{current_weekday}\n"
"农历信息:\n"
"%s%s%s\n" % (lunar.lunarYearCn, lunar.lunarMonthCn[:-1], lunar.lunarDayCn)
+ "干支: %s%s%s\n" % (lunar.year8Char, lunar.month8Char, lunar.day8Char)
@@ -135,4 +121,7 @@ def get_lunar(query=None):
+ "(默认返回干支年和农历日期;仅在要求查询宜忌信息时才返回本日宜忌)"
)
# 缓存农历信息
cache_manager.set(CacheType.LUNAR, lunar_cache_key, response_text)
return ActionResponse(Action.REQLLM, response_text, None)
@@ -151,20 +151,44 @@ def parse_weather_info(soup):
@register_function("get_weather", GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL)
def get_weather(conn, location: str = None, lang: str = "zh_CN"):
api_host = conn.config["plugins"]["get_weather"].get("api_host", "mj7p3y7naa.re.qweatherapi.com")
api_key = conn.config["plugins"]["get_weather"].get("api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da")
from core.utils.cache.manager import cache_manager, CacheType
api_host = conn.config["plugins"]["get_weather"].get(
"api_host", "mj7p3y7naa.re.qweatherapi.com"
)
api_key = conn.config["plugins"]["get_weather"].get(
"api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da"
)
default_location = conn.config["plugins"]["get_weather"]["default_location"]
client_ip = conn.client_ip
# 优先使用用户提供的location参数
if not location:
# 通过客户端IP解析城市
if client_ip:
# 动态解析IP对应的城市信息
ip_info = get_ip_info(client_ip, logger)
location = ip_info.get("city") if ip_info and "city" in ip_info else None
# 先从缓存获取IP对应的城市信息
cached_ip_info = cache_manager.get(CacheType.IP_INFO, client_ip)
if cached_ip_info:
location = cached_ip_info.get("city")
else:
# 缓存未命中,调用API获取
ip_info = get_ip_info(client_ip, logger)
if ip_info:
cache_manager.set(CacheType.IP_INFO, client_ip, ip_info)
location = ip_info.get("city")
if not location:
location = default_location
else:
# 若IP解析失败或无IP,使用默认位置
# 若无IP,使用默认位置
location = default_location
# 尝试从缓存获取完整天气报告
weather_cache_key = f"full_weather_{location}_{lang}"
cached_weather_report = cache_manager.get(CacheType.WEATHER, weather_cache_key)
if cached_weather_report:
return ActionResponse(Action.REQLLM, cached_weather_report, None)
# 缓存未命中,获取实时天气数据
city_info = fetch_city_info(location, api_key, api_host)
if not city_info:
return ActionResponse(
@@ -192,4 +216,7 @@ def get_weather(conn, location: str = None, lang: str = "zh_CN"):
# 提示语
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
# 缓存完整的天气报告
cache_manager.set(CacheType.WEATHER, weather_cache_key, weather_report)
return ActionResponse(Action.REQLLM, weather_report, None)
@@ -1,13 +1,10 @@
from config.logger import setup_logging
import os
import re
import time
import random
import asyncio
import difflib
import traceback
from pathlib import Path
from core.utils import p3
from core.handle.sendAudioHandle import send_stt_message
from plugins_func.register import register_function, ToolType, ActionResponse, Action
from core.utils.dialogue import Message
@@ -51,8 +48,8 @@ def play_music(conn, song_name: str):
)
# 提交异步任务
future = asyncio.run_coroutine_threadsafe(
handle_music_command(conn, music_intent), conn.loop
task = conn.loop.create_task(
handle_music_command(conn, music_intent) # 封装异步逻辑
)
# 非阻塞回调处理
@@ -63,7 +60,7 @@ def play_music(conn, song_name: str):
except Exception as e:
conn.logger.bind(tag=TAG).error(f"播放失败: {e}")
future.add_done_callback(handle_done)
task.add_done_callback(handle_done)
return ActionResponse(
action=Action.NONE, result="指令已接收", response="正在为您播放音乐"
@@ -178,13 +175,13 @@ def _get_random_play_prompt(song_name):
# 移除文件扩展名
clean_name = os.path.splitext(song_name)[0]
prompts = [
f"正在为您播放,{clean_name}",
f"请欣赏歌曲,{clean_name}",
f"即将为您播放,{clean_name}",
f"为您带来,{clean_name}",
f"让我们聆听,{clean_name}",
f"接下来请欣赏,{clean_name}",
f"为您献上,{clean_name}",
f"正在为您播放,{clean_name}",
f"请欣赏歌曲,{clean_name}",
f"即将为您播放,{clean_name}",
f"现在为您带来,{clean_name}",
f"让我们一起聆听,{clean_name}",
f"接下来请欣赏,{clean_name}",
f"此刻为您献上,{clean_name}",
]
# 直接使用random.choice,不设置seed
return random.choice(prompts)
@@ -218,13 +215,14 @@ async def play_local_music(conn, specific_file=None):
await send_stt_message(conn, text)
conn.dialogue.put(Message(role="assistant", content=text))
conn.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=conn.sentence_id,
sentence_type=SentenceType.FIRST,
content_type=ContentType.ACTION,
if conn.intent_type == "intent_llm":
conn.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=conn.sentence_id,
sentence_type=SentenceType.FIRST,
content_type=ContentType.ACTION,
)
)
)
conn.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=conn.sentence_id,
@@ -241,13 +239,14 @@ async def play_local_music(conn, specific_file=None):
content_file=music_path,
)
)
conn.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=conn.sentence_id,
sentence_type=SentenceType.LAST,
content_type=ContentType.ACTION,
if conn.intent_type == "intent_llm":
conn.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=conn.sentence_id,
sentence_type=SentenceType.LAST,
content_type=ContentType.ACTION,
)
)
)
except Exception as e:
conn.logger.bind(tag=TAG).error(f"播放音乐失败: {str(e)}")
+3 -2
View File
@@ -21,7 +21,7 @@ cozepy==0.12.0
mem0ai==0.1.62
bs4==0.0.2
modelscope==1.23.2
sherpa_onnx==1.12.0
sherpa_onnx==1.12.4
mcp==1.8.1
cnlunar==0.2.0
PySocks==1.7.1
@@ -33,4 +33,5 @@ markitdown==0.1.1
mcp-proxy==0.8.0
PyJWT==2.8.0
psutil==7.0.0
portalocker==2.10.1
portalocker==2.10.1
Jinja2==3.1.6