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/>
8、[新闻插件源配置指南](./newsnow_plugin_config.md)<br/>
9、[知识库ragflow集成指南](./ragflow-integration.md)<br/>
10、[如何部署上下文源](./context-provider-integration.md)<br/>
### 11、语音克隆、本地语音部署相关教程
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接入点
首先,你要开启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`,你稍后会用到。
# 第二步 配置到智控台
确保你的智控台版本是`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`。
+2
View File
@@ -164,6 +164,8 @@ http://192.168.1.25:8005/voiceprint/health?key=abcd
# 2、全模块部署时,怎么配置声纹识别
## 第一步 配置接口
首先,你要开启声纹识别功能。在智控台,点击顶部`参数字典`,在下拉菜单中,点击`系统功能配置`页面。在页面上勾选`声纹识别`,点击`保存配置`。即可在新建智能体的卡片上看到`声纹识别`按钮。
如果你是全模块部署,使用管理员账号,登录智控台,点击顶部`参数字典`,选择`参数管理`功能。
然后搜索参数`server.voice_print`,此时,它的值应该是`null`值。
@@ -141,6 +141,11 @@ public interface Constant {
*/
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
@@ -44,6 +44,7 @@ import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
@@ -64,6 +65,7 @@ public class AgentController {
private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService;
private final AgentPluginMappingService agentPluginMappingService;
private final AgentContextProviderService agentContextProviderService;
private final RedisUtils redisUtils;
@GetMapping("/list")
@@ -135,6 +137,8 @@ public class AgentController {
agentChatHistoryService.deleteByAgentId(id, true, true);
// 删除关联的插件
agentPluginMappingService.deleteByAgentId(id);
// 删除关联的上下文源配置
agentContextProviderService.deleteByAgentId(id);
// 再删除智能体
agentService.deleteById(id);
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)
private Integer sort;
@Schema(description = "上下文源配置", nullable = true)
private List<ContextProviderDTO> contextProviders;
@Data
@Schema(description = "插件函数信息")
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.AgentDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentPluginMapping;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
@@ -62,6 +64,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
private final AgentChatHistoryService agentChatHistoryService;
private final AgentTemplateService agentTemplateService;
private final ModelProviderService modelProviderService;
private final AgentContextProviderService agentContextProviderService;
@Override
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());
}
}
// 查询上下文源配置
AgentContextProviderEntity contextProviderEntity = agentContextProviderService.getByAgentId(id);
if (contextProviderEntity != null) {
agent.setContextProviders(contextProviderEntity.getContextProviders());
}
// 无需额外查询插件列表,已通过SQL查询出来
return agent;
}
@@ -331,6 +341,14 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
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());
if (!b) {
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 lombok.Data;
import lombok.EqualsAndHashCode;
import xiaozhi.modules.agent.dto.ContextProviderDTO;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentPluginMapping;
@@ -21,4 +22,7 @@ public class AgentInfoVO extends AgentEntity
@Schema(description = "插件列表Id")
@TableField(typeHandler = JacksonTypeHandler.class)
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.JsonUtils;
import xiaozhi.modules.agent.dao.AgentVoicePrintDao;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentPluginMapping;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.entity.AgentVoicePrintEntity;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService;
@@ -53,6 +55,7 @@ public class ConfigServiceImpl implements ConfigService {
private final TimbreService timbreService;
private final AgentPluginMappingService agentPluginMappingService;
private final AgentMcpAccessPointService agentMcpAccessPointService;
private final AgentContextProviderService agentContextProviderService;
private final VoiceCloneService cloneVoiceService;
private final AgentVoicePrintDao agentVoicePrintDao;
@@ -178,6 +181,13 @@ public class ConfigServiceImpl implements ConfigService {
mcpEndpoint = mcpEndpoint.replace("/mcp/", "/call/");
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);
@@ -72,10 +72,12 @@ public class DeviceController {
return new Result<String>().error(ErrorCode.MCA_NOT_NULL);
}
// 生成六位验证码
String code = String.valueOf(Math.random()).substring(2, 8);
String key = RedisKeys.getDeviceCaptchaKey(code);
String code;
String key;
String existsMac = null;
do {
code = String.valueOf(Math.random()).substring(2, 8);
key = RedisKeys.getDeviceCaptchaKey(code);
existsMac = (String) redisUtils.get(key);
} while (StringUtils.isNotBlank(existsMac));
@@ -1,6 +1,8 @@
package xiaozhi.modules.device.service.impl;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
@@ -169,7 +171,22 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket();
// 从系统参数获取WebSocket URL,如果未配置则使用默认值
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")) {
log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置");
wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/";
@@ -189,7 +206,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
// 添加MQTT UDP配置
// 从系统参数获取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()) {
try {
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);
}
/**
* 生成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配置信息
*
@@ -504,7 +555,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
private DeviceReportRespDTO.MQTT buildMqttConfig(String macAddress, String groupId)
throws Exception {
// 从环境变量或系统参数获取签名密钥
String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", false);
String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", true);
if (StringUtils.isBlank(signatureKey)) {
log.warn("缺少MQTT_SIGNATURE_KEY,跳过MQTT配置生成");
return null;
@@ -6,6 +6,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@@ -23,7 +24,9 @@ import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.TokenDTO;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.common.utils.Sm2DecryptUtil;
import xiaozhi.common.validator.AssertUtils;
import xiaozhi.common.validator.ValidatorUtils;
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.SysUserTokenService;
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.RetrievePasswordDTO;
import xiaozhi.modules.sys.dto.SysUserDTO;
@@ -89,13 +90,13 @@ public class LoginController {
@Operation(summary = "登录")
public Result<TokenDTO> login(@RequestBody LoginDTO login) {
String password = login.getPassword();
// 使用工具类解密并验证验证码
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
password, login.getCaptchaId(), captchaService, sysParamsService);
login.setPassword(actualPassword);
// 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(login.getUsername());
// 判断用户是否存在
@@ -108,8 +109,6 @@ public class LoginController {
}
return sysUserTokenService.createToken(userDTO.getId());
}
@PostMapping("/register")
@Operation(summary = "注册")
@@ -117,15 +116,15 @@ public class LoginController {
if (!sysUserService.getAllowUserRegister()) {
throw new RenException(ErrorCode.USER_REGISTER_DISABLED);
}
String password = login.getPassword();
// 使用工具类解密并验证验证码
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
password, login.getCaptchaId(), captchaService, sysParamsService);
login.setPassword(actualPassword);
// 是否开启手机注册
Boolean isMobileRegister = sysParamsService
.getValueObject(Constant.SysMSMParam.SERVER_ENABLE_MOBILE_REGISTER.getValue(), Boolean.class);
@@ -204,11 +203,11 @@ public class LoginController {
}
String password = dto.getPassword();
// 使用工具类解密并验证验证码
String actualPassword = Sm2DecryptUtil.decryptAndValidateCaptcha(
password, dto.getCaptchaId(), captchaService, sysParamsService);
dto.setPassword(actualPassword);
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("beianGaNum", sysParamsService.getValue(Constant.SysBaseParam.BEIAN_GA_NUM.getValue(), true));
config.put("name", sysParamsService.getValue(Constant.SysBaseParam.SERVER_NAME.getValue(), true));
// SM2公钥
String publicKey = sysParamsService.getValue(Constant.SM2_PUBLIC_KEY, true);
if (StringUtils.isBlank(publicKey)) {
@@ -237,6 +236,12 @@ public class LoginController {
}
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);
}
}
@@ -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:
encoding: utf8
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 }),
content: t('settings.aboutContent', {
appName: import.meta.env.VITE_APP_TITLE,
version: '0.8.9'
version: '0.8.10'
}),
showCancel: false,
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">
{{ $t('home.configureRole') }}
</div>
<div class="settings-btn" @click="handleVoicePrint">
<div v-if="featureStatus.voiceprintRecognition" class="settings-btn" @click="handleVoicePrint">
{{ $t('home.voiceprintRecognition') }}
</div>
<div class="settings-btn" @click="handleDeviceManage">
@@ -49,7 +49,15 @@ import i18n from '@/i18n';
export default {
name: 'DeviceItem',
props: {
device: { type: Object, required: true }
device: { type: Object, required: true },
featureStatus: {
type: Object,
default: () => ({
voiceprintRecognition: false,
voiceClone: false,
knowledgeBase: false
})
}
},
data() {
return { switchValue: false }
@@ -106,7 +106,7 @@
</div>
<!-- MCP区域 -->
<div class="mcp-access-point">
<div class="mcp-access-point" v-if="featureStatus.mcpAccessPoint">
<div class="mcp-container">
<!-- 左侧区域 -->
<div class="mcp-left">
@@ -171,6 +171,7 @@
<script>
import Api from '@/apis/api';
import i18n from '@/i18n';
import featureManager from '@/utils/featureManager';
export default {
i18n,
@@ -205,6 +206,11 @@ export default {
mcpUrl: "",
mcpStatus: "disconnected",
mcpTools: [],
// 功能状态
featureStatus: {
mcpAccessPoint: false
}
}
},
computed: {
@@ -249,6 +255,9 @@ export default {
// 右侧默认指向第一个
this.currentFunction = this.selectedList[0] || null;
// 加载功能状态
this.loadFeatureStatus();
// 加载MCP数据
this.loadMcpAddress();
this.loadMcpTools();
@@ -259,6 +268,19 @@ export default {
}
},
methods: {
/**
* 加载功能状态
*/
async loadFeatureStatus() {
// 确保featureManager已初始化完成
await featureManager.waitForInitialization();
const config = featureManager.getConfig();
this.featureStatus = {
mcpAccessPoint: config.mcpAccessPoint || false
};
},
copyUrl() {
const textarea = document.createElement('textarea');
textarea.value = this.mcpUrl;
@@ -456,6 +478,7 @@ export default {
.function-column {
position: relative;
width: auto;
height:700px;
padding: 10px;
overflow-y: auto;
border-right: 1px solid #EBEEF5;
@@ -463,6 +486,12 @@ export default {
overflow-x: hidden;
}
.mcp-access-point {
position: relative;
z-index: 1;
background: white;
}
.function-column::-webkit-scrollbar {
display: none;
}
+33 -6
View File
@@ -26,7 +26,7 @@
<span class="nav-text">{{ $t("header.smartManagement") }}</span>
</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">
<img loading="lazy" alt="" src="@/assets/header/voice.png" :style="{
filter:
@@ -38,7 +38,7 @@
</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':
$route.path === '/voice-clone-management' ||
$route.path === '/voice-resource-management',
@@ -72,7 +72,7 @@
}" />
<span class="nav-text">{{ $t("header.modelConfig") }}</span>
</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' }"
@click="goKnowledgeBaseManagement">
<img loading="lazy" alt="" src="@/assets/header/knowledge_base.png" :style="{
@@ -89,7 +89,8 @@
$route.path === '/server-side-management' ||
$route.path === '/agent-template-management' ||
$route.path === '/ota-management' ||
$route.path === '/user-management',
$route.path === '/user-management' ||
$route.path === '/feature-management',
}" @visible-change="handleParamDropdownVisibleChange">
<span class="el-dropdown-link">
<img loading="lazy" alt="" src="@/assets/header/param_management.png" :style="{
@@ -100,7 +101,8 @@
$route.path === '/server-side-management' ||
$route.path === '/agent-template-management' ||
$route.path === '/ota-management' ||
$route.path === '/user-management'
$route.path === '/user-management' ||
$route.path === '/feature-management'
? 'brightness(0) invert(1)'
: 'None',
}" />
@@ -129,6 +131,9 @@
<el-dropdown-item @click.native="goServerSideManagement">
{{ $t("header.serverSideManagement") }}
</el-dropdown-item>
<el-dropdown-item @click.native="goFeatureManagement">
{{ $t("header.featureManagement") }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
@@ -186,6 +191,7 @@ import userApi from "@/apis/module/user";
import i18n, { changeLanguage } from "@/i18n";
import { mapActions, mapGetters } from "vuex";
import ChangePasswordDialog from "./ChangePasswordDialog.vue"; // 引入修改密码弹窗组件
import featureManager from "@/utils/featureManager"; // 引入功能管理工具类
export default {
name: "HeaderBar",
@@ -217,6 +223,11 @@ export default {
label: "label",
children: "children",
},
// 功能状态
featureStatus: {
voiceClone: false, // 音色克隆功能状态
knowledgeBase: false, // 知识库功能状态
},
};
},
computed: {
@@ -286,12 +297,14 @@ export default {
];
},
},
mounted() {
async mounted() {
this.fetchUserInfo();
this.checkScreenSize();
window.addEventListener("resize", this.checkScreenSize);
// 从localStorage加载搜索历史
this.loadSearchHistory();
// 等待featureManager初始化完成后再加载功能状态
await this.loadFeatureStatus();
},
//移除事件监听器
beforeDestroy() {
@@ -338,6 +351,20 @@ export default {
goAgentTemplateManagement() {
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() {
userApi.getUserInfo(({ data }) => {
+54 -2
View File
@@ -22,6 +22,7 @@ export default {
'header.clearHistory': 'Verlauf löschen',
'header.providerManagement': 'Anbieterverwaltung',
'header.serverSideManagement': 'Serververwaltung',
'header.featureManagement': 'Systemfunktionsverwaltung',
'header.changePassword': 'Passwort ändern',
'header.logout': 'Abmelden',
'header.searchPlaceholder': 'Nach Namen suchen..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': 'Bitte Namen eingeben',
'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
'voicePrint.pageTitle': 'Stimmabdruck-Erkennung',
'voicePrint.name': 'Name',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': 'Löschen fehlgeschlagen, bitte versuchen Sie es erneut',
'paramManagement.operationCancelled': 'Löschen abgebrochen',
'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.updateFailed': 'Aktualisierung fehlgeschlagen',
'paramManagement.addFailed': 'Hinzufügen fehlgeschlagen',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': 'Aktivieren erfolgreich',
'modelConfig.disableSuccess': 'Deaktivieren erfolgreich',
'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.firstPage': 'Erste Seite',
'modelConfig.prevPage': 'Vorherige Seite',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': 'Inhalt:',
'knowledgeFileUpload.testQuestionRequired': 'Bitte Testfrage 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.providerManagement': 'Provider Management',
'header.serverSideManagement': 'Server Management',
'header.featureManagement': 'System Feature Management',
'header.changePassword': 'Change Password',
'header.logout': 'Logout',
'header.searchPlaceholder': 'Search by name..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': 'Please enter name',
'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
'voicePrint.pageTitle': 'Voice Print Recognition',
'voicePrint.name': 'Name',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': 'Deletion failed, please try again',
'paramManagement.operationCancelled': 'Deletion cancelled',
'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.updateFailed': 'Update failed',
'paramManagement.addFailed': 'Add failed',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': 'Enable successful',
'modelConfig.disableSuccess': 'Disable successful',
'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.firstPage': 'First Page',
'modelConfig.prevPage': 'Previous Page',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': 'Content:',
'knowledgeFileUpload.testQuestionRequired': 'Please enter test question',
'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.providerManagement': 'Quản lý nhà cung cấp',
'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.logout': 'Đăng xuất',
'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.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
'voicePrint.pageTitle': 'Nhận dạng dấu giọng nói',
'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.operationCancelled': 'Đã hủy xóa',
'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.updateFailed': 'Cập nhật 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.disableSuccess': 'Tắt thành công',
'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.firstPage': 'Trang đầu',
'modelConfig.prevPage': 'Trang trước',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': 'Nội dung:',
'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',
// 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.providerManagement': '字段管理',
'header.serverSideManagement': '服务端管理',
'header.featureManagement': '系统功能配置',
'header.changePassword': '修改密码',
'header.logout': '退出登录',
'header.searchPlaceholder': '输入名称搜索..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': '请输入姓名',
'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.name': '姓名',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': '删除失败,请重试',
'paramManagement.operationCancelled': '已取消删除操作',
'paramManagement.operationClosed': '操作已关闭',
'paramManagement.updateSuccess': '修改成功',
'paramManagement.updateSuccess': '修改成功,部分配置需重启xiaozhi-server模块才生效',
'paramManagement.addSuccess': '新增成功',
'paramManagement.updateFailed': '更新失败',
'paramManagement.addFailed': '新增失败',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': '启用成功',
'modelConfig.disableSuccess': '禁用成功',
'modelConfig.operationFailed': '操作失败',
'modelConfig.setDefaultSuccess': '设置默认模型成功',
'modelConfig.setDefaultSuccess': '设置默认模型成功,请及时手动重启xiaozhi-server模块',
'modelConfig.itemsPerPage': '{items}条/页',
'modelConfig.firstPage': '首页',
'modelConfig.prevPage': '上一页',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': '内容:',
'knowledgeFileUpload.testQuestionRequired': '请输入测试问题',
'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.providerManagement': '字段管理',
'header.serverSideManagement': '服務端管理',
'header.featureManagement': '系統功能配置',
'header.changePassword': '修改密碼',
'header.logout': '退出登錄',
'header.searchPlaceholder': '輸入名稱搜索..',
@@ -229,6 +230,26 @@ export default {
'voicePrintDialog.requiredName': '請輸入姓名',
'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.name': '姓名',
@@ -692,7 +713,7 @@ export default {
'paramManagement.deleteFailed': '刪除失敗,請重試',
'paramManagement.operationCancelled': '已取消刪除操作',
'paramManagement.operationClosed': '操作已關閉',
'paramManagement.updateSuccess': '修改成功',
'paramManagement.updateSuccess': '修改成功,部分配置需重啟xiaozhi-server模組才生效',
'paramManagement.addSuccess': '新增成功',
'paramManagement.updateFailed': '更新失敗',
'paramManagement.addFailed': '新增失敗',
@@ -831,7 +852,7 @@ export default {
'modelConfig.enableSuccess': '啟用成功',
'modelConfig.disableSuccess': '禁用成功',
'modelConfig.operationFailed': '操作失敗',
'modelConfig.setDefaultSuccess': '設置默認模型成功',
'modelConfig.setDefaultSuccess': '設置默認模型成功,請及時手動重啟xiaozhi-server模組',
'modelConfig.itemsPerPage': '{items}條/頁',
'modelConfig.firstPage': '首頁',
'modelConfig.prevPage': '上一頁',
@@ -1262,4 +1283,35 @@ export default {
'knowledgeFileUpload.content': '內容:',
'knowledgeFileUpload.testQuestionRequired': '請輸入測試問題',
'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 './styles/global.scss';
import { register as registerServiceWorker } from './registerServiceWorker';
import featureManager from './utils/featureManager';
// 创建事件总线,用于组件间通信
Vue.prototype.$eventBus = new Vue();
+18 -3
View File
@@ -76,7 +76,8 @@ const routes = [
return import('../views/ModelConfig.vue')
}
},
{ path: '/params-management',
{
path: '/params-management',
name: 'ParamsManagement',
component: function () {
return import('../views/ParamsManagement.vue')
@@ -86,7 +87,8 @@ const routes = [
title: '参数管理'
}
},
{ path: '/knowledge-base-management',
{
path: '/knowledge-base-management',
name: 'KnowledgeBaseManagement',
component: function () {
return import('../views/KnowledgeBaseManagement.vue')
@@ -96,7 +98,8 @@ const routes = [
title: '知识库管理'
}
},
{ path: '/knowledge-file-upload',
{
path: '/knowledge-file-upload',
name: 'KnowledgeFileUpload',
component: function () {
return import('../views/KnowledgeFileUpload.vue')
@@ -181,6 +184,18 @@ const routes = [
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({
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 v-else>
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" @configure="goToRoleConfig"
@deviceManage="handleDeviceManage" @delete="handleDeleteAgent" @chat-history="handleShowChatHistory" />
<DeviceItem v-for="(item, index) in devices" :key="index" :device="item" :feature-status="featureStatus"
@configure="goToRoleConfig" @deviceManage="handleDeviceManage" @delete="handleDeleteAgent"
@chat-history="handleShowChatHistory" />
</template>
</div>
</div>
@@ -61,6 +62,7 @@ import ChatHistoryDialog from '@/components/ChatHistoryDialog.vue';
import DeviceItem from '@/components/DeviceItem.vue';
import HeaderBar from '@/components/HeaderBar.vue';
import VersionFooter from '@/components/VersionFooter.vue';
import featureManager from '@/utils/featureManager';
export default {
name: 'HomePage',
@@ -76,15 +78,33 @@ export default {
skeletonCount: localStorage.getItem('skeletonCount') || 8,
showChatHistory: false,
currentAgentId: '',
currentAgentName: ''
currentAgentName: '',
// 功能状态
featureStatus: {
voiceprintRecognition: false,
voiceClone: false,
knowledgeBase: false
}
}
},
mounted() {
async mounted() {
this.fetchAgentList();
await this.loadFeatureStatus();
},
methods: {
// 加载功能状态
async loadFeatureStatus() {
await featureManager.waitForInitialization();
const config = featureManager.getConfig();
this.featureStatus = {
voiceprintRecognition: config.voiceprintRecognition,
voiceClone: config.voiceClone,
knowledgeBase: config.knowledgeBase
};
},
showAddDialog() {
this.addDeviceDialogVisible = true
},
+8
View File
@@ -156,6 +156,7 @@ import VersionFooter from "@/components/VersionFooter.vue";
import i18n, { changeLanguage } from "@/i18n";
import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from "@/utils";
import { mapState } from "vuex";
import featureManager from "@/utils/featureManager";
export default {
name: "login",
@@ -214,6 +215,13 @@ export default {
this.$store.dispatch("fetchPubConfig").then(() => {
// 根据配置决定默认登录方式
this.isMobileLogin = this.enableMobileRegister;
// pub-config接口调用完成后,重新初始化featureManager以确保使用最新的配置
featureManager.waitForInitialization().then(() => {
console.log('featureManager重新初始化完成,使用pub-config配置');
}).catch(error => {
console.warn('featureManager重新初始化失败:', error);
});
});
},
methods: {
+101 -6
View File
@@ -55,10 +55,24 @@
</div>
</div>
</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-input
type="textarea"
rows="9"
rows="8"
resize="none"
:placeholder="$t('roleConfig.pleaseEnterContent')"
v-model="form.systemPrompt"
@@ -71,7 +85,7 @@
<el-form-item :label="$t('roleConfig.memoryHis') + ''">
<el-input
type="textarea"
rows="6"
rows="4"
resize="none"
v-model="form.summaryMemory"
maxlength="2000"
@@ -107,7 +121,11 @@
</div>
<div class="form-column">
<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">
<el-select
v-model="form.model.vadModelId"
@@ -125,7 +143,11 @@
</el-select>
</div>
</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">
<el-select
v-model="form.model.asrModelId"
@@ -267,6 +289,11 @@
@update-functions="handleUpdateFunctions"
@dialog-closed="handleDialogClosed"
/>
<context-provider-dialog
:visible.sync="showContextProviderDialog"
:providers="currentContextProviders"
@confirm="handleUpdateContext"
/>
</div>
</template>
@@ -275,14 +302,17 @@ import Api from "@/apis/api";
import { getServiceUrl } from "@/apis/api";
import RequestService from "@/apis/httpRequest";
import FunctionDialog from "@/components/FunctionDialog.vue";
import ContextProviderDialog from "@/components/ContextProviderDialog.vue";
import HeaderBar from "@/components/HeaderBar.vue";
import i18n from "@/i18n";
import featureManager from "@/utils/featureManager";
export default {
name: "RoleConfigPage",
components: { HeaderBar, FunctionDialog },
components: { HeaderBar, FunctionDialog, ContextProviderDialog },
data() {
return {
showContextProviderDialog: false,
form: {
agentCode: "",
agentName: "",
@@ -320,12 +350,18 @@ export default {
voiceDetails: {}, // 保存完整的音色信息
showFunctionDialog: false,
currentFunctions: [],
currentContextProviders: [],
allFunctions: [],
originalFunctions: [],
playingVoice: false,
isPaused: false,
currentAudio: null,
currentPlayingVoiceId: null,
// 功能状态
featureStatus: {
vad: false, // 语言检测活动功能状态
asr: false, // 语音识别功能状态
},
};
},
methods: {
@@ -356,6 +392,7 @@ export default {
paramInfo: item.params,
};
}),
contextProviders: this.currentContextProviders,
};
Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => {
if (data.code === 0) {
@@ -472,6 +509,9 @@ export default {
};
// 后端只给了最小映射:[{ id, agentId, pluginId }, ...]
const savedMappings = data.data.functions || [];
// 加载上下文配置
this.currentContextProviders = data.data.contextProviders || [];
// 先保证 allFunctions 已经加载(如果没有,则先 fetchAllFunctions
const ensureFuncs = this.allFunctions.length
@@ -646,6 +686,12 @@ export default {
this.showFunctionDialog = true;
}
},
openContextProviderDialog() {
this.showContextProviderDialog = true;
},
handleUpdateContext(providers) {
this.currentContextProviders = providers;
},
handleUpdateFunctions(selected) {
this.currentFunctions = selected;
},
@@ -980,6 +1026,19 @@ export default {
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: {
"form.model.ttsModelId": {
@@ -1002,7 +1061,7 @@ export default {
immediate: true,
},
},
mounted() {
async mounted() {
const agentId = this.$route.query.agentId;
if (agentId) {
this.fetchAgentConfig(agentId);
@@ -1010,6 +1069,8 @@ export default {
}
this.fetchModelOptions();
this.fetchTemplates();
// 加载功能状态,确保featureManager已初始化
await this.loadFeatureStatus();
},
};
</script>
@@ -1298,6 +1359,26 @@ export default {
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 {
display: flex;
align-items: center;
@@ -1345,4 +1426,18 @@ export default {
height: 32px;
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>
@@ -74,6 +74,7 @@
- **今天农历:** {{lunar_date}}
- **用户所在城市:** {{local_address}}
- **当地未来7天天气:** {{weather_info}}
{{ dynamic_context }}
</context>
<memory>
+9 -1
View File
@@ -113,6 +113,15 @@ wakeup_words:
# MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
# 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
mcp_endpoint: 你的接入点 websocket地址
# 上下文源配置
# 用于在系统提示词中注入动态数据,如健康数据、股票信息等
# 可以添加多个上下文源
context_providers:
- url: ""
headers:
Authorization: ""
# 插件的基础配置
plugins:
# 获取天气插件的配置,这里填写你的api_key
@@ -462,7 +471,6 @@ ASR:
domain: slm # 识别领域,iat:日常用语,medical:医疗,finance:金融等
language: zh_cn # 语言,zh_cn:中文,en_us:英文
accent: mandarin # 方言,mandarin:普通话
dwa: wpgs # 动态修正,wpgs:实时返回中间结果
# 调整音频处理参数以提高长语音识别质量
output_dir: tmp/
@@ -68,6 +68,7 @@ async def get_config_from_api_async(config):
"url": config["manager-api"].get("url", ""),
"secret": config["manager-api"].get("secret", ""),
}
auth_enabled = config_data.get("server", {}).get("auth", {}).get("enabled", False)
# server的配置以本地为准
if config.get("server"):
config_data["server"] = {
@@ -77,6 +78,7 @@ async def get_config_from_api_async(config):
"vision_explain": config["server"].get("vision_explain", ""),
"auth_key": config["server"].get("auth_key", ""),
}
config_data["server"]["auth"] = {"enabled": auth_enabled}
# 如果服务器没有prompt_template,则从本地配置读取
if not config_data.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 datetime import datetime
SERVER_VERSION = "0.8.9"
SERVER_VERSION = "0.8.10"
_logger_initialized = False
+59 -25
View File
@@ -69,6 +69,7 @@ class ConnectionHandler:
self.server = server # 保存server实例的引用
self.need_bind = False # 是否需要绑定设备
self.bind_completed_event = asyncio.Event()
self.bind_code = None # 绑定设备的验证码
self.last_bind_prompt_time = 0 # 上次播放绑定提示的时间戳(秒)
self.bind_prompt_interval = 60 # 绑定提示播放间隔(秒)
@@ -162,7 +163,7 @@ class ConnectionHandler:
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):
try:
@@ -266,30 +267,43 @@ class ConnectionHandler:
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):
"""消息路由"""
# 检查是否已经获取到真实的绑定状态
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):
await handleTextMessage(self, message)
elif isinstance(message, bytes):
if self.vad is None or self.asr is None:
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网关的音频包
if self.conn_from_mqtt_gateway and len(message) >= 16:
handled = await self._process_mqtt_audio_message(message)
@@ -413,6 +427,14 @@ class ConnectionHandler:
def _initialize_components(self):
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.config.get("selected_module", {})
)
@@ -436,17 +458,10 @@ class ConnectionHandler:
# 初始化声纹识别
self._initialize_voiceprint()
# 打开语音识别通道
asyncio.run_coroutine_threadsafe(
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()
@@ -461,6 +476,7 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).error(f"实例化组件失败: {e}")
def _init_prompt_enhancement(self):
# 更新上下文信息
self.prompt_manager.update_context_info(self, self.client_ip)
enhanced_prompt = self.prompt_manager.build_enhanced_prompt(
@@ -496,7 +512,11 @@ class ConnectionHandler:
def _initialize_asr(self):
"""初始化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 = self._asr
@@ -536,6 +556,8 @@ class ConnectionHandler:
async def _initialize_private_config_async(self):
"""从接口异步获取差异化配置(异步版本,不阻塞主循环)"""
if not self.read_config_from_api:
self.need_bind = False
self.bind_completed_event.set()
return
try:
begin_time = time.time()
@@ -548,15 +570,20 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).info(
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:
self.need_bind = True
self.bind_completed_event.set() # 状态已确定,设置事件
private_config = {}
except DeviceBindException as e:
self.need_bind = True
self.bind_code = e.bind_code
self.bind_completed_event.set() # 状态已确定,设置事件
private_config = {}
except Exception as e:
self.need_bind = True
self.bind_completed_event.set() # 状态已确定,设置事件
self.logger.bind(tag=TAG).error(f"异步获取差异化配置失败: {e}")
private_config = {}
@@ -630,6 +657,8 @@ class ConnectionHandler:
self.chat_history_conf = int(private_config["chat_history_conf"])
if private_config.get("mcp_endpoint", None) is not None:
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,避免阻塞主循环
try:
@@ -1169,6 +1198,11 @@ class ConnectionHandler:
except queue.Empty:
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(
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
@@ -123,7 +123,7 @@ async def max_out_size(conn):
text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!"
await send_stt_message(conn, text)
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.close_after_chat = True
@@ -142,7 +142,7 @@ async def check_bind_device(conn):
# 播放提示音
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))
# 逐个播放数字
@@ -150,7 +150,7 @@ async def check_bind_device(conn):
try:
digit = conn.bind_code[i]
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))
except Exception as e:
conn.logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
@@ -162,5 +162,5 @@ async def check_bind_device(conn):
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
await send_stt_message(conn, text)
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))
@@ -10,7 +10,6 @@ TTS上报功能已集成到ConnectionHandler类中。
"""
import time
import gc
import opuslib_next
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)
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)
# 发送句子开始消息
@@ -31,6 +43,22 @@ async def sendAudioMessage(conn, sentenceType, audios, text):
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):
"""
发送带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)
# 播放音频 - 使用 AudioRateController 进行精确流控
async def sendAudio(conn, audios, frame_duration=60):
"""
发送音频包,使用 AudioRateController 进行精确的流量控制
@@ -62,128 +89,127 @@ async def sendAudio(conn, audios, frame_duration=60):
conn: 连接对象
audios: 单个opus包(bytes) 或 opus包列表
frame_duration: 帧时长(毫秒),默认60ms
改进点:
1. 使用单一时间基准,避免累积误差
2. 每次检查队列时重新计算 elapsed_ms,更精准
3. 支持高并发而不产生时间偏差
"""
if audios is None or len(audios) == 0:
return
# 获取发送延迟配置
send_delay = conn.config.get("tts_audio_send_delay", -1) / 1000.0
is_single_packet = isinstance(audios, bytes)
if isinstance(audios, bytes):
# 单个 opus 包处理
await _sendAudio_single(conn, audios, send_delay, frame_duration)
else:
# 音频列表处理(如文件型音频)
await _sendAudio_list(conn, audios, send_delay, frame_duration)
# 初始化或获取 RateController
rate_controller, flow_control = _get_or_create_rate_controller(
conn, frame_duration, is_single_packet
)
# 统一转换为列表处理
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 包
使用 AudioRateController 进行流控
获取或创建 RateController 和 flow_control
Args:
conn: 连接对象
frame_duration: 帧时长
is_single_packet: 是否单包模式(True: TTS流式单包, False: 批量包)
Returns:
(rate_controller, flow_control)
"""
# 重置流控状态,第一次读取和会话发生转变时
if not hasattr(conn, "audio_rate_controller") or conn.audio_flow_control.get("sentence_id") != conn.sentence_id:
if hasattr(conn, "audio_rate_controller"):
conn.audio_rate_controller.reset()
else:
# 判断是否需要重置:单包模式且 sentence_id 变化,或者控制器不存在
need_reset = (
is_single_packet
and getattr(conn, "audio_flow_control", {}).get("sentence_id")
!= 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)
else:
conn.audio_rate_controller.reset()
# 初始化 flow_control
conn.audio_flow_control = {
"packet_count": 0,
"sequence": 0,
"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
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
return conn.audio_rate_controller, conn.audio_flow_control
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)
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):
async def send_callback(packet):
# 检查是否应该中止
if conn.client_abort:
return
await _do_send_audio(conn, audios[i], flow_control, frame_duration)
raise asyncio.CancelledError("客户端已中止")
conn.last_activity_time = time.time() * 1000
await _do_send_audio(conn, packet, flow_control)
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:
break
return
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)
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)
await _do_send_audio(conn, packet, flow_control)
conn.client_is_speaking = True
continue
await _do_send_audio(conn, opus_packet, flow_control, frame_duration)
conn.client_is_speaking = True
else:
# 动态流控模式:仅添加到队列,由后台循环负责发送
rate_controller.add_audio(packet)
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", "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 _wait_for_audio_completion(conn)
# 清除服务端讲话状态
conn.clearSpeakStatus()
@@ -1,12 +1,14 @@
import time
import asyncio
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.sendAudioHandle import send_stt_message, send_tts_message
from core.handle.textMessageHandler import TextMessageHandler
from core.handle.textMessageType import TextMessageType
from core.utils.util import remove_punctuation_and_length
from core.providers.asr.dto.dto import InterfaceType
TAG = __name__
@@ -29,8 +31,18 @@ class ListenTextMessageHandler(TextMessageHandler):
elif msg_json["state"] == "stop":
conn.client_have_voice = True
conn.client_voice_stop = True
if len(conn.asr_audio) > 0:
await handleAudioMessage(conn, b"")
if conn.asr.interface_type == InterfaceType.STREAM:
# 流式模式下,发送结束请求
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":
conn.client_have_voice = False
conn.asr_audio.clear()
@@ -5,12 +5,9 @@ import hmac
import base64
import hashlib
import asyncio
import gc
import requests
import websockets
import opuslib_next
import random
from typing import Optional, Tuple, List
from urllib import parse
from datetime import datetime
from config.logger import setup_logging
@@ -140,13 +137,13 @@ class ASRProvider(ASRProviderBase):
conn.asr_audio.append(audio)
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:
await self._start_recognition(conn)
except Exception as e:
logger.bind(tag=TAG).error(f"开始识别失败: {str(e)}")
await self._cleanup(conn)
await self._cleanup()
return
if self.asr_ws and self.is_processing and self.server_ready:
@@ -186,10 +183,8 @@ class ASRProvider(ASRProviderBase):
"header": {
"namespace": "SpeechTranscriber",
"name": "StartTranscription",
"status": 20000000,
"message_id": uuid.uuid4().hex,
"task_id": self.task_id,
"status_text": "Gateway:SUCCESS:Success.",
"appkey": self.appkey
},
"payload": {
@@ -208,18 +203,21 @@ class ASRProvider(ASRProviderBase):
async def _forward_results(self, conn):
"""转发识别结果"""
try:
while self.asr_ws and not conn.stop_event.is_set():
while not conn.stop_event.is_set():
try:
response = await asyncio.wait_for(self.asr_ws.recv(), timeout=1.0)
result = json.loads(response)
header = result.get("header", {})
payload = result.get("payload", {})
message_name = header.get("name", "")
status = header.get("status", 0)
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}")
break
elif status in [40270002, 40270003]: # 音频问题
@@ -228,12 +226,12 @@ class ASRProvider(ASRProviderBase):
else:
logger.bind(tag=TAG).error(f"识别错误,状态码: {status}, 消息: {header.get('status_text', '')}")
continue
# 收到TranscriptionStarted表示服务器准备好接收音频数据
if message_name == "TranscriptionStarted":
self.server_ready = True
logger.bind(tag=TAG).debug("服务器已准备,开始发送缓存音频...")
# 发送缓存音频
if conn.asr_audio:
for cached_audio in conn.asr_audio[-10:]:
@@ -244,89 +242,89 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}")
break
continue
if message_name == "TranscriptionResultChanged":
# 中间结果
text = payload.get("result", "")
if text:
self.text = text
elif message_name == "SentenceEnd":
# 最终结果
# 句子结束(每个句子都会触发)
text = payload.get("result", "")
if text:
self.text = text
conn.reset_vad_states()
# 传递缓存的音频数据
audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
await self.handle_voice_stop(conn, audio_data)
# 清空缓存
conn.asr_audio_for_voiceprint = []
break
elif message_name == "TranscriptionCompleted":
# 识别完成
self.is_processing = False
break
logger.bind(tag=TAG).info(f"识别到文本: {text}")
# 手动模式下累积识别结果
if conn.client_listen_mode == "manual":
if self.text:
self.text += text
else:
self.text = text
# 手动模式下,只有在收到stop信号后才触发处理(仅处理一次)
if conn.client_voice_stop:
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:
continue
except websockets.exceptions.ConnectionClosed:
logger.bind(tag=TAG).error("接收结果超时")
break
except websockets.ConnectionClosed:
logger.bind(tag=TAG).info("ASR服务连接已关闭")
self.is_processing = False
break
except Exception as e:
logger.bind(tag=TAG).error(f"处理结果失败: {str(e)}")
break
except Exception as e:
logger.bind(tag=TAG).error(f"结果转发失败: {str(e)}")
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):
"""清理资源"""
logger.bind(tag=TAG).debug(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}")
# 清理连接的音频缓存
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:
async def _send_stop_request(self):
"""发送停止识别请求(不关闭连接)"""
if self.asr_ws:
try:
# 先停止音频发送
self.is_processing = False
stop_msg = {
"header": {
"namespace": "SpeechTranscriber",
"name": "StopTranscription",
"status": 20000000,
"message_id": uuid.uuid4().hex,
"task_id": self.task_id,
"status_text": "Client:Stop",
"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 asyncio.sleep(0.1)
logger.bind(tag=TAG).debug("ASR终止请求已发送")
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.server_ready = False
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:
try:
@@ -337,7 +335,10 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).error(f"关闭WebSocket连接失败: {e}")
finally:
self.asr_ws = None
# 清理任务引用
self.forward_task = None
logger.bind(tag=TAG).debug("ASR会话清理完成")
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 threading
import opuslib_next
import concurrent.futures
import gc
from abc import ABC, abstractmethod
from config.logger import setup_logging
from typing import Optional, Tuple, List
@@ -54,121 +52,89 @@ class ASRProviderBase(ABC):
# 接收音频
async def receive_audio(self, conn, audio, audio_have_voice):
if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime":
have_voice = audio_have_voice
if conn.client_listen_mode == "manual":
# 手动模式:缓存音频用于ASR识别
conn.asr_audio.append(audio)
else:
have_voice = conn.client_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
# 自动/实时模式:使用VAD检测
have_voice = audio_have_voice
if conn.client_voice_stop:
asr_audio_task = conn.asr_audio.copy()
conn.asr_audio.clear()
conn.reset_vad_states()
conn.asr_audio.append(audio)
if not have_voice and not conn.client_have_voice:
conn.asr_audio = conn.asr_audio[-10:]
return
if len(asr_audio_task) > 15:
await self.handle_voice_stop(conn, asr_audio_task)
# 自动模式下通过VAD检测到语音停止时触发识别
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]):
"""并行处理ASR和声纹识别"""
try:
total_start_time = time.monotonic()
# 准备音频数据
if conn.audio_format == "pcm":
pcm_data = asr_audio_task
else:
pcm_data = self.decode_opus(asr_audio_task)
combined_pcm_data = b"".join(pcm_data)
# 预先准备WAV数据
wav_data = None
if conn.voiceprint_provider and combined_pcm_data:
wav_data = self._pcm_to_wav(combined_pcm_data)
# 定义ASR任务
def run_asr():
start_time = time.monotonic()
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format)
)
end_time = time.monotonic()
logger.bind(tag=TAG).debug(f"ASR耗时: {end_time - start_time:.3f}s")
return result
finally:
loop.close()
except Exception as e:
end_time = time.monotonic()
logger.bind(tag=TAG).error(f"ASR失败: {e}")
return ("", None)
# 定义声纹识别任务
def run_voiceprint():
if not wav_data:
return None
try:
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)
# 记录识别结果
asr_task = self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format)
if conn.voiceprint_provider and wav_data:
voiceprint_task = conn.voiceprint_provider.identify_speaker(wav_data, conn.session_id)
# 并发等待两个结果
asr_result, voiceprint_result = await asyncio.gather(
asr_task, voiceprint_task, return_exceptions=True
)
else:
asr_result = await asr_task
voiceprint_result = None
# 记录识别结果 - 检查是否为异常
if isinstance(asr_result, Exception):
logger.bind(tag=TAG).error(f"ASR识别失败: {asr_result}")
raw_text = ""
else:
raw_text, _ = asr_result
if isinstance(voiceprint_result, Exception):
logger.bind(tag=TAG).error(f"声纹识别失败: {voiceprint_result}")
speaker_name = ""
else:
speaker_name = voiceprint_result
if raw_text:
logger.bind(tag=TAG).info(f"识别文本: {raw_text}")
if speaker_name:
logger.bind(tag=TAG).info(f"识别说话人: {speaker_name}")
# 性能监控
total_time = time.monotonic() - total_start_time
logger.bind(tag=TAG).debug(f"总处理耗时: {total_time:.3f}s")
# 检查文本长度
text_len, _ = remove_punctuation_and_length(raw_text)
self.stop_ws_connection()
if text_len > 0:
# 构建包含说话人信息的JSON字符串
enhanced_text = self._build_enhanced_text(raw_text, speaker_name)
# 使用自定义模块进行上报
await startToChat(conn, enhanced_text)
enqueue_asr_report(conn, enhanced_text, asr_audio_task)
@@ -4,7 +4,6 @@ import uuid
import asyncio
import websockets
import opuslib_next
import gc
from core.providers.asr.base import ASRProviderBase
from config.logger import setup_logging
from core.providers.asr.dto.dto import InterfaceType
@@ -19,8 +18,6 @@ class ASRProvider(ASRProviderBase):
self.interface_type = InterfaceType.STREAM
self.config = config
self.text = ""
self.max_retries = 3
self.retry_delay = 2
self.decoder = opuslib_next.Decoder(16000, 1)
self.asr_ws = None
self.forward_task = None
@@ -57,14 +54,13 @@ class ASRProvider(ASRProviderBase):
async def receive_audio(self, conn, audio, audio_have_voice):
conn.asr_audio.append(audio)
conn.asr_audio = conn.asr_audio[-10:]
# 存储音频数据
if not hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
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)
conn.asr_audio_for_voiceprint = []
@@ -180,6 +176,7 @@ class ASRProvider(ASRProviderBase):
payload.get("audio_info", {}).get("duration", 0) > 2000
and not utterances
and not payload["result"].get("text")
and conn.client_listen_mode != "manual"
):
logger.bind(tag=TAG).error(f"识别文本:空")
self.text = ""
@@ -188,15 +185,44 @@ class ASRProvider(ASRProviderBase):
await self.handle_voice_stop(conn, audio_data)
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:
if utterance.get("definite", False):
self.text = utterance["text"]
current_text = utterance["text"]
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
elif "error" in payload:
error_msg = payload.get("error", "未知错误")
@@ -228,8 +254,6 @@ class ASRProvider(ASRProviderBase):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'):
conn.asr_audio = []
if hasattr(conn, 'has_valid_voice'):
conn.has_valid_voice = False
def stop_ws_connection(self):
if self.asr_ws:
@@ -237,6 +261,20 @@ class ASRProvider(ASRProviderBase):
self.asr_ws = None
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):
req = {
"app": {
@@ -388,5 +426,3 @@ class ASRProvider(ASRProviderBase):
conn.asr_audio_for_voiceprint = []
if hasattr(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 sys
import io
import sys
import time
import shutil
import psutil
import asyncio
from config.logger import setup_logging
from typing import Optional, Tuple, List
from core.providers.asr.base import ASRProviderBase
from funasr import AutoModel
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
TAG = __name__
@@ -90,16 +92,17 @@ class ASRProvider(ASRProviderBase):
else:
file_path = self.save_audio_to_file(pcm_data, session_id)
# 语音识别
# 语音识别 - 使用线程池避免阻塞事件循环
start_time = time.time()
result = self.model.generate(
result = await asyncio.to_thread(
self.model.generate,
input=combined_pcm_data,
cache={},
language="auto",
use_itn=True,
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(
f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}"
)
@@ -1,8 +1,5 @@
import os
import json
import asyncio
import tempfile
import difflib
from typing import Optional, Tuple, List
import dashscope
from config.logger import setup_logging
@@ -16,7 +13,8 @@ logger = setup_logging()
class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool):
super().__init__()
self.interface_type = InterfaceType.STREAM
# 音频文件上传类型,流式文本识别输出
self.interface_type = InterfaceType.NON_STREAM
"""Qwen3-ASR-Flash ASR初始化"""
# 配置参数
@@ -130,27 +128,11 @@ class ASRProvider(ASRProviderBase):
# 处理流式响应
full_text = ""
last_text = "" # 用于存储上一个文本片段
for chunk in response:
try:
text = chunk["output"]["choices"][0]["message"].content[0]["text"]
# 标准化文本片段(去除首尾空格)
normalized_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
# 这里可以实时处理文本片段,例如通过回调函数
# 更新为最新的完整文本
full_text = text.strip()
except:
pass
@@ -35,9 +35,6 @@ class ASRProvider(ASRProviderBase):
self.forward_task = None
self.is_processing = 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")
@@ -52,7 +49,6 @@ class ASRProvider(ASRProviderBase):
"domain": config.get("domain", "slm"),
"language": config.get("language", "zh_cn"),
"accent": config.get("accent", "mandarin"),
"dwa": config.get("dwa", "wpgs"),
"result": {"encoding": "utf8", "compress": "raw", "format": "plain"},
}
@@ -116,7 +112,7 @@ class ASRProvider(ASRProviderBase):
await self._start_recognition(conn)
except Exception as e:
logger.bind(tag=TAG).error(f"建立ASR连接失败: {str(e)}")
await self._cleanup(conn)
await self._cleanup()
return
# 发送当前音频数据
@@ -126,7 +122,7 @@ class ASRProvider(ASRProviderBase):
await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME)
except Exception as e:
logger.bind(tag=TAG).warning(f"发送音频数据时发生错误: {e}")
await self._cleanup(conn)
await self._cleanup()
async def _start_recognition(self, conn):
"""开始识别会话"""
@@ -136,6 +132,10 @@ class ASRProvider(ASRProviderBase):
ws_url = self.create_url()
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(
ws_url,
max_size=1000000000,
@@ -146,8 +146,6 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).info("ASR WebSocket连接已建立")
self.server_ready = False
self.last_frame_sent = False
self.best_text = ""
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))
# 标记是否发送了最终帧
if status == STATUS_LAST_FRAME:
self.last_frame_sent = True
logger.bind(tag=TAG).info("标记最终帧已发送")
async def _forward_results(self, conn):
"""转发识别结果"""
try:
while self.asr_ws and not conn.stop_event.is_set():
# 获取当前连接的音频数据
audio_data = getattr(conn, "asr_audio_for_voiceprint", [])
while not conn.stop_event.is_set():
try:
# 如果已发送最终帧,增加超时时间等待完整结果
timeout = 3.0 if self.last_frame_sent else 30.0
response = await asyncio.wait_for(
self.asr_ws.recv(), timeout=timeout
)
response = await asyncio.wait_for(self.asr_ws.recv(), timeout=60)
result = json.loads(response)
logger.bind(tag=TAG).debug(f"收到ASR结果: {result}")
@@ -236,144 +223,27 @@ class ASRProvider(ASRProviderBase):
# 解码base64文本
decoded_text = base64.b64decode(text_data).decode("utf-8")
text_json = json.loads(decoded_text)
# 提取文本内容
text_ws = text_json.get("ws", [])
result_text = ""
for i in text_ws:
for j in i.get("cw", []):
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:
logger.bind(tag=TAG).info(
f"识别完成状态已到达,当前识别文本: {self.text}"
)
# 如果还没发送最终帧,继续等待
if not self.last_frame_sent:
logger.bind(tag=TAG).info(
"识别完成但最终帧未发送,继续等待..."
)
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}")
if conn.client_listen_mode == "manual":
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()
if len(audio_data) > 15: # 确保有足够音频数据
# 准备处理结果
pass
break
except asyncio.TimeoutError:
if self.last_frame_sent:
# 超时时也使用最佳文本
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
logger.bind(tag=TAG).error("接收结果超时")
break
except websockets.ConnectionClosed:
logger.bind(tag=TAG).info("ASR服务连接已关闭")
self.is_processing = False
@@ -390,17 +260,15 @@ class ASRProvider(ASRProviderBase):
if hasattr(e, "__cause__") and e.__cause__:
logger.bind(tag=TAG).error(f"错误原因: {str(e.__cause__)}")
finally:
if self.asr_ws:
await self.asr_ws.close()
self.asr_ws = None
self.is_processing = False
# 清理连接资源
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 = []
if hasattr(conn, "has_valid_voice"):
conn.has_valid_voice = False
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:
try:
# 取最后一个有效的音频帧作为最后一帧数据
last_frame = b""
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("已发送最后一帧")
await self._send_audio_frame(b"", STATUS_LAST_FRAME)
logger.bind(tag=TAG).debug(f"已发送停止请求")
# 发送最终帧后,给_forward_results适当时间处理最终结果
await asyncio.sleep(0.25)
logger.bind(tag=TAG).info(f"准备处理最终识别结果: {self.text}")
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)
except Exception as e:
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
@@ -437,40 +296,27 @@ class ASRProvider(ASRProviderBase):
self.asr_ws = None
self.is_processing = False
async def _cleanup(self, conn):
"""清理资源"""
logger.bind(tag=TAG).info(
async def _send_stop_request(self):
"""发送停止识别请求(不关闭连接)"""
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}"
)
# 发送最后一帧
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.server_ready = False
self.last_frame_sent = False
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
logger.bind(tag=TAG).debug("ASR状态已重置")
# 关闭连接
if self.asr_ws:
@@ -483,16 +329,10 @@ class ASRProvider(ASRProviderBase):
finally:
self.asr_ws = None
# 清理连接的音频缓存
if conn:
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
# 清理任务引用
self.forward_task = None
logger.bind(tag=TAG).info("ASR会话清理完成")
logger.bind(tag=TAG).debug("ASR会话清理完成")
async def speech_to_text(self, opus_data, session_id, audio_format):
"""获取识别结果"""
@@ -513,7 +353,7 @@ class ASRProvider(ASRProviderBase):
pass
self.forward_task = None
self.is_processing = False
# 显式释放decoder资源
if hasattr(self, 'decoder') and self.decoder is not None:
try:
@@ -530,5 +370,3 @@ class ASRProvider(ASRProviderBase):
conn.asr_audio_for_voiceprint = []
if hasattr(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 torch
import opuslib_next
import gc
from config.logger import setup_logging
from core.providers.vad.base import VADProviderBase
@@ -45,6 +44,10 @@ class VADProvider(VADProviderBase):
pass
def is_vad(self, conn, opus_packet):
# 手动模式:直接返回True,不进行实时VAD检测,所有音频都缓存
if conn.client_listen_mode == "manual":
return True
try:
pcm_frame = self.decoder.decode(opus_packet, 960)
conn.client_audio_buffer.extend(pcm_frame) # 将新数据加入缓冲区
@@ -10,12 +10,6 @@ class AudioRateController:
"""
音频速率控制器 - 按照60ms帧时长精确控制音频发送
解决高并发下的时间累积误差问题
关键改进:
1. 单一时间基准(start_timestamp 只初始化一次)
2. 每次检查队列时重新计算 elapsed_ms,避免累积误差
3. 分离"检查时间""发送"两个操作
4. 支持高并发而不产生延迟
"""
def __init__(self, frame_duration=60):
@@ -29,24 +23,34 @@ class AudioRateController:
self.start_timestamp = None # 开始时间戳(只读,不修改)
self.pending_send_task = None
self.logger = logger
self.queue_empty_event = asyncio.Event() # 队列清空事件
self.queue_empty_event.set() # 初始为空状态
def reset(self):
"""重置控制器状态"""
if self.pending_send_task and not self.pending_send_task.done():
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.play_position = 0
self.start_timestamp = time.time()
self.queue_empty_event.set() # 队列已清空
def add_audio(self, 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):
"""获取已经过的时间(毫秒)"""
@@ -62,34 +66,47 @@ class AudioRateController:
send_audio_callback: 发送音频的回调函数 async def(opus_packet)
"""
if self.start_timestamp is None:
self.reset()
self.start_timestamp = time.time()
while self.queue:
item = self.queue[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
# 计算时间差
elapsed_ms = self._get_elapsed_ms()
output_ms = self.play_position
# 循环等待直到时间到达
while True:
# 计算时间差
elapsed_ms = self._get_elapsed_ms()
output_ms = self.play_position
if elapsed_ms < output_ms:
# 还不到发送时间,计算等待时长
wait_ms = output_ms - elapsed_ms
if elapsed_ms < output_ms:
# 还不到发送时间,计算等待时长
wait_ms = output_ms - elapsed_ms
# 等待后继续检查(允许被中断)
try:
await asyncio.sleep(wait_ms / 1000)
except asyncio.CancelledError:
self.logger.bind(tag=TAG).debug("音频发送任务被取消")
raise
# 等待后继续检查(允许被中断)
try:
await asyncio.sleep(wait_ms / 1000)
except asyncio.CancelledError:
self.logger.bind(tag=TAG).debug("音频发送任务被取消")
raise
# 等待结束后重新检查时间(循环回到 while True)
else:
# 时间已到,跳出等待循环
break
# 继续循环检查(时间可能已到)
continue
# 时间已到,发送音频
# 时间已到,从队列移除并发送
self.queue.pop(0)
self.play_position += self.frame_duration
@@ -99,26 +116,27 @@ class AudioRateController:
self.logger.bind(tag=TAG).error(f"发送音频失败: {e}")
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:
send_audio_callback: 发送音频的回调函数
send_message_callback: 发送消息的回调函数
Returns:
asyncio.Task: 发送任务
"""
async def _send_loop():
try:
while True:
await self.check_queue(send_audio_callback, send_message_callback)
await self.check_queue(send_audio_callback)
# 如果队列空了,短暂等待后再检查(避免 busy loop)
await asyncio.sleep(0.01)
except asyncio.CancelledError:
self.logger.bind(tag=TAG).info("音频发送循环已停止")
self.logger.bind(tag=TAG).debug("音频发送循环已停止")
except Exception as e:
self.logger.bind(tag=TAG).error(f"音频发送循环异常: {e}")
+4
View File
@@ -19,6 +19,7 @@ class CacheType(Enum):
CONFIG = "config"
DEVICE_PROMPT = "device_prompt"
VOICEPRINT_HEALTH = "voiceprint_health" # 声纹识别健康检查
AUDIO_DATA = "audio_data" # 音频数据缓存
@dataclass
@@ -58,5 +59,8 @@ class CacheConfig:
CacheType.VOICEPRINT_HEALTH: cls(
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())
@@ -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 traceback
import numpy as np
import gc
from opuslib_next import Encoder
from opuslib_next import constants
from typing import Optional, Callable, Any
@@ -103,6 +102,9 @@ class OpusEncoderUtils:
def _encode(self, frame: np.ndarray) -> Optional[bytes]:
"""编码一帧音频数据"""
try:
# 编码器已释放,跳过编码
if not hasattr(self, 'encoder') or self.encoder is None:
return None
# 将numpy数组转换为bytes
frame_bytes = frame.tobytes()
# opuslib要求输入字节数必须是channels*2的倍数
@@ -4,7 +4,6 @@
"""
import os
import cnlunar
from typing import Dict, Any
from config.logger import setup_logging
from jinja2 import Template
@@ -60,6 +59,11 @@ class PromptManager:
self.cache_manager = cache_manager
self.CacheType = CacheType
# 初始化上下文源
from core.utils.context_provider import ContextDataProvider
self.context_provider = ContextDataProvider(config, self.logger)
self.context_data = {}
self._load_base_template()
@@ -184,6 +188,14 @@ class PromptManager:
local_address = self._get_location_info(client_ip)
# 获取天气信息(使用全局缓存)
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"上下文信息更新完成")
except Exception as e:
@@ -230,6 +242,7 @@ class PromptManager:
emojiList=EMOJI_List,
device_id=device_id,
client_ip=client_ip,
dynamic_context=self.context_data,
*args,
**kwargs,
)
+62 -36
View File
@@ -4,11 +4,11 @@ import json
import copy
import wave
import socket
import asyncio
import requests
import subprocess
import numpy as np
import opuslib_next
import gc
from io import BytesIO
from core.utils import p3
from pydub import AudioSegment
@@ -269,56 +269,82 @@ def audio_to_data_stream(
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编码的帧列表
Args:
audio_file_path: 音频文件路径
is_opus: 是否进行Opus编码
use_cache: 是否使用缓存
"""
# 获取文件后缀名
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"]
)
from core.utils.cache.manager import cache_manager
from core.utils.cache.config import CacheType
# 转换为单声道/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编码器
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
def _sync_audio_to_data():
# 获取文件后缀名
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"]
)
# 编码参数
frame_duration = 60 # 60ms per frame
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
datas = []
# 按帧处理所有音频数据(包括最后一帧可能补零)
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
# 获取当前帧的二进制数据
chunk = raw_data[i : i + frame_size * 2]
# 获取原始PCM数据(16位小端)
raw_data = audio.raw_data
# 如果最后一帧不足,补零
if len(chunk) < frame_size * 2:
chunk += b"\x00" * (frame_size * 2 - len(chunk))
# 初始化Opus编码器
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
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)
# 编码参数
frame_duration = 60 # 60ms per frame
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
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(
+28 -1
View File
@@ -1,8 +1,35 @@
import asyncio
import json
import logging
import websockets
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 config.config_loader import get_config_from_api_async
from core.auth import AuthManager, AuthenticationError
@@ -50,7 +50,9 @@ class BaseASRTester:
raise NotImplementedError
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:
avg_latency = sum(valid_latencies) / len(valid_latencies)
status = f"成功({len(valid_latencies)}/{test_count}次有效)"
@@ -64,16 +66,45 @@ class DoubaoStreamASRTester(BaseASRTester):
def __init__(self):
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.append((0x01 << 4) | 0x01)
header.append((0x01 << 4) | 0x00)
header.append((0x01 << 4) | 0x01)
header.append(0x00)
header_size = int(len(extension_header) / 4) + 1
header.append((version << 4) | header_size)
header.append((message_type << 4) | message_type_specific_flags)
header.append((serial_method << 4) | compression_type)
header.append(reserved_data)
header.extend(extension_header)
return header
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:
try:
@@ -110,6 +141,7 @@ class DoubaoStreamASRTester(BaseASRTester):
ws_url = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
appid = self.asr_config["appid"]
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")
start_time = time.time()
@@ -130,7 +162,7 @@ class DoubaoStreamASRTester(BaseASRTester):
close_timeout=10
) as ws:
request_params = {
"app": {"appid": appid, "token": access_token},
"app": {"appid": appid, "cluster": cluster, "token": access_token},
"user": {"uid": uid},
"request": {
"reqid": str(uuid.uuid4()),
@@ -166,8 +198,9 @@ class DoubaoStreamASRTester(BaseASRTester):
if audio_data.startswith(b'RIFF'):
audio_data = audio_data[44:]
# 发送音频数据(使用最后一帧标记,告诉服务端音频已结束)
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(payload)
await ws.send(audio_request)
@@ -175,11 +208,12 @@ class DoubaoStreamASRTester(BaseASRTester):
first_chunk = await ws.recv()
latency = time.time() - start_time
latencies.append(latency)
print(f"[豆包ASR] 第{i+1}次 首词延迟: {latency:.3f}s")
await ws.close()
except Exception as e:
print(f"[豆包ASR] 第{i+1}次测试失败: {str(e)}")
latencies.append(0)
latencies.append(None)
return self._calculate_result("豆包流式ASR", latencies, test_count)
@@ -189,11 +223,12 @@ class QwenASRFlashTester(BaseASRTester):
super().__init__("Qwen3ASRFlash")
async def _test_single(self, audio_file_info):
start_time = time.time()
temp_file_path = None
try:
audio_data = audio_file_info['data']
# 优化:将临时文件准备工作移到计时前,减少磁盘IO对性能测试的影响
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
temp_file_path = f.name
@@ -221,6 +256,9 @@ class QwenASRFlashTester(BaseASRTester):
dashscope.api_key = api_key
# 统一计时起点:在API调用前开始计时(但文件准备已完成)
start_time = time.time()
response = dashscope.MultiModalConversation.call(
model="qwen3-asr-flash",
messages=messages,
@@ -257,10 +295,10 @@ class QwenASRFlashTester(BaseASRTester):
# print(f"\n[通义ASR] 开始第 {i+1} 次测试...")
latency = await self._test_single(self.test_audio_files[0])
latencies.append(latency)
# print(f"[通义ASR] 第{i+1}次成功 延迟: {latency:.3f}s")
print(f"[通义ASR] 第{i+1}次 首词延迟: {latency:.3f}s")
except Exception as e:
# print(f"[通义ASR] 第{i+1}次测试失败: {str(e)}")
latencies.append(0)
latencies.append(None)
return self._calculate_result("通义千问ASR", latencies, test_count)
@@ -268,134 +306,115 @@ class QwenASRFlashTester(BaseASRTester):
class XunfeiStreamASRTester(BaseASRTester):
def __init__(self):
super().__init__("XunfeiStreamASR")
def _create_url(self):
"""生成讯飞ASR认证URL"""
url = 'ws://iat.cn-huabei-1.xf-yun.com/v1'
# 生成RFC1123格式的时间戳
url = "wss://iat-api.xfyun.cn/v2/iat"
now = datetime.now()
date = format_date_time(mktime(now.timetuple()))
# 拼接字符串
signature_origin = "host: " + "iat.cn-huabei-1.xf-yun.com" + "\n"
signature_origin += "date: " + date + "\n"
signature_origin += "GET " + "/v1 " + "HTTP/1.1"
signature_origin = f"host: iat-api.xfyun.cn\ndate: {date}\nGET /v2/iat HTTP/1.1"
signature_sha = hmac.new(
self.asr_config["api_secret"].encode('utf-8'),
signature_origin.encode('utf-8'),
hashlib.sha256
).digest()
signature_sha = base64.b64encode(signature_sha).decode()
# 进行hmac-sha256进行加密
signature_sha = hmac.new(self.asr_config["api_secret"].encode('utf-8'), signature_origin.encode('utf-8'),
digestmod=hashlib.sha256).digest()
signature_sha = base64.b64encode(signature_sha).decode(encoding='utf-8')
authorization_origin = f'api_key="{self.asr_config["api_key"]}", algorithm="hmac-sha256", headers="host date request-line", signature="{signature_sha}"'
authorization = base64.b64encode(authorization_origin.encode()).decode()
authorization_origin = "api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"" % (
self.asr_config["api_key"], "hmac-sha256", "host date request-line", signature_sha)
authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8')
v = {"authorization": authorization, "date": date, "host": "iat-api.xfyun.cn"}
return url + "?" + parse.urlencode(v)
# 将请求的鉴权参数组合为字典
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):
async def test(self, test_count: int = 5):
if not self.test_audio_files:
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未找到测试音频"}
if not self.asr_config:
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未配置"}
# 检查必要的配置参数
required_keys = ["app_id", "api_key", "api_secret"]
for key in required_keys:
if key not in self.asr_config:
return {"name": "讯飞流式ASR", "latency": 0, "status": f"失败: 缺少配置项 {key}"}
required = ["app_id", "api_key", "api_secret"]
for k in required:
if k not in self.asr_config:
return {"name": "讯飞流式ASR", "latency": 0, "status": f"失败: 缺少配置 {k}"}
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):
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()
ws_url = self._create_url()
async with websockets.connect(
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_timeout=None,
close_timeout=30,
) as ws:
# 发送首帧数据
await ws.send(json.dumps(first_frame_data, ensure_ascii=False))
print(f"[讯飞ASR] 第{i+1}次测试:已发送首帧,等待响应...")
# 直接等待第一个响应并计算延迟
# 参考豆包和通义千问的实现方式,简化逻辑
response_received = False
while not response_received:
try:
# 设置较大的超时时间
response = await asyncio.wait_for(ws.recv(), timeout=30.0)
# 收到响应立即计算延迟,不管内容是什么
# 这样可以准确测量首包到达时间
latency = time.time() - start_time
latencies.append(latency)
response_received = True
print(f"[讯飞ASR] 第{i+1}次测试:收到首包响应,延迟: {latency:.3f}s")
# 第一帧:移除 punc 字段,避免未知参数错误
await ws.send(json.dumps({
"common": {"app_id": self.asr_config["app_id"]},
"business": {
"domain": "iat",
"language": "zh_cn",
"accent": "mandarin",
"dwa": "wpgs",
"vad_eos": 5000
# 已移除 "punc": True
},
"data": {
"status": 0,
"format": "audio/L16;rate=16000",
"encoding": "raw",
"audio": base64.b64encode(audio_raw[:frame_size]).decode()
}
}, 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
except asyncio.TimeoutError:
print(f"[讯飞ASR] 第{i+1}次测试:响应超时")
raise Exception("获取响应超时")
pos += frame_size
# 接收首词
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:
print(f"[讯飞ASR] 第{i+1}次测试失败: {str(e)}")
latencies.append(0)
return self._calculate_result("讯飞流式ASR", latencies, test_count)
latencies.append(None)
return self._calculate_result("讯飞流式ASR", latencies, test_count)
class ASRPerformanceSuite:
def __init__(self):
self.testers = []
@@ -438,8 +457,9 @@ class ASRPerformanceSuite:
print(tabulate(table_data, headers=["ASR服务", "首词延迟", "状态"], tablefmt="grid"))
print("\n测试说明:")
print("- 测量从发送请求到接收第一个有效识别文本的时间")
print("- 超时控制: DashScope 默认超时,豆包 WebSocket 超时10秒")
print("- 计时起点: 建立连接前(包含握手、发送音频、接收首个识别结果全流程)")
print("- 通义千问优化: 临时文件准备在计时前完成,减少磁盘IO对测试的影响")
print("- 错误处理: 失败的测试不计入平均值,只统计成功测试的延迟")
print("- 排序规则: 成功的按延迟升序,失败的排在后面")
async def run(self, test_count=5):
@@ -35,11 +35,12 @@ class StreamTTSPerformanceTester:
host = tts_config["host"]
ws_url = f"wss://{host}/ws/v1"
# 统一计时起点:在建立连接前开始计时
start_time = time.time()
async with websockets.connect(ws_url, extra_headers={"X-NLS-Token": token}) as ws:
task_id = str(uuid.uuid4())
message_id = str(uuid.uuid4())
start_request = {
"header": {
"message_id": message_id,
@@ -55,14 +56,15 @@ class StreamTTSPerformanceTester:
"volume": 50,
"speech_rate": 0,
"pitch_rate": 0,
"enable_subtitle": True,
}
}
await ws.send(json.dumps(start_request))
start_response = json.loads(await ws.recv())
if start_response["header"]["name"] != "SynthesisStarted":
raise Exception("启动合成失败")
run_request = {
"header": {
"message_id": str(uuid.uuid4()),
@@ -74,23 +76,142 @@ class StreamTTSPerformanceTester:
"payload": {"text": text}
}
await ws.send(json.dumps(run_request))
while True:
response = await ws.recv()
if isinstance(response, bytes):
latency = time.time() - start_time
latencies.append(latency)
print(f"[阿里云TTS] 第{i+1}次 首词延迟: {latency:.3f}s")
break
elif isinstance(response, str):
data = json.loads(response)
if data["header"]["name"] == "TaskFailed":
raise Exception(f"合成失败: {data['payload']['error_info']}")
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)
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):
"""测试火山引擎流式TTS首词延迟(测试多次取平均)"""
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:
session_id = uuid.uuid4().hex
# 发送会话启动请求
header = bytes([
(0b0001 << 4) | 0b0001,
0b0001 << 4 | 0b100,
0b0001 << 4 | 0b0000,
0
(0b0001 << 4) | 0b0001,
0b0001 << 4 | 0b1011,
0b0001 << 4 | 0b0000,
])
optional = bytearray()
optional.extend((1).to_bytes(4, "big", signed=True))
@@ -129,13 +249,13 @@ class StreamTTSPerformanceTester:
optional.extend(session_id_bytes)
payload = json.dumps({"speaker": speaker}).encode()
await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload)
# 发送文本
header = bytes([
(0b0001 << 4) | 0b0001,
0b0001 << 4 | 0b100,
0b0001 << 4 | 0b0000,
0
(0b0001 << 4) | 0b0001,
0b0001 << 4 | 0b1011,
0b0001 << 4 | 0b0000,
0
])
optional = bytearray()
optional.extend((200).to_bytes(4, "big", signed=True))
@@ -144,13 +264,15 @@ class StreamTTSPerformanceTester:
optional.extend(session_id_bytes)
payload = json.dumps({"text": text, "speaker": speaker}).encode()
await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload)
first_chunk = await ws.recv()
latency = time.time() - start_time
latencies.append(latency)
print(f"[火山引擎TTS] 第{i+1}次 首词延迟: {latency:.3f}s")
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)
@@ -191,22 +313,24 @@ class StreamTTSPerformanceTester:
first_chunk = await ws.recv()
latency = time.time() - start_time
latencies.append(latency)
print(f"[PaddleSpeechTTS] 第{i+1}次 首词延迟: {latency:.3f}s")
# 发送结束请求
end_request = {
"task": "tts",
"signal": "end"
}
await ws.send(json.dumps(end_request))
# 确保连接正常关闭
try:
await ws.recv()
except websockets.exceptions.ConnectionClosedOK:
pass
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)
@@ -220,29 +344,32 @@ class StreamTTSPerformanceTester:
tts_config = self.config["TTS"]["IndexStreamTTS"]
api_url = tts_config.get("api_url")
voice = tts_config.get("voice")
# 统一计时起点:在建立连接前开始计时
start_time = time.time()
async with aiohttp.ClientSession() as session:
payload = {"text": text, "character": voice}
async with session.post(api_url, json=payload, timeout=10) as resp:
if resp.status != 200:
raise Exception(f"请求失败: {resp.status}, {await resp.text()}")
async for chunk in resp.content.iter_any():
data = chunk[0] if isinstance(chunk, (list, tuple)) else chunk
if not data:
continue
latency = time.time() - start_time
latencies.append(latency)
print(f"[IndexStreamTTS] 第{i+1}次 首词延迟: {latency:.3f}s")
resp.close()
break
else:
latencies.append(0)
latencies.append(None)
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)
@@ -257,7 +384,8 @@ class StreamTTSPerformanceTester:
api_url = tts_config["api_url"]
access_token = tts_config["access_token"]
voice = tts_config["voice"]
# 统一计时起点:在建立连接前开始计时
start_time = time.time()
async with aiohttp.ClientSession() as session:
params = {
@@ -273,21 +401,23 @@ class StreamTTSPerformanceTester:
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
async with session.get(api_url, params=params, headers=headers, timeout=10) as resp:
if resp.status != 200:
raise Exception(f"请求失败: {resp.status}, {await resp.text()}")
# 接收第一个数据块
async for _ in resp.content.iter_any():
latency = time.time() - start_time
latencies.append(latency)
print(f"[LinkeraiTTS] 第{i+1}次 首词延迟: {latency:.3f}s")
break
else:
latencies.append(0)
latencies.append(None)
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)
@@ -305,10 +435,9 @@ class StreamTTSPerformanceTester:
api_secret = tts_config["api_secret"]
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")
# 生成认证URL
auth_url = self._create_xunfei_auth_url(api_key, api_secret, api_url)
start_time = time.time()
async with websockets.connect(
auth_url,
ping_interval=30,
@@ -318,10 +447,7 @@ class StreamTTSPerformanceTester:
) as ws:
# 构造请求
request = self._build_xunfei_request(app_id, text, voice)
# 发送请求后立即计时,确保准确测量从发送文本到接收首块的时间
await ws.send(json.dumps(request))
start_time = time.time()
# 等待第一个音频数据块
first_audio_received = False
while not first_audio_received:
@@ -329,14 +455,14 @@ class StreamTTSPerformanceTester:
data = json.loads(msg)
header = data.get("header", {})
code = header.get("code")
if code != 0:
message = header.get("message", "未知错误")
raise Exception(f"合成失败: {code} - {message}")
payload = data.get("payload", {})
audio_payload = payload.get("audio", {})
if audio_payload:
status = audio_payload.get("status", 0)
audio_data = audio_payload.get("audio", "")
@@ -344,10 +470,12 @@ class StreamTTSPerformanceTester:
# 收到第一个音频数据块
latency = time.time() - start_time
latencies.append(latency)
print(f"[讯飞TTS] 第{i+1}次 首词延迟: {latency:.3f}s")
first_audio_received = True
break
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)
@@ -431,8 +559,9 @@ class StreamTTSPerformanceTester:
def _calculate_result(self, service_name, latencies, test_count):
"""计算测试结果"""
valid_latencies = [l for l in latencies if l > 0]
"""计算测试结果(正确处理None值,剔除失败测试)"""
# 剔除失败的测试(None值和<=0延迟),只统计有效延迟
valid_latencies = [l for l in latencies if l is not None and l > 0]
if valid_latencies:
avg_latency = sum(valid_latencies) / len(valid_latencies)
status = f"成功({len(valid_latencies)}/{test_count}次有效)"
@@ -466,9 +595,10 @@ class StreamTTSPerformanceTester:
]
print(tabulate(table_data, headers=["TTS服务", "首词延迟(秒)", "状态"], tablefmt="grid"))
print("\n测试说明:测量从发送请求到接收第一个音频数据块的时间,取多次测试平均值")
print("\n测试说明:测量从建立连接到接收第一个音频数据块的时间(包含握手、鉴权、发送文本),取多次测试平均值")
print("- 计时起点: 建立WebSocket/HTTP连接前(统一包含网络建连、握手、发送文本全流程)")
print("- 超时控制: 单个请求最大等待时间为10秒")
print("- 错误处理: 无法连接和超时的列为网络错误")
print("- 错误处理: 失败的测试不计入平均值,只统计成功测试的延迟")
print("- 排序规则: 按平均耗时从快到慢排序")
@@ -494,7 +624,12 @@ class StreamTTSPerformanceTester:
# 测试阿里云TTS
result = await self.test_aliyun_tts(test_text, test_count)
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
result = await self.test_doubao_tts(test_text, test_count)
self.results.append(result)
+1 -1
View File
@@ -13,7 +13,7 @@ pydub==0.25.1
funasr==1.2.7
openai==2.8.1
google-generativeai==0.8.5
edge_tts==7.2.3
edge_tts==7.2.6
httpx==0.28.1
aiohttp==3.13.2
aiohttp_cors==0.8.1
@@ -240,6 +240,24 @@ export class AudioRecorder {
if (this.isRecording) return false;
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()) {
log('无法启动录音: Opus编码器初始化失败', 'error');
return false;