Compare commits

...
62 Commits
Author SHA1 Message Date
hrzandGitHub 15b3f0a8f5 Merge pull request #2695 from xinnan-tech/py_device_bind
update: 未绑定设备策略优化
2025-12-13 23:30:44 +08:00
hrz 5c261528d0 update:常用音频增加缓存,抵御高并发未授权设备访问 2025-12-13 23:10:40 +08:00
hrz 8b2bbec0b9 update:audio_to_data改成异步方法 2025-12-13 22:29:10 +08:00
hrz 8b3a4ad163 update:优化丢弃消息逻辑 2025-12-13 21:45:31 +08:00
hrz b3e272281b Merge branch 'main' into py_device_bind 2025-12-13 20:27:40 +08:00
hrz 06a90d6266 update:恢复audioRateController旧版本 2025-12-13 20:27:11 +08:00
欣南科技andGitHub 6eb7acaf73 Merge pull request #2698 from xinnan-tech/ws_token_auth
Ws token auth
2025-12-13 15:42:21 +08:00
hrz 2e092a7880 update:默认开启server.auth.enabled 2025-12-13 15:34:11 +08:00
hrzandGitHub 515f669546 Merge pull request #2685 from aixiamomo/fix/ws_token_auth
fix: 为Java项目的OTA接口实现WebSocket认证token生成功能,兼容Python端
2025-12-13 14:44:12 +08:00
hrz 7699596597 update:优化 2025-12-13 14:35:55 +08:00
欣南科技andGitHub 34dd325a1d Merge pull request #2697 from xinnan-tech/test_page_fix_abort
fix:edge_tts bug
2025-12-13 00:26:32 +08:00
hrz d33cdd978d fix:edge_tts bug 2025-12-13 00:25:54 +08:00
欣南科技andGitHub 0da9b4a69c Merge pull request #2696 from xinnan-tech/test_page_fix_abort
update:发送语音消息时打断机器人说话
2025-12-13 00:20:39 +08:00
hrz 7fc2eeaaa5 update:发送语音消息时打断机器人说话 2025-12-13 00:19:38 +08:00
hrzandGitHub 508529f66e Merge pull request #2694 from qingshuiyuyu/bugfix
fix:初始化ASR时,判断ASR类型取值错误修复
2025-12-13 00:16:37 +08:00
Sakura-RanChen dc170edbc1 update: 未绑定设备策略优化
fix: 音频队列竞态问题
2025-12-12 18:58:24 +08:00
shiyin 85e65cf9e8 fix:初始化ASR时,判断ASR类型取值错误修复 2025-12-12 18:28:46 +08:00
hrzandGitHub 41887ef431 Merge pull request #2686 from xinnan-tech/py_audio_await
Py audio await
2025-12-12 16:35:10 +08:00
hrz 5dbf796fa9 update:更新版本号 2025-12-12 16:33:50 +08:00
Sakura-RanChen 20f601e607 fix: 同步方法使用线程池避免阻塞 2025-12-12 15:39:30 +08:00
Sakura-RanChen c8d2d2255d fix: 音频影响线程问题 2025-12-12 15:08:40 +08:00
Sakura-RanChen f88abd7638 fix: 聆听设备误触发 2025-12-12 09:46:15 +08:00
Sakura-RanChen fbfc408e94 update: 长按设备ASR适配 2025-12-11 17:12:56 +08:00
panjingpeng 5ac1a1d6a5 fix: 为Java项目的OTA接口实现WebSocket认证token生成功能,确保与Python端完全兼容。 2025-12-11 16:08:45 +08:00
FAN-yeB 3a74b30a0e 更新 qwen3_asr_flash.py 2025-12-10 17:57:26 +08:00
hrzandGitHub fe0a6852e9 Merge pull request #2674 from xinnan-tech/web-context-i18n
update:上下文源功能的国际化
2025-12-10 16:20:03 +08:00
3030332422 bfa04743e2 update:上下文源功能的国际化 2025-12-10 10:26:27 +08:00
Sakura-RanChen 48094f7e37 update: 音频后台队列稳定发送 2025-12-09 18:06:56 +08:00
FAN-yeB 60521b0a7e update:长按说话不走VAD直接触发ASR识别 2025-12-09 14:42:50 +08:00
hrzandGitHub 401deff2c3 Merge pull request #2665 from xinnan-tech/update_performance_tester
update:统一流式测速工具统计时间区间,新增百炼平台流式TTS测速
2025-12-08 16:05:22 +08:00
FAN-yeB 6ffa325b73 update:统一流式测速工具统计时间区间,新增百炼平台流式TTS测速 2025-12-08 15:51:04 +08:00
hrzandGitHub eeedfc8ac9 Merge pull request #2660 from xinnan-tech/py_fix_device-id
fix:修改上下文源的device_id
2025-12-08 14:28:10 +08:00
3030332422 aba7172a03 update:修改上下文源的device_id 2025-12-08 14:25:11 +08:00
hrzandGitHub 192f6f198f Merge pull request #2653 from xinnan-tech/WebMenu
fix:样式调整以及翻译补充
2025-12-05 22:02:22 +08:00
rainv123 43c9d85b8f uptate:更新配置文档,增加在页面上开启功能的教程 2025-12-05 18:33:11 +08:00
rainv123 6d5935fb27 uptate:更新配置文档,增加在页面上开启功能的教程 2025-12-05 18:29:20 +08:00
rainv123 efbef4ff5b fix:样式调整以及翻译补充 2025-12-05 18:08:26 +08:00
欣南科技andGitHub e4c99b9351 Merge pull request #2652 from xinnan-tech/update-doc
Update doc
2025-12-05 17:40:12 +08:00
hrz 11e328ea6a update:优化描述 2025-12-05 17:39:34 +08:00
hrz 8879907e38 update:修改描述 2025-12-05 17:31:37 +08:00
hrzandGitHub 885d72c4f7 Merge pull request #2651 from xinnan-tech/WebMenu
fix:修改初始状态
2025-12-05 17:06:00 +08:00
hrzandGitHub 4538542c25 Merge branch 'main' into WebMenu 2025-12-05 17:05:51 +08:00
rainv123 3d509d75dc fix:修改初始状态 2025-12-05 17:01:40 +08:00
hrzandGitHub 0ef73d7c3e Merge pull request #2648 from xinnan-tech/py_add_protocol
添加数据上下文填充功能
2025-12-05 15:47:35 +08:00
hrzandGitHub 6da90c6a97 Merge branch 'main' into py_add_protocol 2025-12-05 15:47:26 +08:00
hrzandGitHub bacdb794da Merge pull request #2646 from xinnan-tech/WebMenu
uptate:增加功能管理菜单
2025-12-05 15:41:25 +08:00
rainv123 2ece3f399b fix:修改初始状态为false 2025-12-05 15:39:05 +08:00
rainv123 68b539db15 fix:修改 2025-12-05 15:29:28 +08:00
3030332422 7a7bfa26f6 update:修改日志 2025-12-05 14:50:26 +08:00
rainv123 5f229351c8 uptate:增加功能管理菜单 2025-12-05 14:35:53 +08:00
3030332422 82125c4933 update:说明文档添加测试用例 2025-12-05 14:22:07 +08:00
3030332422 33f75d26c1 update:优化前端页面样式 2025-12-05 14:02:50 +08:00
3030332422 3c4d702bc1 update:添加数据上下文填充前端页面 2025-12-05 11:24:57 +08:00
3030332422 db8d100edb update:添加数据库表,添加数据上下文填充功能后端部分,全模块实现数据上下文填充功能 2025-12-05 10:50:44 +08:00
3030332422 6f7e8978ca update:添加数据上下文填充功能的说明文档 2025-12-05 10:40:47 +08:00
hrzandGitHub 856bf2c574 Merge pull request #2641 from qingshuiyuyu/bugfix
修复注册设备接口,验证码死循环BUG
2025-12-04 18:54:33 +08:00
hrzandGitHub b10cfa6b24 Merge pull request #2642 from xinnan-tech/py_audio_fix
fix: 状态修正
2025-12-04 18:53:00 +08:00
Sakura-RanChen d3d329bd43 fix: 状态修正 2025-12-04 18:45:49 +08:00
shiyin f358011900 修复注册设备接口,验证码死循环BUG 2025-12-04 18:32:57 +08:00
3030332422 cd6c3e4f79 update:添加数据上下文填充功能,单模块实现数据上下文填充功能 2025-12-04 11:04:16 +08:00
欣南科技andGitHub ee7342de9d Merge pull request #2639 from xinnan-tech/fix-handshake
fix:过滤8000端口使用https访问时报错日志
2025-12-03 22:50:48 +08:00
hrz 6120d49a35 fix:过滤8000端口使用https访问时报错日志 2025-12-03 22:49:32 +08:00
68 changed files with 3308 additions and 824 deletions
+1
View File
@@ -80,6 +80,7 @@ VAD:
7、[如何开启声纹识别](./voiceprint-integration.md)<br/> 7、[如何开启声纹识别](./voiceprint-integration.md)<br/>
8、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/> 8、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
9、[知识库ragflow集成指南](./ragflow-integration.md)<br/> 9、[知识库ragflow集成指南](./ragflow-integration.md)<br/>
10、[如何部署上下文源](./context-provider-integration.md)<br/>
### 11、语音克隆、本地语音部署相关教程 ### 11、语音克隆、本地语音部署相关教程
1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/> 1、[如何在智控台克隆音色](./huoshan-streamTTS-voice-cloning.md)<br/>
+224
View File
@@ -0,0 +1,224 @@
# 上下文源使用教程
## 概述
`上下文源`,就是为小智系统提示词的上下文添加【数据源】。
`上下文源` 在小智在唤醒那一刻,获取外部系统的数据,并将其动态注入到大模型的系统提示词(System Prompt)中。
让其做到唤醒时感知世界某个事物的状态。
它和MCP、记忆有本质的区别:`上下文源`是强制让小智感知世界的数据;`记忆(Mem)`是让他知道之前聊了什么内容;`MCP(functionc all)`是当需要调用某项能力/知识的时候使用调用。
通过这个功能,在小智唤醒的一刹那,“感知”到:
- 人体健康传感器状态(体温、血压、血氧状态等)
- 业务系统的实时数据(服务器负载、待办数据、股票信息等)
- 任何可以通过 HTTP API 获取的文本信息
**注意**:该功能只是方便小智在唤醒的时候感知事物的状态,而如果想要小智唤醒后实时获取事物的状态,建议在此功能上再结合MCP工具的调用。
## 工作原理
1. **配置源**:用户配置一个或多个 HTTP API 地址。
2. **触发请求**:当系统构建 Prompt 时,如果发现模板中包含 `{{ dynamic_context }}` 占位符,会请求所有配置的 API。
3. **自动注入**:系统会自动将 API 返回的数据格式化为 Markdown 列表,替换 `{{ dynamic_context }}` 占位符。
## 接口规范
为了让小智正确解析数据,您的 API 需要满足以下规范:
- **请求方式**`GET`
- **请求头**:系统会自动添加 `device-id` 字段到 Request Header。
- **响应格式**:必须返回 JSON 格式,且包含 `code``data` 字段。
### 响应示例
**情况 1:返回键值对**
```json
{
"code": 0,
"msg": "success",
"data": {
"客厅温度": "26℃",
"客厅湿度": "45%",
"大门状态": "已关闭"
}
}
```
*注入效果:*
```markdown
<context>
- **客厅温度:** 26℃
- **客厅湿度:** 45%
- **大门状态:** 已关闭
</context>
```
**情况 2:返回列表**
```json
{
"code": 0,
"data": [
"您有10个待办事项",
"当前汽车的行驶速度是100km每小时"
]
}
```
*注入效果:*
```markdown
<context>
- 您有10个待办事项
- 当前汽车的行驶速度是100km每小时
</context>
```
## 配置指南
### 方式 1:智控台配置(全模块部署)
1. 登录智控台,进入**角色配置**页面。
2. 找到**上下文源**配置项(点击“编辑源”按钮)。
3. 点击**添加**,输入您的 API 地址。
4. 如果 API 需要鉴权,可以在**请求头**部分添加 `Authorization` 或其他 Header。
5. 保存配置。
### 方式 2:配置文件配置(单模块部署)
编辑 `xiaozhi-server/data/.config.yaml` 文件,添加 `context_providers` 配置段:
```yaml
# 上下文源配置
context_providers:
- url: "http://api.example.com/data"
headers:
Authorization: "Bearer your-token"
- url: "http://another-api.com/data"
```
## 启用功能
默认情况下,系统的提示词模板文件(`data/.agent-base-prompt.txt`)中已经预置了 `{{ dynamic_context }}` 占位符,您无需手动添加。
**示例:**
```markdown
<context>
【重要!以下信息已实时提供,无需调用工具查询,请直接使用:】
- **设备ID** {{device_id}}
- **当前时间:** {{current_time}}
...
{{ dynamic_context }}
</context>
```
**注意**:如果您不需要使用此功能,可以选择**不配置任何上下文源**,也可以从提示词模板文件中**删除** `{{ dynamic_context }}` 占位符。
## 附录:Mock 测试服务示例
为了方便您测试和开发,我们提供了一个简单的 Python Mock Server 脚本。您可以运行此脚本在本地模拟 API 接口。
**mock_api_server.py**
```python
import http.server
import socketserver
import json
from urllib.parse import urlparse, parse_qs
# 设置端口号
PORT = 8081
class MockRequestHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
# 解析路径和参数
parsed_path = urlparse(self.path)
path = parsed_path.path
query = parse_qs(parsed_path.query)
response_data = {}
status_code = 200
print(f"收到请求: {path}, 参数: {query}")
# Case 1: 模拟健康数据 (返回字典 Dict)
# 路径参数风格: /health
# device_id 从 Header 获取
if path == "/health":
device_id = self.headers.get("device-id", "unknown_device")
print(f"device_id: {device_id}")
response_data = {
"code": 0,
"msg": "success",
"data": {
"测试设备ID": device_id,
"心率": "80 bpm",
"血压": "120/80 mmHg",
"状态": "良好"
}
}
# Case 2: 模拟新闻列表 (返回列表 List)
# 无参数: /news/list
elif path == "/news/list":
response_data = {
"code": 0,
"msg": "success",
"data": [
"今日头条:Python 3.14 发布",
"科技新闻:AI 助手改变生活",
"本地新闻:明日有大雨,记得带伞"
]
}
# Case 3: 模拟天气简报 (返回字符串 String)
# 无参数: /weather/simple
elif path == "/weather/simple":
response_data = {
"code": 0,
"msg": "success",
"data": "今日晴转多云,气温 20-25 度,空气质量优,适合出行。"
}
# Case 4: 模拟设备详情 (Query参数风格)
# 参数风格: /device/info
# device_id 从 Header 获取
elif path == "/device/info":
device_id = self.headers.get("device-id", "unknown_device")
response_data = {
"code": 0,
"msg": "success",
"data": {
"查询方式": "Header参数",
"设备ID": device_id,
"电量": "85%",
"固件": "v2.0.1"
}
}
# Case 5: 404 Not Found
else:
status_code = 404
response_data = {"error": "接口不存在"}
# 发送响应
self.send_response(status_code)
self.send_header('Content-type', 'application/json; charset=utf-8')
self.end_headers()
self.wfile.write(json.dumps(response_data, ensure_ascii=False).encode('utf-8'))
# 启动服务
# 允许地址重用,防止快速重启报错
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("", PORT), MockRequestHandler) as httpd:
print(f"==================================================")
print(f"Mock API Server 已启动: http://localhost:{PORT}")
print(f"可用接口列表:")
print(f"1. [字典] http://localhost:{PORT}/health")
print(f"2. [列表] http://localhost:{PORT}/news/list")
print(f"3. [文本] http://localhost:{PORT}/weather/simple")
print(f"4. [参数] http://localhost:{PORT}/device/info")
print(f"==================================================")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n服务已停止")
```
+1
View File
@@ -71,6 +71,7 @@ docker logs -f mcp-endpoint-server
请你保留好上面两个`接口地址`,下一步要用到。 请你保留好上面两个`接口地址`,下一步要用到。
# 2、全模块部署时,怎么配置MCP接入点 # 2、全模块部署时,怎么配置MCP接入点
首先,你要开启MCP接入点功能。在智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`MCP接入点`,点击`保存配置`。在`角色配置`页面,点击`编辑功能`按钮,即可看到`mcp接入点`功能。
如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。
+4 -2
View File
@@ -238,9 +238,11 @@ docker-compose -f docker-compose.yml up -d
在弹框中,点击"Create new Key"按钮,生成一个API Key。复制这个`API Key`,你稍后会用到。 在弹框中,点击"Create new Key"按钮,生成一个API Key。复制这个`API Key`,你稍后会用到。
# 第二步 配置到智控台 # 第二步 配置到智控台
确保你的智控台版本是`0.8.7`或以上。使用超级管理员账号登录到智控台。在顶部导航栏中,点击`模型配置`,在左侧导航栏中,点击`知识库`。 确保你的智控台版本是`0.8.7`或以上。使用超级管理员账号登录到智控台。
在列表中找到`RAG_RAGFlow`,点击`编辑`按钮 首先,你要先开启知识库功能。在顶部导航栏中,点击`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`知识库`,点击`保存配置`。即可在导航栏看到`知识库`功能
在顶部导航栏中,点击`模型配置`,在左侧导航栏中,点击`知识库`。在列表中找到`RAG_RAGFlow`,点击`编辑`按钮。
在`服务地址`中,填写`http://你的ragflow服务的局域网IP:8008`,例如我的ragflow服务的局域网IP是`192.168.1.100`,那么我就填写`http://192.168.1.100:8008`。 在`服务地址`中,填写`http://你的ragflow服务的局域网IP:8008`,例如我的ragflow服务的局域网IP是`192.168.1.100`,那么我就填写`http://192.168.1.100:8008`。
+2
View File
@@ -164,6 +164,8 @@ http://192.168.1.25:8005/voiceprint/health?key=abcd
# 2、全模块部署时,怎么配置声纹识别 # 2、全模块部署时,怎么配置声纹识别
## 第一步 配置接口 ## 第一步 配置接口
首先,你要开启声纹识别功能。在智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`声纹识别`,点击`保存配置`。即可在新建智能体的卡片上看到`声纹识别`按钮。
如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。 如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。
然后搜索参数`server.voice_print`,此时,它的值应该是`null`值。 然后搜索参数`server.voice_print`,此时,它的值应该是`null`值。
@@ -141,6 +141,11 @@ public interface Constant {
*/ */
String SERVER_MQTT_SECRET = "server.mqtt_signature_key"; String SERVER_MQTT_SECRET = "server.mqtt_signature_key";
/**
* WebSocket认证开关
*/
String SERVER_AUTH_ENABLED = "server.auth.enabled";
/** /**
* 无记忆 * 无记忆
*/ */
@@ -299,7 +304,7 @@ public interface Constant {
/** /**
* 版本号 * 版本号
*/ */
public static final String VERSION = "0.8.9"; public static final String VERSION = "0.8.10";
/** /**
* 无效固件URL * 无效固件URL
@@ -44,6 +44,7 @@ import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentChatAudioService; import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService; import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.agent.service.AgentTemplateService;
@@ -64,6 +65,7 @@ public class AgentController {
private final AgentChatHistoryService agentChatHistoryService; private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService; private final AgentChatAudioService agentChatAudioService;
private final AgentPluginMappingService agentPluginMappingService; private final AgentPluginMappingService agentPluginMappingService;
private final AgentContextProviderService agentContextProviderService;
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
@GetMapping("/list") @GetMapping("/list")
@@ -135,6 +137,8 @@ public class AgentController {
agentChatHistoryService.deleteByAgentId(id, true, true); agentChatHistoryService.deleteByAgentId(id, true, true);
// 删除关联的插件 // 删除关联的插件
agentPluginMappingService.deleteByAgentId(id); agentPluginMappingService.deleteByAgentId(id);
// 删除关联的上下文源配置
agentContextProviderService.deleteByAgentId(id);
// 再删除智能体 // 再删除智能体
agentService.deleteById(id); agentService.deleteById(id);
return new Result<>(); return new Result<>();
@@ -0,0 +1,9 @@
package xiaozhi.modules.agent.dao;
import org.apache.ibatis.annotations.Mapper;
import xiaozhi.common.dao.BaseDao;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
@Mapper
public interface AgentContextProviderDao extends BaseDao<AgentContextProviderEntity> {
}
@@ -69,6 +69,9 @@ public class AgentUpdateDTO implements Serializable {
@Schema(description = "排序", example = "1", nullable = true) @Schema(description = "排序", example = "1", nullable = true)
private Integer sort; private Integer sort;
@Schema(description = "上下文源配置", nullable = true)
private List<ContextProviderDTO> contextProviders;
@Data @Data
@Schema(description = "插件函数信息") @Schema(description = "插件函数信息")
public static class FunctionInfo implements Serializable { public static class FunctionInfo implements Serializable {
@@ -0,0 +1,19 @@
package xiaozhi.modules.agent.dto;
import java.io.Serializable;
import java.util.Map;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "上下文源配置DTO")
public class ContextProviderDTO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "URL地址")
private String url;
@Schema(description = "请求头")
private Map<String, Object> headers;
}
@@ -0,0 +1,43 @@
package xiaozhi.modules.agent.entity;
import java.util.Date;
import java.util.List;
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 com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import xiaozhi.modules.agent.dto.ContextProviderDTO;
@Data
@TableName(value = "ai_agent_context_provider", autoResultMap = true)
@Schema(description = "智能体上下文源配置")
public class AgentContextProviderEntity {
@TableId(type = IdType.ASSIGN_UUID)
@Schema(description = "主键")
private String id;
@Schema(description = "智能体ID")
private String agentId;
@Schema(description = "上下文源配置")
@TableField(typeHandler = JacksonTypeHandler.class)
private List<ContextProviderDTO> contextProviders;
@Schema(description = "创建者")
private Long creator;
@Schema(description = "创建时间")
private Date createdAt;
@Schema(description = "更新者")
private Long updater;
@Schema(description = "更新时间")
private Date updatedAt;
}
@@ -0,0 +1,25 @@
package xiaozhi.modules.agent.service;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
public interface AgentContextProviderService extends BaseService<AgentContextProviderEntity> {
/**
* 根据智能体ID获取上下文源配置
* @param agentId 智能体ID
* @return 上下文源配置实体
*/
AgentContextProviderEntity getByAgentId(String agentId);
/**
* 保存或更新上下文源配置
* @param entity 实体
*/
void saveOrUpdateByAgentId(AgentContextProviderEntity entity);
/**
* 根据智能体ID删除上下文源配置
* @param agentId 智能体ID
*/
void deleteByAgentId(String agentId);
}
@@ -0,0 +1,35 @@
package xiaozhi.modules.agent.service.impl;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.modules.agent.dao.AgentContextProviderDao;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
import xiaozhi.modules.agent.service.AgentContextProviderService;
@Service
public class AgentContextProviderServiceImpl extends BaseServiceImpl<AgentContextProviderDao, AgentContextProviderEntity> implements AgentContextProviderService {
@Override
public AgentContextProviderEntity getByAgentId(String agentId) {
return baseDao.selectOne(new QueryWrapper<AgentContextProviderEntity>().eq("agent_id", agentId));
}
@Override
public void saveOrUpdateByAgentId(AgentContextProviderEntity entity) {
AgentContextProviderEntity exist = getByAgentId(entity.getAgentId());
if (exist != null) {
entity.setId(exist.getId());
updateById(entity);
} else {
insert(entity);
}
}
@Override
public void deleteByAgentId(String agentId) {
baseDao.delete(new QueryWrapper<AgentContextProviderEntity>().eq("agent_id", agentId));
}
}
@@ -32,10 +32,12 @@ import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.dto.AgentCreateDTO; import xiaozhi.modules.agent.dto.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO; import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentPluginMapping;
import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentChatHistoryService; import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.agent.service.AgentTemplateService;
@@ -62,6 +64,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
private final AgentChatHistoryService agentChatHistoryService; private final AgentChatHistoryService agentChatHistoryService;
private final AgentTemplateService agentTemplateService; private final AgentTemplateService agentTemplateService;
private final ModelProviderService modelProviderService; private final ModelProviderService modelProviderService;
private final AgentContextProviderService agentContextProviderService;
@Override @Override
public PageData<AgentEntity> adminAgentList(Map<String, Object> params) { public PageData<AgentEntity> adminAgentList(Map<String, Object> params) {
@@ -85,6 +88,13 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode()); agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.RECORD_TEXT_AUDIO.getCode());
} }
} }
// 查询上下文源配置
AgentContextProviderEntity contextProviderEntity = agentContextProviderService.getByAgentId(id);
if (contextProviderEntity != null) {
agent.setContextProviders(contextProviderEntity.getContextProviders());
}
// 无需额外查询插件列表,已通过SQL查询出来 // 无需额外查询插件列表,已通过SQL查询出来
return agent; return agent;
} }
@@ -331,6 +341,14 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, false); agentChatHistoryService.deleteByAgentId(existingEntity.getId(), true, false);
} }
// 更新上下文源配置
if (dto.getContextProviders() != null) {
AgentContextProviderEntity contextEntity = new AgentContextProviderEntity();
contextEntity.setAgentId(agentId);
contextEntity.setContextProviders(dto.getContextProviders());
agentContextProviderService.saveOrUpdateByAgentId(contextEntity);
}
boolean b = validateLLMIntentParams(dto.getLlmModelId(), dto.getIntentModelId()); boolean b = validateLLMIntentParams(dto.getLlmModelId(), dto.getIntentModelId());
if (!b) { if (!b) {
throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH); throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH);
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import xiaozhi.modules.agent.dto.ContextProviderDTO;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentPluginMapping;
@@ -21,4 +22,7 @@ public class AgentInfoVO extends AgentEntity
@Schema(description = "插件列表Id") @Schema(description = "插件列表Id")
@TableField(typeHandler = JacksonTypeHandler.class) @TableField(typeHandler = JacksonTypeHandler.class)
private List<AgentPluginMapping> functions; private List<AgentPluginMapping> functions;
@Schema(description = "上下文源配置")
private List<ContextProviderDTO> contextProviders;
} }
@@ -20,10 +20,12 @@ import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils; import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.dao.AgentVoicePrintDao; import xiaozhi.modules.agent.dao.AgentVoicePrintDao;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentPluginMapping;
import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.entity.AgentVoicePrintEntity; import xiaozhi.modules.agent.entity.AgentVoicePrintEntity;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentMcpAccessPointService; import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
@@ -53,6 +55,7 @@ public class ConfigServiceImpl implements ConfigService {
private final TimbreService timbreService; private final TimbreService timbreService;
private final AgentPluginMappingService agentPluginMappingService; private final AgentPluginMappingService agentPluginMappingService;
private final AgentMcpAccessPointService agentMcpAccessPointService; private final AgentMcpAccessPointService agentMcpAccessPointService;
private final AgentContextProviderService agentContextProviderService;
private final VoiceCloneService cloneVoiceService; private final VoiceCloneService cloneVoiceService;
private final AgentVoicePrintDao agentVoicePrintDao; private final AgentVoicePrintDao agentVoicePrintDao;
@@ -178,6 +181,13 @@ public class ConfigServiceImpl implements ConfigService {
mcpEndpoint = mcpEndpoint.replace("/mcp/", "/call/"); mcpEndpoint = mcpEndpoint.replace("/mcp/", "/call/");
result.put("mcp_endpoint", mcpEndpoint); result.put("mcp_endpoint", mcpEndpoint);
} }
// 获取上下文源配置
AgentContextProviderEntity contextProviderEntity = agentContextProviderService.getByAgentId(agent.getId());
if (contextProviderEntity != null && contextProviderEntity.getContextProviders() != null && !contextProviderEntity.getContextProviders().isEmpty()) {
result.put("context_providers", contextProviderEntity.getContextProviders());
}
// 获取声纹信息 // 获取声纹信息
buildVoiceprintConfig(agent.getId(), result); buildVoiceprintConfig(agent.getId(), result);
@@ -72,10 +72,12 @@ public class DeviceController {
return new Result<String>().error(ErrorCode.MCA_NOT_NULL); return new Result<String>().error(ErrorCode.MCA_NOT_NULL);
} }
// 生成六位验证码 // 生成六位验证码
String code = String.valueOf(Math.random()).substring(2, 8); String code;
String key = RedisKeys.getDeviceCaptchaKey(code); String key;
String existsMac = null; String existsMac = null;
do { do {
code = String.valueOf(Math.random()).substring(2, 8);
key = RedisKeys.getDeviceCaptchaKey(code);
existsMac = (String) redisUtils.get(key); existsMac = (String) redisUtils.get(key);
} while (StringUtils.isNotBlank(existsMac)); } while (StringUtils.isNotBlank(existsMac));
@@ -1,6 +1,8 @@
package xiaozhi.modules.device.service.impl; package xiaozhi.modules.device.service.impl;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant; import java.time.Instant;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
@@ -169,7 +171,22 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket(); DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
// 从系统参数获取WebSocket URL,如果未配置则使用默认值 // 从系统参数获取WebSocket URL,如果未配置则使用默认值
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true); String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
websocket.setToken("");
// 检查是否启用认证并生成token
String authEnabled = sysParamsService.getValue(Constant.SERVER_AUTH_ENABLED, true);
if ("true".equalsIgnoreCase(authEnabled)) {
try {
// 生成token
String token = generateWebSocketToken(clientId, macAddress);
websocket.setToken(token);
} catch (Exception e) {
log.error("生成WebSocket token失败: {}", e.getMessage());
websocket.setToken("");
}
} else {
websocket.setToken("");
}
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) { if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置"); log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/"; wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
@@ -189,7 +206,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
// 添加MQTT UDP配置 // 添加MQTT UDP配置
// 从系统参数获取MQTT Gateway地址,仅在配置有效时使用 // 从系统参数获取MQTT Gateway地址,仅在配置有效时使用
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, false); String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true);
if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) { if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
try { try {
String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard() String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard()
@@ -494,6 +511,40 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
return Base64.getEncoder().encodeToString(signature); return Base64.getEncoder().encodeToString(signature);
} }
/**
* 生成WebSocket认证token 遵循Python端AuthManager的实现逻辑:token = signature.timestamp
*
* @param clientId 客户端ID
* @param username 用户名 (通常为deviceId/macAddress)
* @return 认证token字符串
*/
private String generateWebSocketToken(String clientId, String username)
throws NoSuchAlgorithmException, InvalidKeyException {
// 从系统参数获取密钥
String secretKey = sysParamsService.getValue(Constant.SERVER_SECRET, false);
if (StringUtils.isBlank(secretKey)) {
throw new IllegalStateException("WebSocket认证密钥未配置(server.secret)");
}
// 获取当前时间戳(秒)
long timestamp = System.currentTimeMillis() / 1000;
// 构建签名内容: clientId|username|timestamp
String content = String.format("%s|%s|%d", clientId, username, timestamp);
// 生成HMAC-SHA256签名
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
hmac.init(keySpec);
byte[] signature = hmac.doFinal(content.getBytes(StandardCharsets.UTF_8));
// Base64 URL-safe编码签名(去除填充符=)
String signatureBase64 = Base64.getUrlEncoder().withoutPadding().encodeToString(signature);
// 返回格式: signature.timestamp
return String.format("%s.%d", signatureBase64, timestamp);
}
/** /**
* 构建MQTT配置信息 * 构建MQTT配置信息
* *
@@ -504,7 +555,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
private DeviceReportRespDTO.MQTT buildMqttConfig(String macAddress, String groupId) private DeviceReportRespDTO.MQTT buildMqttConfig(String macAddress, String groupId)
throws Exception { throws Exception {
// 从环境变量或系统参数获取签名密钥 // 从环境变量或系统参数获取签名密钥
String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", false); String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", true);
if (StringUtils.isBlank(signatureKey)) { if (StringUtils.isBlank(signatureKey)) {
log.warn("缺少MQTT_SIGNATURE_KEY,跳过MQTT配置生成"); log.warn("缺少MQTT_SIGNATURE_KEY,跳过MQTT配置生成");
return null; return null;
@@ -6,6 +6,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
@@ -23,7 +24,9 @@ import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.TokenDTO; import xiaozhi.common.page.TokenDTO;
import xiaozhi.common.user.UserDetail; import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
import xiaozhi.common.utils.Sm2DecryptUtil;
import xiaozhi.common.validator.AssertUtils; import xiaozhi.common.validator.AssertUtils;
import xiaozhi.common.validator.ValidatorUtils; import xiaozhi.common.validator.ValidatorUtils;
import xiaozhi.modules.security.dto.LoginDTO; import xiaozhi.modules.security.dto.LoginDTO;
@@ -32,8 +35,6 @@ import xiaozhi.modules.security.password.PasswordUtils;
import xiaozhi.modules.security.service.CaptchaService; import xiaozhi.modules.security.service.CaptchaService;
import xiaozhi.modules.security.service.SysUserTokenService; import xiaozhi.modules.security.service.SysUserTokenService;
import xiaozhi.modules.security.user.SecurityUser; import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.common.utils.Sm2DecryptUtil;
import org.apache.commons.lang3.StringUtils;
import xiaozhi.modules.sys.dto.PasswordDTO; import xiaozhi.modules.sys.dto.PasswordDTO;
import xiaozhi.modules.sys.dto.RetrievePasswordDTO; import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO; import xiaozhi.modules.sys.dto.SysUserDTO;
@@ -89,13 +90,13 @@ public class LoginController {
@Operation(summary = "登录") @Operation(summary = "登录")
public Result<TokenDTO> login(@RequestBody LoginDTO login) { public Result<TokenDTO> login(@RequestBody LoginDTO login) {
String password = login.getPassword(); String password = login.getPassword();
// 使用工具类解密并验证验证码 // 使用工具类解密并验证验证码
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
password, login.getCaptchaId(), captchaService, sysParamsService); password, login.getCaptchaId(), captchaService, sysParamsService);
login.setPassword(actualPassword); login.setPassword(actualPassword);
// 按照用户名获取用户 // 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername()); SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
// 判断用户是否存在 // 判断用户是否存在
@@ -108,8 +109,6 @@ public class LoginController {
} }
return sysUserTokenService.createToken(userDTO.getId()); return sysUserTokenService.createToken(userDTO.getId());
} }
@PostMapping("/register") @PostMapping("/register")
@Operation(summary = "注册") @Operation(summary = "注册")
@@ -117,15 +116,15 @@ public class LoginController {
if (!sysUserService.getAllowUserRegister()) { if (!sysUserService.getAllowUserRegister()) {
throw new RenException(ErrorCode.USER_REGISTER_DISABLED); throw new RenException(ErrorCode.USER_REGISTER_DISABLED);
} }
String password = login.getPassword(); String password = login.getPassword();
// 使用工具类解密并验证验证码 // 使用工具类解密并验证验证码
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
password, login.getCaptchaId(), captchaService, sysParamsService); password, login.getCaptchaId(), captchaService, sysParamsService);
login.setPassword(actualPassword); login.setPassword(actualPassword);
// 是否开启手机注册 // 是否开启手机注册
Boolean isMobileRegister = sysParamsService Boolean isMobileRegister = sysParamsService
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class); .getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
@@ -204,11 +203,11 @@ public class LoginController {
} }
String password = dto.getPassword(); String password = dto.getPassword();
// 使用工具类解密并验证验证码 // 使用工具类解密并验证验证码
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha( String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
password, dto.getCaptchaId(), captchaService, sysParamsService); password, dto.getCaptchaId(), captchaService, sysParamsService);
dto.setPassword(actualPassword); dto.setPassword(actualPassword);
sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword()); sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword());
@@ -229,7 +228,7 @@ public class LoginController {
config.put("beianIcpNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_ICP_NUM.getValue(), true)); config.put("beianIcpNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_ICP_NUM.getValue(), true));
config.put("beianGaNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_GA_NUM.getValue(), true)); config.put("beianGaNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_GA_NUM.getValue(), true));
config.put("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true)); config.put("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true));
// SM2公钥 // SM2公钥
String publicKey = sysParamsService.getValue(Constant.SM2_PUBLIC_KEY, true); String publicKey = sysParamsService.getValue(Constant.SM2_PUBLIC_KEY, true);
if (StringUtils.isBlank(publicKey)) { if (StringUtils.isBlank(publicKey)) {
@@ -237,6 +236,12 @@ public class LoginController {
} }
config.put("sm2PublicKey", publicKey); config.put("sm2PublicKey", publicKey);
// 获取system-web.menu参数配置
String menuConfig = sysParamsService.getValue("system-web.menu", true);
if (StringUtils.isNotBlank(menuConfig)) {
config.put("systemWebMenu", JsonUtils.parseObject(menuConfig, Object.class));
}
return new Result<Map<String, Object>>().ok(config); return new Result<Map<String, Object>>().ok(config);
} }
} }
@@ -0,0 +1,6 @@
-- 添加系统功能菜单配置参数
delete from `sys_params` where param_code = 'system-web.menu';
-- 添加系统功能菜单配置参数
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES
(600, 'system-web.menu', '{"features":{"voiceprintRecognition":{"name":"feature.voiceprintRecognition.name","enabled":false,"description":"feature.voiceprintRecognition.description"},"voiceClone":{"name":"feature.voiceClone.name","enabled":false,"description":"feature.voiceClone.description"},"knowledgeBase":{"name":"feature.knowledgeBase.name","enabled":false,"description":"feature.knowledgeBase.description"},"mcpAccessPoint":{"name":"feature.mcpAccessPoint.name","enabled":false,"description":"feature.mcpAccessPoint.description"},"vad":{"name":"feature.vad.name","enabled":true,"description":"feature.vad.description"},"asr":{"name":"feature.asr.name","enabled":true,"description":"feature.asr.description"}},"groups":{"featureManagement":["voiceprintRecognition","voiceClone","knowledgeBase","mcpAccessPoint"],"voiceManagement":["vad","asr"]}}', 'json', 1, '系统功能菜单配置');
@@ -0,0 +1,14 @@
-- liquibase formatted sql
-- changeset xiaozhi:202512041515
CREATE TABLE ai_agent_context_provider (
id VARCHAR(32) NOT NULL COMMENT '主键',
agent_id VARCHAR(32) NOT NULL COMMENT '智能体ID',
context_providers JSON COMMENT '上下文源配置',
creator BIGINT COMMENT '创建者',
created_at DATETIME COMMENT '创建时间',
updater BIGINT COMMENT '更新者',
updated_at DATETIME COMMENT '更新时间',
PRIMARY KEY (id),
INDEX idx_agent_id (agent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体上下文源配置表';
@@ -0,0 +1,6 @@
-- 删除server模块是否开启token认证参数
delete from `sys_params` where param_code = 'server.auth.enabled';
-- 添加server模块是否开启token认证参数
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES
(122, 'server.auth.enabled', 'true', 'boolean', 1, 'server模块是否开启token认证');
@@ -423,3 +423,25 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202511131023.sql path: classpath:db/changelog/202511131023.sql
- changeSet:
id: 202512031517
author: rainv123
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202512031517.sql
- changeSet:
id: 202512041515
author: cgd
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202512041515.sql
- changeSet:
id: 202512131453
author: hrz
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202512131453.sql
@@ -235,7 +235,7 @@ function showAbout() {
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }), title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
content: t('settings.aboutContent', { content: t('settings.aboutContent', {
appName: import.meta.env.VITE_APP_TITLE, appName: import.meta.env.VITE_APP_TITLE,
version: '0.8.9' version: '0.8.10'
}), }),
showCancel: false, showCancel: false,
confirmText: t('common.confirm'), confirmText: t('common.confirm'),
@@ -0,0 +1,329 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
width="900px"
:title="$t('contextProviderDialog.title')"
:close-on-click-modal="false"
custom-class="context-provider-dialog"
append-to-body
>
<div class="dialog-content">
<el-empty v-if="localProviders.length === 0" :description="$t('contextProviderDialog.noContextApi')">
<el-button type="primary" icon="el-icon-plus" @click="addProvider(0)">{{ $t('contextProviderDialog.add') }}</el-button>
</el-empty>
<div
v-for="(provider, pIndex) in localProviders"
:key="pIndex"
class="provider-item"
>
<el-card class="provider-card" shadow="hover" :body-style="{ padding: '15px 20px' }">
<!-- URL Row -->
<div class="input-row">
<span class="label-text">{{ $t('contextProviderDialog.apiUrl') }}</span>
<el-input
v-model="provider.url"
:placeholder="$t('contextProviderDialog.apiUrlPlaceholder')"
size="small"
class="flex-1"
></el-input>
</div>
<!-- Headers Section -->
<div class="headers-section">
<div class="label-text" style="margin-top: 6px;">{{ $t('contextProviderDialog.requestHeaders') }}</div>
<div class="headers-list">
<div
v-for="(header, hIndex) in provider.headers"
:key="hIndex"
class="header-row"
>
<el-input
v-model="header.key"
:placeholder="$t('contextProviderDialog.headerKeyPlaceholder')"
size="small"
style="width: 180px;"
></el-input>
<span class="separator">:</span>
<el-input
v-model="header.value"
:placeholder="$t('contextProviderDialog.headerValuePlaceholder')"
size="small"
class="flex-1"
></el-input>
<div class="row-controls">
<el-button
type="primary"
icon="el-icon-plus"
circle
size="mini"
plain
@click="addHeader(pIndex, hIndex + 1)"
></el-button>
<el-button
type="danger"
icon="el-icon-minus"
circle
size="mini"
plain
@click="removeHeader(pIndex, hIndex)"
></el-button>
</div>
</div>
<!-- Empty Headers State -->
<div v-if="provider.headers.length === 0" class="header-row empty-header">
<span class="no-header-text">{{ $t('contextProviderDialog.noHeaders') }}</span>
<el-button
type="text"
icon="el-icon-plus"
size="mini"
@click="addHeader(pIndex, 0)"
>{{ $t('contextProviderDialog.addHeader') }}</el-button>
</div>
</div>
</div>
</el-card>
<!-- Provider Block Controls (Right Side) -->
<div class="block-controls">
<el-button
type="primary"
icon="el-icon-plus"
circle
size="medium"
@click="addProvider(pIndex + 1)"
></el-button>
<el-button
type="danger"
icon="el-icon-minus"
circle
size="medium"
@click="removeProvider(pIndex)"
></el-button>
</div>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">{{ $t('contextProviderDialog.cancel') }}</el-button>
<el-button type="primary" @click="handleConfirm">{{ $t('contextProviderDialog.confirm') }}</el-button>
</span>
</el-dialog>
</template>
<script>
export default {
name: 'ContextProviderDialog',
props: {
visible: {
type: Boolean,
default: false
},
providers: {
type: Array,
default: () => []
}
},
data() {
return {
localProviders: []
};
},
computed: {
dialogVisible: {
get() {
return this.visible;
},
set(val) {
this.$emit('update:visible', val);
}
}
},
watch: {
visible(val) {
if (val) {
this.initLocalData();
}
}
},
methods: {
initLocalData() {
// 深拷贝并将 headers 对象转换为数组
this.localProviders = this.providers.map(p => {
const headers = p.headers || {};
return {
url: p.url || '',
headers: Object.entries(headers).map(([key, value]) => ({ key, value }))
};
});
// 如果为空,添加一个默认块
if (this.localProviders.length === 0) {
this.localProviders.push({ url: '', headers: [{ key: '', value: '' }] });
}
},
addProvider(index) {
this.localProviders.splice(index, 0, {
url: '',
headers: [{ key: '', value: '' }]
});
},
removeProvider(index) {
this.localProviders.splice(index, 1);
},
addHeader(pIndex, hIndex) {
this.localProviders[pIndex].headers.splice(hIndex, 0, { key: '', value: '' });
},
removeHeader(pIndex, hIndex) {
this.localProviders[pIndex].headers.splice(hIndex, 1);
},
handleConfirm() {
const result = this.localProviders
.filter(p => p.url.trim() !== '')
.map(p => {
const headersObj = {};
p.headers.forEach(h => {
if (h.key.trim()) {
headersObj[h.key.trim()] = h.value;
}
});
return {
url: p.url.trim(),
headers: headersObj
};
});
this.$emit('confirm', result);
this.dialogVisible = false;
}
}
};
</script>
<style scoped>
.dialog-content {
max-height: 60vh;
overflow-y: auto;
padding: 20px 25px;
}
.dialog-content::-webkit-scrollbar {
width: 6px;
}
.dialog-content::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 3px;
}
.dialog-content::-webkit-scrollbar-track {
background: #f5f7fa;
}
.provider-item {
display: flex;
gap: 15px;
margin-bottom: 20px;
align-items: center;
}
.provider-card {
flex: 1;
border-radius: 12px;
border: 1px solid #e4e7ed;
border-left: 4px solid #409EFF; /* 左侧强调色 */
background-color: #fff;
transition: all 0.3s ease;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.provider-card:hover {
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.block-controls {
display: flex;
flex-direction: row;
gap: 8px;
}
.input-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.label-text {
width: 60px;
font-weight: 600;
color: #606266;
text-align: right;
font-size: 13px;
white-space: nowrap;
line-height: 32px; /* 垂直居中对齐 */
}
.flex-1 {
flex: 1;
}
.headers-section {
display: flex;
gap: 12px;
align-items: flex-start;
}
.headers-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
background: #fcfcfc;
padding: 15px;
border-radius: 8px;
border: 1px dashed #dcdfe6;
transition: all 0.3s;
}
.headers-list:hover {
border-color: #c0c4cc;
background: #fff;
}
.header-row {
display: flex;
align-items: center;
gap: 10px;
}
.separator {
color: #909399;
font-weight: bold;
margin: 0 2px;
}
.row-controls {
display: flex;
gap: 6px;
margin-left: 8px;
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.2s;
}
.header-row:hover .row-controls {
opacity: 1;
}
.empty-header {
justify-content: center;
padding: 10px;
color: #909399;
font-size: 13px;
}
.no-header-text {
margin-right: 8px;
}
</style>
+10 -2
View File
@@ -23,7 +23,7 @@
<div class="settings-btn" @click="handleConfigure"> <div class="settings-btn" @click="handleConfigure">
{{ $t('home.configureRole') }} {{ $t('home.configureRole') }}
</div> </div>
<div class="settings-btn" @click="handleVoicePrint"> <div v-if="featureStatus.voiceprintRecognition" class="settings-btn" @click="handleVoicePrint">
{{ $t('home.voiceprintRecognition') }} {{ $t('home.voiceprintRecognition') }}
</div> </div>
<div class="settings-btn" @click="handleDeviceManage"> <div class="settings-btn" @click="handleDeviceManage">
@@ -49,7 +49,15 @@ import i18n from '@/i18n';
export default { export default {
name: 'DeviceItem', name: 'DeviceItem',
props: { props: {
device: { type: Object, required: true } device: { type: Object, required: true },
featureStatus: {
type: Object,
default: () => ({
voiceprintRecognition: false,
voiceClone: false,
knowledgeBase: false
})
}
}, },
data() { data() {
return { switchValue: false } return { switchValue: false }
@@ -106,7 +106,7 @@
</div> </div>
<!-- MCP区域 --> <!-- MCP区域 -->
<div class="mcp-access-point"> <div class="mcp-access-point" v-if="featureStatus.mcpAccessPoint">
<div class="mcp-container"> <div class="mcp-container">
<!-- 左侧区域 --> <!-- 左侧区域 -->
<div class="mcp-left"> <div class="mcp-left">
@@ -171,6 +171,7 @@
<script> <script>
import Api from '@/apis/api'; import Api from '@/apis/api';
import i18n from '@/i18n'; import i18n from '@/i18n';
import featureManager from '@/utils/featureManager';
export default { export default {
i18n, i18n,
@@ -205,6 +206,11 @@ export default {
mcpUrl: "", mcpUrl: "",
mcpStatus: "disconnected", mcpStatus: "disconnected",
mcpTools: [], mcpTools: [],
// 功能状态
featureStatus: {
mcpAccessPoint: false
}
} }
}, },
computed: { computed: {
@@ -249,6 +255,9 @@ export default {
// 右侧默认指向第一个 // 右侧默认指向第一个
this.currentFunction = this.selectedList[0] || null; this.currentFunction = this.selectedList[0] || null;
// 加载功能状态
this.loadFeatureStatus();
// 加载MCP数据 // 加载MCP数据
this.loadMcpAddress(); this.loadMcpAddress();
this.loadMcpTools(); this.loadMcpTools();
@@ -259,6 +268,19 @@ export default {
} }
}, },
methods: { methods: {
/**
* 加载功能状态
*/
async loadFeatureStatus() {
// 确保featureManager已初始化完成
await featureManager.waitForInitialization();
const config = featureManager.getConfig();
this.featureStatus = {
mcpAccessPoint: config.mcpAccessPoint || false
};
},
copyUrl() { copyUrl() {
const textarea = document.createElement('textarea'); const textarea = document.createElement('textarea');
textarea.value = this.mcpUrl; textarea.value = this.mcpUrl;
@@ -456,6 +478,7 @@ export default {
.function-column { .function-column {
position: relative; position: relative;
width: auto; width: auto;
height:700px;
padding: 10px; padding: 10px;
overflow-y: auto; overflow-y: auto;
border-right: 1px solid #EBEEF5; border-right: 1px solid #EBEEF5;
@@ -463,6 +486,12 @@ export default {
overflow-x: hidden; overflow-x: hidden;
} }
.mcp-access-point {
position: relative;
z-index: 1;
background: white;
}
.function-column::-webkit-scrollbar { .function-column::-webkit-scrollbar {
display: none; display: none;
} }
+33 -6
View File
@@ -26,7 +26,7 @@
<span class="nav-text">{{ $t("header.smartManagement") }}</span> <span class="nav-text">{{ $t("header.smartManagement") }}</span>
</div> </div>
<!-- 普通用户显示音色克隆 --> <!-- 普通用户显示音色克隆 -->
<div v-if="!isSuperAdmin" class="equipment-management" <div v-if="!isSuperAdmin && featureStatus.voiceClone" class="equipment-management"
:class="{ 'active-tab': $route.path === '/voice-clone-management' }" @click="goVoiceCloneManagement"> :class="{ 'active-tab': $route.path === '/voice-clone-management' }" @click="goVoiceCloneManagement">
<img loading="lazy" alt="" src="@/assets/header/voice.png" :style="{ <img loading="lazy" alt="" src="@/assets/header/voice.png" :style="{
filter: filter:
@@ -38,7 +38,7 @@
</div> </div>
<!-- 超级管理员显示音色克隆下拉菜单 --> <!-- 超级管理员显示音色克隆下拉菜单 -->
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown" :class="{ <el-dropdown v-if="isSuperAdmin && featureStatus.voiceClone" trigger="click" class="equipment-management more-dropdown" :class="{
'active-tab': 'active-tab':
$route.path === '/voice-clone-management' || $route.path === '/voice-clone-management' ||
$route.path === '/voice-resource-management', $route.path === '/voice-resource-management',
@@ -72,7 +72,7 @@
}" /> }" />
<span class="nav-text">{{ $t("header.modelConfig") }}</span> <span class="nav-text">{{ $t("header.modelConfig") }}</span>
</div> </div>
<div class="equipment-management" <div v-if="featureStatus.knowledgeBase" class="equipment-management"
:class="{ 'active-tab': $route.path === '/knowledge-base-management' || $route.path === '/knowledge-file-upload' }" :class="{ 'active-tab': $route.path === '/knowledge-base-management' || $route.path === '/knowledge-file-upload' }"
@click="goKnowledgeBaseManagement"> @click="goKnowledgeBaseManagement">
<img loading="lazy" alt="" src="@/assets/header/knowledge_base.png" :style="{ <img loading="lazy" alt="" src="@/assets/header/knowledge_base.png" :style="{
@@ -89,7 +89,8 @@
$route.path === '/server-side-management' || $route.path === '/server-side-management' ||
$route.path === '/agent-template-management' || $route.path === '/agent-template-management' ||
$route.path === '/ota-management' || $route.path === '/ota-management' ||
$route.path === '/user-management', $route.path === '/user-management' ||
$route.path === '/feature-management',
}" @visible-change="handleParamDropdownVisibleChange"> }" @visible-change="handleParamDropdownVisibleChange">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
<img loading="lazy" alt="" src="@/assets/header/param_management.png" :style="{ <img loading="lazy" alt="" src="@/assets/header/param_management.png" :style="{
@@ -100,7 +101,8 @@
$route.path === '/server-side-management' || $route.path === '/server-side-management' ||
$route.path === '/agent-template-management' || $route.path === '/agent-template-management' ||
$route.path === '/ota-management' || $route.path === '/ota-management' ||
$route.path === '/user-management' $route.path === '/user-management' ||
$route.path === '/feature-management'
? 'brightness(0) invert(1)' ? 'brightness(0) invert(1)'
: 'None', : 'None',
}" /> }" />
@@ -129,6 +131,9 @@
<el-dropdown-item @click.native="goServerSideManagement"> <el-dropdown-item @click.native="goServerSideManagement">
{{ $t("header.serverSideManagement") }} {{ $t("header.serverSideManagement") }}
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item @click.native="goFeatureManagement">
{{ $t("header.featureManagement") }}
</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</el-dropdown> </el-dropdown>
</div> </div>
@@ -186,6 +191,7 @@ import userApi from "@/apis/module/user";
import i18n, { changeLanguage } from "@/i18n"; import i18n, { changeLanguage } from "@/i18n";
import { mapActions, mapGetters } from "vuex"; import { mapActions, mapGetters } from "vuex";
import ChangePasswordDialog from "./ChangePasswordDialog.vue"; // 引入修改密码弹窗组件 import ChangePasswordDialog from "./ChangePasswordDialog.vue"; // 引入修改密码弹窗组件
import featureManager from "@/utils/featureManager"; // 引入功能管理工具类
export default { export default {
name: "HeaderBar", name: "HeaderBar",
@@ -217,6 +223,11 @@ export default {
label: "label", label: "label",
children: "children", children: "children",
}, },
// 功能状态
featureStatus: {
voiceClone: false, // 音色克隆功能状态
knowledgeBase: false, // 知识库功能状态
},
}; };
}, },
computed: { computed: {
@@ -286,12 +297,14 @@ export default {
]; ];
}, },
}, },
mounted() { async mounted() {
this.fetchUserInfo(); this.fetchUserInfo();
this.checkScreenSize(); this.checkScreenSize();
window.addEventListener("resize", this.checkScreenSize); window.addEventListener("resize", this.checkScreenSize);
// 从localStorage加载搜索历史 // 从localStorage加载搜索历史
this.loadSearchHistory(); this.loadSearchHistory();
// 等待featureManager初始化完成后再加载功能状态
await this.loadFeatureStatus();
}, },
//移除事件监听器 //移除事件监听器
beforeDestroy() { beforeDestroy() {
@@ -338,6 +351,20 @@ export default {
goAgentTemplateManagement() { goAgentTemplateManagement() {
this.$router.push("/agent-template-management"); this.$router.push("/agent-template-management");
}, },
// 跳转到功能管理
goFeatureManagement() {
this.$router.push("/feature-management");
},
// 加载功能状态
async loadFeatureStatus() {
// 等待featureManager初始化完成
await featureManager.waitForInitialization();
const config = featureManager.getConfig();
this.featureStatus.voiceClone = config.voiceClone;
this.featureStatus.knowledgeBase = config.knowledgeBase;
},
// 获取用户信息 // 获取用户信息
fetchUserInfo() { fetchUserInfo() {
userApi.getUserInfo(({ data }) => { userApi.getUserInfo(({ data }) => {
+54 -2
View File
@@ -22,6 +22,7 @@ export default {
'header.clearHistory': 'Verlauf löschen', 'header.clearHistory': 'Verlauf löschen',
'header.providerManagement': 'Anbieterverwaltung', 'header.providerManagement': 'Anbieterverwaltung',
'header.serverSideManagement': 'Serververwaltung', 'header.serverSideManagement': 'Serververwaltung',
'header.featureManagement': 'Systemfunktionsverwaltung',
'header.changePassword': 'Passwort ändern', 'header.changePassword': 'Passwort ändern',
'header.logout': 'Abmelden', 'header.logout': 'Abmelden',
'header.searchPlaceholder': 'Nach Namen suchen..', 'header.searchPlaceholder': 'Nach Namen suchen..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': 'Bitte Namen eingeben', 'voicePrintDialog.requiredName': 'Bitte Namen eingeben',
'voicePrintDialog.requiredAudioVector': 'Bitte Audio-Vektor auswählen', 'voicePrintDialog.requiredAudioVector': 'Bitte Audio-Vektor auswählen',
// Context provider dialog related
'contextProviderDialog.title': 'Quelle bearbeiten',
'contextProviderDialog.noContextApi': 'Keine Kontext-API',
'contextProviderDialog.add': 'Hinzufügen',
'contextProviderDialog.apiUrl': 'API-URL',
'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data',
'contextProviderDialog.requestHeaders': 'Anfrage-Header',
'contextProviderDialog.headerKeyPlaceholder': 'Schlüssel',
'contextProviderDialog.headerValuePlaceholder': 'Wert',
'contextProviderDialog.noHeaders': 'Keine Headers',
'contextProviderDialog.addHeader': 'Header hinzufügen',
'contextProviderDialog.cancel': 'Abbrechen',
'contextProviderDialog.confirm': 'Bestätigen',
// Role config page - context provider related
'roleConfig.contextProvider': 'Kontext',
'roleConfig.contextProviderSuccess': '{count} Quellen erfolgreich hinzugefügt.',
'roleConfig.contextProviderDocLink': 'Wie man Kontextquellen bereitstellt',
'roleConfig.editContextProvider': 'Quelle bearbeiten',
// Voice print page related // Voice print page related
'voicePrint.pageTitle': 'Stimmabdruck-Erkennung', 'voicePrint.pageTitle': 'Stimmabdruck-Erkennung',
'voicePrint.name': 'Name', 'voicePrint.name': 'Name',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': 'Löschen fehlgeschlagen, bitte versuchen Sie es erneut', 'paramManagement.deleteFailed': 'Löschen fehlgeschlagen, bitte versuchen Sie es erneut',
'paramManagement.operationCancelled': 'Löschen abgebrochen', 'paramManagement.operationCancelled': 'Löschen abgebrochen',
'paramManagement.operationClosed': 'Operation geschlossen', 'paramManagement.operationClosed': 'Operation geschlossen',
'paramManagement.updateSuccess': 'Aktualisierung erfolgreich', 'paramManagement.updateSuccess': 'Aktualisierung erfolgreich. Einige Konfigurationen werden erst nach Neustart des xiaozhi-server-Moduls wirksam.',
'paramManagement.addSuccess': 'Hinzufügen erfolgreich', 'paramManagement.addSuccess': 'Hinzufügen erfolgreich',
'paramManagement.updateFailed': 'Aktualisierung fehlgeschlagen', 'paramManagement.updateFailed': 'Aktualisierung fehlgeschlagen',
'paramManagement.addFailed': 'Hinzufügen fehlgeschlagen', 'paramManagement.addFailed': 'Hinzufügen fehlgeschlagen',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': 'Aktivieren erfolgreich', 'modelConfig.enableSuccess': 'Aktivieren erfolgreich',
'modelConfig.disableSuccess': 'Deaktivieren erfolgreich', 'modelConfig.disableSuccess': 'Deaktivieren erfolgreich',
'modelConfig.operationFailed': 'Operation fehlgeschlagen', 'modelConfig.operationFailed': 'Operation fehlgeschlagen',
'modelConfig.setDefaultSuccess': 'Standardmodell erfolgreich gesetzt', 'modelConfig.setDefaultSuccess': 'Standardmodell erfolgreich gesetzt, bitte starten Sie das xiaozhi-server-Modul zeitnah manuell neu',
'modelConfig.itemsPerPage': '{items} Einträge/Seite', 'modelConfig.itemsPerPage': '{items} Einträge/Seite',
'modelConfig.firstPage': 'Erste Seite', 'modelConfig.firstPage': 'Erste Seite',
'modelConfig.prevPage': 'Vorherige Seite', 'modelConfig.prevPage': 'Vorherige Seite',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': 'Inhalt:', 'knowledgeFileUpload.content': 'Inhalt:',
'knowledgeFileUpload.testQuestionRequired': 'Bitte Testfrage eingeben', 'knowledgeFileUpload.testQuestionRequired': 'Bitte Testfrage eingeben',
'knowledgeBaseDialog.descriptionRequired': 'Bitte Wissensbasis-Beschreibung eingeben', 'knowledgeBaseDialog.descriptionRequired': 'Bitte Wissensbasis-Beschreibung eingeben',
// Feature Management page text
'featureManagement.selectAll': 'Alle auswählen',
'featureManagement.deselectAll': 'Alle abwählen',
'featureManagement.save': 'Konfiguration speichern',
'featureManagement.reset': 'Zurücksetzen',
'featureManagement.group.featureManagement': 'Funktion/Bereich aktivieren oder deaktivieren',
'featureManagement.group.voiceManagement': 'Für Benutzer bei der Agenten-Konfiguration sichtbar',
'featureManagement.noFeatures': 'Keine Funktionen verfügbar',
'featureManagement.contactAdmin': 'Bitte kontaktieren Sie Administrator zur Funktionskonfiguration',
'featureManagement.saveSuccess': 'Funktionskonfiguration erfolgreich gespeichert',
'featureManagement.resetConfirm': 'Sind Sie sicher, dass Sie alle Funktionskonfigurationen zurücksetzen möchten?',
'featureManagement.confirm': 'Bestätigen',
'featureManagement.cancel': 'Abbrechen',
'featureManagement.resetSuccess': 'Funktionskonfiguration erfolgreich zurückgesetzt',
'featureManagement.noChanges': 'Keine Änderungen zum Speichern',
// Feature names and descriptions
'feature.voiceprintRecognition.name': 'Stimmerkennung',
'feature.voiceprintRecognition.description': 'Benutzeridentität durch Stimmerkennungstechnologie verifizieren, bietet sichere Sprachiinteraktion',
'feature.voiceClone.name': 'Stimmenklon',
'feature.voiceClone.description': 'Spezifische Stimmfarbe mit KI-Technologie klonen, um personalisierte Sprachsynthese zu ermöglichen',
'feature.knowledgeBase.name': 'Wissensbasis',
'feature.knowledgeBase.description': 'Wissensbasissystem aufbauen und verwalten, um professionelle Wissensunterstützung für KI-Assistenten zu bieten',
'feature.mcpAccessPoint.name': 'MCP-Zugangspunkt',
'feature.mcpAccessPoint.description': 'MCP-Protokoll-Zugangspunkte bereitstellen, um Integration externer Tools und Dienste zu unterstützen',
'feature.vad.name': 'Sprachaktivitätserkennung',
'feature.vad.description': 'Sprachaktivität automatisch erkennen, um die Effizienz der Sprachiinteraktion zu optimieren',
'feature.asr.name': 'Spracherkennung',
'feature.asr.description': 'Sprache in Text umwandeln, um natürliche Sprachinteraktionsfunktionalität zu ermöglichen',
} }
+54 -2
View File
@@ -22,6 +22,7 @@ export default {
'header.clearHistory': 'Clear History', 'header.clearHistory': 'Clear History',
'header.providerManagement': 'Provider Management', 'header.providerManagement': 'Provider Management',
'header.serverSideManagement': 'Server Management', 'header.serverSideManagement': 'Server Management',
'header.featureManagement': 'System Feature Management',
'header.changePassword': 'Change Password', 'header.changePassword': 'Change Password',
'header.logout': 'Logout', 'header.logout': 'Logout',
'header.searchPlaceholder': 'Search by name..', 'header.searchPlaceholder': 'Search by name..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': 'Please enter name', 'voicePrintDialog.requiredName': 'Please enter name',
'voicePrintDialog.requiredAudioVector': 'Please select audio vector', 'voicePrintDialog.requiredAudioVector': 'Please select audio vector',
// Context provider dialog related
'contextProviderDialog.title': 'Edit Source',
'contextProviderDialog.noContextApi': 'No Context API',
'contextProviderDialog.add': 'Add',
'contextProviderDialog.apiUrl': 'API URL',
'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data',
'contextProviderDialog.requestHeaders': 'Request Headers',
'contextProviderDialog.headerKeyPlaceholder': 'Key',
'contextProviderDialog.headerValuePlaceholder': 'Value',
'contextProviderDialog.noHeaders': 'No Headers',
'contextProviderDialog.addHeader': 'Add Header',
'contextProviderDialog.cancel': 'Cancel',
'contextProviderDialog.confirm': 'Confirm',
// Role config page - context provider related
'roleConfig.contextProvider': 'Context',
'roleConfig.contextProviderSuccess': 'Successfully added {count} sources.',
'roleConfig.contextProviderDocLink': 'How to deploy context provider',
'roleConfig.editContextProvider': 'Edit Source',
// Voice print page related // Voice print page related
'voicePrint.pageTitle': 'Voice Print Recognition', 'voicePrint.pageTitle': 'Voice Print Recognition',
'voicePrint.name': 'Name', 'voicePrint.name': 'Name',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': 'Deletion failed, please try again', 'paramManagement.deleteFailed': 'Deletion failed, please try again',
'paramManagement.operationCancelled': 'Deletion cancelled', 'paramManagement.operationCancelled': 'Deletion cancelled',
'paramManagement.operationClosed': 'Operation closed', 'paramManagement.operationClosed': 'Operation closed',
'paramManagement.updateSuccess': 'Update successful', 'paramManagement.updateSuccess': 'Update successful. Some configurations will take effect only after restarting the xiaozhi-server module.',
'paramManagement.addSuccess': 'Add successful', 'paramManagement.addSuccess': 'Add successful',
'paramManagement.updateFailed': 'Update failed', 'paramManagement.updateFailed': 'Update failed',
'paramManagement.addFailed': 'Add failed', 'paramManagement.addFailed': 'Add failed',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': 'Enable successful', 'modelConfig.enableSuccess': 'Enable successful',
'modelConfig.disableSuccess': 'Disable successful', 'modelConfig.disableSuccess': 'Disable successful',
'modelConfig.operationFailed': 'Operation failed', 'modelConfig.operationFailed': 'Operation failed',
'modelConfig.setDefaultSuccess': 'Set default model successful', 'modelConfig.setDefaultSuccess': 'Set default model successful, please restart the xiaozhi-server module manually in time',
'modelConfig.itemsPerPage': '{items} items/page', 'modelConfig.itemsPerPage': '{items} items/page',
'modelConfig.firstPage': 'First Page', 'modelConfig.firstPage': 'First Page',
'modelConfig.prevPage': 'Previous Page', 'modelConfig.prevPage': 'Previous Page',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': 'Content:', 'knowledgeFileUpload.content': 'Content:',
'knowledgeFileUpload.testQuestionRequired': 'Please enter test question', 'knowledgeFileUpload.testQuestionRequired': 'Please enter test question',
'knowledgeBaseDialog.descriptionRequired': 'Please enter knowledge base description', 'knowledgeBaseDialog.descriptionRequired': 'Please enter knowledge base description',
// Feature Management page text
'featureManagement.selectAll': 'Select All',
'featureManagement.deselectAll': 'Deselect All',
'featureManagement.save': 'Save Configuration',
'featureManagement.reset': 'Reset',
'featureManagement.group.featureManagement': 'Enable/Disable the feature/section',
'featureManagement.group.voiceManagement': 'Visible to users during agent configuration',
'featureManagement.noFeatures': 'No features available',
'featureManagement.contactAdmin': 'Please contact administrator to configure features',
'featureManagement.saveSuccess': 'Feature configuration saved successfully',
'featureManagement.resetConfirm': 'Are you sure you want to reset all feature configurations?',
'featureManagement.confirm': 'Confirm',
'featureManagement.cancel': 'Cancel',
'featureManagement.resetSuccess': 'Feature configuration reset successfully',
'featureManagement.noChanges': 'No changes to save',
// Feature names and descriptions
'feature.voiceprintRecognition.name': 'Voiceprint Recognition',
'feature.voiceprintRecognition.description': 'Verify user identity through voiceprint recognition technology, providing secure voice interaction experience',
'feature.voiceClone.name': 'Voice Clone',
'feature.voiceClone.description': 'Clone specific voice timbre using AI technology to achieve personalized voice synthesis',
'feature.knowledgeBase.name': 'Knowledge Base',
'feature.knowledgeBase.description': 'Build and manage knowledge base system to provide professional knowledge support for AI assistants',
'feature.mcpAccessPoint.name': 'MCP Access Point',
'feature.mcpAccessPoint.description': 'Provide MCP protocol access points to support integration of external tools and services',
'feature.vad.name': 'Voice Activity Detection',
'feature.vad.description': 'Automatically detect voice activity to optimize voice interaction response efficiency',
'feature.asr.name': 'Speech Recognition',
'feature.asr.description': 'Convert speech to text to enable natural language interaction functionality',
} }
+54 -2
View File
@@ -22,6 +22,7 @@ export default {
'header.clearHistory': 'Xóa lịch sử', 'header.clearHistory': 'Xóa lịch sử',
'header.providerManagement': 'Quản lý nhà cung cấp', 'header.providerManagement': 'Quản lý nhà cung cấp',
'header.serverSideManagement': 'Quản lý máy chủ', 'header.serverSideManagement': 'Quản lý máy chủ',
'header.featureManagement': 'Cấu hình chức năng hệ thống',
'header.changePassword': 'Đổi mật khẩu', 'header.changePassword': 'Đổi mật khẩu',
'header.logout': 'Đăng xuất', 'header.logout': 'Đăng xuất',
'header.searchPlaceholder': 'Tìm kiếm theo tên..', 'header.searchPlaceholder': 'Tìm kiếm theo tên..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': 'Vui lòng nhập tên', 'voicePrintDialog.requiredName': 'Vui lòng nhập tên',
'voicePrintDialog.requiredAudioVector': 'Vui lòng chọn vector âm thanh', 'voicePrintDialog.requiredAudioVector': 'Vui lòng chọn vector âm thanh',
// Context provider dialog related
'contextProviderDialog.title': 'Chỉnh sửa nguồn',
'contextProviderDialog.noContextApi': 'Không có API ngữ cảnh',
'contextProviderDialog.add': 'Thêm',
'contextProviderDialog.apiUrl': 'Địa chỉ API',
'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data',
'contextProviderDialog.requestHeaders': 'Header yêu cầu',
'contextProviderDialog.headerKeyPlaceholder': 'Khóa',
'contextProviderDialog.headerValuePlaceholder': 'Giá trị',
'contextProviderDialog.noHeaders': 'Không có Headers',
'contextProviderDialog.addHeader': 'Thêm Header',
'contextProviderDialog.cancel': 'Hủy bỏ',
'contextProviderDialog.confirm': 'Xác nhận',
// Role config page - context provider related
'roleConfig.contextProvider': 'Bối cảnh',
'roleConfig.contextProviderSuccess': 'Đã thêm thành công {count} nguồn.',
'roleConfig.contextProviderDocLink': 'Cách triển khai nguồn ngữ cảnh',
'roleConfig.editContextProvider': 'Chỉnh sửa nguồn',
// Voice print page related // Voice print page related
'voicePrint.pageTitle': 'Nhận dạng dấu giọng nói', 'voicePrint.pageTitle': 'Nhận dạng dấu giọng nói',
'voicePrint.name': 'Tên', 'voicePrint.name': 'Tên',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': 'Xóa thất bại, vui lòng thử lại', 'paramManagement.deleteFailed': 'Xóa thất bại, vui lòng thử lại',
'paramManagement.operationCancelled': 'Đã hủy xóa', 'paramManagement.operationCancelled': 'Đã hủy xóa',
'paramManagement.operationClosed': 'Đã đóng thao tác', 'paramManagement.operationClosed': 'Đã đóng thao tác',
'paramManagement.updateSuccess': 'Cập nhật thành công', 'paramManagement.updateSuccess': 'Cập nhật thành công. Một số cấu hình chỉ có hiệu lực sau khi khởi động lại mô-đun xiaozhi-server.',
'paramManagement.addSuccess': 'Thêm thành công', 'paramManagement.addSuccess': 'Thêm thành công',
'paramManagement.updateFailed': 'Cập nhật thất bại', 'paramManagement.updateFailed': 'Cập nhật thất bại',
'paramManagement.addFailed': 'Thêm thất bại', 'paramManagement.addFailed': 'Thêm thất bại',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': 'Bật thành công', 'modelConfig.enableSuccess': 'Bật thành công',
'modelConfig.disableSuccess': 'Tắt thành công', 'modelConfig.disableSuccess': 'Tắt thành công',
'modelConfig.operationFailed': 'Thao tác thất bại', 'modelConfig.operationFailed': 'Thao tác thất bại',
'modelConfig.setDefaultSuccess': 'Đặt mô hình mặc định thành công', 'modelConfig.setDefaultSuccess': 'Đặt mô hình mặc định thành công, vui lòng khởi động lại module xiaozhi-server thủ công kịp thời',
'modelConfig.itemsPerPage': '{items} mục/trang', 'modelConfig.itemsPerPage': '{items} mục/trang',
'modelConfig.firstPage': 'Trang đầu', 'modelConfig.firstPage': 'Trang đầu',
'modelConfig.prevPage': 'Trang trước', 'modelConfig.prevPage': 'Trang trước',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': 'Nội dung:', 'knowledgeFileUpload.content': 'Nội dung:',
'knowledgeFileUpload.testQuestionRequired': 'Vui lòng nhập câu hỏi kiểm tra', 'knowledgeFileUpload.testQuestionRequired': 'Vui lòng nhập câu hỏi kiểm tra',
'knowledgeBaseDialog.descriptionRequired': 'Vui lòng nhập mô tả cơ sở kiến thức', 'knowledgeBaseDialog.descriptionRequired': 'Vui lòng nhập mô tả cơ sở kiến thức',
// Feature Management page text
'featureManagement.selectAll': 'Chọn tất cả',
'featureManagement.deselectAll': 'Bỏ chọn tất cả',
'featureManagement.save': 'Lưu cấu hình',
'featureManagement.reset': 'Đặt lại',
'featureManagement.group.featureManagement': 'Có bật tính năng/khối này lên hay không',
'featureManagement.group.voiceManagement': 'Khi cấu hình agent, có hiển thị với người dùng hay không',
'featureManagement.noFeatures': 'Chưa có tính năng nào',
'featureManagement.contactAdmin': 'Vui lòng liên hệ quản trị viên để cấu hình tính năng',
'featureManagement.saveSuccess': 'Cấu hình tính năng đã được lưu thành công',
'featureManagement.resetConfirm': 'Bạn có chắc chắn muốn đặt lại tất cả cấu hình tính năng?',
'featureManagement.confirm': 'Xác nhận',
'featureManagement.cancel': 'Hủy bỏ',
'featureManagement.resetSuccess': 'Cấu hình tính năng đã được đặt lại thành công',
'featureManagement.noChanges': 'Không có thay đổi nào để lưu',
// Feature names and descriptions
'feature.voiceprintRecognition.name': 'Nhận dạng giọng nói',
'feature.voiceprintRecognition.description': 'Xác minh danh tính người dùng thông qua công nghệ nhận dạng giọng nói, cung cấp trải nghiệm tương tác giọng nói an toàn',
'feature.voiceClone.name': 'Nhân bản âm sắc',
'feature.voiceClone.description': 'Sử dụng công nghệ AI để nhân bản âm sắc cụ thể, đạt được tổng hợp giọng nói cá nhân hóa',
'feature.knowledgeBase.name': 'Cơ sở kiến thức',
'feature.knowledgeBase.description': 'Xây dựng và quản lý hệ thống cơ sở kiến thức, cung cấp hỗ trợ kiến thức chuyên môn cho trợ lý AI',
'feature.mcpAccessPoint.name': 'Điểm truy cập MCP',
'feature.mcpAccessPoint.description': 'Cung cấp điểm truy cập giao thức MCP, hỗ trợ tích hợp các công cụ và dịch vụ bên ngoài',
'feature.vad.name': 'Phát hiện hoạt động giọng nói',
'feature.vad.description': 'Tự động phát hiện hoạt động giọng nói, tối ưu hóa hiệu suất phản hồi tương tác giọng nói',
'feature.asr.name': 'Nhận dạng giọng nói',
'feature.asr.description': 'Chuyển đổi giọng nói thành văn bản, thực hiện chức năng tương tác ngôn ngữ tự nhiên',
} }
+54 -2
View File
@@ -22,6 +22,7 @@ export default {
'header.clearHistory': '清空历史', 'header.clearHistory': '清空历史',
'header.providerManagement': '字段管理', 'header.providerManagement': '字段管理',
'header.serverSideManagement': '服务端管理', 'header.serverSideManagement': '服务端管理',
'header.featureManagement': '系统功能配置',
'header.changePassword': '修改密码', 'header.changePassword': '修改密码',
'header.logout': '退出登录', 'header.logout': '退出登录',
'header.searchPlaceholder': '输入名称搜索..', 'header.searchPlaceholder': '输入名称搜索..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': '请输入姓名', 'voicePrintDialog.requiredName': '请输入姓名',
'voicePrintDialog.requiredAudioVector': '请选择音频向量', 'voicePrintDialog.requiredAudioVector': '请选择音频向量',
// 上下文源对话框相关
'contextProviderDialog.title': '编辑源',
'contextProviderDialog.noContextApi': '暂无上下文API',
'contextProviderDialog.add': '添加',
'contextProviderDialog.apiUrl': '接口地址',
'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data',
'contextProviderDialog.requestHeaders': '请求头',
'contextProviderDialog.headerKeyPlaceholder': 'Key',
'contextProviderDialog.headerValuePlaceholder': 'Value',
'contextProviderDialog.noHeaders': '暂无 Headers',
'contextProviderDialog.addHeader': '添加 Header',
'contextProviderDialog.cancel': '取消',
'contextProviderDialog.confirm': '确定',
// 角色配置页面-上下文源相关
'roleConfig.contextProvider': '上下文源',
'roleConfig.contextProviderSuccess': '已成功添加 {count} 个源。',
'roleConfig.contextProviderDocLink': '如何部署上下文源',
'roleConfig.editContextProvider': '编辑源',
// 声纹页面相关 // 声纹页面相关
'voicePrint.pageTitle': '声纹识别', 'voicePrint.pageTitle': '声纹识别',
'voicePrint.name': '姓名', 'voicePrint.name': '姓名',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': '删除失败,请重试', 'paramManagement.deleteFailed': '删除失败,请重试',
'paramManagement.operationCancelled': '已取消删除操作', 'paramManagement.operationCancelled': '已取消删除操作',
'paramManagement.operationClosed': '操作已关闭', 'paramManagement.operationClosed': '操作已关闭',
'paramManagement.updateSuccess': '修改成功', 'paramManagement.updateSuccess': '修改成功,部分配置需重启xiaozhi-server模块才生效',
'paramManagement.addSuccess': '新增成功', 'paramManagement.addSuccess': '新增成功',
'paramManagement.updateFailed': '更新失败', 'paramManagement.updateFailed': '更新失败',
'paramManagement.addFailed': '新增失败', 'paramManagement.addFailed': '新增失败',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': '启用成功', 'modelConfig.enableSuccess': '启用成功',
'modelConfig.disableSuccess': '禁用成功', 'modelConfig.disableSuccess': '禁用成功',
'modelConfig.operationFailed': '操作失败', 'modelConfig.operationFailed': '操作失败',
'modelConfig.setDefaultSuccess': '设置默认模型成功', 'modelConfig.setDefaultSuccess': '设置默认模型成功,请及时手动重启xiaozhi-server模块',
'modelConfig.itemsPerPage': '{items}条/页', 'modelConfig.itemsPerPage': '{items}条/页',
'modelConfig.firstPage': '首页', 'modelConfig.firstPage': '首页',
'modelConfig.prevPage': '上一页', 'modelConfig.prevPage': '上一页',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': '内容:', 'knowledgeFileUpload.content': '内容:',
'knowledgeFileUpload.testQuestionRequired': '请输入测试问题', 'knowledgeFileUpload.testQuestionRequired': '请输入测试问题',
'knowledgeBaseDialog.descriptionRequired': '请输入知识库描述', 'knowledgeBaseDialog.descriptionRequired': '请输入知识库描述',
// 系统功能配置页面文本
'featureManagement.selectAll': '全选',
'featureManagement.deselectAll': '取消全选',
'featureManagement.save': '保存配置',
'featureManagement.reset': '重置',
'featureManagement.group.featureManagement': '是否开启功能/板块',
'featureManagement.group.voiceManagement': '配置智能体时是否对用户可见',
'featureManagement.noFeatures': '暂无功能',
'featureManagement.contactAdmin': '请联系管理员配置功能',
'featureManagement.saveSuccess': '功能配置保存成功',
'featureManagement.resetConfirm': '确定要重置所有功能配置吗?',
'featureManagement.confirm': '确定',
'featureManagement.cancel': '取消',
'featureManagement.resetSuccess': '功能配置重置成功',
'featureManagement.noChanges': '没有需要保存的更改',
// 功能名称和描述
'feature.voiceprintRecognition.name': '声纹识别',
'feature.voiceprintRecognition.description': '通过声纹识别技术验证用户身份,提供安全的语音交互体验',
'feature.voiceClone.name': '音色克隆',
'feature.voiceClone.description': '使用AI技术克隆特定音色,实现个性化语音合成',
'feature.knowledgeBase.name': '知识库',
'feature.knowledgeBase.description': '构建和管理知识库系统,为AI助手提供专业知识支持',
'feature.mcpAccessPoint.name': 'MCP接入点',
'feature.mcpAccessPoint.description': '提供MCP协议接入点,支持外部工具和服务的集成',
'feature.vad.name': '语音活动检测',
'feature.vad.description': '自动检测语音活动,优化语音交互的响应效率',
'feature.asr.name': '语音识别',
'feature.asr.description': '将语音转换为文本,实现自然语言交互功能',
} }
+54 -2
View File
@@ -22,6 +22,7 @@ export default {
'header.clearHistory': '清空歷史', 'header.clearHistory': '清空歷史',
'header.providerManagement': '字段管理', 'header.providerManagement': '字段管理',
'header.serverSideManagement': '服務端管理', 'header.serverSideManagement': '服務端管理',
'header.featureManagement': '系統功能配置',
'header.changePassword': '修改密碼', 'header.changePassword': '修改密碼',
'header.logout': '退出登錄', 'header.logout': '退出登錄',
'header.searchPlaceholder': '輸入名稱搜索..', 'header.searchPlaceholder': '輸入名稱搜索..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': '請輸入姓名', 'voicePrintDialog.requiredName': '請輸入姓名',
'voicePrintDialog.requiredAudioVector': '請選擇音頻向量', 'voicePrintDialog.requiredAudioVector': '請選擇音頻向量',
// 上下文源對話框相關
'contextProviderDialog.title': '編輯源',
'contextProviderDialog.noContextApi': '暫無上下文API',
'contextProviderDialog.add': '添加',
'contextProviderDialog.apiUrl': '接口地址',
'contextProviderDialog.apiUrlPlaceholder': 'http://api.example.com/data',
'contextProviderDialog.requestHeaders': '請求頭',
'contextProviderDialog.headerKeyPlaceholder': 'Key',
'contextProviderDialog.headerValuePlaceholder': 'Value',
'contextProviderDialog.noHeaders': '暫無 Headers',
'contextProviderDialog.addHeader': '添加 Header',
'contextProviderDialog.cancel': '取消',
'contextProviderDialog.confirm': '確定',
// 角色配置頁面-上下文源相關
'roleConfig.contextProvider': '上下文源',
'roleConfig.contextProviderSuccess': '已成功添加 {count} 個源。',
'roleConfig.contextProviderDocLink': '如何部署上下文源',
'roleConfig.editContextProvider': '編輯源',
// 聲紋頁面相關 // 聲紋頁面相關
'voicePrint.pageTitle': '聲紋識別', 'voicePrint.pageTitle': '聲紋識別',
'voicePrint.name': '姓名', 'voicePrint.name': '姓名',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': '刪除失敗,請重試', 'paramManagement.deleteFailed': '刪除失敗,請重試',
'paramManagement.operationCancelled': '已取消刪除操作', 'paramManagement.operationCancelled': '已取消刪除操作',
'paramManagement.operationClosed': '操作已關閉', 'paramManagement.operationClosed': '操作已關閉',
'paramManagement.updateSuccess': '修改成功', 'paramManagement.updateSuccess': '修改成功,部分配置需重啟xiaozhi-server模組才生效',
'paramManagement.addSuccess': '新增成功', 'paramManagement.addSuccess': '新增成功',
'paramManagement.updateFailed': '更新失敗', 'paramManagement.updateFailed': '更新失敗',
'paramManagement.addFailed': '新增失敗', 'paramManagement.addFailed': '新增失敗',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': '啟用成功', 'modelConfig.enableSuccess': '啟用成功',
'modelConfig.disableSuccess': '禁用成功', 'modelConfig.disableSuccess': '禁用成功',
'modelConfig.operationFailed': '操作失敗', 'modelConfig.operationFailed': '操作失敗',
'modelConfig.setDefaultSuccess': '設置默認模型成功', 'modelConfig.setDefaultSuccess': '設置默認模型成功,請及時手動重啟xiaozhi-server模組',
'modelConfig.itemsPerPage': '{items}條/頁', 'modelConfig.itemsPerPage': '{items}條/頁',
'modelConfig.firstPage': '首頁', 'modelConfig.firstPage': '首頁',
'modelConfig.prevPage': '上一頁', 'modelConfig.prevPage': '上一頁',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': '內容:', 'knowledgeFileUpload.content': '內容:',
'knowledgeFileUpload.testQuestionRequired': '請輸入測試問題', 'knowledgeFileUpload.testQuestionRequired': '請輸入測試問題',
'knowledgeBaseDialog.descriptionRequired': '請輸入知识库描述', 'knowledgeBaseDialog.descriptionRequired': '請輸入知识库描述',
// 功能管理頁面文本
'featureManagement.selectAll': '全選',
'featureManagement.deselectAll': '取消全選',
'featureManagement.save': '儲存配置',
'featureManagement.reset': '重置',
'featureManagement.group.featureManagement': '是否開啟功能/板块',
'featureManagement.group.voiceManagement': '配置智能体時是否對用戶可見',
'featureManagement.noFeatures': '暫無功能',
'featureManagement.contactAdmin': '請聯繫管理員配置功能',
'featureManagement.saveSuccess': '功能配置儲存成功',
'featureManagement.resetConfirm': '確定要重置所有功能配置嗎?',
'featureManagement.confirm': '確定',
'featureManagement.cancel': '取消',
'featureManagement.resetSuccess': '功能配置重置成功',
'featureManagement.noChanges': '沒有需要儲存的更改',
// 功能名稱和描述
'feature.voiceprintRecognition.name': '聲紋識別',
'feature.voiceprintRecognition.description': '通過聲紋識別技術驗證用戶身份,提供安全的語音交互體驗',
'feature.voiceClone.name': '音色複刻',
'feature.voiceClone.description': '使用AI技術複刻特定音色,實現個性化語音合成',
'feature.knowledgeBase.name': '知識庫',
'feature.knowledgeBase.description': '構建和管理知識庫系統,為AI助手提供專業知識支持',
'feature.mcpAccessPoint.name': 'MCP接入點',
'feature.mcpAccessPoint.description': '提供MCP協議接入點,支持外部工具和服務的整合',
'feature.vad.name': '語音活動檢測',
'feature.vad.description': '自動檢測語音活動,優化語音交互的響應效率',
'feature.asr.name': '語音識別',
'feature.asr.description': '將語音轉換為文本,實現自然語言交互功能',
} }
+1
View File
@@ -8,6 +8,7 @@ import store from './store';
import i18n from './i18n'; import i18n from './i18n';
import './styles/global.scss'; import './styles/global.scss';
import { register as registerServiceWorker } from './registerServiceWorker'; import { register as registerServiceWorker } from './registerServiceWorker';
import featureManager from './utils/featureManager';
// 创建事件总线,用于组件间通信 // 创建事件总线,用于组件间通信
Vue.prototype.$eventBus = new Vue(); Vue.prototype.$eventBus = new Vue();
+18 -3
View File
@@ -76,7 +76,8 @@ const routes = [
return import('../views/ModelConfig.vue') return import('../views/ModelConfig.vue')
} }
}, },
{ path: '/params-management', {
path: '/params-management',
name: 'ParamsManagement', name: 'ParamsManagement',
component: function () { component: function () {
return import('../views/ParamsManagement.vue') return import('../views/ParamsManagement.vue')
@@ -86,7 +87,8 @@ const routes = [
title: '参数管理' title: '参数管理'
} }
}, },
{ path: '/knowledge-base-management', {
path: '/knowledge-base-management',
name: 'KnowledgeBaseManagement', name: 'KnowledgeBaseManagement',
component: function () { component: function () {
return import('../views/KnowledgeBaseManagement.vue') return import('../views/KnowledgeBaseManagement.vue')
@@ -96,7 +98,8 @@ const routes = [
title: '知识库管理' title: '知识库管理'
} }
}, },
{ path: '/knowledge-file-upload', {
path: '/knowledge-file-upload',
name: 'KnowledgeFileUpload', name: 'KnowledgeFileUpload',
component: function () { component: function () {
return import('../views/KnowledgeFileUpload.vue') return import('../views/KnowledgeFileUpload.vue')
@@ -181,6 +184,18 @@ const routes = [
return import('../views/TemplateQuickConfig.vue') return import('../views/TemplateQuickConfig.vue')
} }
}, },
// 功能配置页面路由
{
path: '/feature-management',
name: 'FeatureManagement',
component: function () {
return import('../views/FeatureManagement.vue')
},
meta: {
requiresAuth: true,
title: '功能配置'
}
},
] ]
const router = new VueRouter({ const router = new VueRouter({
base: process.env.VUE_APP_PUBLIC_PATH || '/', base: process.env.VUE_APP_PUBLIC_PATH || '/',
@@ -0,0 +1,340 @@
//功能配置工具
import Api from "@/apis/api";
class FeatureManager {
constructor() {
this.defaultFeatures = {
voiceprintRecognition: {
name: 'feature.voiceprintRecognition.name',
enabled: false,
description: 'feature.voiceprintRecognition.description'
},
voiceClone: {
name: 'feature.voiceClone.name',
enabled: false,
description: 'feature.voiceClone.description'
},
knowledgeBase: {
name: 'feature.knowledgeBase.name',
enabled: false,
description: 'feature.knowledgeBase.description'
},
mcpAccessPoint: {
name: 'feature.mcpAccessPoint.name',
enabled: false,
description: 'feature.mcpAccessPoint.description'
},
vad: {
name: 'feature.vad.name',
enabled: false,
description: 'feature.vad.description'
},
asr: {
name: 'feature.asr.name',
enabled: false,
description: 'feature.asr.description'
}
};
this.currentFeatures = { ...this.defaultFeatures }; // 当前内存中的配置
this.initialized = false;
this.initPromise = null;
}
/**
* 等待初始化完成
*/
async waitForInitialization() {
if (!this.initPromise) {
this.initPromise = this.init();
}
await this.initPromise;
return this.initialized;
}
/**
* 初始化功能配置
*/
async init() {
try {
// 从pub-config接口获取配置
const config = await this.getConfigFromPubConfig();
if (config) {
this.currentFeatures = { ...config }; // 保存到内存
this.initialized = true;
return;
}
} catch (error) {
console.warn('从pub-config接口获取配置失败:', error);
}
// pub-config接口失败,使用默认配置
this.currentFeatures = { ...this.defaultFeatures }; // 保存默认配置到内存
this.initialized = true;
}
/**
* 从pub-config接口获取配置
*/
async getConfigFromPubConfig() {
return new Promise((resolve) => {
// 直接调用pub-config接口获取配置
Api.user.getPubConfig((result) => {
// 检查返回结果的结构
if (result && result.status === 200) {
// 检查是否有data字段
if (result.data) {
// 检查是否有code字段,如果有则按照code判断
if (result.data.code !== undefined) {
if (result.data.code === 0 && result.data.data && result.data.data.systemWebMenu) {
try {
let config;
if (typeof result.data.data.systemWebMenu === 'string') {
// 如果是字符串,需要解析JSON
config = JSON.parse(result.data.data.systemWebMenu);
} else {
// 如果已经是对象,直接使用
config = result.data.data.systemWebMenu;
}
// 检查配置中是否包含features对象
if (config && config.features) {
// 确保knowledgeBase功能存在且配置正确
if (!config.features.knowledgeBase) {
console.warn('配置中缺少knowledgeBase功能,合并默认配置');
config.features = { ...this.defaultFeatures, ...config.features };
}
resolve(config.features);
} else {
console.warn('配置中缺少features对象,使用默认配置');
resolve(this.defaultFeatures);
}
} catch (error) {
console.warn('处理systemWebMenu配置失败:', error);
resolve(null);
}
} else {
console.warn('接口返回code不为0或缺少必要数据,使用默认配置');
resolve(null);
}
} else {
// 如果没有code字段,直接检查systemWebMenu
if (result.data && result.data.systemWebMenu) {
try {
let config;
if (typeof result.data.systemWebMenu === 'string') {
// 如果是字符串,需要解析JSON
config = JSON.parse(result.data.systemWebMenu);
} else {
// 如果已经是对象,直接使用
config = result.data.systemWebMenu;
}
// 检查配置中是否包含features对象
if (config && config.features) {
// 确保knowledgeBase功能存在且配置正确
if (!config.features.knowledgeBase) {
console.warn('配置中缺少knowledgeBase功能,合并默认配置');
config.features = { ...this.defaultFeatures, ...config.features };
}
resolve(config.features);
} else {
console.warn('配置中缺少features对象,使用默认配置');
resolve(this.defaultFeatures);
}
} catch (error) {
console.warn('处理systemWebMenu配置失败:', error);
resolve(null);
}
} else {
console.warn('接口返回缺少systemWebMenu数据,使用默认配置');
resolve(null);
}
}
} else {
console.warn('接口返回数据中缺少data字段,使用默认配置');
resolve(null);
}
} else {
console.warn('pub-config接口调用失败,使用默认配置');
resolve(null);
}
});
});
}
/**
* 获取当前配置
*/
getCurrentConfig() {
// 返回内存中的当前配置
return this.currentFeatures;
}
/**
* 保存配置到后端API
*/
async saveConfig(config) {
try {
// 更新内存中的配置
this.currentFeatures = { ...config };
// 异步保存到后端API
this.saveConfigToAPI(config).catch(error => {
console.warn('保存配置到API失败:', error);
});
// 触发配置变更事件
window.dispatchEvent(new CustomEvent('featureConfigChanged', {
detail: config
}));
} catch (error) {
console.error('保存功能配置失败:', error);
}
}
/**
* 保存配置到后端API
*/
async saveConfigToAPI(config) {
return new Promise((resolve) => {
// 直接使用已知的ID(600)更新参数
Api.admin.updateParam(
{
id: 600,
paramCode: 'system-web.menu',
paramValue: JSON.stringify({
features: config,
groups: {
featureManagement: ["voiceprintRecognition", "voiceClone", "knowledgeBase", "mcpAccessPoint"],
voiceManagement: ["vad", "asr"]
}
}),
valueType: 'json',
remark: '系统功能菜单配置'
},
(updateResult) => {
if (updateResult.code === 0) {
resolve();
} else {
// 如果更新失败,可能是参数不存在或其他错误,记录但不阻止保存到localStorage
console.warn('更新参数失败:', updateResult.msg);
resolve(); // 不阻止保存到localStorage
}
},
(error) => {
console.warn('更新参数失败:', error);
resolve(); // 不阻止保存到localStorage
}
);
});
}
/**
* 获取所有功能配置
*/
getAllFeatures() {
return this.getCurrentConfig();
}
/**
* 获取简化的配置对象(用于首页组件)
*/
getConfig() {
const features = this.getAllFeatures();
return {
voiceprintRecognition: features.voiceprintRecognition?.enabled || false,
voiceClone: features.voiceClone?.enabled || false,
knowledgeBase: features.knowledgeBase?.enabled || false,
mcpAccessPoint: features.mcpAccessPoint?.enabled || false,
vad: features.vad?.enabled || false,
asr: features.asr?.enabled || false
};
}
/**
* 获取指定功能的状态
*/
getFeatureStatus(featureKey) {
const features = this.getAllFeatures();
return features[featureKey]?.enabled || false;
}
/**
* 设置功能状态
*/
setFeatureStatus(featureKey, enabled) {
const features = this.getAllFeatures();
if (features[featureKey]) {
features[featureKey].enabled = enabled;
this.saveConfig(features);
return true;
}
return false;
}
/**
* 启用功能
*/
enableFeature(featureKey) {
return this.setFeatureStatus(featureKey, true);
}
/**
* 禁用功能
*/
disableFeature(featureKey) {
return this.setFeatureStatus(featureKey, false);
}
/**
* 切换功能状态
*/
toggleFeature(featureKey) {
const currentStatus = this.getFeatureStatus(featureKey);
return this.setFeatureStatus(featureKey, !currentStatus);
}
/**
* 重置所有功能为默认状态
*/
resetToDefault() {
this.saveConfig(this.defaultFeatures);
}
/**
* 批量更新功能状态
*/
updateFeatures(featureUpdates) {
const features = this.getAllFeatures();
Object.keys(featureUpdates).forEach(featureKey => {
if (features[featureKey]) {
features[featureKey].enabled = featureUpdates[featureKey];
}
});
this.saveConfig(features);
}
/**
* 获取已启用的功能列表
*/
getEnabledFeatures() {
const features = this.getAllFeatures();
return Object.keys(features).filter(key => features[key].enabled);
}
/**
* 检查功能是否启用
*/
isFeatureEnabled(featureKey) {
return this.getFeatureStatus(featureKey);
}
}
// 创建单例实例
const featureManager = new FeatureManager();
export default featureManager;
@@ -0,0 +1,601 @@
<template>
<div class="welcome">
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">{{ $t('header.featureManagement') }}</h2>
</div>
<div class="main-wrapper">
<div class="content-panel">
<div class="content-area">
<el-card class="feature-card" shadow="never">
<div class="config-header">
<div class="header-icon">
<img loading="lazy" src="@/assets/home/equipment.png" alt="" />
</div>
<div class="header-actions">
<el-button @click="!isSaving && toggleSelectAll()" class="btn-select-all" :disabled="isSaving">
{{ isAllSelected ? $t('featureManagement.deselectAll') : $t('featureManagement.selectAll') }}
</el-button>
<el-button type="primary" class="save-btn" @click="handleSave" :disabled="isSaving">
{{ isSaving ? $t('featureManagement.saving') : $t('featureManagement.save') }}
</el-button>
<el-button class="reset-btn" @click="handleReset" :disabled="isSaving">
{{ $t('featureManagement.reset') }}
</el-button>
</div>
</div>
<div class="divider"></div>
<!-- 功能分组容器 - 左右布局 -->
<div class="feature-groups-container">
<!-- 功能管理分组 -->
<div v-if="featureManagementFeatures.length > 0" class="feature-group">
<h3 class="group-title">{{ $t('featureManagement.group.featureManagement') }}</h3>
<div class="features-grid">
<div
v-for="feature in featureManagementFeatures"
:key="feature.id"
class="feature-card-item"
:class="{ 'feature-enabled': feature.enabled, 'feature-disabled': isSaving }"
@click="!isSaving && toggleFeature(feature)"
>
<div class="feature-header">
<h3 class="feature-name">{{ $t(`feature.${feature.id}.name`) }}</h3>
<el-checkbox
v-model="feature.enabled"
@change="!isSaving && toggleFeature(feature)"
class="feature-checkbox"
:disabled="isSaving"
/>
</div>
<p class="feature-description">{{ $t(`feature.${feature.id}.description`) }}</p>
</div>
</div>
</div>
<!-- 语音管理分组 -->
<div v-if="voiceManagementFeatures.length > 0" class="feature-group">
<h3 class="group-title">{{ $t('featureManagement.group.voiceManagement') }}</h3>
<div class="features-grid">
<div
v-for="feature in voiceManagementFeatures"
:key="feature.id"
class="feature-card-item"
:class="{ 'feature-enabled': feature.enabled, 'feature-disabled': isSaving }"
@click="!isSaving && toggleFeature(feature)"
>
<div class="feature-header">
<h3 class="feature-name">{{ $t(`feature.${feature.id}.name`) }}</h3>
<el-checkbox
v-model="feature.enabled"
@change="!isSaving && toggleFeature(feature)"
class="feature-checkbox"
:disabled="isSaving"
/>
</div>
<p class="feature-description">{{ $t(`feature.${feature.id}.description`) }}</p>
</div>
</div>
</div>
</div>
<div v-if="filteredFeatures.length === 0" class="empty-state">
<el-empty :description="$t('featureManagement.noFeatures')">
<p class="empty-tip">{{ $t('featureManagement.contactAdmin') }}</p>
</el-empty>
</div>
</el-card>
</div>
</div>
</div>
<el-footer>
<VersionFooter />
</el-footer>
</div>
</template>
<script>
import HeaderBar from "@/components/HeaderBar.vue";
import VersionFooter from "@/components/VersionFooter.vue";
import featureManager from "@/utils/featureManager.js";
export default {
name: "FeatureManagement",
components: {
HeaderBar,
VersionFooter
},
data() {
return {
pendingChanges: false,
featureManagementFeatures: [],
voiceManagementFeatures: [],
isSaving: false // 添加保存状态锁定
}
},
computed: {
// 所有功能列表
filteredFeatures() {
return [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
},
// 判断是否所有功能都已选中
isAllSelected() {
const allFeatures = [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
return allFeatures.length > 0 && allFeatures.every(feature => feature.enabled)
}
},
async created() {
// 等待功能配置管理器初始化完成
try {
console.log('等待功能配置管理器初始化...')
await featureManager.waitForInitialization()
console.log('功能配置管理器初始化完成,开始加载功能配置')
await this.loadFeatures()
this.setupConfigChangeListener()
} catch (error) {
console.error('功能配置管理器初始化等待失败:', error)
await this.loadFeatures()
this.setupConfigChangeListener()
}
},
beforeDestroy() {
this.removeConfigChangeListener()
},
methods: {
// 根据ID列表获取功能
async getFeaturesByIds(featureIds) {
try {
const featureConfig = await featureManager.getAllFeatures()
console.log('获取到的功能配置:', JSON.stringify(featureConfig, null, 2))
console.log('请求的功能ID列表:', featureIds)
const result = featureIds.map(id => {
const feature = featureConfig[id]
console.log(`功能 ${id} 的配置:`, feature)
console.log(`功能 ${id} 的启用状态:`, feature?.enabled)
return {
id: id,
name: this.$t(`feature.${id}.name`),
description: this.$t(`feature.${id}.description`),
enabled: feature?.enabled || false
}
})
console.log('最终返回的功能列表:', JSON.stringify(result, null, 2))
return result
} catch (error) {
console.error('获取功能配置失败:', error)
// 如果获取失败,返回默认配置
return featureIds.map(id => ({
id: id,
name: this.$t(`feature.${id}.name`),
description: this.$t(`feature.${id}.description`),
enabled: false
}))
}
},
// 加载功能配置
async loadFeatures() {
// 保存当前用户的选择状态
const currentFeatureStates = {}
const allCurrentFeatures = [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
allCurrentFeatures.forEach(feature => {
currentFeatureStates[feature.id] = feature.enabled
})
// 重新加载配置
this.featureManagementFeatures = await this.getFeaturesByIds(['voiceprintRecognition', 'voiceClone', 'knowledgeBase', 'mcpAccessPoint'])
this.voiceManagementFeatures = await this.getFeaturesByIds(['vad', 'asr'])
// 恢复用户的选择状态(如果存在)
const allFeatures = [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
allFeatures.forEach(feature => {
if (currentFeatureStates.hasOwnProperty(feature.id)) {
feature.enabled = currentFeatureStates[feature.id]
}
})
},
// 切换功能状态
async toggleFeature(feature) {
// 如果正在保存,阻止操作
if (this.isSaving) {
return
}
feature.enabled = !feature.enabled
this.pendingChanges = true
// 不再立即更新到配置管理器,只在保存时统一更新
},
// 保存配置
async handleSave() {
if (!this.pendingChanges) {
this.$message.info({
message: this.$t('featureManagement.noChanges'),
showClose: true
})
return
}
// 设置保存状态,锁定界面
this.isSaving = true
try {
// 获取当前所有功能的状态并保存
const featureUpdates = {}
const allFeatures = [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
allFeatures.forEach(feature => {
featureUpdates[feature.id] = feature.enabled
})
await featureManager.updateFeatures(featureUpdates)
this.pendingChanges = false
this.$message.success({
message: this.$t('featureManagement.saveSuccess'),
showClose: true
})
setTimeout(() => {
this.loadFeatures()
this.$router.go(0)
}, 1000)
} catch (error) {
console.error('保存配置失败:', error)
this.$message.error({
message: this.$t('featureManagement.saveError'),
showClose: true
})
} finally {
// 无论成功与否,都解除保存状态锁定
this.isSaving = false
}
},
// 设置配置变化监听器
setupConfigChangeListener() {
this.configChangeHandler = () => {
console.log('检测到配置变化,重新加载功能列表')
this.loadFeatures()
}
window.addEventListener('featureConfigReloaded', this.configChangeHandler)
},
// 移除配置变化监听器
removeConfigChangeListener() {
if (this.configChangeHandler) {
window.removeEventListener('featureConfigReloaded', this.configChangeHandler)
}
},
// 重置配置
async handleReset() {
try {
await this.$confirm(
this.$t('featureManagement.resetConfirm'),
this.$t('featureManagement.reset'),
{
confirmButtonText: this.$t('featureManagement.confirm'),
cancelButtonText: this.$t('featureManagement.cancel'),
type: 'warning'
}
)
featureManager.resetToDefault()
this.loadFeatures()
this.pendingChanges = false
this.$message.success({
message: this.$t('featureManagement.resetSuccess'),
showClose: true
})
setTimeout(() => {
this.loadFeatures()
this.$router.go(0)
}, 1000)
} catch (error) {
// 用户取消操作
}
},
// 搜索功能(预留接口)
handleSearch() {
// 搜索功能待实现
},
// 全选/取消全选
toggleSelectAll() {
// 如果正在保存,阻止操作
if (this.isSaving) {
return
}
const allFeatures = [...this.featureManagementFeatures, ...this.voiceManagementFeatures]
const newStatus = !this.isAllSelected
allFeatures.forEach(feature => {
feature.enabled = newStatus
})
this.pendingChanges = true
}
}
}
</script>
<style 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;
}
.operation-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
}
.page-title {
font-size: 24px;
margin: 0;
}
.config-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0 16px 0;
}
.header-icon {
width: 40px;
height: 40px;
background: #5778ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
}
.header-icon img {
width: 20px;
height: 20px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.divider {
height: 1px;
background: #e0e0e0;
margin-bottom: 20px;
}
.btn-select-all {
background: #e6ebff;
color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 8px 16px;
height: 32px;
font-size: 14px;
}
.btn-select-all:hover {
background: #d0d8ff;
}
.save-btn {
background: #5778ff;
color: white;
border: none;
border-radius: 18px;
padding: 8px 16px;
height: 32px;
font-size: 14px;
}
.save-btn:hover {
background: #4a6ae8;
}
.reset-btn {
background: #e6ebff;
color: #5778ff;
border: 1px solid #adbdff;
border-radius: 18px;
padding: 8px 16px;
height: 32px;
}
.reset-btn:hover {
background: #d0d8ff;
}
.main-wrapper {
margin: 0 22px 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;
}
.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;
}
.feature-card {
background: white;
flex: 1;
display: flex;
flex-direction: column;
border: none;
box-shadow: none;
overflow: hidden;
}
.feature-card ::v-deep .el-card__body {
padding: 24px;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.feature-card-item {
display: flex;
flex-direction: column;
padding: 20px;
border-radius: 12px;
border: 2px solid #e0e0e0;
background-color: white;
cursor: pointer;
transition: all 0.3s ease;
user-select: none;
position: relative;
}
.feature-card-item:hover {
border-color: #869bf0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.feature-card-item.feature-enabled {
border-color:#5778ff;
box-shadow: 0 4px 16px rgba(95, 112, 243, 0.2);
transform: translateY(-2px);
}
.feature-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.feature-checkbox ::v-deep .el-checkbox__input {
transform: scale(1.2);
}
.feature-checkbox ::v-deep .el-checkbox__input.is-checked .el-checkbox__inner {
background-color: #5778ff;
border-color: #5778ff;
}
.feature-checkbox ::v-deep .el-checkbox__input.is-checked + .el-checkbox__label {
color: #5778ff;
}
.feature-name {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
transition: color 0.3s ease;
}
.feature-description {
font-size: 14px;
line-height: 1.6;
color: #666;
margin: 0 0 12px 0;
transition: color 0.3s ease;
text-align: left;
}
/* 功能分组容器 - 左右布局 */
.feature-groups-container {
display: flex;
gap: 32px;
align-items: flex-start;
position: relative;
}
/* 分组之间的分隔线 */
.feature-groups-container::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
height: 550px;
background: #e0e0e0;
opacity: 0.5;
transform: translateX(-50%);
}
/* 分组样式 */
.feature-group {
flex: 1;
min-width: 0;
margin-bottom: 32px;
}
.group-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
padding-left: 12px;
border-left: 4px solid #5f70f3;
text-align: left;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
</style>
+24 -4
View File
@@ -39,8 +39,9 @@
</template> </template>
<template v-else> <template v-else>
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" @configure="goToRoleConfig" <DeviceItem v-for="(item, index) in devices" :key="index" :device="item" :feature-status="featureStatus"
@deviceManage="handleDeviceManage" @delete="handleDeleteAgent" @chat-history="handleShowChatHistory" /> @configure="goToRoleConfig" @deviceManage="handleDeviceManage" @delete="handleDeleteAgent"
@chat-history="handleShowChatHistory" />
</template> </template>
</div> </div>
</div> </div>
@@ -61,6 +62,7 @@ import ChatHistoryDialog from '@/components/ChatHistoryDialog.vue';
import DeviceItem from '@/components/DeviceItem.vue'; import DeviceItem from '@/components/DeviceItem.vue';
import HeaderBar from '@/components/HeaderBar.vue'; import HeaderBar from '@/components/HeaderBar.vue';
import VersionFooter from '@/components/VersionFooter.vue'; import VersionFooter from '@/components/VersionFooter.vue';
import featureManager from '@/utils/featureManager';
export default { export default {
name: 'HomePage', name: 'HomePage',
@@ -76,15 +78,33 @@ export default {
skeletonCount: localStorage.getItem('skeletonCount') || 8, skeletonCount: localStorage.getItem('skeletonCount') || 8,
showChatHistory: false, showChatHistory: false,
currentAgentId: '', currentAgentId: '',
currentAgentName: '' currentAgentName: '',
// 功能状态
featureStatus: {
voiceprintRecognition: false,
voiceClone: false,
knowledgeBase: false
}
} }
}, },
mounted() { async mounted() {
this.fetchAgentList(); this.fetchAgentList();
await this.loadFeatureStatus();
}, },
methods: { methods: {
// 加载功能状态
async loadFeatureStatus() {
await featureManager.waitForInitialization();
const config = featureManager.getConfig();
this.featureStatus = {
voiceprintRecognition: config.voiceprintRecognition,
voiceClone: config.voiceClone,
knowledgeBase: config.knowledgeBase
};
},
showAddDialog() { showAddDialog() {
this.addDeviceDialogVisible = true this.addDeviceDialogVisible = true
}, },
+8
View File
@@ -156,6 +156,7 @@ import VersionFooter from "@/components/VersionFooter.vue";
import i18n, { changeLanguage } from "@/i18n"; import i18n, { changeLanguage } from "@/i18n";
import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from "@/utils"; import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from "@/utils";
import { mapState } from "vuex"; import { mapState } from "vuex";
import featureManager from "@/utils/featureManager";
export default { export default {
name: "login", name: "login",
@@ -214,6 +215,13 @@ export default {
this.$store.dispatch("fetchPubConfig").then(() => { this.$store.dispatch("fetchPubConfig").then(() => {
// 根据配置决定默认登录方式 // 根据配置决定默认登录方式
this.isMobileLogin = this.enableMobileRegister; this.isMobileLogin = this.enableMobileRegister;
// pub-config接口调用完成后,重新初始化featureManager以确保使用最新的配置
featureManager.waitForInitialization().then(() => {
console.log('featureManager重新初始化完成,使用pub-config配置');
}).catch(error => {
console.warn('featureManager重新初始化失败:', error);
});
}); });
}, },
methods: { methods: {
+101 -6
View File
@@ -55,10 +55,24 @@
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item :label="$t('roleConfig.contextProvider') + ''" class="context-provider-item">
<div style="display: flex; align-items: center; justify-content: space-between;">
<span style="color: #606266; font-size: 13px;">
{{ $t('roleConfig.contextProviderSuccess', { count: currentContextProviders.length }) }}<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/context-provider-integration.md" target="_blank" class="doc-link">{{ $t('roleConfig.contextProviderDocLink') }}</a>
</span>
<el-button
class="edit-function-btn"
size="small"
@click="openContextProviderDialog"
>
{{ $t('roleConfig.editContextProvider') }}
</el-button>
</div>
</el-form-item>
<el-form-item :label="$t('roleConfig.roleIntroduction') + ''"> <el-form-item :label="$t('roleConfig.roleIntroduction') + ''">
<el-input <el-input
type="textarea" type="textarea"
rows="9" rows="8"
resize="none" resize="none"
:placeholder="$t('roleConfig.pleaseEnterContent')" :placeholder="$t('roleConfig.pleaseEnterContent')"
v-model="form.systemPrompt" v-model="form.systemPrompt"
@@ -71,7 +85,7 @@
<el-form-item :label="$t('roleConfig.memoryHis') + ''"> <el-form-item :label="$t('roleConfig.memoryHis') + ''">
<el-input <el-input
type="textarea" type="textarea"
rows="6" rows="4"
resize="none" resize="none"
v-model="form.summaryMemory" v-model="form.summaryMemory"
maxlength="2000" maxlength="2000"
@@ -107,7 +121,11 @@
</div> </div>
<div class="form-column"> <div class="form-column">
<div class="model-row"> <div class="model-row">
<el-form-item :label="$t('roleConfig.vad')" class="model-item"> <el-form-item
v-if="featureStatus.vad"
:label="$t('roleConfig.vad')"
class="model-item"
>
<div class="model-select-wrapper"> <div class="model-select-wrapper">
<el-select <el-select
v-model="form.model.vadModelId" v-model="form.model.vadModelId"
@@ -125,7 +143,11 @@
</el-select> </el-select>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item :label="$t('roleConfig.asr')" class="model-item"> <el-form-item
v-if="featureStatus.asr"
:label="$t('roleConfig.asr')"
class="model-item"
>
<div class="model-select-wrapper"> <div class="model-select-wrapper">
<el-select <el-select
v-model="form.model.asrModelId" v-model="form.model.asrModelId"
@@ -267,6 +289,11 @@
@update-functions="handleUpdateFunctions" @update-functions="handleUpdateFunctions"
@dialog-closed="handleDialogClosed" @dialog-closed="handleDialogClosed"
/> />
<context-provider-dialog
:visible.sync="showContextProviderDialog"
:providers="currentContextProviders"
@confirm="handleUpdateContext"
/>
</div> </div>
</template> </template>
@@ -275,14 +302,17 @@ import Api from "@/apis/api";
import { getServiceUrl } from "@/apis/api"; import { getServiceUrl } from "@/apis/api";
import RequestService from "@/apis/httpRequest"; import RequestService from "@/apis/httpRequest";
import FunctionDialog from "@/components/FunctionDialog.vue"; import FunctionDialog from "@/components/FunctionDialog.vue";
import ContextProviderDialog from "@/components/ContextProviderDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue"; import HeaderBar from "@/components/HeaderBar.vue";
import i18n from "@/i18n"; import i18n from "@/i18n";
import featureManager from "@/utils/featureManager";
export default { export default {
name: "RoleConfigPage", name: "RoleConfigPage",
components: { HeaderBar, FunctionDialog }, components: { HeaderBar, FunctionDialog, ContextProviderDialog },
data() { data() {
return { return {
showContextProviderDialog: false,
form: { form: {
agentCode: "", agentCode: "",
agentName: "", agentName: "",
@@ -320,12 +350,18 @@ export default {
voiceDetails: {}, // 保存完整的音色信息 voiceDetails: {}, // 保存完整的音色信息
showFunctionDialog: false, showFunctionDialog: false,
currentFunctions: [], currentFunctions: [],
currentContextProviders: [],
allFunctions: [], allFunctions: [],
originalFunctions: [], originalFunctions: [],
playingVoice: false, playingVoice: false,
isPaused: false, isPaused: false,
currentAudio: null, currentAudio: null,
currentPlayingVoiceId: null, currentPlayingVoiceId: null,
// 功能状态
featureStatus: {
vad: false, // 语言检测活动功能状态
asr: false, // 语音识别功能状态
},
}; };
}, },
methods: { methods: {
@@ -356,6 +392,7 @@ export default {
paramInfo: item.params, paramInfo: item.params,
}; };
}), }),
contextProviders: this.currentContextProviders,
}; };
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => { Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => {
if (data.code === 0) { if (data.code === 0) {
@@ -472,6 +509,9 @@ export default {
}; };
// 后端只给了最小映射:[{ id, agentId, pluginId }, ...] // 后端只给了最小映射:[{ id, agentId, pluginId }, ...]
const savedMappings = data.data.functions || []; const savedMappings = data.data.functions || [];
// 加载上下文配置
this.currentContextProviders = data.data.contextProviders || [];
// 先保证 allFunctions 已经加载(如果没有,则先 fetchAllFunctions // 先保证 allFunctions 已经加载(如果没有,则先 fetchAllFunctions
const ensureFuncs = this.allFunctions.length const ensureFuncs = this.allFunctions.length
@@ -646,6 +686,12 @@ export default {
this.showFunctionDialog = true; this.showFunctionDialog = true;
} }
}, },
openContextProviderDialog() {
this.showContextProviderDialog = true;
},
handleUpdateContext(providers) {
this.currentContextProviders = providers;
},
handleUpdateFunctions(selected) { handleUpdateFunctions(selected) {
this.currentFunctions = selected; this.currentFunctions = selected;
}, },
@@ -980,6 +1026,19 @@ export default {
this.form.chatHistoryConf = 0; this.form.chatHistoryConf = 0;
} }
}, },
// 加载功能状态
async loadFeatureStatus() {
try {
// 确保featureManager已初始化完成
await featureManager.waitForInitialization();
const config = featureManager.getConfig();
this.featureStatus.voiceprintRecognition = config.voiceprintRecognition || false;
this.featureStatus.vad = config.vad || false;
this.featureStatus.asr = config.asr || false;
} catch (error) {
console.error("加载功能状态失败:", error);
}
},
}, },
watch: { watch: {
"form.model.ttsModelId": { "form.model.ttsModelId": {
@@ -1002,7 +1061,7 @@ export default {
immediate: true, immediate: true,
}, },
}, },
mounted() { async mounted() {
const agentId = this.$route.query.agentId; const agentId = this.$route.query.agentId;
if (agentId) { if (agentId) {
this.fetchAgentConfig(agentId); this.fetchAgentConfig(agentId);
@@ -1010,6 +1069,8 @@ export default {
} }
this.fetchModelOptions(); this.fetchModelOptions();
this.fetchTemplates(); this.fetchTemplates();
// 加载功能状态,确保featureManager已初始化
await this.loadFeatureStatus();
}, },
}; };
</script> </script>
@@ -1298,6 +1359,26 @@ export default {
justify-content: flex-end; justify-content: flex-end;
} }
.chat-history-options ::v-deep .el-radio-button {
border-color: #5778ff;
}
.chat-history-options ::v-deep .el-radio-button .el-radio-button__inner {
color: #5778ff;
border-color: #5778ff;
background-color: transparent;
}
.chat-history-options ::v-deep .el-radio-button.is-active .el-radio-button__inner {
background-color: #5778ff;
border-color: #5778ff;
color: white;
}
.chat-history-options ::v-deep .el-radio-button .el-radio-button__inner:hover {
color: #5778ff;
}
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1345,4 +1426,18 @@ export default {
height: 32px; height: 32px;
margin-left: 8px; margin-left: 8px;
} }
.context-provider-item ::v-deep .el-form-item__label {
line-height: 42px !important;
}
.doc-link {
color: #5778ff;
text-decoration: none;
margin-left: 4px;
&:hover {
text-decoration: underline;
}
}
</style> </style>
@@ -74,6 +74,7 @@
- **今天农历:** {{lunar_date}} - **今天农历:** {{lunar_date}}
- **用户所在城市:** {{local_address}} - **用户所在城市:** {{local_address}}
- **当地未来7天天气:** {{weather_info}} - **当地未来7天天气:** {{weather_info}}
{{ dynamic_context }}
</context> </context>
<memory> <memory>
+9 -1
View File
@@ -113,6 +113,15 @@ wakeup_words:
# MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token # MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
# 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md # 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
mcp_endpoint: 你的接入点 websocket地址 mcp_endpoint: 你的接入点 websocket地址
# 上下文源配置
# 用于在系统提示词中注入动态数据,如健康数据、股票信息等
# 可以添加多个上下文源
context_providers:
- url: ""
headers:
Authorization: ""
# 插件的基础配置 # 插件的基础配置
plugins: plugins:
# 获取天气插件的配置,这里填写你的api_key # 获取天气插件的配置,这里填写你的api_key
@@ -462,7 +471,6 @@ ASR:
domain: slm # 识别领域,iat:日常用语,medical:医疗,finance:金融等 domain: slm # 识别领域,iat:日常用语,medical:医疗,finance:金融等
language: zh_cn # 语言,zh_cn:中文,en_us:英文 language: zh_cn # 语言,zh_cn:中文,en_us:英文
accent: mandarin # 方言,mandarin:普通话 accent: mandarin # 方言,mandarin:普通话
dwa: wpgs # 动态修正,wpgs:实时返回中间结果
# 调整音频处理参数以提高长语音识别质量 # 调整音频处理参数以提高长语音识别质量
output_dir: tmp/ output_dir: tmp/
@@ -68,6 +68,7 @@ async def get_config_from_api_async(config):
"url": config["manager-api"].get("url", ""), "url": config["manager-api"].get("url", ""),
"secret": config["manager-api"].get("secret", ""), "secret": config["manager-api"].get("secret", ""),
} }
auth_enabled = config_data.get("server", {}).get("auth", {}).get("enabled", False)
# server的配置以本地为准 # server的配置以本地为准
if config.get("server"): if config.get("server"):
config_data["server"] = { config_data["server"] = {
@@ -77,6 +78,7 @@ async def get_config_from_api_async(config):
"vision_explain": config["server"].get("vision_explain", ""), "vision_explain": config["server"].get("vision_explain", ""),
"auth_key": config["server"].get("auth_key", ""), "auth_key": config["server"].get("auth_key", ""),
} }
config_data["server"]["auth"] = {"enabled": auth_enabled}
# 如果服务器没有prompt_template,则从本地配置读取 # 如果服务器没有prompt_template,则从本地配置读取
if not config_data.get("prompt_template"): if not config_data.get("prompt_template"):
config_data["prompt_template"] = config.get("prompt_template") config_data["prompt_template"] = config.get("prompt_template")
+1 -1
View File
@@ -5,7 +5,7 @@ from config.config_loader import load_config
from config.settings import check_config_file from config.settings import check_config_file
from datetime import datetime from datetime import datetime
SERVER_VERSION = "0.8.9" SERVER_VERSION = "0.8.10"
_logger_initialized = False _logger_initialized = False
+59 -25
View File
@@ -69,6 +69,7 @@ class ConnectionHandler:
self.server = server # 保存server实例的引用 self.server = server # 保存server实例的引用
self.need_bind = False # 是否需要绑定设备 self.need_bind = False # 是否需要绑定设备
self.bind_completed_event = asyncio.Event()
self.bind_code = None # 绑定设备的验证码 self.bind_code = None # 绑定设备的验证码
self.last_bind_prompt_time = 0 # 上次播放绑定提示的时间戳(秒) self.last_bind_prompt_time = 0 # 上次播放绑定提示的时间戳(秒)
self.bind_prompt_interval = 60 # 绑定提示播放间隔(秒) self.bind_prompt_interval = 60 # 绑定提示播放间隔(秒)
@@ -162,7 +163,7 @@ class ConnectionHandler:
self.conn_from_mqtt_gateway = False self.conn_from_mqtt_gateway = False
# 初始化提示词管理器 # 初始化提示词管理器
self.prompt_manager = PromptManager(config, self.logger) self.prompt_manager = PromptManager(self.config, self.logger)
async def handle_connection(self, ws): async def handle_connection(self, ws):
try: try:
@@ -266,30 +267,43 @@ class ConnectionHandler:
f"保存记忆后关闭连接失败: {close_error}" f"保存记忆后关闭连接失败: {close_error}"
) )
async def _discard_message_with_bind_prompt(self):
"""丢弃消息并检查是否需要播放绑定提示"""
current_time = time.time()
# 检查是否需要播放绑定提示
if current_time - self.last_bind_prompt_time >= self.bind_prompt_interval:
self.last_bind_prompt_time = current_time
# 复用现有的绑定提示逻辑
from core.handle.receiveAudioHandle import check_bind_device
asyncio.create_task(check_bind_device(self))
async def _route_message(self, message): async def _route_message(self, message):
"""消息路由""" """消息路由"""
# 检查是否已经获取到真实的绑定状态
if not self.bind_completed_event.is_set():
# 还没有获取到真实状态,等待直到获取到真实状态或超时
try:
await asyncio.wait_for(self.bind_completed_event.wait(), timeout=1)
except asyncio.TimeoutError:
# 超时仍未获取到真实状态,丢弃消息
await self._discard_message_with_bind_prompt()
return
# 已经获取到真实状态,检查是否需要绑定
if self.need_bind:
# 需要绑定,丢弃消息
await self._discard_message_with_bind_prompt()
return
# 不需要绑定,继续处理消息
if isinstance(message, str): if isinstance(message, str):
await handleTextMessage(self, message) await handleTextMessage(self, message)
elif isinstance(message, bytes): elif isinstance(message, bytes):
if self.vad is None or self.asr is None: if self.vad is None or self.asr is None:
return return
# 未绑定设备直接丢弃所有音频,不进行ASR处理
if self.need_bind:
current_time = time.time()
# 检查是否需要播放绑定提示
if (
current_time - self.last_bind_prompt_time
>= self.bind_prompt_interval
):
self.last_bind_prompt_time = current_time
# 复用现有的绑定提示逻辑
from core.handle.receiveAudioHandle import check_bind_device
asyncio.create_task(check_bind_device(self))
# 直接丢弃音频,不进行ASR处理
return
# 处理来自MQTT网关的音频包 # 处理来自MQTT网关的音频包
if self.conn_from_mqtt_gateway and len(message) >= 16: if self.conn_from_mqtt_gateway and len(message) >= 16:
handled = await self._process_mqtt_audio_message(message) handled = await self._process_mqtt_audio_message(message)
@@ -413,6 +427,14 @@ class ConnectionHandler:
def _initialize_components(self): def _initialize_components(self):
try: try:
if self.tts is None:
self.tts = self._initialize_tts()
# 打开语音合成通道
asyncio.run_coroutine_threadsafe(
self.tts.open_audio_channels(self), self.loop
)
if self.need_bind:
return
self.selected_module_str = build_module_string( self.selected_module_str = build_module_string(
self.config.get("selected_module", {}) self.config.get("selected_module", {})
) )
@@ -436,17 +458,10 @@ class ConnectionHandler:
# 初始化声纹识别 # 初始化声纹识别
self._initialize_voiceprint() self._initialize_voiceprint()
# 打开语音识别通道 # 打开语音识别通道
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
self.asr.open_audio_channels(self), self.loop self.asr.open_audio_channels(self), self.loop
) )
if self.tts is None:
self.tts = self._initialize_tts()
# 打开语音合成通道
asyncio.run_coroutine_threadsafe(
self.tts.open_audio_channels(self), self.loop
)
"""加载记忆""" """加载记忆"""
self._initialize_memory() self._initialize_memory()
@@ -461,6 +476,7 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}") self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}")
def _init_prompt_enhancement(self): def _init_prompt_enhancement(self):
# 更新上下文信息 # 更新上下文信息
self.prompt_manager.update_context_info(self, self.client_ip) self.prompt_manager.update_context_info(self, self.client_ip)
enhanced_prompt = self.prompt_manager.build_enhanced_prompt( enhanced_prompt = self.prompt_manager.build_enhanced_prompt(
@@ -496,7 +512,11 @@ class ConnectionHandler:
def _initialize_asr(self): def _initialize_asr(self):
"""初始化ASR""" """初始化ASR"""
if self._asr.interface_type == InterfaceType.LOCAL: if (
self._asr is not None
and hasattr(self._asr, "interface_type")
and self._asr.interface_type == InterfaceType.LOCAL
):
# 如果公共ASR是本地服务,则直接返回 # 如果公共ASR是本地服务,则直接返回
# 因为本地一个实例ASR,可以被多个连接共享 # 因为本地一个实例ASR,可以被多个连接共享
asr = self._asr asr = self._asr
@@ -536,6 +556,8 @@ class ConnectionHandler:
async def _initialize_private_config_async(self): async def _initialize_private_config_async(self):
"""从接口异步获取差异化配置(异步版本,不阻塞主循环)""" """从接口异步获取差异化配置(异步版本,不阻塞主循环)"""
if not self.read_config_from_api: if not self.read_config_from_api:
self.need_bind = False
self.bind_completed_event.set()
return return
try: try:
begin_time = time.time() begin_time = time.time()
@@ -548,15 +570,20 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).info( self.logger.bind(tag=TAG).info(
f"{time.time() - begin_time} 秒,异步获取差异化配置成功: {json.dumps(filter_sensitive_info(private_config), ensure_ascii=False)}" f"{time.time() - begin_time} 秒,异步获取差异化配置成功: {json.dumps(filter_sensitive_info(private_config), ensure_ascii=False)}"
) )
self.need_bind = False
self.bind_completed_event.set()
except DeviceNotFoundException as e: except DeviceNotFoundException as e:
self.need_bind = True self.need_bind = True
self.bind_completed_event.set() # 状态已确定,设置事件
private_config = {} private_config = {}
except DeviceBindException as e: except DeviceBindException as e:
self.need_bind = True self.need_bind = True
self.bind_code = e.bind_code self.bind_code = e.bind_code
self.bind_completed_event.set() # 状态已确定,设置事件
private_config = {} private_config = {}
except Exception as e: except Exception as e:
self.need_bind = True self.need_bind = True
self.bind_completed_event.set() # 状态已确定,设置事件
self.logger.bind(tag=TAG).error(f"异步获取差异化配置失败: {e}") self.logger.bind(tag=TAG).error(f"异步获取差异化配置失败: {e}")
private_config = {} private_config = {}
@@ -630,6 +657,8 @@ class ConnectionHandler:
self.chat_history_conf = int(private_config["chat_history_conf"]) self.chat_history_conf = int(private_config["chat_history_conf"])
if private_config.get("mcp_endpoint", None) is not None: if private_config.get("mcp_endpoint", None) is not None:
self.config["mcp_endpoint"] = private_config["mcp_endpoint"] self.config["mcp_endpoint"] = private_config["mcp_endpoint"]
if private_config.get("context_providers", None) is not None:
self.config["context_providers"] = private_config["context_providers"]
# 使用 run_in_executor 在线程池中执行 initialize_modules,避免阻塞主循环 # 使用 run_in_executor 在线程池中执行 initialize_modules,避免阻塞主循环
try: try:
@@ -1169,6 +1198,11 @@ class ConnectionHandler:
except queue.Empty: except queue.Empty:
break break
# 重置音频流控器(取消后台任务并清空队列)
if hasattr(self, "audio_rate_controller") and self.audio_rate_controller:
self.audio_rate_controller.reset()
self.logger.bind(tag=TAG).debug("已重置音频流控器")
self.logger.bind(tag=TAG).debug( self.logger.bind(tag=TAG).debug(
f"清理结束: TTS队列大小={self.tts.tts_text_queue.qsize()}, 音频队列大小={self.tts.tts_audio_queue.qsize()}" f"清理结束: TTS队列大小={self.tts.tts_text_queue.qsize()}, 音频队列大小={self.tts.tts_audio_queue.qsize()}"
) )
@@ -101,7 +101,7 @@ async def checkWakeupWords(conn, text):
} }
# 获取音频数据 # 获取音频数据
opus_packets = audio_to_data(response.get("file_path")) opus_packets = await audio_to_data(response.get("file_path"), use_cache=False)
# 播放唤醒词回复 # 播放唤醒词回复
conn.client_abort = False conn.client_abort = False
@@ -123,7 +123,7 @@ async def max_out_size(conn):
text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!" text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!"
await send_stt_message(conn, text) await send_stt_message(conn, text)
file_path = "config/assets/max_output_size.wav" file_path = "config/assets/max_output_size.wav"
opus_packets = audio_to_data(file_path) opus_packets = await audio_to_data(file_path)
conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text)) conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text))
conn.close_after_chat = True conn.close_after_chat = True
@@ -142,7 +142,7 @@ async def check_bind_device(conn):
# 播放提示音 # 播放提示音
music_path = "config/assets/bind_code.wav" music_path = "config/assets/bind_code.wav"
opus_packets = audio_to_data(music_path) opus_packets = await audio_to_data(music_path)
conn.tts.tts_audio_queue.put((SentenceType.FIRST, opus_packets, text)) conn.tts.tts_audio_queue.put((SentenceType.FIRST, opus_packets, text))
# 逐个播放数字 # 逐个播放数字
@@ -150,7 +150,7 @@ async def check_bind_device(conn):
try: try:
digit = conn.bind_code[i] digit = conn.bind_code[i]
num_path = f"config/assets/bind_code/{digit}.wav" num_path = f"config/assets/bind_code/{digit}.wav"
num_packets = audio_to_data(num_path) num_packets = await audio_to_data(num_path)
conn.tts.tts_audio_queue.put((SentenceType.MIDDLE, num_packets, None)) conn.tts.tts_audio_queue.put((SentenceType.MIDDLE, num_packets, None))
except Exception as e: except Exception as e:
conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}") conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
@@ -162,5 +162,5 @@ async def check_bind_device(conn):
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。" text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
await send_stt_message(conn, text) await send_stt_message(conn, text)
music_path = "config/assets/bind_not_found.wav" music_path = "config/assets/bind_not_found.wav"
opus_packets = audio_to_data(music_path) opus_packets = await audio_to_data(music_path)
conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text)) conn.tts.tts_audio_queue.put((SentenceType.LAST, opus_packets, text))
@@ -10,7 +10,6 @@ TTS上报功能已集成到ConnectionHandler类中。
""" """
import time import time
import gc
import opuslib_next import opuslib_next
from config.manage_api_client import report as manage_report from config.manage_api_client import report as manage_report
@@ -16,7 +16,19 @@ async def sendAudioMessage(conn, sentenceType, audios, text):
await send_tts_message(conn, "start", None) await send_tts_message(conn, "start", None)
if sentenceType == SentenceType.FIRST: if sentenceType == SentenceType.FIRST:
await send_tts_message(conn, "sentence_start", text) # 同一句子的后续消息加入流控队列,其他情况立即发送
if (
hasattr(conn, "audio_rate_controller")
and conn.audio_rate_controller
and getattr(conn, "audio_flow_control", {}).get("sentence_id")
== conn.sentence_id
):
conn.audio_rate_controller.add_message(
lambda: send_tts_message(conn, "sentence_start", text)
)
else:
# 新句子或流控器未初始化,立即发送
await send_tts_message(conn, "sentence_start", text)
await sendAudio(conn, audios) await sendAudio(conn, audios)
# 发送句子开始消息 # 发送句子开始消息
@@ -31,6 +43,22 @@ async def sendAudioMessage(conn, sentenceType, audios, text):
await conn.close() await conn.close()
async def _wait_for_audio_completion(conn):
"""
等待音频队列清空
Args:
conn: 连接对象
"""
if hasattr(conn, "audio_rate_controller") and conn.audio_rate_controller:
rate_controller = conn.audio_rate_controller
conn.logger.bind(tag=TAG).debug(
f"等待音频发送完成,队列中还有 {len(rate_controller.queue)} 个包"
)
await rate_controller.queue_empty_event.wait()
conn.logger.bind(tag=TAG).debug("音频发送完成")
async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence): async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence):
""" """
发送带16字节头部的opus数据包给mqtt_gateway 发送带16字节头部的opus数据包给mqtt_gateway
@@ -53,7 +81,6 @@ async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence):
await conn.websocket.send(complete_packet) await conn.websocket.send(complete_packet)
# 播放音频 - 使用 AudioRateController 进行精确流控
async def sendAudio(conn, audios, frame_duration=60): async def sendAudio(conn, audios, frame_duration=60):
""" """
发送音频包,使用 AudioRateController 进行精确的流量控制 发送音频包,使用 AudioRateController 进行精确的流量控制
@@ -62,128 +89,127 @@ async def sendAudio(conn, audios, frame_duration=60):
conn: 连接对象 conn: 连接对象
audios: 单个opus包(bytes) 或 opus包列表 audios: 单个opus包(bytes) 或 opus包列表
frame_duration: 帧时长(毫秒),默认60ms frame_duration: 帧时长(毫秒),默认60ms
改进点:
1. 使用单一时间基准,避免累积误差
2. 每次检查队列时重新计算 elapsed_ms,更精准
3. 支持高并发而不产生时间偏差
""" """
if audios is None or len(audios) == 0: if audios is None or len(audios) == 0:
return return
# 获取发送延迟配置
send_delay = conn.config.get("tts_audio_send_delay", -1) / 1000.0 send_delay = conn.config.get("tts_audio_send_delay", -1) / 1000.0
is_single_packet = isinstance(audios, bytes)
if isinstance(audios, bytes): # 初始化或获取 RateController
# 单个 opus 包处理 rate_controller, flow_control = _get_or_create_rate_controller(
await _sendAudio_single(conn, audios, send_delay, frame_duration) conn, frame_duration, is_single_packet
else: )
# 音频列表处理(如文件型音频)
await _sendAudio_list(conn, audios, send_delay, frame_duration) # 统一转换为列表处理
audio_list = [audios] if is_single_packet else audios
# 发送音频包
await _send_audio_with_rate_control(
conn, audio_list, rate_controller, flow_control, send_delay
)
async def _sendAudio_single(conn, opus_packet, send_delay, frame_duration=60): def _get_or_create_rate_controller(conn, frame_duration, is_single_packet):
""" """
发送单个 opus 包 获取或创建 RateController 和 flow_control
使用 AudioRateController 进行流控
Args:
conn: 连接对象
frame_duration: 帧时长
is_single_packet: 是否单包模式(True: TTS流式单包, False: 批量包)
Returns:
(rate_controller, flow_control)
""" """
# 重置流控状态,第一次读取和会话发生转变时 # 判断是否需要重置:单包模式且 sentence_id 变化,或者控制器不存在
if not hasattr(conn, "audio_rate_controller") or conn.audio_flow_control.get("sentence_id") != conn.sentence_id: need_reset = (
if hasattr(conn, "audio_rate_controller"): is_single_packet
conn.audio_rate_controller.reset() and getattr(conn, "audio_flow_control", {}).get("sentence_id")
else: != conn.sentence_id
) or not hasattr(conn, "audio_rate_controller")
if need_reset:
# 创建或获取 rate_controller
if not hasattr(conn, "audio_rate_controller"):
conn.audio_rate_controller = AudioRateController(frame_duration) conn.audio_rate_controller = AudioRateController(frame_duration)
else:
conn.audio_rate_controller.reset() conn.audio_rate_controller.reset()
# 初始化 flow_control
conn.audio_flow_control = { conn.audio_flow_control = {
"packet_count": 0, "packet_count": 0,
"sequence": 0, "sequence": 0,
"sentence_id": conn.sentence_id, "sentence_id": conn.sentence_id,
} }
if conn.client_abort: # 启动后台发送循环
return _start_background_sender(
conn, conn.audio_rate_controller, conn.audio_flow_control
)
conn.last_activity_time = time.time() * 1000 return conn.audio_rate_controller, conn.audio_flow_control
rate_controller = conn.audio_rate_controller
flow_control = conn.audio_flow_control
packet_count = flow_control["packet_count"]
# 预缓冲:前5个包直接发送,不做延迟
pre_buffer_count = 5
if packet_count < pre_buffer_count or send_delay > 0:
# 预缓冲阶段或固定延迟模式,直接发送
await _do_send_audio(conn, opus_packet, flow_control, frame_duration)
if send_delay > 0 and packet_count >= pre_buffer_count:
await asyncio.sleep(send_delay)
else:
# 使用流控器进行精确的速率控制
rate_controller.add_audio(opus_packet)
async def send_callback(packet):
await _do_send_audio(conn, packet, flow_control, frame_duration)
await rate_controller.check_queue(send_callback)
# 更新流控状态
flow_control["packet_count"] += 1
flow_control["sequence"] += 1
async def _sendAudio_list(conn, audios, send_delay, frame_duration=60): def _start_background_sender(conn, rate_controller, flow_control):
""" """
发送音频列表(如文件型音频) 启动后台发送循环任务
Args:
conn: 连接对象
rate_controller: 速率控制器
flow_control: 流控状态
""" """
if not audios:
return
rate_controller = AudioRateController(frame_duration) async def send_callback(packet):
rate_controller.reset() # 检查是否应该中止
flow_control = {
"packet_count": 0,
"sequence": 0,
}
# 预缓冲:前5个包直接发送
pre_buffer_frames = min(5, len(audios))
for i in range(pre_buffer_frames):
if conn.client_abort: if conn.client_abort:
return raise asyncio.CancelledError("客户端已中止")
await _do_send_audio(conn, audios[i], flow_control, frame_duration)
conn.last_activity_time = time.time() * 1000
await _do_send_audio(conn, packet, flow_control)
conn.client_is_speaking = True conn.client_is_speaking = True
remaining_audios = audios[pre_buffer_frames:] # 使用 start_sending 启动后台循环
rate_controller.start_sending(send_callback)
# 处理剩余音频帧
for i, opus_packet in enumerate(remaining_audios): async def _send_audio_with_rate_control(
conn, audio_list, rate_controller, flow_control, send_delay
):
"""
使用 rate_controller 发送音频包
Args:
conn: 连接对象
audio_list: 音频包列表
rate_controller: 速率控制器
flow_control: 流控状态
send_delay: 固定延迟(秒),-1表示使用动态流控
"""
pre_buffer_count = 5
for packet in audio_list:
if conn.client_abort: if conn.client_abort:
break return
conn.last_activity_time = time.time() * 1000 conn.last_activity_time = time.time() * 1000
if send_delay > 0: # 预缓冲:前5个包直接发送
if flow_control["packet_count"] < pre_buffer_count:
await _do_send_audio(conn, packet, flow_control)
conn.client_is_speaking = True
elif send_delay > 0:
# 固定延迟模式 # 固定延迟模式
await asyncio.sleep(send_delay) await asyncio.sleep(send_delay)
else: await _do_send_audio(conn, packet, flow_control)
# 使用流控器进行精确延迟
rate_controller.add_audio(opus_packet)
async def send_callback(packet):
await _do_send_audio(conn, packet, flow_control, frame_duration)
await rate_controller.check_queue(send_callback)
conn.client_is_speaking = True conn.client_is_speaking = True
continue else:
# 动态流控模式:仅添加到队列,由后台循环负责发送
await _do_send_audio(conn, opus_packet, flow_control, frame_duration) rate_controller.add_audio(packet)
conn.client_is_speaking = True
async def _do_send_audio(conn, opus_packet, flow_control, frame_duration=60): async def _do_send_audio(conn, opus_packet, flow_control):
""" """
执行实际的音频发送 执行实际的音频发送
""" """
@@ -220,8 +246,10 @@ async def send_tts_message(conn, state, text=None):
stop_tts_notify_voice = conn.config.get( stop_tts_notify_voice = conn.config.get(
"stop_tts_notify_voice", "config/assets/tts_notify.mp3" "stop_tts_notify_voice", "config/assets/tts_notify.mp3"
) )
audios = audio_to_data(stop_tts_notify_voice, is_opus=True) audios = await audio_to_data(stop_tts_notify_voice, is_opus=True)
await sendAudio(conn, audios) await sendAudio(conn, audios)
# 等待所有音频包发送完成
await _wait_for_audio_completion(conn)
# 清除服务端讲话状态 # 清除服务端讲话状态
conn.clearSpeakStatus() conn.clearSpeakStatus()
@@ -1,12 +1,14 @@
import time import time
import asyncio
from typing import Dict, Any from typing import Dict, Any
from core.handle.receiveAudioHandle import handleAudioMessage, startToChat from core.handle.receiveAudioHandle import startToChat
from core.handle.reportHandle import enqueue_asr_report from core.handle.reportHandle import enqueue_asr_report
from core.handle.sendAudioHandle import send_stt_message, send_tts_message from core.handle.sendAudioHandle import send_stt_message, send_tts_message
from core.handle.textMessageHandler import TextMessageHandler from core.handle.textMessageHandler import TextMessageHandler
from core.handle.textMessageType import TextMessageType from core.handle.textMessageType import TextMessageType
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length
from core.providers.asr.dto.dto import InterfaceType
TAG = __name__ TAG = __name__
@@ -29,8 +31,18 @@ class ListenTextMessageHandler(TextMessageHandler):
elif msg_json["state"] == "stop": elif msg_json["state"] == "stop":
conn.client_have_voice = True conn.client_have_voice = True
conn.client_voice_stop = True conn.client_voice_stop = True
if len(conn.asr_audio) > 0: if conn.asr.interface_type == InterfaceType.STREAM:
await handleAudioMessage(conn, b"") # 流式模式下,发送结束请求
asyncio.create_task(conn.asr._send_stop_request())
else:
# 非流式模式:直接触发ASR识别
if len(conn.asr_audio) > 0:
asr_audio_task = conn.asr_audio.copy()
conn.asr_audio.clear()
conn.reset_vad_states()
if len(asr_audio_task) > 0:
await conn.asr.handle_voice_stop(conn, asr_audio_task)
elif msg_json["state"] == "detect": elif msg_json["state"] == "detect":
conn.client_have_voice = False conn.client_have_voice = False
conn.asr_audio.clear() conn.asr_audio.clear()
@@ -5,12 +5,9 @@ import hmac
import base64 import base64
import hashlib import hashlib
import asyncio import asyncio
import gc
import requests import requests
import websockets import websockets
import opuslib_next import opuslib_next
import random
from typing import Optional, Tuple, List
from urllib import parse from urllib import parse
from datetime import datetime from datetime import datetime
from config.logger import setup_logging from config.logger import setup_logging
@@ -140,13 +137,13 @@ class ASRProvider(ASRProviderBase):
conn.asr_audio.append(audio) conn.asr_audio.append(audio)
conn.asr_audio = conn.asr_audio[-10:] conn.asr_audio = conn.asr_audio[-10:]
# 只在有声音且没有连接时建立连接 # 只在有声音且没有连接时建立连接(排除正在停止的情况)
if audio_have_voice and not self.is_processing: if audio_have_voice and not self.is_processing and not self.asr_ws:
try: try:
await self._start_recognition(conn) await self._start_recognition(conn)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"开始识别失败: {str(e)}") logger.bind(tag=TAG).error(f"开始识别失败: {str(e)}")
await self._cleanup(conn) await self._cleanup()
return return
if self.asr_ws and self.is_processing and self.server_ready: if self.asr_ws and self.is_processing and self.server_ready:
@@ -186,10 +183,8 @@ class ASRProvider(ASRProviderBase):
"header": { "header": {
"namespace": "SpeechTranscriber", "namespace": "SpeechTranscriber",
"name": "StartTranscription", "name": "StartTranscription",
"status": 20000000,
"message_id": uuid.uuid4().hex, "message_id": uuid.uuid4().hex,
"task_id": self.task_id, "task_id": self.task_id,
"status_text": "Gateway:SUCCESS:Success.",
"appkey": self.appkey "appkey": self.appkey
}, },
"payload": { "payload": {
@@ -208,18 +203,21 @@ class ASRProvider(ASRProviderBase):
async def _forward_results(self, conn): async def _forward_results(self, conn):
"""转发识别结果""" """转发识别结果"""
try: try:
while self.asr_ws and not conn.stop_event.is_set(): while not conn.stop_event.is_set():
try: try:
response = await asyncio.wait_for(self.asr_ws.recv(), timeout=1.0) response = await asyncio.wait_for(self.asr_ws.recv(), timeout=1.0)
result = json.loads(response) result = json.loads(response)
header = result.get("header", {}) header = result.get("header", {})
payload = result.get("payload", {}) payload = result.get("payload", {})
message_name = header.get("name", "") message_name = header.get("name", "")
status = header.get("status", 0) status = header.get("status", 0)
if status != 20000000: if status != 20000000:
if status in [40000004, 40010004]: # 连接超时或客户端断开 if status == 40010004:
logger.bind(tag=TAG).warning(f"请在服务端响应完成后再关闭链接,状态码: {status}")
break
if status in [40000004, 40010003]: # 连接超时或客户端断开
logger.bind(tag=TAG).warning(f"连接问题,状态码: {status}") logger.bind(tag=TAG).warning(f"连接问题,状态码: {status}")
break break
elif status in [40270002, 40270003]: # 音频问题 elif status in [40270002, 40270003]: # 音频问题
@@ -228,12 +226,12 @@ class ASRProvider(ASRProviderBase):
else: else:
logger.bind(tag=TAG).error(f"识别错误,状态码: {status}, 消息: {header.get('status_text', '')}") logger.bind(tag=TAG).error(f"识别错误,状态码: {status}, 消息: {header.get('status_text', '')}")
continue continue
# 收到TranscriptionStarted表示服务器准备好接收音频数据 # 收到TranscriptionStarted表示服务器准备好接收音频数据
if message_name == "TranscriptionStarted": if message_name == "TranscriptionStarted":
self.server_ready = True self.server_ready = True
logger.bind(tag=TAG).debug("服务器已准备,开始发送缓存音频...") logger.bind(tag=TAG).debug("服务器已准备,开始发送缓存音频...")
# 发送缓存音频 # 发送缓存音频
if conn.asr_audio: if conn.asr_audio:
for cached_audio in conn.asr_audio[-10:]: for cached_audio in conn.asr_audio[-10:]:
@@ -244,89 +242,89 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}") logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}")
break break
continue continue
if message_name == "TranscriptionResultChanged":
# 中间结果
text = payload.get("result", "")
if text:
self.text = text
elif message_name == "SentenceEnd": elif message_name == "SentenceEnd":
# 最终结果 # 句子结束(每个句子都会触发)
text = payload.get("result", "") text = payload.get("result", "")
if text: if text:
self.text = text logger.bind(tag=TAG).info(f"识别到文本: {text}")
conn.reset_vad_states()
# 传递缓存的音频数据 # 手动模式下累积识别结果
audio_data = getattr(conn, 'asr_audio_for_voiceprint', []) if conn.client_listen_mode == "manual":
await self.handle_voice_stop(conn, audio_data) if self.text:
# 清空缓存 self.text += text
conn.asr_audio_for_voiceprint = [] else:
break self.text = text
elif message_name == "TranscriptionCompleted":
# 识别完成 # 手动模式下,只有在收到stop信号后才触发处理(仅处理一次)
self.is_processing = False if conn.client_voice_stop:
break audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
if len(audio_data) > 0:
logger.bind(tag=TAG).debug("收到最终识别结果,触发处理")
await self.handle_voice_stop(conn, audio_data)
# 清理音频缓存
conn.asr_audio.clear()
conn.reset_vad_states()
break
else:
# 自动模式下直接覆盖
self.text = text
conn.reset_vad_states()
audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
await self.handle_voice_stop(conn, audio_data)
break
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue logger.bind(tag=TAG).error("接收结果超时")
except websockets.exceptions.ConnectionClosed: break
except websockets.ConnectionClosed:
logger.bind(tag=TAG).info("ASR服务连接已关闭")
self.is_processing = False
break break
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"处理结果失败: {str(e)}") logger.bind(tag=TAG).error(f"处理结果失败: {str(e)}")
break break
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"结果转发失败: {str(e)}") logger.bind(tag=TAG).error(f"结果转发失败: {str(e)}")
finally: finally:
await self._cleanup(conn) # 清理连接的音频缓存
await self._cleanup()
if conn:
if hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'):
conn.asr_audio = []
async def _cleanup(self, conn): async def _send_stop_request(self):
"""清理资源""" """发送停止识别请求(不关闭连接)"""
logger.bind(tag=TAG).debug(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}") if self.asr_ws:
# 清理连接的音频缓存
if conn and hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
# 判断是否需要发送终止请求
should_stop = self.is_processing or self.server_ready
# 发送停止识别请求
if self.asr_ws and should_stop:
try: try:
# 先停止音频发送
self.is_processing = False
stop_msg = { stop_msg = {
"header": { "header": {
"namespace": "SpeechTranscriber", "namespace": "SpeechTranscriber",
"name": "StopTranscription", "name": "StopTranscription",
"status": 20000000,
"message_id": uuid.uuid4().hex, "message_id": uuid.uuid4().hex,
"task_id": self.task_id, "task_id": self.task_id,
"status_text": "Client:Stop",
"appkey": self.appkey "appkey": self.appkey
} }
} }
logger.bind(tag=TAG).debug("正在发送ASR终止请求") logger.bind(tag=TAG).debug("停止识别请求已发送")
await self.asr_ws.send(json.dumps(stop_msg, ensure_ascii=False)) await self.asr_ws.send(json.dumps(stop_msg, ensure_ascii=False))
await asyncio.sleep(0.1)
logger.bind(tag=TAG).debug("ASR终止请求已发送")
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"ASR终止请求发送失败: {e}") logger.bind(tag=TAG).error(f"发送停止识别请求失败: {e}")
# 状态重置(在终止请求发送后) async def _cleanup(self):
"""清理资源(关闭连接)"""
logger.bind(tag=TAG).debug(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}")
# 状态重置
self.is_processing = False self.is_processing = False
self.server_ready = False self.server_ready = False
logger.bind(tag=TAG).debug("ASR状态已重置") logger.bind(tag=TAG).debug("ASR状态已重置")
# 清理任务
if self.forward_task and not self.forward_task.done():
self.forward_task.cancel()
try:
await asyncio.wait_for(self.forward_task, timeout=1.0)
except Exception as e:
logger.bind(tag=TAG).debug(f"forward_task取消异常: {e}")
finally:
self.forward_task = None
# 关闭连接 # 关闭连接
if self.asr_ws: if self.asr_ws:
try: try:
@@ -337,7 +335,10 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).error(f"关闭WebSocket连接失败: {e}") logger.bind(tag=TAG).error(f"关闭WebSocket连接失败: {e}")
finally: finally:
self.asr_ws = None self.asr_ws = None
# 清理任务引用
self.forward_task = None
logger.bind(tag=TAG).debug("ASR会话清理完成") logger.bind(tag=TAG).debug("ASR会话清理完成")
async def speech_to_text(self, opus_data, session_id, audio_format): async def speech_to_text(self, opus_data, session_id, audio_format):
+50 -84
View File
@@ -9,8 +9,6 @@ import asyncio
import traceback import traceback
import threading import threading
import opuslib_next import opuslib_next
import concurrent.futures
import gc
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from config.logger import setup_logging from config.logger import setup_logging
from typing import Optional, Tuple, List from typing import Optional, Tuple, List
@@ -54,121 +52,89 @@ class ASRProviderBase(ABC):
# 接收音频 # 接收音频
async def receive_audio(self, conn, audio, audio_have_voice): async def receive_audio(self, conn, audio, audio_have_voice):
if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime": if conn.client_listen_mode == "manual":
have_voice = audio_have_voice # 手动模式:缓存音频用于ASR识别
conn.asr_audio.append(audio)
else: else:
have_voice = conn.client_have_voice # 自动/实时模式:使用VAD检测
have_voice = audio_have_voice
conn.asr_audio.append(audio)
if not have_voice and not conn.client_have_voice:
conn.asr_audio = conn.asr_audio[-10:]
return
if conn.client_voice_stop: conn.asr_audio.append(audio)
asr_audio_task = conn.asr_audio.copy() if not have_voice and not conn.client_have_voice:
conn.asr_audio.clear() conn.asr_audio = conn.asr_audio[-10:]
conn.reset_vad_states() return
if len(asr_audio_task) > 15: # 自动模式下通过VAD检测到语音停止时触发识别
await self.handle_voice_stop(conn, asr_audio_task) if conn.client_voice_stop:
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: List[bytes]): async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]):
"""并行处理ASR和声纹识别""" """并行处理ASR和声纹识别"""
try: try:
total_start_time = time.monotonic() total_start_time = time.monotonic()
# 准备音频数据 # 准备音频数据
if conn.audio_format == "pcm": if conn.audio_format == "pcm":
pcm_data = asr_audio_task pcm_data = asr_audio_task
else: else:
pcm_data = self.decode_opus(asr_audio_task) pcm_data = self.decode_opus(asr_audio_task)
combined_pcm_data = b"".join(pcm_data) combined_pcm_data = b"".join(pcm_data)
# 预先准备WAV数据 # 预先准备WAV数据
wav_data = None wav_data = None
if conn.voiceprint_provider and combined_pcm_data: if conn.voiceprint_provider and combined_pcm_data:
wav_data = self._pcm_to_wav(combined_pcm_data) wav_data = self._pcm_to_wav(combined_pcm_data)
# 定义ASR任务 # 定义ASR任务
def run_asr(): asr_task = self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format)
start_time = time.monotonic()
try: if conn.voiceprint_provider and wav_data:
loop = asyncio.new_event_loop() voiceprint_task = conn.voiceprint_provider.identify_speaker(wav_data, conn.session_id)
asyncio.set_event_loop(loop) # 并发等待两个结果
try: asr_result, voiceprint_result = await asyncio.gather(
result = loop.run_until_complete( asr_task, voiceprint_task, return_exceptions=True
self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format) )
) else:
end_time = time.monotonic() asr_result = await asr_task
logger.bind(tag=TAG).debug(f"ASR耗时: {end_time - start_time:.3f}s") voiceprint_result = None
return result
finally: # 记录识别结果 - 检查是否为异常
loop.close() if isinstance(asr_result, Exception):
except Exception as e: logger.bind(tag=TAG).error(f"ASR识别失败: {asr_result}")
end_time = time.monotonic() raw_text = ""
logger.bind(tag=TAG).error(f"ASR失败: {e}") else:
return ("", None) raw_text, _ = asr_result
# 定义声纹识别任务 if isinstance(voiceprint_result, Exception):
def run_voiceprint(): logger.bind(tag=TAG).error(f"声纹识别失败: {voiceprint_result}")
if not wav_data: speaker_name = ""
return None else:
try: speaker_name = voiceprint_result
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# 使用连接的声纹识别提供者
result = loop.run_until_complete(
conn.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
# 使用线程池执行器并行运行
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as thread_executor:
asr_future = thread_executor.submit(run_asr)
if conn.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}
# 处理结果
raw_text, _ = results.get("asr", ("", None))
speaker_name = results.get("voiceprint", None)
# 记录识别结果
if raw_text: if raw_text:
logger.bind(tag=TAG).info(f"识别文本: {raw_text}") logger.bind(tag=TAG).info(f"识别文本: {raw_text}")
if speaker_name: if speaker_name:
logger.bind(tag=TAG).info(f"识别说话人: {speaker_name}") logger.bind(tag=TAG).info(f"识别说话人: {speaker_name}")
# 性能监控 # 性能监控
total_time = time.monotonic() - total_start_time total_time = time.monotonic() - total_start_time
logger.bind(tag=TAG).debug(f"总处理耗时: {total_time:.3f}s") logger.bind(tag=TAG).debug(f"总处理耗时: {total_time:.3f}s")
# 检查文本长度 # 检查文本长度
text_len, _ = remove_punctuation_and_length(raw_text) text_len, _ = remove_punctuation_and_length(raw_text)
self.stop_ws_connection() self.stop_ws_connection()
if text_len > 0: if text_len > 0:
# 构建包含说话人信息的JSON字符串 # 构建包含说话人信息的JSON字符串
enhanced_text = self._build_enhanced_text(raw_text, speaker_name) enhanced_text = self._build_enhanced_text(raw_text, speaker_name)
# 使用自定义模块进行上报 # 使用自定义模块进行上报
await startToChat(conn, enhanced_text) await startToChat(conn, enhanced_text)
enqueue_asr_report(conn, enhanced_text, asr_audio_task) enqueue_asr_report(conn, enhanced_text, asr_audio_task)
@@ -4,7 +4,6 @@ import uuid
import asyncio import asyncio
import websockets import websockets
import opuslib_next import opuslib_next
import gc
from core.providers.asr.base import ASRProviderBase from core.providers.asr.base import ASRProviderBase
from config.logger import setup_logging from config.logger import setup_logging
from core.providers.asr.dto.dto import InterfaceType from core.providers.asr.dto.dto import InterfaceType
@@ -19,8 +18,6 @@ class ASRProvider(ASRProviderBase):
self.interface_type = InterfaceType.STREAM self.interface_type = InterfaceType.STREAM
self.config = config self.config = config
self.text = "" self.text = ""
self.max_retries = 3
self.retry_delay = 2
self.decoder = opuslib_next.Decoder(16000, 1) self.decoder = opuslib_next.Decoder(16000, 1)
self.asr_ws = None self.asr_ws = None
self.forward_task = None self.forward_task = None
@@ -57,14 +54,13 @@ class ASRProvider(ASRProviderBase):
async def receive_audio(self, conn, audio, audio_have_voice): async def receive_audio(self, conn, audio, audio_have_voice):
conn.asr_audio.append(audio) conn.asr_audio.append(audio)
conn.asr_audio = conn.asr_audio[-10:] conn.asr_audio = conn.asr_audio[-10:]
# 存储音频数据 # 存储音频数据
if not hasattr(conn, 'asr_audio_for_voiceprint'): if not hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = [] conn.asr_audio_for_voiceprint = []
conn.asr_audio_for_voiceprint.append(audio) conn.asr_audio_for_voiceprint.append(audio)
# 当没有音频数据时处理完整语音片段 # 当没有音频数据时处理完整语音片段
if not audio and len(conn.asr_audio_for_voiceprint) > 0: if conn.client_listen_mode != "manual" and not audio and len(conn.asr_audio_for_voiceprint) > 0:
await self.handle_voice_stop(conn, conn.asr_audio_for_voiceprint) await self.handle_voice_stop(conn, conn.asr_audio_for_voiceprint)
conn.asr_audio_for_voiceprint = [] conn.asr_audio_for_voiceprint = []
@@ -180,6 +176,7 @@ class ASRProvider(ASRProviderBase):
payload.get("audio_info", {}).get("duration", 0) > 2000 payload.get("audio_info", {}).get("duration", 0) > 2000
and not utterances and not utterances
and not payload["result"].get("text") and not payload["result"].get("text")
and conn.client_listen_mode != "manual"
): ):
logger.bind(tag=TAG).error(f"识别文本:空") logger.bind(tag=TAG).error(f"识别文本:空")
self.text = "" self.text = ""
@@ -188,15 +185,44 @@ class ASRProvider(ASRProviderBase):
await self.handle_voice_stop(conn, audio_data) await self.handle_voice_stop(conn, audio_data)
break break
# 专门处理没有文本的识别结果(手动模式下可能已经识别完成但是没松按键)
elif not payload["result"].get("text") and not utterances:
if conn.client_listen_mode == "manual" and conn.client_voice_stop and len(audio_data) > 0:
logger.bind(tag=TAG).debug("消息结束收到停止信号,触发处理")
await self.handle_voice_stop(conn, audio_data)
# 清理音频缓存
conn.asr_audio.clear()
conn.reset_vad_states()
break
for utterance in utterances: for utterance in utterances:
if utterance.get("definite", False): if utterance.get("definite", False):
self.text = utterance["text"] current_text = utterance["text"]
logger.bind(tag=TAG).info( logger.bind(tag=TAG).info(
f"识别到文本: {self.text}" f"识别到文本: {current_text}"
) )
conn.reset_vad_states()
if len(audio_data) > 15: # 确保有足够音频数据 # 手动模式下累积识别结果
await self.handle_voice_stop(conn, audio_data) if conn.client_listen_mode == "manual":
if self.text:
self.text += current_text
else:
self.text = current_text
# 在接收消息中途时收到停止信号
if conn.client_voice_stop and len(audio_data) > 0:
logger.bind(tag=TAG).debug("消息中途收到停止信号,触发处理")
await self.handle_voice_stop(conn, audio_data)
# 清理音频缓存
conn.asr_audio.clear()
conn.reset_vad_states()
break
else:
# 自动模式下直接覆盖
self.text = current_text
conn.reset_vad_states()
if len(audio_data) > 15: # 确保有足够音频数据
await self.handle_voice_stop(conn, audio_data)
break break
elif "error" in payload: elif "error" in payload:
error_msg = payload.get("error", "未知错误") error_msg = payload.get("error", "未知错误")
@@ -228,8 +254,6 @@ class ASRProvider(ASRProviderBase):
conn.asr_audio_for_voiceprint = [] conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'): if hasattr(conn, 'asr_audio'):
conn.asr_audio = [] conn.asr_audio = []
if hasattr(conn, 'has_valid_voice'):
conn.has_valid_voice = False
def stop_ws_connection(self): def stop_ws_connection(self):
if self.asr_ws: if self.asr_ws:
@@ -237,6 +261,20 @@ class ASRProvider(ASRProviderBase):
self.asr_ws = None self.asr_ws = None
self.is_processing = False self.is_processing = False
async def _send_stop_request(self):
"""发送最后一个音频帧以通知服务器结束"""
if self.asr_ws:
try:
# 发送结束标记的音频帧(gzip压缩的空数据)
empty_payload = gzip.compress(b"")
last_audio_request = bytearray(self.generate_last_audio_default_header())
last_audio_request.extend(len(empty_payload).to_bytes(4, "big"))
last_audio_request.extend(empty_payload)
await self.asr_ws.send(last_audio_request)
logger.bind(tag=TAG).debug("已发送结束音频帧")
except Exception as e:
logger.bind(tag=TAG).debug(f"发送结束音频帧时出错: {e}")
def construct_request(self, reqid): def construct_request(self, reqid):
req = { req = {
"app": { "app": {
@@ -388,5 +426,3 @@ class ASRProvider(ASRProviderBase):
conn.asr_audio_for_voiceprint = [] conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'): if hasattr(conn, 'asr_audio'):
conn.asr_audio = [] conn.asr_audio = []
if hasattr(conn, 'has_valid_voice'):
conn.has_valid_voice = False
@@ -1,14 +1,16 @@
import time
import os import os
import sys
import io import io
import sys
import time
import shutil
import psutil import psutil
import asyncio
from config.logger import setup_logging from config.logger import setup_logging
from typing import Optional, Tuple, List from typing import Optional, Tuple, List
from core.providers.asr.base import ASRProviderBase
from funasr import AutoModel from funasr import AutoModel
from funasr.utils.postprocess_utils import rich_transcription_postprocess from funasr.utils.postprocess_utils import rich_transcription_postprocess
import shutil from core.providers.asr.base import ASRProviderBase
from core.providers.asr.dto.dto import InterfaceType from core.providers.asr.dto.dto import InterfaceType
TAG = __name__ TAG = __name__
@@ -90,16 +92,17 @@ class ASRProvider(ASRProviderBase):
else: else:
file_path = self.save_audio_to_file(pcm_data, session_id) file_path = self.save_audio_to_file(pcm_data, session_id)
# 语音识别 # 语音识别 - 使用线程池避免阻塞事件循环
start_time = time.time() start_time = time.time()
result = self.model.generate( result = await asyncio.to_thread(
self.model.generate,
input=combined_pcm_data, input=combined_pcm_data,
cache={}, cache={},
language="auto", language="auto",
use_itn=True, use_itn=True,
batch_size_s=60, batch_size_s=60,
) )
text = rich_transcription_postprocess(result[0]["text"]) text = await asyncio.to_thread(rich_transcription_postprocess, result[0]["text"])
logger.bind(tag=TAG).debug( logger.bind(tag=TAG).debug(
f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}" f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}"
) )
@@ -1,8 +1,5 @@
import os import os
import json
import asyncio
import tempfile import tempfile
import difflib
from typing import Optional, Tuple, List from typing import Optional, Tuple, List
import dashscope import dashscope
from config.logger import setup_logging from config.logger import setup_logging
@@ -16,7 +13,8 @@ logger = setup_logging()
class ASRProvider(ASRProviderBase): class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool): def __init__(self, config: dict, delete_audio_file: bool):
super().__init__() super().__init__()
self.interface_type = InterfaceType.STREAM # 音频文件上传类型,流式文本识别输出
self.interface_type = InterfaceType.NON_STREAM
"""Qwen3-ASR-Flash ASR初始化""" """Qwen3-ASR-Flash ASR初始化"""
# 配置参数 # 配置参数
@@ -130,27 +128,11 @@ class ASRProvider(ASRProviderBase):
# 处理流式响应 # 处理流式响应
full_text = "" full_text = ""
last_text = "" # 用于存储上一个文本片段
for chunk in response: for chunk in response:
try: try:
text = chunk["output"]["choices"][0]["message"].content[0]["text"] text = chunk["output"]["choices"][0]["message"].content[0]["text"]
# 标准化文本片段(去除首尾空格) # 更新为最新的完整文本
normalized_text = text.strip() full_text = text.strip()
# 只有当新文本片段与上一个不同时才处理
if normalized_text != last_text:
# 提取新增的文本部分
# 通过比较当前文本和上一个文本,找到新增的部分
if normalized_text.startswith(last_text):
# 如果当前文本以最后一个文本开头,则新增部分是两者的差集
new_part = normalized_text[len(last_text):]
else:
# 如果不以最后一个文本开头,说明识别结果发生了较大变化,直接使用当前文本
new_part = normalized_text
# 将新增部分添加到完整文本中
full_text += new_part
last_text = normalized_text
# 这里可以实时处理文本片段,例如通过回调函数
except: except:
pass pass
@@ -35,9 +35,6 @@ class ASRProvider(ASRProviderBase):
self.forward_task = None self.forward_task = None
self.is_processing = False self.is_processing = False
self.server_ready = False self.server_ready = False
self.last_frame_sent = False # 标记是否已发送最终帧
self.best_text = "" # 保存最佳识别结果
self.has_final_result = False # 标记是否收到最终识别结果
# 讯飞配置 # 讯飞配置
self.app_id = config.get("app_id") self.app_id = config.get("app_id")
@@ -52,7 +49,6 @@ class ASRProvider(ASRProviderBase):
"domain": config.get("domain", "slm"), "domain": config.get("domain", "slm"),
"language": config.get("language", "zh_cn"), "language": config.get("language", "zh_cn"),
"accent": config.get("accent", "mandarin"), "accent": config.get("accent", "mandarin"),
"dwa": config.get("dwa", "wpgs"),
"result": {"encoding": "utf8", "compress": "raw", "format": "plain"}, "result": {"encoding": "utf8", "compress": "raw", "format": "plain"},
} }
@@ -116,7 +112,7 @@ class ASRProvider(ASRProviderBase):
await self._start_recognition(conn) await self._start_recognition(conn)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"建立ASR连接失败: {str(e)}") logger.bind(tag=TAG).error(f"建立ASR连接失败: {str(e)}")
await self._cleanup(conn) await self._cleanup()
return return
# 发送当前音频数据 # 发送当前音频数据
@@ -126,7 +122,7 @@ class ASRProvider(ASRProviderBase):
await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME) await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).warning(f"发送音频数据时发生错误: {e}") logger.bind(tag=TAG).warning(f"发送音频数据时发生错误: {e}")
await self._cleanup(conn) await self._cleanup()
async def _start_recognition(self, conn): async def _start_recognition(self, conn):
"""开始识别会话""" """开始识别会话"""
@@ -136,6 +132,10 @@ class ASRProvider(ASRProviderBase):
ws_url = self.create_url() ws_url = self.create_url()
logger.bind(tag=TAG).info(f"正在连接ASR服务: {ws_url[:50]}...") logger.bind(tag=TAG).info(f"正在连接ASR服务: {ws_url[:50]}...")
# 如果为手动模式,设置超时时长为一分钟
if conn.client_listen_mode == "manual":
self.iat_params["eos"] = 60000
self.asr_ws = await websockets.connect( self.asr_ws = await websockets.connect(
ws_url, ws_url,
max_size=1000000000, max_size=1000000000,
@@ -146,8 +146,6 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).info("ASR WebSocket连接已建立") logger.bind(tag=TAG).info("ASR WebSocket连接已建立")
self.server_ready = False self.server_ready = False
self.last_frame_sent = False
self.best_text = ""
self.forward_task = asyncio.create_task(self._forward_results(conn)) self.forward_task = asyncio.create_task(self._forward_results(conn))
# 发送首帧音频 # 发送首帧音频
@@ -196,23 +194,12 @@ class ASRProvider(ASRProviderBase):
await self.asr_ws.send(json.dumps(frame_data, ensure_ascii=False)) await self.asr_ws.send(json.dumps(frame_data, ensure_ascii=False))
# 标记是否发送了最终帧
if status == STATUS_LAST_FRAME:
self.last_frame_sent = True
logger.bind(tag=TAG).info("标记最终帧已发送")
async def _forward_results(self, conn): async def _forward_results(self, conn):
"""转发识别结果""" """转发识别结果"""
try: try:
while self.asr_ws and not conn.stop_event.is_set(): while not conn.stop_event.is_set():
# 获取当前连接的音频数据
audio_data = getattr(conn, "asr_audio_for_voiceprint", [])
try: try:
# 如果已发送最终帧,增加超时时间等待完整结果 response = await asyncio.wait_for(self.asr_ws.recv(), timeout=60)
timeout = 3.0 if self.last_frame_sent else 30.0
response = await asyncio.wait_for(
self.asr_ws.recv(), timeout=timeout
)
result = json.loads(response) result = json.loads(response)
logger.bind(tag=TAG).debug(f"收到ASR结果: {result}") logger.bind(tag=TAG).debug(f"收到ASR结果: {result}")
@@ -236,144 +223,27 @@ class ASRProvider(ASRProviderBase):
# 解码base64文本 # 解码base64文本
decoded_text = base64.b64decode(text_data).decode("utf-8") decoded_text = base64.b64decode(text_data).decode("utf-8")
text_json = json.loads(decoded_text) text_json = json.loads(decoded_text)
# 提取文本内容 # 提取文本内容
text_ws = text_json.get("ws", []) text_ws = text_json.get("ws", [])
result_text = ""
for i in text_ws: for i in text_ws:
for j in i.get("cw", []): for j in i.get("cw", []):
w = j.get("w", "") w = j.get("w", "")
result_text += w self.text += w
# 更新识别文本 - 实时更新策略
# 只检查是否为空字符串,不再过滤任何标点符号
# 这样可以确保所有识别到的内容,包括标点符号都能被实时更新
if result_text and result_text.strip():
# 实时更新:正常情况下都更新,提高响应速度
should_update = True
# 保存最佳文本
# 1. 如果是识别完成状态或最终帧后收到的结果,优先保存
# 2. 否则保存最长的有意义文本
# 取消对标点符号的过滤,只检查是否为空
# 这样可以保留所有识别到的内容,包括各种标点符号
is_valid_text = len(result_text.strip()) > 0
if (
self.last_frame_sent or status == 2
) and is_valid_text:
self.best_text = result_text
self.has_final_result = True # 标记已收到最终结果
logger.bind(tag=TAG).debug(
f"保存最终识别结果: {self.best_text}"
)
elif (
len(result_text) > len(self.best_text)
and is_valid_text
and not self.has_final_result
):
self.best_text = result_text
logger.bind(tag=TAG).debug(
f"保存中间最佳文本: {self.best_text}"
)
# 如果已发送最终帧,只过滤空文本
if self.last_frame_sent:
# 只拒绝完全空的结果
if not result_text.strip():
should_update = False
logger.bind(tag=TAG).warning(
f"最终帧后拒绝空文本"
)
if should_update:
# 处理流式识别结果,避免简单替换导致内容丢失
# 1. 如果是中间状态(非最终帧后),可能需要替换为更完整的识别
# 2. 如果是最终帧后收到的结果,可能是对前面文本的补充
if self.last_frame_sent:
# 最终帧后收到的结果可能是标点符号等补充内容
# 检查是否需要合并文本而不是替换
# 如果当前文本是纯标点而前面已有内容,应该追加而不是替换
if len(
self.text
) > 0 and result_text.strip() in [
"",
".",
"?",
"",
"!",
"",
",",
"",
";",
"",
]:
# 对于标点符号,追加到现有文本后
self.text = (
self.text.rstrip().rstrip("。.")
+ result_text
)
else:
# 其他情况保持替换逻辑
self.text = result_text
else:
# 中间状态替换为新的识别结果
self.text = result_text
logger.bind(tag=TAG).info(
f"实时更新识别文本: {self.text} (最终帧已发送: {self.last_frame_sent})"
)
# 识别完成,但如果还没发送最终帧,继续等待
if status == 2: if status == 2:
logger.bind(tag=TAG).info( if conn.client_listen_mode == "manual":
f"识别完成状态已到达,当前识别文本: {self.text}" audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
) if len(audio_data) > 0:
logger.bind(tag=TAG).debug("收到最终识别结果,触发处理")
# 如果还没发送最终帧,继续等待 await self.handle_voice_stop(conn, audio_data)
if not self.last_frame_sent: # 清理音频缓存
logger.bind(tag=TAG).info( conn.asr_audio.clear()
"识别完成但最终帧未发送,继续等待..."
)
continue
# 已发送最终帧且收到完成状态,使用最佳策略选择最终结果
# 优先使用识别完成状态下的最新结果,而不是仅仅基于长度
if self.best_text:
# 如果当前文本是在最终帧发送后或识别完成状态下收到的,优先使用
if (
self.last_frame_sent or status == 2
) and self.text.strip():
logger.bind(tag=TAG).info(
f"使用完成状态下的最新识别结果: {self.text}"
)
elif len(self.best_text) > len(self.text):
logger.bind(tag=TAG).info(
f"使用更长的最佳文本作为最终结果: {self.text} -> {self.best_text}"
)
self.text = self.best_text
logger.bind(tag=TAG).info(f"获取到最终完整文本: {self.text}")
conn.reset_vad_states() conn.reset_vad_states()
if len(audio_data) > 15: # 确保有足够音频数据
# 准备处理结果
pass
break break
except asyncio.TimeoutError: except asyncio.TimeoutError:
if self.last_frame_sent: logger.bind(tag=TAG).error("接收结果超时")
# 超时时也使用最佳文本 break
if self.best_text and len(self.best_text) > len(self.text):
logger.bind(tag=TAG).info(
f"超时,使用最佳文本: {self.text} -> {self.best_text}"
)
self.text = self.best_text
logger.bind(tag=TAG).info(
f"最终帧后超时,使用结果: {self.text}"
)
break
# 如果还没发送最终帧,继续等待
continue
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
logger.bind(tag=TAG).info("ASR服务连接已关闭") logger.bind(tag=TAG).info("ASR服务连接已关闭")
self.is_processing = False self.is_processing = False
@@ -390,17 +260,15 @@ class ASRProvider(ASRProviderBase):
if hasattr(e, "__cause__") and e.__cause__: if hasattr(e, "__cause__") and e.__cause__:
logger.bind(tag=TAG).error(f"错误原因: {str(e.__cause__)}") logger.bind(tag=TAG).error(f"错误原因: {str(e.__cause__)}")
finally: finally:
if self.asr_ws: # 清理连接资源
await self.asr_ws.close() await self._cleanup()
self.asr_ws = None
self.is_processing = False # 清理连接的音频缓存
if conn: if conn:
if hasattr(conn, "asr_audio_for_voiceprint"): if hasattr(conn, "asr_audio_for_voiceprint"):
conn.asr_audio_for_voiceprint = [] conn.asr_audio_for_voiceprint = []
if hasattr(conn, "asr_audio"): if hasattr(conn, "asr_audio"):
conn.asr_audio = [] conn.asr_audio = []
if hasattr(conn, "has_valid_voice"):
conn.has_valid_voice = False
async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]): async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]):
"""处理语音停止,发送最后一帧并处理识别结果""" """处理语音停止,发送最后一帧并处理识别结果"""
@@ -408,22 +276,13 @@ class ASRProvider(ASRProviderBase):
# 先发送最后一帧表示音频结束 # 先发送最后一帧表示音频结束
if self.asr_ws and self.is_processing: if self.asr_ws and self.is_processing:
try: try:
# 取最后一个有效的音频帧作为最后一帧数据 await self._send_audio_frame(b"", STATUS_LAST_FRAME)
last_frame = b"" logger.bind(tag=TAG).debug(f"已发送停止请求")
if asr_audio_task:
last_audio = asr_audio_task[-1]
last_frame = self.decoder.decode(last_audio, 960)
await self._send_audio_frame(last_frame, STATUS_LAST_FRAME)
logger.bind(tag=TAG).info("已发送最后一帧")
# 发送最终帧后,给_forward_results适当时间处理最终结果
await asyncio.sleep(0.25) await asyncio.sleep(0.25)
logger.bind(tag=TAG).info(f"准备处理最终识别结果: {self.text}")
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"发送最后一帧失败: {e}") logger.bind(tag=TAG).error(f"发送停止请求失败: {e}")
# 调用父类的handle_voice_stop方法处理识别结果
await super().handle_voice_stop(conn, asr_audio_task) await super().handle_voice_stop(conn, asr_audio_task)
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}") logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
@@ -437,40 +296,27 @@ class ASRProvider(ASRProviderBase):
self.asr_ws = None self.asr_ws = None
self.is_processing = False self.is_processing = False
async def _cleanup(self, conn): async def _send_stop_request(self):
"""清理资源""" """发送停止识别请求(不关闭连接)"""
logger.bind(tag=TAG).info( if self.asr_ws:
try:
# 先停止音频发送
self.is_processing = False
await self._send_audio_frame(b"", STATUS_LAST_FRAME)
logger.bind(tag=TAG).debug("已发送停止请求")
except Exception as e:
logger.bind(tag=TAG).error(f"发送停止请求失败: {e}")
async def _cleanup(self):
"""清理资源(关闭连接)"""
logger.bind(tag=TAG).debug(
f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}" f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}"
) )
# 发送最后一帧
if self.asr_ws and self.is_processing:
try:
await self._send_audio_frame(b"", STATUS_LAST_FRAME)
await asyncio.sleep(0.1)
logger.bind(tag=TAG).info("已发送最后一帧")
except Exception as e:
logger.bind(tag=TAG).error(f"发送最后一帧失败: {e}")
# 状态重置 # 状态重置
self.is_processing = False self.is_processing = False
self.server_ready = False self.server_ready = False
self.last_frame_sent = False logger.bind(tag=TAG).debug("ASR状态已重置")
self.best_text = ""
self.has_final_result = False
logger.bind(tag=TAG).info("ASR状态已重置")
# 清理任务
if self.forward_task and not self.forward_task.done():
self.forward_task.cancel()
try:
await asyncio.wait_for(self.forward_task, timeout=1.0)
except asyncio.CancelledError:
pass
except Exception as e:
logger.bind(tag=TAG).debug(f"forward_task取消异常: {e}")
finally:
self.forward_task = None
# 关闭连接 # 关闭连接
if self.asr_ws: if self.asr_ws:
@@ -483,16 +329,10 @@ class ASRProvider(ASRProviderBase):
finally: finally:
self.asr_ws = None self.asr_ws = None
# 清理连接的音频缓存 # 清理任务引用
if conn: self.forward_task = None
if hasattr(conn, "asr_audio_for_voiceprint"):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, "asr_audio"):
conn.asr_audio = []
if hasattr(conn, "has_valid_voice"):
conn.has_valid_voice = False
logger.bind(tag=TAG).info("ASR会话清理完成") logger.bind(tag=TAG).debug("ASR会话清理完成")
async def speech_to_text(self, opus_data, session_id, audio_format): async def speech_to_text(self, opus_data, session_id, audio_format):
"""获取识别结果""" """获取识别结果"""
@@ -513,7 +353,7 @@ class ASRProvider(ASRProviderBase):
pass pass
self.forward_task = None self.forward_task = None
self.is_processing = False self.is_processing = False
# 显式释放decoder资源 # 显式释放decoder资源
if hasattr(self, 'decoder') and self.decoder is not None: if hasattr(self, 'decoder') and self.decoder is not None:
try: try:
@@ -530,5 +370,3 @@ class ASRProvider(ASRProviderBase):
conn.asr_audio_for_voiceprint = [] conn.asr_audio_for_voiceprint = []
if hasattr(conn, "asr_audio"): if hasattr(conn, "asr_audio"):
conn.asr_audio = [] conn.asr_audio = []
if hasattr(conn, "has_valid_voice"):
conn.has_valid_voice = False
@@ -2,7 +2,6 @@ import time
import numpy as np import numpy as np
import torch import torch
import opuslib_next import opuslib_next
import gc
from config.logger import setup_logging from config.logger import setup_logging
from core.providers.vad.base import VADProviderBase from core.providers.vad.base import VADProviderBase
@@ -45,6 +44,10 @@ class VADProvider(VADProviderBase):
pass pass
def is_vad(self, conn, opus_packet): def is_vad(self, conn, opus_packet):
# 手动模式:直接返回True,不进行实时VAD检测,所有音频都缓存
if conn.client_listen_mode == "manual":
return True
try: try:
pcm_frame = self.decoder.decode(opus_packet, 960) pcm_frame = self.decoder.decode(opus_packet, 960)
conn.client_audio_buffer.extend(pcm_frame) # 将新数据加入缓冲区 conn.client_audio_buffer.extend(pcm_frame) # 将新数据加入缓冲区
@@ -10,12 +10,6 @@ class AudioRateController:
""" """
音频速率控制器 - 按照60ms帧时长精确控制音频发送 音频速率控制器 - 按照60ms帧时长精确控制音频发送
解决高并发下的时间累积误差问题 解决高并发下的时间累积误差问题
关键改进:
1. 单一时间基准(start_timestamp 只初始化一次)
2. 每次检查队列时重新计算 elapsed_ms,避免累积误差
3. 分离"检查时间""发送"两个操作
4. 支持高并发而不产生延迟
""" """
def __init__(self, frame_duration=60): def __init__(self, frame_duration=60):
@@ -29,24 +23,34 @@ class AudioRateController:
self.start_timestamp = None # 开始时间戳(只读,不修改) self.start_timestamp = None # 开始时间戳(只读,不修改)
self.pending_send_task = None self.pending_send_task = None
self.logger = logger self.logger = logger
self.queue_empty_event = asyncio.Event() # 队列清空事件
self.queue_empty_event.set() # 初始为空状态
def reset(self): def reset(self):
"""重置控制器状态""" """重置控制器状态"""
if self.pending_send_task and not self.pending_send_task.done(): if self.pending_send_task and not self.pending_send_task.done():
self.pending_send_task.cancel() self.pending_send_task.cancel()
try: # 取消任务后,任务会在下次事件循环时清理,无需阻塞等待
# 等待任务取消完成
asyncio.get_event_loop().run_until_complete(self.pending_send_task)
except asyncio.CancelledError:
pass
self.queue.clear() self.queue.clear()
self.play_position = 0 self.play_position = 0
self.start_timestamp = time.time() self.start_timestamp = time.time()
self.queue_empty_event.set() # 队列已清空
def add_audio(self, opus_packet): def add_audio(self, opus_packet):
"""添加音频包到队列""" """添加音频包到队列"""
self.queue.append(("audio", opus_packet)) self.queue.append(("audio", opus_packet))
self.queue_empty_event.clear() # 队列非空,清除事件
def add_message(self, message_callback):
"""
添加消息到队列(立即发送,不占用播放时间)
Args:
message_callback: 消息发送回调函数 async def()
"""
self.queue.append(("message", message_callback))
self.queue_empty_event.clear() # 队列非空,清除事件
def _get_elapsed_ms(self): def _get_elapsed_ms(self):
"""获取已经过的时间(毫秒)""" """获取已经过的时间(毫秒)"""
@@ -62,34 +66,47 @@ class AudioRateController:
send_audio_callback: 发送音频的回调函数 async def(opus_packet) send_audio_callback: 发送音频的回调函数 async def(opus_packet)
""" """
if self.start_timestamp is None: if self.start_timestamp is None:
self.reset() self.start_timestamp = time.time()
while self.queue: while self.queue:
item = self.queue[0] item = self.queue[0]
item_type = item[0] item_type = item[0]
if item_type == "audio": if item_type == "message":
# 消息类型:立即发送,不占用播放时间
_, message_callback = item
self.queue.pop(0)
try:
await message_callback()
except Exception as e:
self.logger.bind(tag=TAG).error(f"发送消息失败: {e}")
raise
elif item_type == "audio":
_, opus_packet = item _, opus_packet = item
# 计算时间差 # 循环等待直到时间到达
elapsed_ms = self._get_elapsed_ms() while True:
output_ms = self.play_position # 计算时间差
elapsed_ms = self._get_elapsed_ms()
output_ms = self.play_position
if elapsed_ms < output_ms: if elapsed_ms < output_ms:
# 还不到发送时间,计算等待时长 # 还不到发送时间,计算等待时长
wait_ms = output_ms - elapsed_ms wait_ms = output_ms - elapsed_ms
# 等待后继续检查(允许被中断) # 等待后继续检查(允许被中断)
try: try:
await asyncio.sleep(wait_ms / 1000) await asyncio.sleep(wait_ms / 1000)
except asyncio.CancelledError: except asyncio.CancelledError:
self.logger.bind(tag=TAG).debug("音频发送任务被取消") self.logger.bind(tag=TAG).debug("音频发送任务被取消")
raise raise
# 等待结束后重新检查时间(循环回到 while True)
else:
# 时间已到,跳出等待循环
break
# 继续循环检查(时间可能已到) # 时间已到,从队列移除并发送
continue
# 时间已到,发送音频
self.queue.pop(0) self.queue.pop(0)
self.play_position += self.frame_duration self.play_position += self.frame_duration
@@ -99,26 +116,27 @@ class AudioRateController:
self.logger.bind(tag=TAG).error(f"发送音频失败: {e}") self.logger.bind(tag=TAG).error(f"发送音频失败: {e}")
raise raise
self.queue_empty_event.set()
async def start_sending(self, send_audio_callback, send_message_callback=None): def start_sending(self, send_audio_callback):
""" """
启动异步发送任务 启动异步发送任务
Args: Args:
send_audio_callback: 发送音频的回调函数 send_audio_callback: 发送音频的回调函数
send_message_callback: 发送消息的回调函数
Returns: Returns:
asyncio.Task: 发送任务 asyncio.Task: 发送任务
""" """
async def _send_loop(): async def _send_loop():
try: try:
while True: while True:
await self.check_queue(send_audio_callback, send_message_callback) await self.check_queue(send_audio_callback)
# 如果队列空了,短暂等待后再检查(避免 busy loop) # 如果队列空了,短暂等待后再检查(避免 busy loop)
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
except asyncio.CancelledError: except asyncio.CancelledError:
self.logger.bind(tag=TAG).info("音频发送循环已停止") self.logger.bind(tag=TAG).debug("音频发送循环已停止")
except Exception as e: except Exception as e:
self.logger.bind(tag=TAG).error(f"音频发送循环异常: {e}") self.logger.bind(tag=TAG).error(f"音频发送循环异常: {e}")
+4
View File
@@ -19,6 +19,7 @@ class CacheType(Enum):
CONFIG = "config" CONFIG = "config"
DEVICE_PROMPT = "device_prompt" DEVICE_PROMPT = "device_prompt"
VOICEPRINT_HEALTH = "voiceprint_health" # 声纹识别健康检查 VOICEPRINT_HEALTH = "voiceprint_health" # 声纹识别健康检查
AUDIO_DATA = "audio_data" # 音频数据缓存
@dataclass @dataclass
@@ -58,5 +59,8 @@ class CacheConfig:
CacheType.VOICEPRINT_HEALTH: cls( CacheType.VOICEPRINT_HEALTH: cls(
strategy=CacheStrategy.TTL, ttl=600, max_size=100 # 10分钟过期 strategy=CacheStrategy.TTL, ttl=600, max_size=100 # 10分钟过期
), ),
CacheType.AUDIO_DATA: cls(
strategy=CacheStrategy.TTL, ttl=600, max_size=100 # 10分钟过期
),
} }
return configs.get(cache_type, cls()) return configs.get(cache_type, cls())
@@ -0,0 +1,64 @@
import httpx
from typing import Dict, Any, List
from config.logger import setup_logging
TAG = __name__
class ContextDataProvider:
"""数据上下文填充,负责从配置的API获取数据"""
def __init__(self, config: Dict[str, Any], logger=None):
self.config = config
self.logger = logger or setup_logging()
self.context_data = ""
def fetch_all(self, device_id: str) -> str:
"""获取所有配置的上下文数据"""
context_providers = self.config.get("context_providers", [])
if not context_providers:
return ""
formatted_lines = []
for provider in context_providers:
url = provider.get("url")
headers = provider.get("headers", {})
if not url:
continue
try:
headers = headers.copy() if isinstance(headers, dict) else {}
# 将 device_id 添加到请求头
headers["device-id"] = device_id
# 发送请求
response = httpx.get(url, headers=headers, timeout=3)
if response.status_code == 200:
result = response.json()
if isinstance(result, dict):
if result.get("code") == 0:
data = result.get("data")
# 格式化数据
if isinstance(data, dict):
for k, v in data.items():
formatted_lines.append(f"- **{k}** {v}")
elif isinstance(data, list):
for item in data:
formatted_lines.append(f"- {item}")
else:
formatted_lines.append(f"- {data}")
else:
self.logger.bind(tag=TAG).warning(f"API {url} 返回错误码: {result.get('msg')}")
else:
self.logger.bind(tag=TAG).warning(f"API {url} 返回的不是JSON字典")
else:
self.logger.bind(tag=TAG).warning(f"API {url} 请求失败: {response.status_code}")
except Exception as e:
self.logger.bind(tag=TAG).error(f"获取上下文数据 {url} 失败: {e}")
# 将所有格式化后的行拼接成一个字符串
self.context_data = "\n".join(formatted_lines)
if self.context_data:
self.logger.bind(tag=TAG).debug(f"已注入动态上下文数据:\n{self.context_data}")
return self.context_data
@@ -6,7 +6,6 @@ Opus编码工具类
import logging import logging
import traceback import traceback
import numpy as np import numpy as np
import gc
from opuslib_next import Encoder from opuslib_next import Encoder
from opuslib_next import constants from opuslib_next import constants
from typing import Optional, Callable, Any from typing import Optional, Callable, Any
@@ -103,6 +102,9 @@ class OpusEncoderUtils:
def _encode(self, frame: np.ndarray) -> Optional[bytes]: def _encode(self, frame: np.ndarray) -> Optional[bytes]:
"""编码一帧音频数据""" """编码一帧音频数据"""
try: try:
# 编码器已释放,跳过编码
if not hasattr(self, 'encoder') or self.encoder is None:
return None
# 将numpy数组转换为bytes # 将numpy数组转换为bytes
frame_bytes = frame.tobytes() frame_bytes = frame.tobytes()
# opuslib要求输入字节数必须是channels*2的倍数 # opuslib要求输入字节数必须是channels*2的倍数
@@ -4,7 +4,6 @@
""" """
import os import os
import cnlunar
from typing import Dict, Any from typing import Dict, Any
from config.logger import setup_logging from config.logger import setup_logging
from jinja2 import Template from jinja2 import Template
@@ -60,6 +59,11 @@ class PromptManager:
self.cache_manager = cache_manager self.cache_manager = cache_manager
self.CacheType = CacheType self.CacheType = CacheType
# 初始化上下文源
from core.utils.context_provider import ContextDataProvider
self.context_provider = ContextDataProvider(config, self.logger)
self.context_data = {}
self._load_base_template() self._load_base_template()
@@ -184,6 +188,14 @@ class PromptManager:
local_address = self._get_location_info(client_ip) local_address = self._get_location_info(client_ip)
# 获取天气信息(使用全局缓存) # 获取天气信息(使用全局缓存)
self._get_weather_info(conn, local_address) self._get_weather_info(conn, local_address)
# 获取配置的上下文数据
if hasattr(conn, "device_id") and conn.device_id:
if self.base_prompt_template and "dynamic_context" in self.base_prompt_template:
self.context_data = self.context_provider.fetch_all(conn.device_id)
else:
self.context_data = ""
self.logger.bind(tag=TAG).debug(f"上下文信息更新完成") self.logger.bind(tag=TAG).debug(f"上下文信息更新完成")
except Exception as e: except Exception as e:
@@ -230,6 +242,7 @@ class PromptManager:
emojiList=EMOJI_List, emojiList=EMOJI_List,
device_id=device_id, device_id=device_id,
client_ip=client_ip, client_ip=client_ip,
dynamic_context=self.context_data,
*args, *args,
**kwargs, **kwargs,
) )
+62 -36
View File
@@ -4,11 +4,11 @@ import json
import copy import copy
import wave import wave
import socket import socket
import asyncio
import requests import requests
import subprocess import subprocess
import numpy as np import numpy as np
import opuslib_next import opuslib_next
import gc
from io import BytesIO from io import BytesIO
from core.utils import p3 from core.utils import p3
from pydub import AudioSegment from pydub import AudioSegment
@@ -269,56 +269,82 @@ def audio_to_data_stream(
pcm_to_data_stream(raw_data, is_opus, callback) pcm_to_data_stream(raw_data, is_opus, callback)
def audio_to_data(audio_file_path: str, is_opus: bool = True) -> list[bytes]: async def audio_to_data(
audio_file_path: str, is_opus: bool = True, use_cache: bool = True
) -> list[bytes]:
""" """
将音频文件转换为Opus/PCM编码的帧列表 将音频文件转换为Opus/PCM编码的帧列表
Args: Args:
audio_file_path: 音频文件路径 audio_file_path: 音频文件路径
is_opus: 是否进行Opus编码 is_opus: 是否进行Opus编码
use_cache: 是否使用缓存
""" """
# 获取文件后缀名 from core.utils.cache.manager import cache_manager
file_type = os.path.splitext(audio_file_path)[1] from core.utils.cache.config import CacheType
if file_type:
file_type = file_type.lstrip(".")
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
audio = AudioSegment.from_file(
audio_file_path, format=file_type, parameters=["-nostdin"]
)
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配) # 生成缓存键,包含文件路径和编码类型
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2) cache_key = f"{audio_file_path}:{is_opus}"
# 获取原始PCM数据(16位小端) # 尝试从缓存获取结果
raw_data = audio.raw_data if use_cache:
cached_result = cache_manager.get(CacheType.AUDIO_DATA, cache_key)
if cached_result is not None:
return cached_result
# 初始化Opus编码器 def _sync_audio_to_data():
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO) # 获取文件后缀名
file_type = os.path.splitext(audio_file_path)[1]
if file_type:
file_type = file_type.lstrip(".")
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
audio = AudioSegment.from_file(
audio_file_path, format=file_type, parameters=["-nostdin"]
)
# 编码参数 # 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
frame_duration = 60 # 60ms per frame audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
datas = [] # 获取原始PCM数据(16位小端)
# 按帧处理所有音频数据(包括最后一帧可能补零) raw_data = audio.raw_data
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
# 获取当前帧的二进制数据
chunk = raw_data[i : i + frame_size * 2]
# 如果最后一帧不足,补零 # 初始化Opus编码器
if len(chunk) < frame_size * 2: encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
chunk += b"\x00" * (frame_size * 2 - len(chunk))
if is_opus: # 编码参数
# 转换为numpy数组处理 frame_duration = 60 # 60ms per frame
np_frame = np.frombuffer(chunk, dtype=np.int16) frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
# 编码Opus数据
frame_data = encoder.encode(np_frame.tobytes(), frame_size)
else:
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
datas.append(frame_data) datas = []
# 按帧处理所有音频数据(包括最后一帧可能补零)
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
# 获取当前帧的二进制数据
chunk = raw_data[i : i + frame_size * 2]
return datas # 如果最后一帧不足,补零
if len(chunk) < frame_size * 2:
chunk += b"\x00" * (frame_size * 2 - len(chunk))
if is_opus:
# 转换为numpy数组处理
np_frame = np.frombuffer(chunk, dtype=np.int16)
# 编码Opus数据
frame_data = encoder.encode(np_frame.tobytes(), frame_size)
else:
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
datas.append(frame_data)
return datas
loop = asyncio.get_running_loop()
# 在单独的线程中执行同步的音频处理操作
result = await loop.run_in_executor(None, _sync_audio_to_data)
# 将结果存入缓存,使用配置中定义的TTL(10分钟)
if use_cache:
cache_manager.set(CacheType.AUDIO_DATA, cache_key, result)
return result
def audio_bytes_to_data_stream( def audio_bytes_to_data_stream(
+28 -1
View File
@@ -1,8 +1,35 @@
import asyncio import asyncio
import json import logging
import websockets import websockets
from config.logger import setup_logging from config.logger import setup_logging
class SuppressInvalidHandshakeFilter(logging.Filter):
"""过滤掉无效握手错误日志(如HTTPS访问WS端口)"""
def filter(self, record):
msg = record.getMessage()
suppress_keywords = [
"opening handshake failed",
"did not receive a valid HTTP request",
"connection closed while reading HTTP request",
"line without CRLF",
]
return not any(keyword in msg for keyword in suppress_keywords)
def _setup_websockets_logger():
"""配置 websockets 相关的所有 logger,过滤无效握手错误"""
filter_instance = SuppressInvalidHandshakeFilter()
for logger_name in ["websockets", "websockets.server", "websockets.client"]:
logger = logging.getLogger(logger_name)
logger.addFilter(filter_instance)
_setup_websockets_logger()
from core.connection import ConnectionHandler from core.connection import ConnectionHandler
from config.config_loader import get_config_from_api_async from config.config_loader import get_config_from_api_async
from core.auth import AuthManager, AuthenticationError from core.auth import AuthManager, AuthenticationError
@@ -50,7 +50,9 @@ class BaseASRTester:
raise NotImplementedError raise NotImplementedError
def _calculate_result(self, service_name, latencies, test_count): def _calculate_result(self, service_name, latencies, test_count):
valid_latencies = [l for l in latencies if l > 0] """计算测试结果(修复:正确处理None值,剔除失败测试)"""
# 剔除None值(失败的测试)和无效延迟,只统计有效延迟
valid_latencies = [l for l in latencies if l is not None and l > 0]
if valid_latencies: if valid_latencies:
avg_latency = sum(valid_latencies) / len(valid_latencies) avg_latency = sum(valid_latencies) / len(valid_latencies)
status = f"成功({len(valid_latencies)}/{test_count}次有效)" status = f"成功({len(valid_latencies)}/{test_count}次有效)"
@@ -64,16 +66,45 @@ class DoubaoStreamASRTester(BaseASRTester):
def __init__(self): def __init__(self):
super().__init__("DoubaoStreamASR") super().__init__("DoubaoStreamASR")
def _generate_header(self): def _generate_header(
self,
version=0x01,
message_type=0x01,
message_type_specific_flags=0x00,
serial_method=0x01,
compression_type=0x01,
reserved_data=0x00,
extension_header: bytes = b"",
):
"""生成协议头(修复:使用正确的Header格式)"""
header = bytearray() header = bytearray()
header.append((0x01 << 4) | 0x01) header_size = int(len(extension_header) / 4) + 1
header.append((0x01 << 4) | 0x00) header.append((version << 4) | header_size)
header.append((0x01 << 4) | 0x01) header.append((message_type << 4) | message_type_specific_flags)
header.append(0x00) header.append((serial_method << 4) | compression_type)
header.append(reserved_data)
header.extend(extension_header)
return header return header
def _generate_audio_default_header(self): def _generate_audio_default_header(self):
return self._generate_header() """生成音频数据Header"""
return self._generate_header(
version=0x01,
message_type=0x02,
message_type_specific_flags=0x00, # 普通音频帧
serial_method=0x01,
compression_type=0x01,
)
def _generate_last_audio_header(self):
"""生成最后一帧音频的Header(标记音频结束)"""
return self._generate_header(
version=0x01,
message_type=0x02,
message_type_specific_flags=0x02, # 0x02表示这是最后一帧
serial_method=0x01,
compression_type=0x01,
)
def _parse_response(self, res: bytes) -> dict: def _parse_response(self, res: bytes) -> dict:
try: try:
@@ -110,6 +141,7 @@ class DoubaoStreamASRTester(BaseASRTester):
ws_url = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel" ws_url = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
appid = self.asr_config["appid"] appid = self.asr_config["appid"]
access_token = self.asr_config["access_token"] access_token = self.asr_config["access_token"]
cluster = self.asr_config.get("cluster", "volcengine_input_common")
uid = self.asr_config.get("uid", "streaming_asr_service") uid = self.asr_config.get("uid", "streaming_asr_service")
start_time = time.time() start_time = time.time()
@@ -130,7 +162,7 @@ class DoubaoStreamASRTester(BaseASRTester):
close_timeout=10 close_timeout=10
) as ws: ) as ws:
request_params = { request_params = {
"app": {"appid": appid, "token": access_token}, "app": {"appid": appid, "cluster": cluster, "token": access_token},
"user": {"uid": uid}, "user": {"uid": uid},
"request": { "request": {
"reqid": str(uuid.uuid4()), "reqid": str(uuid.uuid4()),
@@ -166,8 +198,9 @@ class DoubaoStreamASRTester(BaseASRTester):
if audio_data.startswith(b'RIFF'): if audio_data.startswith(b'RIFF'):
audio_data = audio_data[44:] audio_data = audio_data[44:]
# 发送音频数据(使用最后一帧标记,告诉服务端音频已结束)
payload = gzip.compress(audio_data) payload = gzip.compress(audio_data)
audio_request = bytearray(self._generate_audio_default_header()) audio_request = bytearray(self._generate_last_audio_header()) # 修复:使用最后一帧Header
audio_request.extend(len(payload).to_bytes(4, "big")) audio_request.extend(len(payload).to_bytes(4, "big"))
audio_request.extend(payload) audio_request.extend(payload)
await ws.send(audio_request) await ws.send(audio_request)
@@ -175,11 +208,12 @@ class DoubaoStreamASRTester(BaseASRTester):
first_chunk = await ws.recv() first_chunk = await ws.recv()
latency = time.time() - start_time latency = time.time() - start_time
latencies.append(latency) latencies.append(latency)
print(f"[豆包ASR] 第{i+1}次 首词延迟: {latency:.3f}s")
await ws.close() await ws.close()
except Exception as e: except Exception as e:
print(f"[豆包ASR] 第{i+1}次测试失败: {str(e)}") print(f"[豆包ASR] 第{i+1}次测试失败: {str(e)}")
latencies.append(0) latencies.append(None)
return self._calculate_result("豆包流式ASR", latencies, test_count) return self._calculate_result("豆包流式ASR", latencies, test_count)
@@ -189,11 +223,12 @@ class QwenASRFlashTester(BaseASRTester):
super().__init__("Qwen3ASRFlash") super().__init__("Qwen3ASRFlash")
async def _test_single(self, audio_file_info): async def _test_single(self, audio_file_info):
start_time = time.time()
temp_file_path = None temp_file_path = None
try: try:
audio_data = audio_file_info['data'] audio_data = audio_file_info['data']
# 优化:将临时文件准备工作移到计时前,减少磁盘IO对性能测试的影响
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
temp_file_path = f.name temp_file_path = f.name
@@ -221,6 +256,9 @@ class QwenASRFlashTester(BaseASRTester):
dashscope.api_key = api_key dashscope.api_key = api_key
# 统一计时起点:在API调用前开始计时(但文件准备已完成)
start_time = time.time()
response = dashscope.MultiModalConversation.call( response = dashscope.MultiModalConversation.call(
model="qwen3-asr-flash", model="qwen3-asr-flash",
messages=messages, messages=messages,
@@ -257,10 +295,10 @@ class QwenASRFlashTester(BaseASRTester):
# print(f"\n[通义ASR] 开始第 {i+1} 次测试...") # print(f"\n[通义ASR] 开始第 {i+1} 次测试...")
latency = await self._test_single(self.test_audio_files[0]) latency = await self._test_single(self.test_audio_files[0])
latencies.append(latency) latencies.append(latency)
# print(f"[通义ASR] 第{i+1}次成功 延迟: {latency:.3f}s") print(f"[通义ASR] 第{i+1}次 首词延迟: {latency:.3f}s")
except Exception as e: except Exception as e:
# print(f"[通义ASR] 第{i+1}次测试失败: {str(e)}") # print(f"[通义ASR] 第{i+1}次测试失败: {str(e)}")
latencies.append(0) latencies.append(None)
return self._calculate_result("通义千问ASR", latencies, test_count) return self._calculate_result("通义千问ASR", latencies, test_count)
@@ -268,134 +306,115 @@ class QwenASRFlashTester(BaseASRTester):
class XunfeiStreamASRTester(BaseASRTester): class XunfeiStreamASRTester(BaseASRTester):
def __init__(self): def __init__(self):
super().__init__("XunfeiStreamASR") super().__init__("XunfeiStreamASR")
def _create_url(self): def _create_url(self):
"""生成讯飞ASR认证URL""" url = "wss://iat-api.xfyun.cn/v2/iat"
url = 'ws://iat.cn-huabei-1.xf-yun.com/v1'
# 生成RFC1123格式的时间戳
now = datetime.now() now = datetime.now()
date = format_date_time(mktime(now.timetuple())) date = format_date_time(mktime(now.timetuple()))
# 拼接字符串 signature_origin = f"host: iat-api.xfyun.cn\ndate: {date}\nGET /v2/iat HTTP/1.1"
signature_origin = "host: " + "iat.cn-huabei-1.xf-yun.com" + "\n" signature_sha = hmac.new(
signature_origin += "date: " + date + "\n" self.asr_config["api_secret"].encode('utf-8'),
signature_origin += "GET " + "/v1 " + "HTTP/1.1" signature_origin.encode('utf-8'),
hashlib.sha256
).digest()
signature_sha = base64.b64encode(signature_sha).decode()
# 进行hmac-sha256进行加密 authorization_origin = f'api_key="{self.asr_config["api_key"]}", algorithm="hmac-sha256", headers="host date request-line", signature="{signature_sha}"'
signature_sha = hmac.new(self.asr_config["api_secret"].encode('utf-8'), signature_origin.encode('utf-8'), authorization = base64.b64encode(authorization_origin.encode()).decode()
digestmod=hashlib.sha256).digest()
signature_sha = base64.b64encode(signature_sha).decode(encoding='utf-8')
authorization_origin = "api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"" % ( v = {"authorization": authorization, "date": date, "host": "iat-api.xfyun.cn"}
self.asr_config["api_key"], "hmac-sha256", "host date request-line", signature_sha) return url + "?" + parse.urlencode(v)
authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8')
# 将请求的鉴权参数组合为字典 async def test(self, test_count: int = 5):
v = {
"authorization": authorization,
"date": date,
"host": "iat.cn-huabei-1.xf-yun.com"
}
# 拼接鉴权参数,生成url
url = url + '?' + parse.urlencode(v)
return url
async def test(self, test_count=5):
if not self.test_audio_files: if not self.test_audio_files:
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未找到测试音频"} return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未找到测试音频"}
if not self.asr_config: if not self.asr_config:
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未配置"} return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未配置"}
# 检查必要的配置参数 required = ["app_id", "api_key", "api_secret"]
required_keys = ["app_id", "api_key", "api_secret"] for k in required:
for key in required_keys: if k not in self.asr_config:
if key not in self.asr_config: return {"name": "讯飞流式ASR", "latency": 0, "status": f"失败: 缺少配置 {k}"}
return {"name": "讯飞流式ASR", "latency": 0, "status": f"失败: 缺少配置项 {key}"}
latencies = [] latencies = []
STATUS_FIRST_FRAME = 0 frame_size = 1280
audio_raw = self.test_audio_files[0]['data']
if audio_raw.startswith(b'RIFF'):
audio_raw = audio_raw[44:]
for i in range(test_count): for i in range(test_count):
try: try:
# 生成认证URL
ws_url = self._create_url()
# 获取音频数据
audio_data = self.test_audio_files[0]['data']
if audio_data.startswith(b'RIFF'):
audio_data = audio_data[44:] # 跳过WAV文件头
# 识别参数
iat_params = {
"domain": self.asr_config.get("domain", "slm"),
"language": self.asr_config.get("language", "zh_cn"),
"accent": self.asr_config.get("accent", "mandarin"),
"dwa": self.asr_config.get("dwa", "wpgs"),
"result": {
"encoding": "utf8",
"compress": "raw",
"format": "plain"
}
}
# 准备首帧数据
first_frame_data = {
"header": {
"status": STATUS_FIRST_FRAME,
"app_id": self.asr_config["app_id"]
},
"parameter": {
"iat": iat_params
},
"payload": {
"audio": {
"audio": base64.b64encode(audio_data[:960]).decode('utf-8'),
"sample_rate": 16000,
"encoding": "raw"
}
}
}
# 启动连接并测量时间
start_time = time.time() start_time = time.time()
ws_url = self._create_url()
async with websockets.connect( async with websockets.connect(
ws_url, ws_url,
max_size=1000000000, additional_headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},
max_size=1 << 30,
ping_interval=None, ping_interval=None,
ping_timeout=None, ping_timeout=None,
close_timeout=30, close_timeout=30,
) as ws: ) as ws:
# 发送首帧数据
await ws.send(json.dumps(first_frame_data, ensure_ascii=False)) # 第一帧:移除 punc 字段,避免未知参数错误
print(f"[讯飞ASR] 第{i+1}次测试:已发送首帧,等待响应...") await ws.send(json.dumps({
"common": {"app_id": self.asr_config["app_id"]},
# 直接等待第一个响应并计算延迟 "business": {
# 参考豆包和通义千问的实现方式,简化逻辑 "domain": "iat",
response_received = False "language": "zh_cn",
while not response_received: "accent": "mandarin",
try: "dwa": "wpgs",
# 设置较大的超时时间 "vad_eos": 5000
response = await asyncio.wait_for(ws.recv(), timeout=30.0) # 已移除 "punc": True
},
# 收到响应立即计算延迟,不管内容是什么 "data": {
# 这样可以准确测量首包到达时间 "status": 0,
latency = time.time() - start_time "format": "audio/L16;rate=16000",
latencies.append(latency) "encoding": "raw",
response_received = True "audio": base64.b64encode(audio_raw[:frame_size]).decode()
}
print(f"[讯飞ASR] 第{i+1}次测试:收到首包响应,延迟: {latency:.3f}s") }, ensure_ascii=False))
# 后续所有帧
pos = frame_size
while pos < len(audio_raw):
chunk = audio_raw[pos:pos + frame_size]
status = 2 if (pos + frame_size >= len(audio_raw)) else 1
await ws.send(json.dumps({
"data": {
"status": status,
"format": "audio/L16;rate=16000",
"encoding": "raw",
"audio": base64.b64encode(chunk).decode()
}
}, ensure_ascii=False))
if status == 2:
break break
except asyncio.TimeoutError: pos += frame_size
print(f"[讯飞ASR] 第{i+1}次测试:响应超时")
raise Exception("获取响应超时") # 接收首词
first_token = True
async for message in ws:
data = json.loads(message)
if data.get("code") != 0:
raise Exception(f"讯飞错误: {data.get('message')}")
ws_result = data.get("data", {}).get("result", {}).get("ws")
if ws_result:
text = "".join(cw.get("w", "") for seg in ws_result for cw in seg.get("cw", []))
if text.strip() and first_token:
latency = time.time() - start_time
latencies.append(latency)
print(f"[讯飞ASR] 第{i+1}次 首词延迟: {latency:.3f}s")
first_token = False
break
except Exception as e: except Exception as e:
print(f"[讯飞ASR] 第{i+1}次测试失败: {str(e)}") print(f"[讯飞ASR] 第{i+1}次测试失败: {str(e)}")
latencies.append(0) latencies.append(None)
return self._calculate_result("讯飞流式ASR", latencies, test_count)
return self._calculate_result("讯飞流式ASR", latencies, test_count)
class ASRPerformanceSuite: class ASRPerformanceSuite:
def __init__(self): def __init__(self):
self.testers = [] self.testers = []
@@ -438,8 +457,9 @@ class ASRPerformanceSuite:
print(tabulate(table_data, headers=["ASR服务", "首词延迟", "状态"], tablefmt="grid")) print(tabulate(table_data, headers=["ASR服务", "首词延迟", "状态"], tablefmt="grid"))
print("\n测试说明:") print("\n测试说明:")
print("- 测量从发送请求到接收第一个有效识别文本的时间") print("- 计时起点: 建立连接前(包含握手、发送音频、接收首个识别结果全流程)")
print("- 超时控制: DashScope 默认超时,豆包 WebSocket 超时10秒") print("- 通义千问优化: 临时文件准备在计时前完成,减少磁盘IO对测试的影响")
print("- 错误处理: 失败的测试不计入平均值,只统计成功测试的延迟")
print("- 排序规则: 成功的按延迟升序,失败的排在后面") print("- 排序规则: 成功的按延迟升序,失败的排在后面")
async def run(self, test_count=5): async def run(self, test_count=5):
@@ -35,11 +35,12 @@ class StreamTTSPerformanceTester:
host = tts_config["host"] host = tts_config["host"]
ws_url = f"wss://{host}/ws/v1" ws_url = f"wss://{host}/ws/v1"
# 统一计时起点:在建立连接前开始计时
start_time = time.time() start_time = time.time()
async with websockets.connect(ws_url, extra_headers={"X-NLS-Token": token}) as ws: async with websockets.connect(ws_url, extra_headers={"X-NLS-Token": token}) as ws:
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
message_id = str(uuid.uuid4()) message_id = str(uuid.uuid4())
start_request = { start_request = {
"header": { "header": {
"message_id": message_id, "message_id": message_id,
@@ -55,14 +56,15 @@ class StreamTTSPerformanceTester:
"volume": 50, "volume": 50,
"speech_rate": 0, "speech_rate": 0,
"pitch_rate": 0, "pitch_rate": 0,
"enable_subtitle": True,
} }
} }
await ws.send(json.dumps(start_request)) await ws.send(json.dumps(start_request))
start_response = json.loads(await ws.recv()) start_response = json.loads(await ws.recv())
if start_response["header"]["name"] != "SynthesisStarted": if start_response["header"]["name"] != "SynthesisStarted":
raise Exception("启动合成失败") raise Exception("启动合成失败")
run_request = { run_request = {
"header": { "header": {
"message_id": str(uuid.uuid4()), "message_id": str(uuid.uuid4()),
@@ -74,23 +76,142 @@ class StreamTTSPerformanceTester:
"payload": {"text": text} "payload": {"text": text}
} }
await ws.send(json.dumps(run_request)) await ws.send(json.dumps(run_request))
while True: while True:
response = await ws.recv() response = await ws.recv()
if isinstance(response, bytes): if isinstance(response, bytes):
latency = time.time() - start_time latency = time.time() - start_time
latencies.append(latency) latencies.append(latency)
print(f"[阿里云TTS] 第{i+1}次 首词延迟: {latency:.3f}s")
break break
elif isinstance(response, str): elif isinstance(response, str):
data = json.loads(response) data = json.loads(response)
if data["header"]["name"] == "TaskFailed": if data["header"]["name"] == "TaskFailed":
raise Exception(f"合成失败: {data['payload']['error_info']}") raise Exception(f"合成失败: {data['payload']['error_info']}")
except Exception as e: except Exception as e:
latencies.append(0) print(f"[阿里云TTS] 第{i+1}次测试失败: {str(e)}")
latencies.append(None)
return self._calculate_result("阿里云TTS", latencies, test_count) return self._calculate_result("阿里云TTS", latencies, test_count)
async def test_alibl_tts(self, text=None, test_count=5):
"""测试阿里云百炼CosyVoice流式TTS首词延迟"""
text = text or self.test_texts[0]
latencies = []
for i in range(test_count):
try:
tts_config = self.config["TTS"]["AliBLTTS"]
api_key = tts_config["api_key"]
model = tts_config.get("model", "cosyvoice-v2")
voice = tts_config.get("voice", "longxiaochun_v2")
format_type = tts_config.get("format", "pcm")
sample_rate = int(tts_config.get("sample_rate", "24000"))
ws_url = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/"
headers = {
"Authorization": f"Bearer {api_key}",
"X-DashScope-DataInspection": "enable",
}
start_time = time.time()
async with websockets.connect(
ws_url,
additional_headers=headers,
ping_interval=30,
ping_timeout=10,
close_timeout=10,
max_size=10 * 1024 * 1024,
) as ws:
session_id = uuid.uuid4().hex
# 1. 发送 run-task(启动任务)
run_task_message = {
"header": {
"action": "run-task",
"task_id": session_id,
"streaming": "duplex",
},
"payload": {
"task_group": "audio",
"task": "tts",
"function": "SpeechSynthesizer",
"model": model,
"parameters": {
"text_type": "PlainText",
"voice": voice,
"format": format_type,
"sample_rate": sample_rate,
"volume": 50,
"rate": 1.0,
"pitch": 1.0,
},
"input": {}
},
}
await ws.send(json.dumps(run_task_message))
# 2. 等待 task-started 事件(关键!必须等这个再发文本)
task_started = False
while not task_started:
msg = await ws.recv()
if isinstance(msg, str):
data = json.loads(msg)
header = data.get("header", {})
event = header.get("event")
if event == "task-started":
task_started = True
print(f"[阿里云百炼TTS] 第{i+1}次 任务启动成功")
elif event == "task-failed":
raise Exception(f"启动失败: {header.get('error_message', '未知错误')}")
# 3. 发送 continue-task(发送文本!这是正确动作)
continue_task_message = {
"header": {
"action": "continue-task", # 改回 continue-task
"task_id": session_id,
"streaming": "duplex",
},
"payload": {"input": {"text": text}},
}
await ws.send(json.dumps(continue_task_message))
# 4. 发送 finish-task(结束任务)
finish_task_message = {
"header": {
"action": "finish-task",
"task_id": session_id,
"streaming": "duplex",
},
"payload": {"input": {}}
}
await ws.send(json.dumps(finish_task_message))
# 5. 等待第一个音频数据块
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=15.0)
if isinstance(msg, (bytes, bytearray)) and len(msg) > 0:
latency = time.time() - start_time
print(f"[阿里云百炼TTS] 第{i+1}次 首词延迟: {latency:.3f}s")
latencies.append(latency)
break
elif isinstance(msg, str):
data = json.loads(msg)
event = data.get("header", {}).get("event")
if event == "task-failed":
raise Exception(f"合成失败: {data}")
elif event == "task-finished":
if not latencies or latencies[-1] is None:
raise Exception("任务结束但未收到音频")
except Exception as e:
print(f"[阿里云百炼TTS] 第{i+1}次失败: {str(e)}")
latencies.append(None)
return self._calculate_result("阿里云百炼TTS", latencies, test_count)
async def test_doubao_tts(self, text=None, test_count=5): async def test_doubao_tts(self, text=None, test_count=5):
"""测试火山引擎流式TTS首词延迟(测试多次取平均)""" """测试火山引擎流式TTS首词延迟(测试多次取平均)"""
text = text or self.test_texts[0] text = text or self.test_texts[0]
@@ -114,13 +235,12 @@ class StreamTTSPerformanceTester:
} }
async with websockets.connect(ws_url, additional_headers=ws_header, max_size=1000000000) as ws: async with websockets.connect(ws_url, additional_headers=ws_header, max_size=1000000000) as ws:
session_id = uuid.uuid4().hex session_id = uuid.uuid4().hex
# 发送会话启动请求 # 发送会话启动请求
header = bytes([ header = bytes([
(0b0001 << 4) | 0b0001, (0b0001 << 4) | 0b0001,
0b0001 << 4 | 0b100, 0b0001 << 4 | 0b1011,
0b0001 << 4 | 0b0000, 0b0001 << 4 | 0b0000,
0
]) ])
optional = bytearray() optional = bytearray()
optional.extend((1).to_bytes(4, "big", signed=True)) optional.extend((1).to_bytes(4, "big", signed=True))
@@ -129,13 +249,13 @@ class StreamTTSPerformanceTester:
optional.extend(session_id_bytes) optional.extend(session_id_bytes)
payload = json.dumps({"speaker": speaker}).encode() payload = json.dumps({"speaker": speaker}).encode()
await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload) await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload)
# 发送文本 # 发送文本
header = bytes([ header = bytes([
(0b0001 << 4) | 0b0001, (0b0001 << 4) | 0b0001,
0b0001 << 4 | 0b100, 0b0001 << 4 | 0b1011,
0b0001 << 4 | 0b0000, 0b0001 << 4 | 0b0000,
0 0
]) ])
optional = bytearray() optional = bytearray()
optional.extend((200).to_bytes(4, "big", signed=True)) optional.extend((200).to_bytes(4, "big", signed=True))
@@ -144,13 +264,15 @@ class StreamTTSPerformanceTester:
optional.extend(session_id_bytes) optional.extend(session_id_bytes)
payload = json.dumps({"text": text, "speaker": speaker}).encode() payload = json.dumps({"text": text, "speaker": speaker}).encode()
await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload) await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload)
first_chunk = await ws.recv() first_chunk = await ws.recv()
latency = time.time() - start_time latency = time.time() - start_time
latencies.append(latency) latencies.append(latency)
print(f"[火山引擎TTS] 第{i+1}次 首词延迟: {latency:.3f}s")
except Exception as e: except Exception as e:
latencies.append(0) print(f"[火山引擎TTS] 第{i+1}次测试失败: {str(e)}")
latencies.append(None)
return self._calculate_result("火山引擎TTS", latencies, test_count) return self._calculate_result("火山引擎TTS", latencies, test_count)
@@ -191,22 +313,24 @@ class StreamTTSPerformanceTester:
first_chunk = await ws.recv() first_chunk = await ws.recv()
latency = time.time() - start_time latency = time.time() - start_time
latencies.append(latency) latencies.append(latency)
print(f"[PaddleSpeechTTS] 第{i+1}次 首词延迟: {latency:.3f}s")
# 发送结束请求 # 发送结束请求
end_request = { end_request = {
"task": "tts", "task": "tts",
"signal": "end" "signal": "end"
} }
await ws.send(json.dumps(end_request)) await ws.send(json.dumps(end_request))
# 确保连接正常关闭 # 确保连接正常关闭
try: try:
await ws.recv() await ws.recv()
except websockets.exceptions.ConnectionClosedOK: except websockets.exceptions.ConnectionClosedOK:
pass pass
except Exception as e: except Exception as e:
latencies.append(0) print(f"[PaddleSpeechTTS] 第{i+1}次测试失败: {str(e)}")
latencies.append(None)
return self._calculate_result("PaddleSpeechTTS", latencies, test_count) return self._calculate_result("PaddleSpeechTTS", latencies, test_count)
@@ -220,29 +344,32 @@ class StreamTTSPerformanceTester:
tts_config = self.config["TTS"]["IndexStreamTTS"] tts_config = self.config["TTS"]["IndexStreamTTS"]
api_url = tts_config.get("api_url") api_url = tts_config.get("api_url")
voice = tts_config.get("voice") voice = tts_config.get("voice")
# 统一计时起点:在建立连接前开始计时
start_time = time.time() start_time = time.time()
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
payload = {"text": text, "character": voice} payload = {"text": text, "character": voice}
async with session.post(api_url, json=payload, timeout=10) as resp: async with session.post(api_url, json=payload, timeout=10) as resp:
if resp.status != 200: if resp.status != 200:
raise Exception(f"请求失败: {resp.status}, {await resp.text()}") raise Exception(f"请求失败: {resp.status}, {await resp.text()}")
async for chunk in resp.content.iter_any(): async for chunk in resp.content.iter_any():
data = chunk[0] if isinstance(chunk, (list, tuple)) else chunk data = chunk[0] if isinstance(chunk, (list, tuple)) else chunk
if not data: if not data:
continue continue
latency = time.time() - start_time latency = time.time() - start_time
latencies.append(latency) latencies.append(latency)
print(f"[IndexStreamTTS] 第{i+1}次 首词延迟: {latency:.3f}s")
resp.close() resp.close()
break break
else: else:
latencies.append(0) latencies.append(None)
except Exception as e: except Exception as e:
latencies.append(0) print(f"[IndexStreamTTS] 第{i+1}次测试失败: {str(e)}")
latencies.append(None)
return self._calculate_result("IndexStreamTTS", latencies, test_count) return self._calculate_result("IndexStreamTTS", latencies, test_count)
@@ -257,7 +384,8 @@ class StreamTTSPerformanceTester:
api_url = tts_config["api_url"] api_url = tts_config["api_url"]
access_token = tts_config["access_token"] access_token = tts_config["access_token"]
voice = tts_config["voice"] voice = tts_config["voice"]
# 统一计时起点:在建立连接前开始计时
start_time = time.time() start_time = time.time()
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
params = { params = {
@@ -273,21 +401,23 @@ class StreamTTSPerformanceTester:
"Authorization": f"Bearer {access_token}", "Authorization": f"Bearer {access_token}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
async with session.get(api_url, params=params, headers=headers, timeout=10) as resp: async with session.get(api_url, params=params, headers=headers, timeout=10) as resp:
if resp.status != 200: if resp.status != 200:
raise Exception(f"请求失败: {resp.status}, {await resp.text()}") raise Exception(f"请求失败: {resp.status}, {await resp.text()}")
# 接收第一个数据块 # 接收第一个数据块
async for _ in resp.content.iter_any(): async for _ in resp.content.iter_any():
latency = time.time() - start_time latency = time.time() - start_time
latencies.append(latency) latencies.append(latency)
print(f"[LinkeraiTTS] 第{i+1}次 首词延迟: {latency:.3f}s")
break break
else: else:
latencies.append(0) latencies.append(None)
except Exception as e: except Exception as e:
latencies.append(0) print(f"[LinkeraiTTS] 第{i+1}次测试失败: {str(e)}")
latencies.append(None)
return self._calculate_result("LinkeraiTTS", latencies, test_count) return self._calculate_result("LinkeraiTTS", latencies, test_count)
@@ -305,10 +435,9 @@ class StreamTTSPerformanceTester:
api_secret = tts_config["api_secret"] api_secret = tts_config["api_secret"]
api_url = tts_config.get("api_url", "wss://cbm01.cn-huabei-1.xf-yun.com/v1/private/mcd9m97e6") api_url = tts_config.get("api_url", "wss://cbm01.cn-huabei-1.xf-yun.com/v1/private/mcd9m97e6")
voice = tts_config.get("voice", "x5_lingxiaoxuan_flow") voice = tts_config.get("voice", "x5_lingxiaoxuan_flow")
# 生成认证URL # 生成认证URL
auth_url = self._create_xunfei_auth_url(api_key, api_secret, api_url) auth_url = self._create_xunfei_auth_url(api_key, api_secret, api_url)
start_time = time.time()
async with websockets.connect( async with websockets.connect(
auth_url, auth_url,
ping_interval=30, ping_interval=30,
@@ -318,10 +447,7 @@ class StreamTTSPerformanceTester:
) as ws: ) as ws:
# 构造请求 # 构造请求
request = self._build_xunfei_request(app_id, text, voice) request = self._build_xunfei_request(app_id, text, voice)
# 发送请求后立即计时,确保准确测量从发送文本到接收首块的时间
await ws.send(json.dumps(request)) await ws.send(json.dumps(request))
start_time = time.time()
# 等待第一个音频数据块 # 等待第一个音频数据块
first_audio_received = False first_audio_received = False
while not first_audio_received: while not first_audio_received:
@@ -329,14 +455,14 @@ class StreamTTSPerformanceTester:
data = json.loads(msg) data = json.loads(msg)
header = data.get("header", {}) header = data.get("header", {})
code = header.get("code") code = header.get("code")
if code != 0: if code != 0:
message = header.get("message", "未知错误") message = header.get("message", "未知错误")
raise Exception(f"合成失败: {code} - {message}") raise Exception(f"合成失败: {code} - {message}")
payload = data.get("payload", {}) payload = data.get("payload", {})
audio_payload = payload.get("audio", {}) audio_payload = payload.get("audio", {})
if audio_payload: if audio_payload:
status = audio_payload.get("status", 0) status = audio_payload.get("status", 0)
audio_data = audio_payload.get("audio", "") audio_data = audio_payload.get("audio", "")
@@ -344,10 +470,12 @@ class StreamTTSPerformanceTester:
# 收到第一个音频数据块 # 收到第一个音频数据块
latency = time.time() - start_time latency = time.time() - start_time
latencies.append(latency) latencies.append(latency)
print(f"[讯飞TTS] 第{i+1}次 首词延迟: {latency:.3f}s")
first_audio_received = True first_audio_received = True
break break
except Exception as e: except Exception as e:
latencies.append(0) print(f"[讯飞TTS] 第{i+1}次测试失败: {str(e)}")
latencies.append(None)
return self._calculate_result("讯飞TTS", latencies, test_count) return self._calculate_result("讯飞TTS", latencies, test_count)
@@ -431,8 +559,9 @@ class StreamTTSPerformanceTester:
def _calculate_result(self, service_name, latencies, test_count): def _calculate_result(self, service_name, latencies, test_count):
"""计算测试结果""" """计算测试结果(正确处理None值,剔除失败测试)"""
valid_latencies = [l for l in latencies if l > 0] # 剔除失败的测试(None值和<=0延迟),只统计有效延迟
valid_latencies = [l for l in latencies if l is not None and l > 0]
if valid_latencies: if valid_latencies:
avg_latency = sum(valid_latencies) / len(valid_latencies) avg_latency = sum(valid_latencies) / len(valid_latencies)
status = f"成功({len(valid_latencies)}/{test_count}次有效)" status = f"成功({len(valid_latencies)}/{test_count}次有效)"
@@ -466,9 +595,10 @@ class StreamTTSPerformanceTester:
] ]
print(tabulate(table_data, headers=["TTS服务", "首词延迟(秒)", "状态"], tablefmt="grid")) print(tabulate(table_data, headers=["TTS服务", "首词延迟(秒)", "状态"], tablefmt="grid"))
print("\n测试说明:测量从发送请求到接收第一个音频数据块的时间,取多次测试平均值") print("\n测试说明:测量从建立连接到接收第一个音频数据块的时间(包含握手、鉴权、发送文本),取多次测试平均值")
print("- 计时起点: 建立WebSocket/HTTP连接前(统一包含网络建连、握手、发送文本全流程)")
print("- 超时控制: 单个请求最大等待时间为10秒") print("- 超时控制: 单个请求最大等待时间为10秒")
print("- 错误处理: 无法连接和超时的列为网络错误") print("- 错误处理: 失败的测试不计入平均值,只统计成功测试的延迟")
print("- 排序规则: 按平均耗时从快到慢排序") print("- 排序规则: 按平均耗时从快到慢排序")
@@ -494,7 +624,12 @@ class StreamTTSPerformanceTester:
# 测试阿里云TTS # 测试阿里云TTS
result = await self.test_aliyun_tts(test_text, test_count) result = await self.test_aliyun_tts(test_text, test_count)
self.results.append(result) self.results.append(result)
# 测试阿里云百炼TTS
if self.config.get("TTS", {}).get("AliBLTTS"):
result = await self.test_alibl_tts(test_text, test_count)
self.results.append(result)
# 测试火山引擎TTS # 测试火山引擎TTS
result = await self.test_doubao_tts(test_text, test_count) result = await self.test_doubao_tts(test_text, test_count)
self.results.append(result) self.results.append(result)
+1 -1
View File
@@ -13,7 +13,7 @@ pydub==0.25.1
funasr==1.2.7 funasr==1.2.7
openai==2.8.1 openai==2.8.1
google-generativeai==0.8.5 google-generativeai==0.8.5
edge_tts==7.2.3 edge_tts==7.2.6
httpx==0.28.1 httpx==0.28.1
aiohttp==3.13.2 aiohttp==3.13.2
aiohttp_cors==0.8.1 aiohttp_cors==0.8.1
@@ -240,6 +240,24 @@ export class AudioRecorder {
if (this.isRecording) return false; if (this.isRecording) return false;
try { try {
// 检查是否有WebSocketHandler实例
const { getWebSocketHandler } = await import('../network/websocket.js');
const wsHandler = getWebSocketHandler();
// 如果机器正在说话,发送打断消息
if (wsHandler && wsHandler.isRemoteSpeaking && wsHandler.currentSessionId) {
const abortMessage = {
session_id: wsHandler.currentSessionId,
type: 'abort',
reason: 'wake_word_detected'
};
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify(abortMessage));
log('发送打断消息', 'info');
}
}
if (!this.initEncoder()) { if (!this.initEncoder()) {
log('无法启动录音: Opus编码器初始化失败', 'error'); log('无法启动录音: Opus编码器初始化失败', 'error');
return false; return false;