Merge branch 'main' into py_tts_huoshan

This commit is contained in:
CGD
2025-12-26 14:46:51 +08:00
committed by GitHub
134 changed files with 5839 additions and 1176 deletions
@@ -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.8";
public static final String VERSION = "0.8.10";
/**
* 无效固件URL
@@ -159,4 +159,11 @@ public class RedisKeys {
public static String getKnowledgeBaseCacheKey(String datasetId) {
return "knowledge:base:" + datasetId;
}
/**
* 获取临时注册设备标记key
*/
public static String getTmpRegisterMacKey(String deviceId) {
return "tmp_register_mac:" + deviceId;
}
}
@@ -44,6 +44,8 @@ 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.AgentChatSummaryService;
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 +66,8 @@ public class AgentController {
private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService;
private final AgentPluginMappingService agentPluginMappingService;
private final AgentContextProviderService agentContextProviderService;
private final AgentChatSummaryService agentChatSummaryService;
private final RedisUtils redisUtils;
@GetMapping("/list")
@@ -117,6 +121,27 @@ public class AgentController {
return new Result<>();
}
@PostMapping("/chat-summary/{sessionId}/save")
@Operation(summary = "根据会话ID生成聊天记录总结并保存(异步执行)")
public Result<Void> generateAndSaveChatSummary(@PathVariable String sessionId) {
try {
// 异步执行总结生成任务,立即返回成功响应
new Thread(() -> {
try {
agentChatSummaryService.generateAndSaveChatSummary(sessionId);
System.out.println("异步执行会话 " + sessionId + " 的聊天记录总结完成");
} catch (Exception e) {
System.err.println("异步执行会话 " + sessionId + " 的聊天记录总结失败: " + e.getMessage());
}
}).start();
// 立即返回成功响应,不等待总结生成完成
return new Result<Void>().ok(null);
} catch (Exception e) {
return new Result<Void>().error("启动异步总结生成任务失败: " + e.getMessage());
}
}
@PutMapping("/{id}")
@Operation(summary = "更新智能体")
@RequiresPermissions("sys:role:normal")
@@ -135,6 +160,8 @@ public class AgentController {
agentChatHistoryService.deleteByAgentId(id, true, true);
// 删除关联的插件
agentPluginMappingService.deleteByAgentId(id);
// 删除关联的上下文源配置
agentContextProviderService.deleteByAgentId(id);
// 再删除智能体
agentService.deleteById(id);
return new Result<>();
@@ -182,6 +209,7 @@ public class AgentController {
List<AgentChatHistoryDTO> result = agentChatHistoryService.getChatHistoryBySessionId(id, sessionId);
return new Result<List<AgentChatHistoryDTO>>().ok(result);
}
@GetMapping("/{id}/chat-history/user")
@Operation(summary = "获取智能体聊天记录(用户)")
@RequiresPermissions("sys:role:normal")
@@ -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> {
}
@@ -1,6 +1,9 @@
package xiaozhi.modules.agent.dao;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
@@ -15,12 +18,6 @@ import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
*/
@Mapper
public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity> {
/**
* 根据智能体ID删除音频
*
* @param agentId 智能体ID
*/
void deleteAudioByAgentId(String agentId);
/**
* 根据智能体ID删除聊天历史记录
@@ -35,4 +32,19 @@ public interface AiAgentChatHistoryDao extends BaseMapper<AgentChatHistoryEntity
* @param agentId 智能体ID
*/
void deleteAudioIdByAgentId(String agentId);
/**
* 根据智能体ID获取所有音频ID列表
*
* @param agentId 智能体ID
* @return 音频ID列表
*/
List<String> getAudioIdsByAgentId(String agentId);
/**
* 批量删除音频
*
* @param audioIds 音频ID列表
*/
void deleteAudioByIds(@Param("audioIds") List<String> audioIds);
}
@@ -0,0 +1,45 @@
package xiaozhi.modules.agent.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 智能体聊天记录总结DTO
*/
@Data
@Schema(description = "智能体聊天记录总结对象")
public class AgentChatSummaryDTO {
@Schema(description = "会话ID")
private String sessionId;
@Schema(description = "智能体ID")
private String agentId;
@Schema(description = "总结内容")
private String summary;
@Schema(description = "总结状态")
private boolean success;
@Schema(description = "错误信息")
private String errorMessage;
public AgentChatSummaryDTO() {
this.success = true;
}
public AgentChatSummaryDTO(String sessionId, String agentId, String summary) {
this.sessionId = sessionId;
this.agentId = agentId;
this.summary = summary;
this.success = true;
}
public AgentChatSummaryDTO(String sessionId, String errorMessage) {
this.sessionId = sessionId;
this.errorMessage = errorMessage;
this.success = false;
}
}
@@ -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,15 @@
package xiaozhi.modules.agent.service;
/**
* 智能体聊天记录总结服务接口
*/
public interface AgentChatSummaryService {
/**
* 根据会话ID生成聊天记录总结并保存到智能体记忆
*
* @param sessionId 会话ID
* @return 保存结果
*/
boolean generateAndSaveChatSummary(String sessionId);
}
@@ -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);
}
@@ -17,6 +17,7 @@ import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentChatSummaryService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService;
import xiaozhi.modules.device.entity.DeviceEntity;
@@ -36,6 +37,7 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
private final AgentService agentService;
private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService;
private final AgentChatSummaryService agentChatSummaryService;
private final RedisUtils redisUtils;
private final DeviceService deviceService;
@@ -50,7 +52,8 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
public Boolean report(AgentChatHistoryReportDTO report) {
String macAddress = report.getMacAddress();
Byte chatType = report.getChatType();
Long reportTimeMillis = null != report.getReportTime() ? report.getReportTime() * 1000 : System.currentTimeMillis();
Long reportTimeMillis = null != report.getReportTime() ? report.getReportTime() * 1000
: System.currentTimeMillis();
log.info("小智设备聊天上报请求: macAddress={}, type={} reportTime={}", macAddress, chatType, reportTimeMillis);
// 根据设备MAC地址查询对应的默认智能体,判断是否需要上报
@@ -105,7 +108,8 @@ public class AgentChatHistoryBizServiceImpl implements AgentChatHistoryBizServic
/**
* 组装上报数据
*/
private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId, Long reportTime) {
private void saveChatText(AgentChatHistoryReportDTO report, String agentId, String macAddress, String audioId,
Long reportTime) {
// 构建聊天记录实体
AgentChatHistoryEntity entity = AgentChatHistoryEntity.builder()
.macAddress(macAddress)
@@ -84,7 +84,16 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
@Transactional(rollbackFor = Exception.class)
public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
if (deleteAudio) {
baseMapper.deleteAudioByAgentId(agentId);
// 分批删除音频,避免超时
List<String> audioIds = baseMapper.getAudioIdsByAgentId(agentId);
if (audioIds != null && !audioIds.isEmpty()) {
int batchSize = 1000; // 每批删除1000条
for (int i = 0; i < audioIds.size(); i += batchSize) {
int end = Math.min(i + batchSize, audioIds.size());
List<String> batch = audioIds.subList(i, end);
baseMapper.deleteAudioByIds(batch);
}
}
}
if (deleteAudio && !deleteText) {
baseMapper.deleteAudioIdByAgentId(agentId);
@@ -107,7 +116,7 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
// 添加此行,确保查询结果按照创建时间降序排列
// 使用id的原因:数据形式,id越大的创建时间就越晚,所以使用id的结果和创建时间降序排列结果一样
// id作为降序排列的优势,性能高,有主键索引,不用在排序的时候重新进行排除扫描比较
.orderByDesc(AgentChatHistoryEntity::getId);
.orderByDesc(AgentChatHistoryEntity::getId);
// 构建分页查询,查询前50页数据
Page<AgentChatHistoryEntity> pageParam = new Page<>(0, 50);
@@ -0,0 +1,423 @@
package xiaozhi.modules.agent.service.impl;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.RequiredArgsConstructor;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatSummaryDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentChatSummaryService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.vo.AgentInfoVO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.llm.service.LLMService;
import xiaozhi.modules.model.entity.ModelConfigEntity;
import xiaozhi.modules.model.service.ModelConfigService;
/**
* 智能体聊天记录总结服务实现类
* 实现Python端mem_local_short.py中的总结逻辑
*/
@Service
@RequiredArgsConstructor
public class AgentChatSummaryServiceImpl implements AgentChatSummaryService {
private static final Logger log = LoggerFactory.getLogger(AgentChatSummaryServiceImpl.class);
private final AgentChatHistoryService agentChatHistoryService;
private final AgentService agentService;
private final DeviceService deviceService;
private final LLMService llmService;
private final ModelConfigService modelConfigService;
// 总结规则常量
private static final int MAX_SUMMARY_LENGTH = 1800; // 最大总结长度
private static final Pattern JSON_PATTERN = Pattern.compile("\\{.*?\\}", Pattern.DOTALL);
private static final Pattern DEVICE_CONTROL_PATTERN = Pattern.compile("设备控制|设备操作|控制设备|设备状态",
Pattern.CASE_INSENSITIVE);
private static final Pattern WEATHER_PATTERN = Pattern.compile("天气|温度|湿度|降雨|气象", Pattern.CASE_INSENSITIVE);
private static final Pattern DATE_PATTERN = Pattern.compile("日期|时间|星期|月份|年份", Pattern.CASE_INSENSITIVE);
private AgentChatSummaryDTO generateChatSummary(String sessionId) {
try {
System.out.println("开始生成会话 " + sessionId + " 的聊天记录总结");
// 1. 根据sessionId获取聊天记录
List<AgentChatHistoryDTO> chatHistory = getChatHistoryBySessionId(sessionId);
if (chatHistory == null || chatHistory.isEmpty()) {
return new AgentChatSummaryDTO(sessionId, "未找到该会话的聊天记录");
}
// 2. 获取智能体信息
String agentId = getAgentIdFromSession(sessionId, chatHistory);
if (StringUtils.isBlank(agentId)) {
return new AgentChatSummaryDTO(sessionId, "无法获取智能体信息");
}
// 3. 提取关键对话内容
List<String> meaningfulMessages = extractMeaningfulMessages(chatHistory);
if (meaningfulMessages.isEmpty()) {
return new AgentChatSummaryDTO(sessionId, "没有有效的对话内容可总结");
}
// 4. 生成总结(generateSummaryFromMessages方法已包含长度限制逻辑)
String summary = generateSummaryFromMessages(meaningfulMessages, agentId);
System.out.println("成功生成会话 " + sessionId + " 的聊天记录总结,长度: " + summary.length() + " 字符");
return new AgentChatSummaryDTO(sessionId, agentId, summary);
} catch (Exception e) {
System.err.println("生成会话 " + sessionId + " 的聊天记录总结时发生错误: " + e.getMessage());
return new AgentChatSummaryDTO(sessionId, "生成总结时发生错误: " + e.getMessage());
}
}
@Override
public boolean generateAndSaveChatSummary(String sessionId) {
try {
// 1. 生成总结
AgentChatSummaryDTO summaryDTO = generateChatSummary(sessionId);
if (!summaryDTO.isSuccess()) {
System.err.println("生成总结失败: " + summaryDTO.getErrorMessage());
return false;
}
// 2. 获取设备信息(通过会话关联的设备)
DeviceEntity device = getDeviceBySessionId(sessionId);
if (device == null) {
System.err.println("未找到与会话 " + sessionId + " 关联的设备");
return false;
}
// 3. 更新智能体记忆
AgentMemoryDTO memoryDTO = new AgentMemoryDTO();
memoryDTO.setSummaryMemory(summaryDTO.getSummary());
// 调用现有接口更新记忆
agentService.updateAgentById(device.getAgentId(),
new AgentUpdateDTO() {
{
setSummaryMemory(summaryDTO.getSummary());
}
});
System.out.println("成功保存会话 " + sessionId + " 的聊天记录总结到智能体 " + device.getAgentId());
return true;
} catch (Exception e) {
System.err.println("保存会话 " + sessionId + " 的聊天记录总结时发生错误: " + e.getMessage());
return false;
}
}
/**
* 根据会话ID获取聊天记录
*/
private List<AgentChatHistoryDTO> getChatHistoryBySessionId(String sessionId) {
try {
// 这里需要根据sessionId获取聊天记录
// 由于现有接口需要agentId,我们需要先找到关联的agentId
String agentId = findAgentIdBySessionId(sessionId);
if (StringUtils.isBlank(agentId)) {
return null;
}
return agentChatHistoryService.getChatHistoryBySessionId(agentId, sessionId);
} catch (Exception e) {
System.err.println("获取会话 " + sessionId + " 的聊天记录失败: " + e.getMessage());
return null;
}
}
/**
* 根据会话ID查找关联的智能体ID
*/
private String findAgentIdBySessionId(String sessionId) {
try {
// 查询该会话的第一条记录获取agentId
QueryWrapper<AgentChatHistoryEntity> wrapper = new QueryWrapper<>();
wrapper.select("agent_id")
.eq("session_id", sessionId)
.last("LIMIT 1");
AgentChatHistoryEntity entity = agentChatHistoryService.getOne(wrapper);
return entity != null ? entity.getAgentId() : null;
} catch (Exception e) {
System.err.println("根据会话ID " + sessionId + " 查找智能体ID失败: " + e.getMessage());
return null;
}
}
/**
* 从会话中获取智能体ID
*/
private String getAgentIdFromSession(String sessionId, List<AgentChatHistoryDTO> chatHistory) {
// 直接从数据库查询智能体ID
return findAgentIdBySessionId(sessionId);
}
/**
* 提取有意义的对话内容(只提取用户消息,排除AI回复)
*/
private List<String> extractMeaningfulMessages(List<AgentChatHistoryDTO> chatHistory) {
List<String> meaningfulMessages = new ArrayList<>();
for (AgentChatHistoryDTO message : chatHistory) {
// 只处理用户消息(chatType = 1
if (message.getChatType() != null && message.getChatType() == 1) {
String content = extractContentFromMessage(message);
if (isMeaningfulMessage(content)) {
meaningfulMessages.add(content);
}
}
}
return meaningfulMessages;
}
/**
* 从消息中提取内容(处理JSON格式)
*/
private String extractContentFromMessage(AgentChatHistoryDTO message) {
String content = message.getContent();
if (StringUtils.isBlank(content)) {
return "";
}
// 处理JSON格式内容(与前端ChatHistoryDialog.vue逻辑一致)
Matcher matcher = JSON_PATTERN.matcher(content);
if (matcher.find()) {
String jsonContent = matcher.group();
// 简化处理:提取JSON中的文本内容
return extractTextFromJson(jsonContent);
}
return content;
}
/**
* 从JSON中提取文本内容
*/
private String extractTextFromJson(String jsonContent) {
// 简化处理:提取"content"字段的值
Pattern contentPattern = Pattern.compile("\"content\"\s*:\s*\"([^\"]*)\"");
Matcher matcher = contentPattern.matcher(jsonContent);
if (matcher.find()) {
return matcher.group(1);
}
return jsonContent;
}
/**
* 判断是否为有意义的消息
*/
private boolean isMeaningfulMessage(String content) {
if (StringUtils.isBlank(content)) {
return false;
}
// 排除设备控制信息
if (DEVICE_CONTROL_PATTERN.matcher(content).find()) {
return false;
}
// 排除日期天气等无关内容
if (WEATHER_PATTERN.matcher(content).find() || DATE_PATTERN.matcher(content).find()) {
return false;
}
// 排除过短的消息
return content.length() >= 5;
}
/**
* 从消息生成总结
*/
private String generateSummaryFromMessages(List<String> messages, String agentId) {
if (messages.isEmpty()) {
return "本次对话内容较少,没有需要总结的重要信息。";
}
// 构建完整的对话内容
StringBuilder conversation = new StringBuilder();
for (int i = 0; i < messages.size(); i++) {
conversation.append("消息").append(i + 1).append(": ").append(messages.get(i)).append("\n");
}
try {
// 获取当前智能体的历史记忆
String historyMemory = getCurrentAgentMemory(agentId);
// 调用LLM服务进行智能总结,传递agentId以获取正确的模型配置
String summary = callJavaLLMForSummaryWithHistory(conversation.toString(), historyMemory, agentId);
// 应用总结规则:限制最大长度
if (summary.length() > MAX_SUMMARY_LENGTH) {
summary = summary.substring(0, MAX_SUMMARY_LENGTH) + "...";
}
return summary;
} catch (Exception e) {
System.err.println("调用Java端LLM服务失败: " + e.getMessage());
throw new RuntimeException("LLM服务不可用,无法生成聊天总结");
}
}
/**
* 获取当前智能体的历史记忆
*/
private String getCurrentAgentMemory(String agentId) {
try {
if (StringUtils.isBlank(agentId)) {
return null;
}
// 获取智能体信息
AgentInfoVO agentInfo = agentService.getAgentById(agentId);
if (agentInfo == null) {
return null;
}
// 返回智能体的当前总结记忆
return agentInfo.getSummaryMemory();
} catch (Exception e) {
System.err.println("获取智能体历史记忆失败,agentId: " + agentId + ", 错误: " + e.getMessage());
return null;
}
}
/**
* 调用Java端LLM服务进行智能总结(支持历史记忆合并)
*/
private String callJavaLLMForSummaryWithHistory(String conversation, String historyMemory, String agentId) {
try {
// 获取智能体配置,从中提取记忆总结的模型ID
String modelId = getMemorySummaryModelId(agentId);
if (StringUtils.isBlank(modelId)) {
System.out.println("未找到记忆总结的LLM模型配置,使用默认LLM服务");
return llmService.generateSummaryWithHistory(conversation, historyMemory, null, null);
}
// 使用指定的模型ID调用LLM服务(支持历史记忆合并)
String summary = llmService.generateSummaryWithHistory(conversation, historyMemory, null, modelId);
if (StringUtils.isNotBlank(summary) && !summary.equals("服务暂不可用") && !summary.equals("总结生成失败")) {
return summary;
}
throw new RuntimeException("Java端LLM服务返回异常: " + summary);
} catch (Exception e) {
System.err.println("调用Java端LLM服务异常,agentId: " + agentId + ", 错误: " + e.getMessage());
throw e;
}
}
/**
* 调用Java端LLM服务进行智能总结
*/
private String callJavaLLMForSummary(String conversation, String agentId) {
try {
// 获取智能体配置,从中提取记忆总结的模型ID
String modelId = getMemorySummaryModelId(agentId);
if (StringUtils.isBlank(modelId)) {
System.out.println("未找到记忆总结的LLM模型配置,使用默认LLM服务");
return llmService.generateSummary(conversation);
}
// 使用指定的模型ID调用LLM服务
String summary = llmService.generateSummaryWithModel(conversation, modelId);
if (StringUtils.isNotBlank(summary) && !summary.equals("服务暂不可用") && !summary.equals("总结生成失败")) {
return summary;
}
throw new RuntimeException("Java端LLM服务返回异常: " + summary);
} catch (Exception e) {
System.err.println("调用Java端LLM服务异常,agentId: " + agentId + ", 错误: " + e.getMessage());
throw e;
}
}
/**
* 获取记忆总结的LLM模型ID
*/
private String getMemorySummaryModelId(String agentId) {
try {
if (StringUtils.isBlank(agentId)) {
return null;
}
// 获取智能体信息
AgentInfoVO agentInfo = agentService.getAgentById(agentId);
if (agentInfo == null) {
return null;
}
// 获取智能体的记忆模型ID
String memModelId = agentInfo.getMemModelId();
if (StringUtils.isBlank(memModelId)) {
return null;
}
// 获取记忆模型配置
ModelConfigEntity memModelConfig = modelConfigService.getModelByIdFromCache(memModelId);
if (memModelConfig == null || memModelConfig.getConfigJson() == null) {
return null;
}
// 从记忆模型配置中提取对应的LLM模型ID
Map<String, Object> configMap = memModelConfig.getConfigJson();
String llmModelId = (String) configMap.get("llm");
if (StringUtils.isBlank(llmModelId)) {
// 如果记忆模型没有配置独立的LLM,则使用智能体的默认LLM模型
return agentInfo.getLlmModelId();
}
return llmModelId;
} catch (Exception e) {
System.err.println("获取记忆总结LLM模型ID失败,agentId: " + agentId + ", 错误: " + e.getMessage());
return null;
}
}
/**
* 根据会话ID获取设备信息
*/
private DeviceEntity getDeviceBySessionId(String sessionId) {
try {
// 查询该会话的第一条记录获取macAddress
QueryWrapper<AgentChatHistoryEntity> wrapper = new QueryWrapper<>();
wrapper.select("mac_address")
.eq("session_id", sessionId)
.last("LIMIT 1");
AgentChatHistoryEntity entity = agentChatHistoryService.getOne(wrapper);
if (entity != null && StringUtils.isNotBlank(entity.getMacAddress())) {
return deviceService.getDeviceByMacAddress(entity.getMacAddress());
}
return null;
} catch (Exception e) {
System.err.println("根据会话ID " + sessionId + " 查找设备信息失败: " + e.getMessage());
return null;
}
}
}
@@ -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;
@@ -103,6 +106,15 @@ public class ConfigServiceImpl implements ConfigService {
@Override
public Map<String, Object> getAgentModels(String macAddress, Map<String, String> selectedModule) {
// 检查是否为管理控制台请求
String redisKey = RedisKeys.getTmpRegisterMacKey(macAddress);
Object isAdminRequest = redisUtils.get(redisKey);
if (isAdminRequest != null && "true".equals(isAdminRequest)) {
// 管理控制台请求,返回getConfig的结果
redisUtils.delete(redisKey); // 使用后清理
return (Map<String, Object>) getConfig(true);
}
// 根据MAC地址查找设备
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device == null) {
@@ -178,6 +190,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));
@@ -98,4 +98,14 @@ public interface DeviceService extends BaseService<DeviceEntity> {
*/
void updateDeviceConnectionInfo(String agentId, String deviceId, String appVersion);
/**
* 生成WebSocket认证token
*
* @param clientId 客户端ID
* @param username 用户名(通常为deviceId)
* @return 认证token字符串
* @throws Exception 生成token时的异常
*/
String generateWebSocketToken(String clientId, String username) throws Exception;
}
@@ -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字符串
*/
public 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;
@@ -0,0 +1,70 @@
package xiaozhi.modules.llm.service;
/**
* LLM服务接口
* 支持多种大模型调用
*/
public interface LLMService {
/**
* 生成聊天记录总结
*
* @param conversation 对话内容
* @param promptTemplate 提示词模板
* @return 总结结果
*/
String generateSummary(String conversation, String promptTemplate);
/**
* 生成聊天记录总结(使用默认提示词)
*
* @param conversation 对话内容
* @return 总结结果
*/
String generateSummary(String conversation);
/**
* 生成聊天记录总结(指定模型ID)
*
* @param conversation 对话内容
* @param modelId 模型ID
* @return 总结结果
*/
String generateSummaryWithModel(String conversation, String modelId);
/**
* 生成聊天记录总结(指定模型ID和提示词模板)
*
* @param conversation 对话内容
* @param promptTemplate 提示词模板
* @param modelId 模型ID
* @return 总结结果
*/
String generateSummary(String conversation, String promptTemplate, String modelId);
/**
* 生成聊天记录总结(包含历史记忆合并)
*
* @param conversation 对话内容
* @param historyMemory 历史记忆
* @param promptTemplate 提示词模板
* @param modelId 模型ID
* @return 总结结果
*/
String generateSummaryWithHistory(String conversation, String historyMemory, String promptTemplate, String modelId);
/**
* 检查服务是否可用
*
* @return 是否可用
*/
boolean isAvailable();
/**
* 检查指定模型的服务是否可用
*
* @param modelId 模型ID
* @return 是否可用
*/
boolean isAvailable(String modelId);
}
@@ -0,0 +1,305 @@
package xiaozhi.modules.llm.service.impl;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.modules.llm.service.LLMService;
import xiaozhi.modules.model.entity.ModelConfigEntity;
import xiaozhi.modules.model.service.ModelConfigService;
/**
* OpenAI风格API的LLM服务实现
* 支持阿里云、DeepSeek、ChatGLM等兼容OpenAI API的模型
*/
@Slf4j
@Service
public class OpenAIStyleLLMServiceImpl implements LLMService {
@Autowired
private ModelConfigService modelConfigService;
private final RestTemplate restTemplate = new RestTemplate();
private static final String DEFAULT_SUMMARY_PROMPT = "你是一个经验丰富的记忆总结者,擅长将对话内容进行总结摘要,遵循以下规则:\n1、总结用户的重要信息,以便在未来的对话中提供更个性化的服务\n2、不要重复总结,不要遗忘之前记忆,除非原来的记忆超过了1800字,否则不要遗忘、不要压缩用户的历史记忆\n3、用户操控的设备音量、播放音乐、天气、退出、不想对话等和用户本身无关的内容,这些信息不需要加入到总结中\n4、聊天内容中的今天的日期时间、今天的天气情况与用户事件无关的数据,这些信息如果当成记忆存储会影响后续对话,这些信息不需要加入到总结中\n5、不要把设备操控的成果结果和失败结果加入到总结中,也不要把用户的一些废话加入到总结中\n6、不要为了总结而总结,如果用户的聊天没有意义,请返回原来的历史记录也是可以的\n7、只需要返回总结摘要,严格控制在1800字内\n8、不要包含代码、xml,不需要解释、注释和说明,保存记忆时仅从对话提取信息,不要混入示例内容\n9、如果提供了历史记忆,请将新对话内容与历史记忆进行智能合并,保留有价值的历史信息,同时添加新的重要信息\n\n历史记忆:\n{history_memory}\n\n新对话内容:\n{conversation}";
@Override
public String generateSummary(String conversation) {
return generateSummary(conversation, null, null);
}
@Override
public String generateSummaryWithModel(String conversation, String modelId) {
return generateSummary(conversation, null, modelId);
}
@Override
public String generateSummary(String conversation, String promptTemplate, String modelId) {
if (!isAvailable()) {
log.warn("LLM服务不可用,无法生成总结");
return "LLM服务不可用,无法生成总结";
}
try {
// 从智控台获取LLM模型配置
ModelConfigEntity llmConfig;
if (modelId != null && !modelId.trim().isEmpty()) {
// 通过具体模型ID获取配置
llmConfig = modelConfigService.getModelByIdFromCache(modelId);
} else {
// 保持向后兼容,使用默认配置
llmConfig = getDefaultLLMConfig();
}
if (llmConfig == null || llmConfig.getConfigJson() == null) {
log.error("未找到可用的LLM模型配置,modelId: {}", modelId);
return "未找到可用的LLM模型配置";
}
JSONObject configJson = llmConfig.getConfigJson();
String baseUrl = configJson.getStr("base_url");
String model = configJson.getStr("model_name");
String apiKey = configJson.getStr("api_key");
Double temperature = configJson.getDouble("temperature");
Integer maxTokens = configJson.getInt("max_tokens");
if (StringUtils.isBlank(baseUrl) || StringUtils.isBlank(apiKey)) {
log.error("LLM配置不完整,baseUrl或apiKey为空");
return "LLM配置不完整,无法生成总结";
}
// 构建提示词
String prompt = (promptTemplate != null ? promptTemplate : DEFAULT_SUMMARY_PROMPT).replace("{conversation}",
conversation);
// 构建请求体
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
Map<String, Object>[] messages = new Map[1];
Map<String, Object> message = new HashMap<>();
message.put("role", "user");
message.put("content", prompt);
messages[0] = message;
requestBody.put("messages", messages);
requestBody.put("temperature", temperature != null ? temperature : 0.7);
requestBody.put("max_tokens", maxTokens != null ? maxTokens : 2000);
// 发送HTTP请求
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + apiKey);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
// 构建完整的API URL
String apiUrl = baseUrl;
if (!apiUrl.endsWith("/chat/completions")) {
if (!apiUrl.endsWith("/")) {
apiUrl += "/";
}
apiUrl += "chat/completions";
}
ResponseEntity<String> response = restTemplate.exchange(
apiUrl, HttpMethod.POST, entity, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
JSONObject responseJson = JSONUtil.parseObj(response.getBody());
JSONArray choices = responseJson.getJSONArray("choices");
if (choices != null && choices.size() > 0) {
JSONObject choice = choices.getJSONObject(0);
JSONObject messageObj = choice.getJSONObject("message");
return messageObj.getStr("content");
}
} else {
log.error("LLM API调用失败,状态码:{},响应:{}", response.getStatusCode(), response.getBody());
}
} catch (Exception e) {
log.error("调用LLM服务生成总结时发生异常,modelId: {}", modelId, e);
}
return "生成总结失败,请稍后重试";
}
@Override
public String generateSummary(String conversation, String promptTemplate) {
return generateSummary(conversation, promptTemplate, null);
}
@Override
public String generateSummaryWithHistory(String conversation, String historyMemory, String promptTemplate,
String modelId) {
if (!isAvailable()) {
log.warn("LLM服务不可用,无法生成总结");
return "LLM服务不可用,无法生成总结";
}
try {
// 从智控台获取LLM模型配置
ModelConfigEntity llmConfig;
if (modelId != null && !modelId.trim().isEmpty()) {
// 通过具体模型ID获取配置
llmConfig = modelConfigService.getModelByIdFromCache(modelId);
} else {
// 保持向后兼容,使用默认配置
llmConfig = getDefaultLLMConfig();
}
if (llmConfig == null || llmConfig.getConfigJson() == null) {
log.error("未找到可用的LLM模型配置,modelId: {}", modelId);
return "未找到可用的LLM模型配置";
}
JSONObject configJson = llmConfig.getConfigJson();
String baseUrl = configJson.getStr("base_url");
String model = configJson.getStr("model_name");
String apiKey = configJson.getStr("api_key");
if (StringUtils.isBlank(baseUrl) || StringUtils.isBlank(apiKey)) {
log.error("LLM配置不完整,baseUrl或apiKey为空");
return "LLM配置不完整,无法生成总结";
}
// 构建提示词,包含历史记忆
String prompt = (promptTemplate != null ? promptTemplate : DEFAULT_SUMMARY_PROMPT)
.replace("{history_memory}", historyMemory != null ? historyMemory : "无历史记忆")
.replace("{conversation}", conversation);
// 构建请求体
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
Map<String, Object>[] messages = new Map[1];
Map<String, Object> message = new HashMap<>();
message.put("role", "user");
message.put("content", prompt);
messages[0] = message;
requestBody.put("messages", messages);
requestBody.put("temperature", 0.2);
requestBody.put("max_tokens", 2000);
// 发送HTTP请求
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + apiKey);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
// 构建完整的API URL
String apiUrl = baseUrl;
if (!apiUrl.endsWith("/chat/completions")) {
if (!apiUrl.endsWith("/")) {
apiUrl += "/";
}
apiUrl += "chat/completions";
}
ResponseEntity<String> response = restTemplate.exchange(
apiUrl, HttpMethod.POST, entity, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
JSONObject responseJson = JSONUtil.parseObj(response.getBody());
JSONArray choices = responseJson.getJSONArray("choices");
if (choices != null && choices.size() > 0) {
JSONObject choice = choices.getJSONObject(0);
JSONObject messageObj = choice.getJSONObject("message");
return messageObj.getStr("content");
}
} else {
log.error("LLM API调用失败,状态码:{},响应:{}", response.getStatusCode(), response.getBody());
}
} catch (Exception e) {
log.error("调用LLM服务生成总结时发生异常,modelId: {}", modelId, e);
}
return "生成总结失败,请稍后重试";
}
@Override
public boolean isAvailable() {
try {
ModelConfigEntity defaultLLMConfig = getDefaultLLMConfig();
if (defaultLLMConfig == null || defaultLLMConfig.getConfigJson() == null) {
return false;
}
JSONObject configJson = defaultLLMConfig.getConfigJson();
String baseUrl = configJson.getStr("base_url");
String apiKey = configJson.getStr("api_key");
return baseUrl != null && !baseUrl.trim().isEmpty() &&
apiKey != null && !apiKey.trim().isEmpty();
} catch (Exception e) {
log.error("检查LLM服务可用性时发生异常:", e);
return false;
}
}
@Override
public boolean isAvailable(String modelId) {
try {
if (modelId == null || modelId.trim().isEmpty()) {
return isAvailable();
}
// 通过具体模型ID获取配置
ModelConfigEntity modelConfig = modelConfigService.getModelByIdFromCache(modelId);
if (modelConfig == null || modelConfig.getConfigJson() == null) {
log.warn("未找到指定的LLM模型配置,modelId: {}", modelId);
return false;
}
JSONObject configJson = modelConfig.getConfigJson();
String baseUrl = configJson.getStr("base_url");
String apiKey = configJson.getStr("api_key");
return baseUrl != null && !baseUrl.trim().isEmpty() &&
apiKey != null && !apiKey.trim().isEmpty();
} catch (Exception e) {
log.error("检查LLM服务可用性时发生异常,modelId: {}", modelId, e);
return false;
}
}
/**
* 从智控台获取默认的LLM模型配置
*/
private ModelConfigEntity getDefaultLLMConfig() {
try {
// 获取所有启用的LLM模型配置
List<ModelConfigEntity> llmConfigs = modelConfigService.getEnabledModelsByType("LLM");
if (llmConfigs == null || llmConfigs.isEmpty()) {
return null;
}
// 优先返回默认配置,如果没有默认配置则返回第一个启用的配置
for (ModelConfigEntity config : llmConfigs) {
if (config.getIsDefault() != null && config.getIsDefault() == 1) {
return config;
}
}
return llmConfigs.get(0);
} catch (Exception e) {
log.error("获取LLM模型配置时发生异常:", e);
return null;
}
}
}
@@ -55,4 +55,12 @@ public interface ModelConfigService extends BaseService<ModelConfigEntity> {
* @return TTS平台列表(id和modelName)
*/
List<Map<String, Object>> getTtsPlatformList();
/**
* 根据模型类型获取所有启用的模型配置
*
* @param modelType 模型类型(如:LLM, TTS, ASR等)
* @return 启用的模型配置列表
*/
List<ModelConfigEntity> getEnabledModelsByType(String modelType);
}
@@ -502,4 +502,22 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
public List<Map<String, Object>> getTtsPlatformList() {
return modelConfigDao.getTtsPlatformList();
}
/**
* 根据模型类型获取所有启用的模型配置
*/
@Override
public List<ModelConfigEntity> getEnabledModelsByType(String modelType) {
if (StringUtils.isBlank(modelType)) {
return null;
}
List<ModelConfigEntity> entities = modelConfigDao.selectList(
new QueryWrapper<ModelConfigEntity>()
.eq("model_type", modelType)
.eq("is_enabled", 1)
.orderByAsc("sort"));
return entities;
}
}
@@ -89,7 +89,7 @@ public class ShiroConfig {
filterMap.put("/config/**", "server");
filterMap.put("/agent/chat-history/report", "server");
filterMap.put("/agent/chat-history/download/**", "anon");
filterMap.put("/agent/saveMemory/**", "server");
filterMap.put("/agent/chat-summary/**", "server");
filterMap.put("/agent/play/**", "anon");
filterMap.put("/voiceClone/play/**", "anon");
filterMap.put("/**", "oauth2");
@@ -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);
}
}
@@ -31,6 +31,8 @@ import xiaozhi.modules.sys.dto.ServerActionResponseDTO;
import xiaozhi.modules.sys.enums.ServerActionEnum;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.utils.WebSocketClientManager;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.common.redis.RedisUtils;
/**
* 服务端管理控制器
@@ -41,6 +43,8 @@ import xiaozhi.modules.sys.utils.WebSocketClientManager;
@AllArgsConstructor
public class ServerSideManageController {
private final SysParamsService sysParamsService;
private final DeviceService deviceService;
private final RedisUtils redisUtils;
private static final ObjectMapper objectMapper;
static {
objectMapper = new ObjectMapper();
@@ -85,9 +89,22 @@ public class ServerSideManageController {
return false;
}
String serverSK = sysParamsService.getValue(Constant.SERVER_SECRET, true);
String deviceId = UUID.randomUUID().toString();
String clientId = UUID.randomUUID().toString();
String redisKey = xiaozhi.common.redis.RedisKeys.getTmpRegisterMacKey(deviceId);
redisUtils.set(redisKey, "true", 300); // 5分钟有效期
WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
headers.add("device-id", UUID.randomUUID().toString());
headers.add("client-id", UUID.randomUUID().toString());
headers.add("device-id", deviceId);
headers.add("client-id", clientId);
try {
String token = deviceService.generateWebSocketToken(clientId, deviceId);
headers.add("authorization", "Bearer " + token);
} catch (Exception e) {
throw new RenException(ErrorCode.WEB_SOCKET_CONNECT_FAILED);
}
try (WebSocketClientManager client = new WebSocketClientManager.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
@@ -174,7 +174,7 @@ public class SysParamsController {
return;
}
if (StringUtils.isBlank(url) || url.equals("null")) {
throw new RenException(ErrorCode.OTA_URL_EMPTY);
return;
}
// 检查是否包含localhost或127.0.0.1
@@ -211,7 +211,7 @@ public class SysParamsController {
return;
}
if (StringUtils.isBlank(url) || url.equals("null")) {
throw new RenException(ErrorCode.MCP_URL_EMPTY);
return;
}
if (url.contains("localhost") || url.contains("127.0.0.1")) {
throw new RenException(ErrorCode.MCP_URL_LOCALHOST);
@@ -242,7 +242,7 @@ public class SysParamsController {
return;
}
if (StringUtils.isBlank(url) || url.equals("null")) {
throw new RenException(ErrorCode.VOICEPRINT_URL_EMPTY);
return;
}
if (url.contains("localhost") || url.contains("127.0.0.1")) {
throw new RenException(ErrorCode.VOICEPRINT_URL_LOCALHOST);
@@ -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认证');
@@ -0,0 +1 @@
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES (311, 'enable_websocket_ping', 'false', 'boolean', 1, '是否启用WebSocket心跳保活机制');
@@ -0,0 +1,2 @@
-- 为智能体聊天历史记录添加音频ID索引
ALTER TABLE ai_agent_chat_history ADD INDEX idx_ai_agent_chat_history_audio_id (audio_id);
@@ -0,0 +1,10 @@
-- 更新豆包流式ASR供应器,增加end_window_size配置
delete from `ai_model_provider` where id = 'SYSTEM_ASR_DoubaoStreamASR';
INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES
('SYSTEM_ASR_DoubaoStreamASR', 'ASR', 'doubao_stream', '火山引擎语音识别(流式)', '[{"key":"appid","label":"应用ID","type":"string"},{"key":"access_token","label":"访问令牌","type":"string"},{"key":"cluster","label":"集群","type":"string"},{"key":"boosting_table_name","label":"热词文件名称","type":"string"},{"key":"correct_table_name","label":"替换词文件名称","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"end_window_size","label":"静音判定时长(ms)","type":"number"}]', 3, 1, NOW(), 1, NOW());
-- 更新豆包流式ASR模型配置,增加end_window_size默认值
UPDATE `ai_model_config` SET
`config_json` = JSON_SET(`config_json`, '$.end_window_size', 200)
WHERE `id` = 'ASR_DoubaoStreamASR' AND JSON_EXTRACT(`config_json`, '$.end_window_size') IS NULL;
@@ -430,3 +430,46 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202511221450.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
- changeSet:
id: 202512161529
author: RanChen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202512161529.sql
- changeSet:
id: 202512192245
author: hrz
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202512192245.sql
- changeSet:
id: 202512221117
author: RanChen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202512221117.sql
@@ -22,13 +22,18 @@
created_at, updated_at
</sql>
<delete id="deleteAudioByAgentId">
DELETE FROM ai_agent_chat_audio
WHERE id IN (
SELECT audio_id
FROM ai_agent_chat_history
WHERE agent_id = #{agentId}
)
<select id="getAudioIdsByAgentId" resultType="java.lang.String">
SELECT DISTINCT audio_id
FROM ai_agent_chat_history
WHERE agent_id = #{agentId} AND audio_id IS NOT NULL
</select>
<delete id="deleteAudioByIds">
DELETE FROM ai_agent_chat_audio
WHERE id IN
<foreach collection="audioIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
<update id="deleteAudioIdByAgentId">
@@ -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.8'
version: '0.8.10'
}),
showCancel: false,
confirmText: t('common.confirm'),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -404,7 +404,7 @@ export default {
.custom-input-bg .el-input__inner,
.custom-input-bg .el-textarea__inner {
background-color: #f6f8fc;
background-color: #ffffff;
}
@@ -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;
}
+52 -7
View File
@@ -4,7 +4,7 @@
<!-- 左侧元素 -->
<div class="header-left" @click="goHome">
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" class="logo-img" />
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" class="brand-img" />
<img loading="lazy" alt="" :src="xiaozhiAiIcon" class="brand-img" />
</div>
<!-- 中间导航菜单 -->
@@ -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: {
@@ -246,6 +257,24 @@ export default {
return this.$t("language.zhCN");
}
},
// 根据当前语言获取对应的xiaozhi-ai图标
xiaozhiAiIcon() {
const currentLang = this.currentLanguage;
switch (currentLang) {
case "zh_CN":
return require("@/assets/xiaozhi-ai.png");
case "zh_TW":
return require("@/assets/xiaozhi-ai_zh_TW.png");
case "en":
return require("@/assets/xiaozhi-ai_en.png");
case "de":
return require("@/assets/xiaozhi-ai_de.png");
case "vi":
return require("@/assets/xiaozhi-ai_vi.png");
default:
return require("@/assets/xiaozhi-ai.png");
}
},
// 用户菜单选项
userMenuOptions() {
return [
@@ -286,12 +315,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 +369,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 }) => {
+55 -3
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',
@@ -800,7 +821,7 @@ export default {
'modelConfig.rag': 'RAG',
'modelConfig.modelId': 'Modell-ID',
'modelConfig.modelName': 'Modellname',
'modelConfig.provider': 'Anbieter',
'modelConfig.provider': 'Schnittstellentyp',
'modelConfig.unknown': 'Unbekannt',
'modelConfig.isEnabled': 'Aktiviert',
'modelConfig.isDefault': 'Standard',
@@ -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',
}
+55 -3
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',
@@ -800,7 +821,7 @@ export default {
'modelConfig.rag': 'RAG',
'modelConfig.modelId': 'Model ID',
'modelConfig.modelName': 'Model Name',
'modelConfig.provider': 'Provider',
'modelConfig.provider': 'Interface Type',
'modelConfig.unknown': 'Unknown',
'modelConfig.isEnabled': 'Enabled',
'modelConfig.isDefault': 'Default',
@@ -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',
}
+56 -4
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',
@@ -329,7 +350,7 @@ export default {
'paramDialog.jsonType': 'Đối tượng JSON',
// Login page text
'login.title': 'Đăng nhập',
'login.title': 'DN',
'login.welcome': 'CHÀO MỪNG ĐẾN VỚI ĐĂNG NHẬP',
'login.username': 'Tên đăng nhập',
'login.usernamePlaceholder': 'Vui lòng nhập tên đăng nhập',
@@ -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',
@@ -800,7 +821,7 @@ export default {
'modelConfig.rag': 'RAG',
'modelConfig.modelId': 'ID mô hình',
'modelConfig.modelName': 'Tên mô hình',
'modelConfig.provider': 'Nhà cung cấp',
'modelConfig.provider': 'Loại giao diện',
'modelConfig.unknown': 'Không xác định',
'modelConfig.isEnabled': 'Đã bật',
'modelConfig.isDefault': 'Mặc định',
@@ -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',
}
+55 -3
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': '新增失败',
@@ -800,7 +821,7 @@ export default {
'modelConfig.rag': '知识库',
'modelConfig.modelId': '模型ID',
'modelConfig.modelName': '模型名称',
'modelConfig.provider': '提供商',
'modelConfig.provider': '接口类型',
'modelConfig.unknown': '未知',
'modelConfig.isEnabled': '是否启用',
'modelConfig.isDefault': '是否默认',
@@ -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': '将语音转换为文本,实现自然语言交互功能',
}
+55 -3
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': '新增失敗',
@@ -800,7 +821,7 @@ export default {
'modelConfig.rag': '知識庫',
'modelConfig.modelId': '模型ID',
'modelConfig.modelName': '模型名稱',
'modelConfig.provider': '提供商',
'modelConfig.provider': '接口類型',
'modelConfig.unknown': '未知',
'modelConfig.isEnabled': '是否啟用',
'modelConfig.isDefault': '是否默認',
@@ -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>
@@ -214,7 +214,7 @@
<el-dialog :title="$t('knowledgeFileUpload.retrievalTest')" :visible.sync="retrievalTestDialogVisible"
width="1200px" class="retrieval-test-dialog">
<div class="retrieval-test-form">
<el-form :model="retrievalTestForm" label-width="80px">
<el-form :model="retrievalTestForm" label-width="80px" @submit.native.prevent="runRetrievalTest">
<el-form-item :label="$t('knowledgeFileUpload.testQuestion')" required>
<el-input v-model="retrievalTestForm.question"
:placeholder="$t('knowledgeFileUpload.testQuestionPlaceholder')" style="width: 100%; max-height: 80px;"
+2 -2
View File
@@ -38,11 +38,11 @@
position: absolute;
top: 50%;
transform: translateY(-50%);
right: 15%;
right: 18%;
background-color: #fff;
border-radius: 20px;
padding: 35px 0;
width: 550px;
width: 450px;
box-sizing: border-box;
}
+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
},
+27 -1
View File
@@ -10,7 +10,7 @@
gap: 10px;
">
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px; height: 45px" />
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="height: 18px" />
<img loading="lazy" alt="" :src="xiaozhiAiIcon" style="height: 18px" />
</div>
</el-header>
<div class="login-person">
@@ -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",
@@ -191,6 +192,24 @@ export default {
return this.$t("language.zhCN");
}
},
// 根据当前语言获取对应的xiaozhi-ai图标
xiaozhiAiIcon() {
const currentLang = this.currentLanguage;
switch (currentLang) {
case "zh_CN":
return require("@/assets/xiaozhi-ai.png");
case "zh_TW":
return require("@/assets/xiaozhi-ai_zh_TW.png");
case "en":
return require("@/assets/xiaozhi-ai_en.png");
case "de":
return require("@/assets/xiaozhi-ai_de.png");
case "vi":
return require("@/assets/xiaozhi-ai_vi.png");
default:
return require("@/assets/xiaozhi-ai.png");
}
},
},
data() {
return {
@@ -214,6 +233,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: {
+25 -2
View File
@@ -5,7 +5,7 @@
<el-header>
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;" />
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="height: 18px;" />
<img loading="lazy" alt="" :src="xiaozhiAiIcon" style="height: 18px;" />
</div>
</el-header>
<div class="login-person">
@@ -108,7 +108,7 @@
<div style="font-size: 14px;color: #979db1;">
{{ $t('register.agreeTo') }}
<div style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.userAgreement') }}</div>
{{ $t('register.and') }}
{{ $t('login.and') }}
<div style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.privacyPolicy') }}</div>
</div>
</div>
@@ -127,6 +127,7 @@ import Api from '@/apis/api';
import VersionFooter from '@/components/VersionFooter.vue';
import { getUUID, goToPage, showDanger, showSuccess, sm2Encrypt, validateMobile } from '@/utils';
import { mapState } from 'vuex';
import i18n from '@/i18n';
// 导入语言切换功能
@@ -142,6 +143,28 @@ export default {
mobileAreaList: state => state.pubConfig.mobileAreaList,
sm2PublicKey: state => state.pubConfig.sm2PublicKey,
}),
// 获取当前语言
currentLanguage() {
return i18n.locale || "zh_CN";
},
// 根据当前语言获取对应的xiaozhi-ai图标
xiaozhiAiIcon() {
const currentLang = this.currentLanguage;
switch (currentLang) {
case "zh_CN":
return require("@/assets/xiaozhi-ai.png");
case "zh_TW":
return require("@/assets/xiaozhi-ai_zh_TW.png");
case "en":
return require("@/assets/xiaozhi-ai_en.png");
case "de":
return require("@/assets/xiaozhi-ai_de.png");
case "vi":
return require("@/assets/xiaozhi-ai_vi.png");
default:
return require("@/assets/xiaozhi-ai.png");
}
},
canSendMobileCaptcha() {
return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode);
}
@@ -5,7 +5,7 @@
<el-header>
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;" />
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="height: 18px;" />
<img loading="lazy" alt="" :src="xiaozhiAiIcon" style="height: 18px;" />
</div>
</el-header>
<div class="login-person">
@@ -83,7 +83,7 @@
<div style="font-size: 14px;color: #979db1;">
{{ $t('retrievePassword.agreeTo') }}
<div style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.userAgreement') }}</div>
{{ $t('register.and') }}
{{ $t('login.and') }}
<div style="display: inline-block;color: #5778FF;cursor: pointer;">{{ $t('register.privacyPolicy') }}</div>
</div>
</div>
@@ -103,6 +103,7 @@ import Api from '@/apis/api';
import VersionFooter from '@/components/VersionFooter.vue';
import { getUUID, goToPage, showDanger, showSuccess, validateMobile, sm2Encrypt } from '@/utils';
import { mapState } from 'vuex';
import i18n from '@/i18n';
// 导入语言切换功能
import { changeLanguage } from '@/i18n';
@@ -118,6 +119,28 @@ export default {
mobileAreaList: state => state.pubConfig.mobileAreaList,
sm2PublicKey: state => state.pubConfig.sm2PublicKey
}),
// 获取当前语言
currentLanguage() {
return i18n.locale || "zh_CN";
},
// 根据当前语言获取对应的xiaozhi-ai图标
xiaozhiAiIcon() {
const currentLang = this.currentLanguage;
switch (currentLang) {
case "zh_CN":
return require("@/assets/xiaozhi-ai.png");
case "zh_TW":
return require("@/assets/xiaozhi-ai_zh_TW.png");
case "en":
return require("@/assets/xiaozhi-ai_en.png");
case "de":
return require("@/assets/xiaozhi-ai_de.png");
case "vi":
return require("@/assets/xiaozhi-ai_vi.png");
default:
return require("@/assets/xiaozhi-ai.png");
}
},
canSendMobileCaptcha() {
return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode);
}
+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>
+8
View File
@@ -9,6 +9,7 @@ from core.utils.util import get_local_ip, validate_mcp_endpoint
from core.http_server import SimpleHttpServer
from core.websocket_server import WebSocketServer
from core.utils.util import check_ffmpeg_installed
from core.utils.gc_manager import get_gc_manager
TAG = __name__
logger = setup_logging()
@@ -63,6 +64,10 @@ async def main():
# 添加 stdin 监控任务
stdin_task = asyncio.create_task(monitor_stdin())
# 启动全局GC管理器(5分钟清理一次)
gc_manager = get_gc_manager(interval_seconds=300)
await gc_manager.start()
# 启动 WebSocket 服务器
ws_server = WebSocketServer(config)
ws_task = asyncio.create_task(ws_server.start())
@@ -122,6 +127,9 @@ async def main():
except asyncio.CancelledError:
print("任务被取消,清理资源中...")
finally:
# 停止全局GC管理器
await gc_manager.stop()
# 取消所有任务(关键修复点)
stdin_task.cancel()
ws_task.cancel()
+14 -1
View File
@@ -69,6 +69,9 @@ enable_greeting: true
enable_stop_tts_notify: false
# 说完话是否开启提示音,音效地址
stop_tts_notify_voice: "config/assets/tts_notify.mp3"
# 是否启用WebSocket心跳保活机制
enable_websocket_ping: false
# TTS音频发送延迟配置
# tts_audio_send_delay: 控制音频包发送间隔
@@ -113,6 +116,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
@@ -334,6 +346,8 @@ ASR:
# 热词、替换词使用流程:https://www.volcengine.com/docs/6561/155738
boosting_table_name: (选填)你的热词文件名称
correct_table_name: (选填)你的替换词文件名称
# 静音判定时长(ms),默认200ms
end_window_size: 200
output_dir: tmp/
TencentASR:
# token申请地址:https://console.cloud.tencent.com/cam/capi
@@ -462,7 +476,6 @@ ASR:
domain: slm # 识别领域,iat:日常用语,medical:医疗,finance:金融等
language: zh_cn # 语言,zh_cn:中文,en_us:英文
accent: mandarin # 方言,mandarin:普通话
dwa: wpgs # 动态修正,wpgs:实时返回中间结果
# 调整音频处理参数以提高长语音识别质量
output_dir: tmp/
+17 -6
View File
@@ -32,7 +32,16 @@ def load_config():
custom_config = read_config(custom_config_path)
if custom_config.get("manager-api", {}).get("url"):
config = get_config_from_api(custom_config)
import asyncio
try:
loop = asyncio.get_running_loop()
# 如果已经在事件循环中,使用异步版本
config = asyncio.run_coroutine_threadsafe(
get_config_from_api_async(custom_config), loop
).result()
except RuntimeError:
# 如果不在事件循环中(启动时),创建新的事件循环
config = asyncio.run(get_config_from_api_async(custom_config))
else:
# 合并配置
config = merge_configs(default_config, custom_config)
@@ -44,13 +53,13 @@ def load_config():
return config
def get_config_from_api(config):
"""从Java API获取配置"""
async def get_config_from_api_async(config):
"""从Java API获取配置(异步版本)"""
# 初始化API客户端
init_service(config)
# 获取服务器配置
config_data = get_server_config()
config_data = await get_server_config()
if config_data is None:
raise Exception("Failed to fetch server config from API")
@@ -59,6 +68,7 @@ def get_config_from_api(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"] = {
@@ -68,15 +78,16 @@ def get_config_from_api(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")
return config_data
def get_private_config_from_api(config, device_id, client_id):
async def get_private_config_from_api(config, device_id, client_id):
"""从Java API获取私有配置"""
return get_agent_models(device_id, client_id, config["selected_module"])
return await get_agent_models(device_id, client_id, config["selected_module"])
def ensure_directories(config):
+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.8"
SERVER_VERSION = "0.8.10"
_logger_initialized = False
+67 -42
View File
@@ -1,5 +1,4 @@
import os
import time
import base64
from typing import Optional, Dict
@@ -20,7 +19,7 @@ class DeviceBindException(Exception):
class ManageApiClient:
_instance = None
_client = None
_async_clients = {} # 为每个事件循环存储独立的客户端
_secret = None
def __new__(cls, config):
@@ -32,7 +31,7 @@ class ManageApiClient:
@classmethod
def _init_client(cls, config):
"""初始化持久化连接池"""
"""初始化配置(延迟创建客户端)"""
cls.config = config.get("manager-api")
if not cls.config:
@@ -47,23 +46,41 @@ class ManageApiClient:
cls._secret = cls.config.get("secret")
cls.max_retries = cls.config.get("max_retries", 6) # 最大重试次数
cls.retry_delay = cls.config.get("retry_delay", 10) # 初始重试延迟(秒)
# NOTE(goody): 2025/4/16 http相关资源统一管理,后续可以增加线程池或者超时
# 后续也可以统一配置apiToken之类的走通用的Auth
cls._client = httpx.Client(
base_url=cls.config.get("url"),
headers={
"User-Agent": f"PythonClient/2.0 (PID:{os.getpid()})",
"Accept": "application/json",
"Authorization": "Bearer " + cls._secret,
},
timeout=cls.config.get("timeout", 30), # 默认超时时间30秒
)
# 不在这里创建 AsyncClient,延迟到实际使用时创建
cls._async_clients = {}
@classmethod
def _request(cls, method: str, endpoint: str, **kwargs) -> Dict:
"""发送单次HTTP请求并处理响应"""
async def _ensure_async_client(cls):
"""确保异步客户端已创建(为每个事件循环创建独立的客户端)"""
import asyncio
try:
loop = asyncio.get_running_loop()
loop_id = id(loop)
# 为每个事件循环创建独立的客户端
if loop_id not in cls._async_clients:
cls._async_clients[loop_id] = httpx.AsyncClient(
base_url=cls.config.get("url"),
headers={
"User-Agent": f"PythonClient/2.0 (PID:{os.getpid()})",
"Accept": "application/json",
"Authorization": "Bearer " + cls._secret,
},
timeout=cls.config.get("timeout", 30),
)
return cls._async_clients[loop_id]
except RuntimeError:
# 如果没有运行中的事件循环,创建一个临时的
raise Exception("必须在异步上下文中调用")
@classmethod
async def _async_request(cls, method: str, endpoint: str, **kwargs) -> Dict:
"""发送单次异步HTTP请求并处理响应"""
# 确保客户端已创建
client = await cls._ensure_async_client()
endpoint = endpoint.lstrip("/")
response = cls._client.request(method, endpoint, **kwargs)
response = await client.request(method, endpoint, **kwargs)
response.raise_for_status()
result = response.json()
@@ -96,22 +113,24 @@ class ManageApiClient:
return False
@classmethod
def _execute_request(cls, method: str, endpoint: str, **kwargs) -> Dict:
"""带重试机制的请求执行器"""
async def _execute_async_request(cls, method: str, endpoint: str, **kwargs) -> Dict:
"""带重试机制的异步请求执行器"""
import asyncio
retry_count = 0
while retry_count <= cls.max_retries:
try:
# 执行请求
return cls._request(method, endpoint, **kwargs)
# 执行异步请求
return await cls._async_request(method, endpoint, **kwargs)
except Exception as e:
# 判断是否应该重试
if retry_count < cls.max_retries and cls._should_retry(e):
retry_count += 1
print(
f"{method} {endpoint} 请求失败,将在 {cls.retry_delay:.1f} 秒后进行第 {retry_count} 次重试"
f"{method} {endpoint} 异步请求失败,将在 {cls.retry_delay:.1f} 秒后进行第 {retry_count} 次重试"
)
time.sleep(cls.retry_delay)
await asyncio.sleep(cls.retry_delay)
continue
else:
# 不重试,直接抛出异常
@@ -119,22 +138,30 @@ class ManageApiClient:
@classmethod
def safe_close(cls):
"""安全关闭连接池"""
if cls._client:
cls._client.close()
cls._instance = None
"""安全关闭所有异步连接池"""
import asyncio
for client in list(cls._async_clients.values()):
try:
asyncio.run(client.aclose())
except Exception:
pass
cls._async_clients.clear()
cls._instance = None
def get_server_config() -> Optional[Dict]:
async def get_server_config() -> Optional[Dict]:
"""获取服务器基础配置"""
return ManageApiClient._instance._execute_request("POST", "/config/server-base")
return await ManageApiClient._instance._execute_async_request(
"POST", "/config/server-base"
)
def get_agent_models(
async def get_agent_models(
mac_address: str, client_id: str, selected_module: Dict
) -> Optional[Dict]:
"""获取代理模型配置"""
return ManageApiClient._instance._execute_request(
return await ManageApiClient._instance._execute_async_request(
"POST",
"/config/agent-models",
json={
@@ -145,28 +172,26 @@ def get_agent_models(
)
def save_mem_local_short(mac_address: str, short_momery: str) -> Optional[Dict]:
async def generate_and_save_chat_summary(session_id: str) -> Optional[Dict]:
"""生成并保存聊天记录总结"""
try:
return ManageApiClient._instance._execute_request(
"PUT",
f"/agent/saveMemory/" + mac_address,
json={
"summaryMemory": short_momery,
},
return await ManageApiClient._instance._execute_async_request(
"POST",
f"/agent/chat-summary/{session_id}/save",
)
except Exception as e:
print(f"存储短期记忆到服务器失败: {e}")
print(f"生成并保存聊天记录总结失败: {e}")
return None
def report(
async def report(
mac_address: str, session_id: str, chat_type: int, content: str, audio, report_time
) -> Optional[Dict]:
"""带熔断的业务方法示例"""
"""异步聊天记录上报"""
if not content or not ManageApiClient._instance:
return None
try:
return ManageApiClient._instance._execute_request(
return await ManageApiClient._instance._execute_async_request(
"POST",
f"/agent/chat-history/report",
json={
+9 -1
View File
@@ -10,7 +10,15 @@ class BaseHandler:
def _add_cors_headers(self, response):
"""添加CORS头信息"""
response.headers["Access-Control-Allow-Headers"] = (
"client-id, content-type, device-id"
"client-id, content-type, device-id, authorization"
)
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Origin"] = "*"
async def handle_options(self, request):
"""处理OPTIONS请求,添加CORS头信息"""
response = web.Response(body=b"", content_type="text/plain")
self._add_cors_headers(response)
# 添加允许的方法
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
return response
+231 -15
View File
@@ -3,15 +3,46 @@ import time
import base64
import hashlib
import hmac
import os
import re
import glob
from typing import Dict, List, Tuple
from aiohttp import web
from core.auth import AuthManager
from core.utils.util import get_local_ip
from core.utils.util import get_local_ip, get_vision_url
from core.api.base_handler import BaseHandler
TAG = __name__
def _safe_basename(filename: str) -> str:
# Prevent directory traversal
return os.path.basename(filename)
def _parse_version(ver: str) -> Tuple[int, ...]:
# conservative parser: split by non-digit, keep numeric parts
parts = re.findall(r"\d+", ver)
return tuple(int(p) for p in parts) if parts else (0,)
def _is_higher_version(a: str, b: str) -> bool:
"""Return True if version string a > b (semver-like numeric compare)."""
ta = _parse_version(a)
tb = _parse_version(b)
# compare tuple lexicographically, but allow different lengths
maxlen = max(len(ta), len(tb))
for i in range(maxlen):
ai = ta[i] if i < len(ta) else 0
bi = tb[i] if i < len(tb) else 0
if ai > bi:
return True
if ai < bi:
return False
return False
class OTAHandler(BaseHandler):
def __init__(self, config: dict):
super().__init__(config)
@@ -23,6 +54,54 @@ class OTAHandler(BaseHandler):
expire_seconds = auth_config.get("expire_seconds")
self.auth = AuthManager(secret_key=secret_key, expire_seconds=expire_seconds)
# firmware storage
self.bin_dir = os.path.join(os.getcwd(), "data", "bin")
# cache structure: { 'updated_at': timestamp, 'ttl': seconds, 'files_by_model': { model: [(version, filename), ...] } }
self._bin_cache: Dict = {
"updated_at": 0,
"ttl": config.get("firmware_cache_ttl", 30),
"files_by_model": {},
}
def _refresh_bin_cache_if_needed(self):
now = int(time.time())
ttl = int(self._bin_cache.get("ttl", 30))
if now - int(
self._bin_cache.get("updated_at", 0)
) < ttl and self._bin_cache.get("files_by_model"):
return
files_by_model: Dict[str, List[Tuple[str, str]]] = {}
try:
if not os.path.isdir(self.bin_dir):
os.makedirs(self.bin_dir, exist_ok=True)
# match files like model_1.2.3.bin (allow dots, dashes, underscores in model and version)
pattern = os.path.join(self.bin_dir, "*.bin")
for path in glob.glob(pattern):
fname = os.path.basename(path)
# filename format: {model}_{version}.bin
m = re.match(r"^(.+?)_([0-9][A-Za-z0-9\.\-_]*)\.bin$", fname)
if not m:
# skip files not conforming to naming rule
continue
model = m.group(1)
version = m.group(2)
files_by_model.setdefault(model, []).append((version, fname))
# sort versions for each model descending
for model, items in files_by_model.items():
items.sort(key=lambda it: _parse_version(it[0]), reverse=True)
self._bin_cache["files_by_model"] = files_by_model
self._bin_cache["updated_at"] = now
self.logger.bind(tag=TAG).info(
f"Firmware cache refreshed: {len(files_by_model)} models"
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"刷新固件缓存失败: {e}")
# keep previous cache if any
def generate_password_signature(self, content: str, secret_key: str) -> str:
"""生成MQTT密码签名
@@ -62,7 +141,14 @@ class OTAHandler(BaseHandler):
return f"ws://{local_ip}:{port}/xiaozhi/v1/"
async def handle_post(self, request):
"""处理 OTA POST 请求"""
"""处理 OTA POST 请求
This handler will:
- read device id/client id (as before)
- attempt to determine device model and current firmware version (prefer headers, fallback to body)
- check data/bin for newer firmware for that model
- if found a newer firmware, set firmware.url to the download endpoint
"""
try:
data = await request.text()
self.logger.bind(tag=TAG).debug(f"OTA请求方法: {request.method}")
@@ -81,33 +167,76 @@ class OTAHandler(BaseHandler):
else:
raise Exception("OTA请求ClientID为空")
data_json = json.loads(data)
data_json = {}
try:
data_json = json.loads(data) if data else {}
except Exception:
data_json = {}
server_config = self.config["server"]
port = int(server_config.get("port", 8000))
# Distinguish ports:
# - websocket_port is used to construct websocket URL (server["port"])
# - http_port is used to construct OTA download URLs (server["http_port"])
websocket_port = int(server_config.get("port", 8000))
http_port = int(server_config.get("http_port", 8003))
local_ip = get_local_ip()
# Determine device model (prefer headers)
device_model = ""
# header candidates
for h in ("device-model", "device_model", "model"):
if h in request.headers:
device_model = request.headers.get(h, "").strip()
break
# body fallback
if not device_model:
try:
if "board" in data_json and isinstance(data_json["board"], dict):
device_model = data_json["board"].get("type", "")
elif "model" in data_json:
device_model = data_json.get("model", "")
except Exception:
device_model = ""
if not device_model:
device_model = "default"
# Determine device current version (prefer headers)
device_version = ""
for h in (
"device-version",
"device_version",
"firmware-version",
"app-version",
"application-version",
):
if h in request.headers:
device_version = request.headers.get(h, "").strip()
break
if not device_version:
try:
device_version = data_json.get("application", {}).get("version", "")
except Exception:
device_version = ""
if not device_version:
device_version = "0.0.0"
return_json = {
"server_time": {
"timestamp": int(round(time.time() * 1000)),
"timezone_offset": server_config.get("timezone_offset", 8) * 60,
},
"firmware": {
"version": data_json["application"].get("version", "1.0.0"),
"version": device_version,
"url": "",
},
}
# existing mqtt/websocket logic (unchanged)
mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
if mqtt_gateway_endpoint: # 如果配置了非空字符串
# 尝试从请求数据中获取设备型号
device_model = "default"
# 尝试从请求数据中获取设备型号(已解析 above
try:
if "device" in data_json and isinstance(data_json["device"], dict):
device_model = data_json["device"].get("model", "default")
elif "model" in data_json:
device_model = data_json["model"]
group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_")
except Exception as e:
self.logger.bind(tag=TAG).error(f"获取设备型号失败: {e}")
@@ -159,20 +288,61 @@ class OTAHandler(BaseHandler):
token = self.auth.generate_token(client_id, device_id)
else:
token = self.auth.generate_token(client_id, device_id)
# NOTE: use websocket_port here
return_json["websocket"] = {
"url": self._get_websocket_url(local_ip, port),
"url": self._get_websocket_url(local_ip, websocket_port),
"token": token,
}
self.logger.bind(tag=TAG).info(
f"未配置MQTT网关,为设备 {device_id} 下发WebSocket配置"
)
self.logger.bind(tag=TAG).info(f"{return_json}")
# Now check firmware files for updates
try:
self._refresh_bin_cache_if_needed()
files_by_model = self._bin_cache.get("files_by_model", {})
candidates = files_by_model.get(device_model, [])
self.logger.bind(tag=TAG).info(
f"查找型号 {device_model} 的固件,找到 {len(candidates)} 个候选"
)
chosen_url = ""
chosen_version = device_version
# candidates are sorted descending by version
for ver, fname in candidates:
if _is_higher_version(ver, device_version):
# build download url (only allow download via our download endpoint)
chosen_version = ver
# Use get_vision_url to get the base URL and replace the path
vision_url = get_vision_url(self.config)
# Replace the path from "/mcp/vision/explain" to "/xiaozhi/ota/download/{fname}"
chosen_url = vision_url.replace(
"/mcp/vision/explain", f"/xiaozhi/ota/download/{fname}"
)
break
if chosen_url:
return_json["firmware"]["version"] = chosen_version
return_json["firmware"]["url"] = chosen_url
self.logger.bind(tag=TAG).info(
f"为设备 {device_id} 下发固件 {chosen_version} [如果地址前缀有误,请检查配置文件中的server.vision_explain]-> {chosen_url} "
)
else:
self.logger.bind(tag=TAG).info(
f"设备 {device_id} 固件已是最新: {device_version}"
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"检查固件版本时出错: {e}")
response = web.Response(
text=json.dumps(return_json, separators=(",", ":")),
content_type="application/json",
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"OTA POST处理异常: {e}")
return_json = {"success": False, "message": "request error."}
response = web.Response(
text=json.dumps(return_json, separators=(",", ":")),
@@ -187,8 +357,9 @@ class OTAHandler(BaseHandler):
try:
server_config = self.config["server"]
local_ip = get_local_ip()
port = int(server_config.get("port", 8000))
websocket_url = self._get_websocket_url(local_ip, port)
# use websocket port for websocket URL
websocket_port = int(server_config.get("port", 8000))
websocket_url = self._get_websocket_url(local_ip, websocket_port)
message = f"OTA接口运行正常,向设备发送的websocket地址是:{websocket_url}"
response = web.Response(text=message, content_type="text/plain")
except Exception as e:
@@ -197,3 +368,48 @@ class OTAHandler(BaseHandler):
finally:
self._add_cors_headers(response)
return response
async def handle_download(self, request):
"""
下载固件接口
URL: /xiaozhi/ota/download/{filename}
- 只允许下载 data/bin 目录下的 .bin 文件
- filename 必须是 basename 且匹配安全的模式
"""
try:
fname = request.match_info.get("filename", "")
if not fname:
raise web.HTTPBadRequest(text="filename required")
# sanitize
fname = _safe_basename(fname)
# pattern: allow letters, numbers, dot, underscore, dash
if not re.match(r"^[A-Za-z0-9\.\-_]+\.bin$", fname):
raise web.HTTPBadRequest(text="invalid filename")
file_path = os.path.join(self.bin_dir, fname)
# ensure realpath is under bin_dir
file_real = os.path.realpath(file_path)
bin_dir_real = os.path.realpath(self.bin_dir)
if (
not file_real.startswith(bin_dir_real + os.sep)
and file_real != bin_dir_real
):
raise web.HTTPForbidden(text="forbidden")
if not os.path.isfile(file_real):
raise web.HTTPNotFound(text="file not found")
# use FileResponse to stream file
resp = web.FileResponse(path=file_real)
except web.HTTPError as e:
resp = e
except Exception as e:
self.logger.bind(tag=TAG).error(f"固件下载异常: {e}")
resp = web.Response(text="download error", status=500)
finally:
try:
self._add_cors_headers(resp)
except Exception:
pass
return resp
+4 -12
View File
@@ -2,6 +2,7 @@ import json
import copy
from aiohttp import web
from config.logger import setup_logging
from core.api.base_handler import BaseHandler
from core.utils.util import get_vision_url, is_valid_image_file
from core.utils.vllm import create_instance
from config.config_loader import get_private_config_from_api
@@ -16,10 +17,9 @@ TAG = __name__
MAX_FILE_SIZE = 5 * 1024 * 1024
class VisionHandler:
class VisionHandler(BaseHandler):
def __init__(self, config: dict):
self.config = config
self.logger = setup_logging()
super().__init__(config)
# 初始化认证工具
self.auth = AuthToken(config["server"]["auth_key"])
@@ -96,7 +96,7 @@ class VisionHandler:
current_config = copy.deepcopy(self.config)
read_config_from_api = current_config.get("read_config_from_api", False)
if read_config_from_api:
current_config = get_private_config_from_api(
current_config = await get_private_config_from_api(
current_config,
device_id,
client_id,
@@ -172,11 +172,3 @@ class VisionHandler:
finally:
self._add_cors_headers(response)
return response
def _add_cors_headers(self, response):
"""添加CORS头信息"""
response.headers["Access-Control-Allow-Headers"] = (
"client-id, content-type, device-id"
)
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Origin"] = "*"
+141 -49
View File
@@ -68,8 +68,12 @@ class ConnectionHandler:
self.logger = setup_logging()
self.server = server # 保存server实例的引用
self.need_bind = False
self.bind_code = None
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 # 绑定提示播放间隔(秒)
self.read_config_from_api = self.config.get("read_config_from_api", False)
self.websocket = None
@@ -88,7 +92,7 @@ class ConnectionHandler:
self.client_listen_mode = "auto"
# 线程任务相关
self.loop = asyncio.get_event_loop()
self.loop = None # 在 handle_connection 中获取运行中的事件循环
self.stop_event = threading.Event()
self.executor = ThreadPoolExecutor(max_workers=5)
@@ -116,6 +120,7 @@ class ConnectionHandler:
self.client_audio_buffer = bytearray()
self.client_have_voice = False
self.client_voice_window = deque(maxlen=5)
self.first_activity_time = 0.0 # 记录首次活动的时间(毫秒)
self.last_activity_time = 0.0 # 统一的活动时间戳(毫秒)
self.client_voice_stop = False
self.last_is_voice = False
@@ -158,10 +163,13 @@ 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:
# 获取运行中的事件循环(必须在异步上下文中)
self.loop = asyncio.get_running_loop()
# 获取并验证headers
self.headers = dict(ws.request.headers)
real_ip = self.headers.get("x-real-ip") or self.headers.get(
@@ -187,6 +195,7 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).info("连接来自:MQTT网关")
# 初始化活动时间戳
self.first_activity_time = time.time() * 1000
self.last_activity_time = time.time() * 1000
# 启动超时检查任务
@@ -195,10 +204,8 @@ class ConnectionHandler:
self.welcome_msg = self.config["xiaozhi"]
self.welcome_msg["session_id"] = self.session_id
# 获取差异化配置
self._initialize_private_config()
# 异步初始化
self.executor.submit(self._initialize_components)
# 在后台初始化配置和组件(完全不阻塞主循环)
asyncio.create_task(self._background_initialize())
try:
async for message in self.websocket:
@@ -237,7 +244,9 @@ class ConnectionHandler:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(
self.memory.save_memory(self.dialogue.dialogue)
self.memory.save_memory(
self.dialogue.dialogue, self.session_id
)
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"保存记忆失败: {e}")
@@ -260,8 +269,37 @@ 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):
@@ -391,6 +429,15 @@ 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:
self.bind_completed_event.set()
return
self.selected_module_str = build_module_string(
self.config.get("selected_module", {})
)
@@ -414,17 +461,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()
@@ -439,6 +479,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(
@@ -474,7 +515,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
@@ -501,22 +546,35 @@ class ConnectionHandler:
except Exception as e:
self.logger.bind(tag=TAG).warning(f"声纹识别初始化失败: {str(e)}")
def _initialize_private_config(self):
"""如果是从配置文件获取,则进行二次实例化"""
async def _background_initialize(self):
"""在后台初始化配置和组件(完全不阻塞主循环)"""
try:
# 异步获取差异化配置
await self._initialize_private_config_async()
# 在线程池中初始化组件
self.executor.submit(self._initialize_components)
except Exception as e:
self.logger.bind(tag=TAG).error(f"后台初始化失败: {e}")
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()
private_config = get_private_config_from_api(
private_config = await get_private_config_from_api(
self.config,
self.headers.get("device-id"),
self.headers.get("client-id", self.headers.get("device-id")),
)
private_config["delete_audio"] = bool(self.config.get("delete_audio", True))
self.logger.bind(tag=TAG).info(
f"{time.time() - begin_time} 秒,获取差异化配置成功: {json.dumps(filter_sensitive_info(private_config), ensure_ascii=False)}"
f"{time.time() - begin_time} 秒,异步获取差异化配置成功: {json.dumps(filter_sensitive_info(private_config), ensure_ascii=False)}"
)
self.need_bind = False
self.bind_completed_event.set()
except DeviceNotFoundException as e:
self.need_bind = True
private_config = {}
@@ -526,7 +584,7 @@ class ConnectionHandler:
private_config = {}
except Exception as e:
self.need_bind = True
self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}")
self.logger.bind(tag=TAG).error(f"异步获取差异化配置失败: {e}")
private_config = {}
init_llm, init_tts, init_memory, init_intent = (
@@ -599,8 +657,14 @@ 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:
modules = initialize_modules(
modules = await self.loop.run_in_executor(
None, # 使用默认线程池
initialize_modules,
self.logger,
private_config,
init_vad,
@@ -744,18 +808,26 @@ class ConnectionHandler:
force_final_answer = False # 标记是否强制最终回答
if depth >= MAX_DEPTH:
self.logger.bind(tag=TAG).debug(f"已达到最大工具调用深度 {MAX_DEPTH},将强制基于现有信息回答")
self.logger.bind(tag=TAG).debug(
f"已达到最大工具调用深度 {MAX_DEPTH},将强制基于现有信息回答"
)
force_final_answer = True
# 添加系统指令,要求 LLM 基于现有信息回答
self.dialogue.put(Message(
role="user",
content="[系统提示] 已达到最大工具调用次数限制,请你基于目前已经获取的所有信息,直接给出最终答案。不要再尝试调用任何工具。"
))
self.dialogue.put(
Message(
role="user",
content="[系统提示] 已达到最大工具调用次数限制,请你基于目前已经获取的所有信息,直接给出最终答案。不要再尝试调用任何工具。",
)
)
# Define intent functions
functions = None
# 达到最大深度时,禁用工具调用,强制 LLM 直接回答
if self.intent_type == "function_call" and hasattr(self, "func_handler") and not force_final_answer:
if (
self.intent_type == "function_call"
and hasattr(self, "func_handler")
and not force_final_answer
):
functions = self.func_handler.get_functions()
response_message = []
@@ -844,11 +916,16 @@ class ConnectionHandler:
if a is not None:
try:
content_arguments_json = json.loads(a)
tool_calls_list.append({
"id": str(uuid.uuid4().hex),
"name": content_arguments_json["name"],
"arguments": json.dumps(content_arguments_json["arguments"], ensure_ascii=False)
})
tool_calls_list.append(
{
"id": str(uuid.uuid4().hex),
"name": content_arguments_json["name"],
"arguments": json.dumps(
content_arguments_json["arguments"],
ensure_ascii=False,
),
}
)
except Exception as e:
bHasError = True
response_message.append(a)
@@ -880,7 +957,9 @@ class ConnectionHandler:
)
future = asyncio.run_coroutine_threadsafe(
self.func_handler.handle_llm_function_call(self, tool_call_data),
self.func_handler.handle_llm_function_call(
self, tool_call_data
),
self.loop,
)
futures_with_data.append((future, tool_call_data))
@@ -888,7 +967,7 @@ class ConnectionHandler:
# 等待协程结束(实际等待时长为最慢的那个)
tool_results = []
for future, tool_call_data in futures_with_data:
result = future.result()
result = future.result()
tool_results.append((result, tool_call_data))
# 统一处理所有工具调用结果
@@ -922,7 +1001,11 @@ class ConnectionHandler:
need_llm_tools = []
for result, tool_call_data in tool_results:
if result.action in [Action.RESPONSE, Action.NOTFOUND, Action.ERROR]: # 直接回复前端
if result.action in [
Action.RESPONSE,
Action.NOTFOUND,
Action.ERROR,
]: # 直接回复前端
text = result.response if result.response else result.result
self.tts.tts_one_sentence(self, ContentType.TEXT, content_detail=text)
self.dialogue.put(Message(role="assistant", content=text))
@@ -957,7 +1040,11 @@ class ConnectionHandler:
self.dialogue.put(
Message(
role="tool",
tool_call_id=str(uuid.uuid4()) if tool_call_data["id"] is None else tool_call_data["id"],
tool_call_id=(
str(uuid.uuid4())
if tool_call_data["id"] is None
else tool_call_data["id"]
),
content=text,
)
)
@@ -990,8 +1077,8 @@ class ConnectionHandler:
def _process_report(self, type, text, audio_data, report_time):
"""处理上报任务"""
try:
# 执行上报(传入二进制数据
report(self, type, text, audio_data, report_time)
# 执行异步上报(在事件循环中运行
asyncio.run(report(self, type, text, audio_data, report_time))
except Exception as e:
self.logger.bind(tag=TAG).error(f"上报处理异常: {e}")
finally:
@@ -1082,7 +1169,6 @@ class ConnectionHandler:
f"关闭线程池时出错: {executor_error}"
)
self.executor = None
self.logger.bind(tag=TAG).info("连接资源已释放")
except Exception as e:
self.logger.bind(tag=TAG).error(f"关闭连接时出错: {e}")
@@ -1112,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()}"
)
@@ -1137,13 +1228,14 @@ class ConnectionHandler:
"""检查连接超时"""
try:
while not self.stop_event.is_set():
last_activity_time = self.last_activity_time
if self.need_bind:
last_activity_time = self.first_activity_time
# 检查是否超时(只有在时间戳已初始化的情况下)
if self.last_activity_time > 0.0:
if last_activity_time > 0.0:
current_time = time.time() * 1000
if (
current_time - self.last_activity_time
> self.timeout_seconds * 1000
):
if current_time - last_activity_time > self.timeout_seconds * 1000:
if not self.stop_event.is_set():
self.logger.bind(tag=TAG).info("连接超时,准备关闭")
# 设置停止事件,防止重复处理
@@ -1171,7 +1263,7 @@ class ConnectionHandler:
tools_call: 新的工具调用
"""
for tool_call in tools_call:
tool_index = getattr(tool_call, 'index', None)
tool_index = getattr(tool_call, "index", None)
if tool_index is None:
if tool_call.function.name:
# 有 function_name,说明是新的工具调用
@@ -1189,4 +1281,4 @@ class ConnectionHandler:
if tool_call.function.name:
tool_calls_list[tool_index]["name"] = tool_call.function.name
if tool_call.function.arguments:
tool_calls_list[tool_index]["arguments"] += tool_call.function.arguments
tool_calls_list[tool_index]["arguments"] += tool_call.function.arguments
@@ -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))
+41 -34
View File
@@ -10,7 +10,6 @@ TTS上报功能已集成到ConnectionHandler类中。
"""
import time
import opuslib_next
from config.manage_api_client import report as manage_report
@@ -18,7 +17,7 @@ from config.manage_api_client import report as manage_report
TAG = __name__
def report(conn, type, text, opus_data, report_time):
async def report(conn, type, text, opus_data, report_time):
"""执行聊天记录上报操作
Args:
@@ -33,8 +32,8 @@ def report(conn, type, text, opus_data, report_time):
audio_data = opus_to_wav(conn, opus_data)
else:
audio_data = None
# 执行上报
manage_report(
# 执行异步上报
await manage_report(
mac_address=conn.device_id,
session_id=conn.session_id,
chat_type=type,
@@ -56,41 +55,49 @@ def opus_to_wav(conn, opus_data):
Returns:
bytes: WAV格式的音频数据
"""
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
decoder = None
try:
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道
pcm_data = []
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
conn.logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
for opus_packet in opus_data:
try:
pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
conn.logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True)
if not pcm_data:
raise ValueError("没有有效的PCM数据")
if not pcm_data:
raise ValueError("没有有效的PCM数据")
# 创建WAV文件头
pcm_data_bytes = b"".join(pcm_data)
num_samples = len(pcm_data_bytes) // 2 # 16-bit samples
# 创建WAV文件头
pcm_data_bytes = b"".join(pcm_data)
num_samples = len(pcm_data_bytes) // 2 # 16-bit samples
# WAV文件头
wav_header = bytearray()
wav_header.extend(b"RIFF") # ChunkID
wav_header.extend((36 + len(pcm_data_bytes)).to_bytes(4, "little")) # ChunkSize
wav_header.extend(b"WAVE") # Format
wav_header.extend(b"fmt ") # Subchunk1ID
wav_header.extend((16).to_bytes(4, "little")) # Subchunk1Size
wav_header.extend((1).to_bytes(2, "little")) # AudioFormat (PCM)
wav_header.extend((1).to_bytes(2, "little")) # NumChannels
wav_header.extend((16000).to_bytes(4, "little")) # SampleRate
wav_header.extend((32000).to_bytes(4, "little")) # ByteRate
wav_header.extend((2).to_bytes(2, "little")) # BlockAlign
wav_header.extend((16).to_bytes(2, "little")) # BitsPerSample
wav_header.extend(b"data") # Subchunk2ID
wav_header.extend(len(pcm_data_bytes).to_bytes(4, "little")) # Subchunk2Size
# WAV文件头
wav_header = bytearray()
wav_header.extend(b"RIFF") # ChunkID
wav_header.extend((36 + len(pcm_data_bytes)).to_bytes(4, "little")) # ChunkSize
wav_header.extend(b"WAVE") # Format
wav_header.extend(b"fmt ") # Subchunk1ID
wav_header.extend((16).to_bytes(4, "little")) # Subchunk1Size
wav_header.extend((1).to_bytes(2, "little")) # AudioFormat (PCM)
wav_header.extend((1).to_bytes(2, "little")) # NumChannels
wav_header.extend((16000).to_bytes(4, "little")) # SampleRate
wav_header.extend((32000).to_bytes(4, "little")) # ByteRate
wav_header.extend((2).to_bytes(2, "little")) # BlockAlign
wav_header.extend((16).to_bytes(2, "little")) # BitsPerSample
wav_header.extend(b"data") # Subchunk2ID
wav_header.extend(len(pcm_data_bytes).to_bytes(4, "little")) # Subchunk2Size
# 返回完整的WAV数据
return bytes(wav_header) + pcm_data_bytes
# 返回完整的WAV数据
return bytes(wav_header) + pcm_data_bytes
finally:
if decoder is not None:
try:
del decoder
except Exception as e:
conn.logger.bind(tag=TAG).debug(f"释放decoder资源时出错: {e}")
def enqueue_tts_report(conn, text, opus_data):
+159 -126
View File
@@ -4,8 +4,13 @@ import asyncio
from core.utils import textUtils
from core.utils.util import audio_to_data
from core.providers.tts.dto.dto import SentenceType
from core.utils.audioRateController import AudioRateController
TAG = __name__
# 音频帧时长(毫秒)
AUDIO_FRAME_DURATION = 60
# 预缓冲包数量,直接发送以减少延迟
PRE_BUFFER_COUNT = 5
async def sendAudioMessage(conn, sentenceType, audios, text):
@@ -15,7 +20,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)
# 发送句子开始消息
@@ -30,29 +47,27 @@ async def sendAudioMessage(conn, sentenceType, audios, text):
await conn.close()
def calculate_timestamp_and_sequence(conn, start_time, packet_index, frame_duration=60):
async def _wait_for_audio_completion(conn):
"""
计算音频数据包的时间戳和序列号
等待音频队列清空并等待预缓冲包播放完成
Args:
conn: 连接对象
start_time: 起始时间(性能计数器值)
packet_index: 数据包索引
frame_duration: 帧时长(毫秒),匹配 Opus 编码
Returns:
tuple: (timestamp, sequence)
"""
# 计算时间戳(使用播放位置计算)
timestamp = int((start_time + packet_index * frame_duration / 1000) * 1000) % (
2**32
)
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()
# 计算序列号
if hasattr(conn, "audio_flow_control"):
sequence = conn.audio_flow_control["sequence"]
else:
sequence = packet_index # 如果没有流控状态,直接使用索引
# 等待预缓冲包播放完成
# 前N个包直接发送,增加2个网络抖动包,需要额外等待它们在客户端播放完成
frame_duration_ms = rate_controller.frame_duration
pre_buffer_playback_time = (PRE_BUFFER_COUNT + 2) * frame_duration_ms / 1000.0
await asyncio.sleep(pre_buffer_playback_time)
return timestamp, sequence
conn.logger.bind(tag=TAG).debug("音频发送完成")
async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence):
@@ -77,135 +92,151 @@ async def _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence):
await conn.websocket.send(complete_packet)
# 播放音频
async def sendAudio(conn, audios, frame_duration=60):
async def sendAudio(conn, audios, frame_duration=AUDIO_FRAME_DURATION):
"""
发送单个opus包,支持流控
发送音频包,使用 AudioRateController 进行精确的流量控制
Args:
conn: 连接对象
opus_packet: 单个opus数据包
pre_buffer: 快速发送音频
frame_duration: 帧时长(毫秒),匹配 Opus 编码
audios: 单个opus包(bytes) 或 opus包列表
frame_duration: 帧时长(毫秒),默认使用全局常量AUDIO_FRAME_DURATION
"""
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):
# 重置流控状态,第一次读取和会话发生转变时
if not hasattr(conn, "audio_flow_control") or conn.audio_flow_control.get("sentence_id") != conn.sentence_id:
conn.audio_flow_control = {
"last_send_time": 0,
"packet_count": 0,
"start_time": time.perf_counter(),
"sequence": 0, # 添加序列号
"sentence_id": conn.sentence_id,
}
# 初始化或获取 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
)
def _get_or_create_rate_controller(conn, frame_duration, is_single_packet):
"""
获取或创建 RateController 和 flow_control
Args:
conn: 连接对象
frame_duration: 帧时长
is_single_packet: 是否单包模式(True: TTS流式单包, False: 批量包)
Returns:
(rate_controller, flow_control)
"""
# 判断是否需要重置:单包模式且 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,
}
# 启动后台发送循环
_start_background_sender(
conn, conn.audio_rate_controller, conn.audio_flow_control
)
return conn.audio_rate_controller, conn.audio_flow_control
def _start_background_sender(conn, rate_controller, flow_control):
"""
启动后台发送循环任务
Args:
conn: 连接对象
rate_controller: 速率控制器
flow_control: 流控状态
"""
async def send_callback(packet):
# 检查是否应该中止
if conn.client_abort:
raise asyncio.CancelledError("客户端已中止")
conn.last_activity_time = time.time() * 1000
await _do_send_audio(conn, packet, flow_control)
conn.client_is_speaking = True
# 使用 start_sending 启动后台循环
rate_controller.start_sending(send_callback)
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表示使用动态流控
"""
for packet in audio_list:
if conn.client_abort:
return
conn.last_activity_time = time.time() * 1000
# 预缓冲:前5个包直接发送,不做延迟
pre_buffer_count = 5
flow_control = conn.audio_flow_control
current_time = time.perf_counter()
if flow_control["packet_count"] < pre_buffer_count:
# 预缓冲阶段,直接发送不延迟
pass
# 预缓冲:前N个包直接发送
if flow_control["packet_count"] < PRE_BUFFER_COUNT:
await _do_send_audio(conn, packet, flow_control)
conn.client_is_speaking = True
elif send_delay > 0:
# 使用固定延迟
# 固定延迟模式
await asyncio.sleep(send_delay)
await _do_send_audio(conn, packet, flow_control)
conn.client_is_speaking = True
else:
effective_packet = flow_control["packet_count"] - pre_buffer_count
expected_time = flow_control["start_time"] + (
effective_packet * frame_duration / 1000
)
delay = expected_time - current_time
if delay > 0:
await asyncio.sleep(delay)
else:
# 纠正误差
flow_control["start_time"] += abs(delay)
# 动态流控模式:仅添加到队列,由后台循环负责发送
rate_controller.add_audio(packet)
if conn.conn_from_mqtt_gateway:
# 计算时间戳和序列号
timestamp, sequence = calculate_timestamp_and_sequence(
conn,
flow_control["start_time"],
flow_control["packet_count"],
frame_duration,
)
# 调用通用函数发送带头部的数据包
await _send_to_mqtt_gateway(conn, audios, timestamp, sequence)
else:
# 直接发送opus数据包,不添加头部
await conn.websocket.send(audios)
conn.client_is_speaking = True
# 更新流控状态
flow_control["packet_count"] += 1
flow_control["sequence"] += 1
flow_control["last_send_time"] = time.perf_counter()
async def _do_send_audio(conn, opus_packet, flow_control):
"""
执行实际的音频发送
"""
packet_index = flow_control.get("packet_count", 0)
sequence = flow_control.get("sequence", 0)
if conn.conn_from_mqtt_gateway:
# 计算时间戳(基于播放位置)
start_time = time.time()
timestamp = int(start_time * 1000) % (2**32)
await _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence)
else:
# 文件型音频走普通播放
start_time = time.perf_counter()
play_position = 0
# 直接发送opus数据包
await conn.websocket.send(opus_packet)
# 执行预缓冲
pre_buffer_frames = min(5, len(audios))
for i in range(pre_buffer_frames):
if conn.conn_from_mqtt_gateway:
# 计算时间戳和序列号
timestamp, sequence = calculate_timestamp_and_sequence(
conn, start_time, i, frame_duration
)
# 调用通用函数发送带头部的数据包
await _send_to_mqtt_gateway(conn, audios[i], timestamp, sequence)
else:
# 直接发送预缓冲包,不添加头部
await conn.websocket.send(audios[i])
conn.client_is_speaking = True
remaining_audios = audios[pre_buffer_frames:]
# 播放剩余音频帧
for i, opus_packet in enumerate(remaining_audios):
if conn.client_abort:
break
# 重置没有声音的状态
conn.last_activity_time = time.time() * 1000
if send_delay > 0:
# 固定延迟模式
await asyncio.sleep(send_delay)
else:
# 计算预期发送时间
expected_time = start_time + (play_position / 1000)
current_time = time.perf_counter()
delay = expected_time - current_time
if delay > 0:
await asyncio.sleep(delay)
if conn.conn_from_mqtt_gateway:
# 计算时间戳和序列号(使用当前的数据包索引确保连续性)
packet_index = pre_buffer_frames + i
timestamp, sequence = calculate_timestamp_and_sequence(
conn, start_time, packet_index, frame_duration
)
# 调用通用函数发送带头部的数据包
await _send_to_mqtt_gateway(conn, opus_packet, timestamp, sequence)
else:
# 直接发送opus数据包,不添加头部
await conn.websocket.send(opus_packet)
conn.client_is_speaking = True
play_position += frame_duration
# 更新流控状态
flow_control["packet_count"] = packet_index + 1
flow_control["sequence"] = sequence + 1
async def send_tts_message(conn, state, text=None):
@@ -224,8 +255,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()
@@ -57,6 +69,7 @@ class ListenTextMessageHandler(TextMessageHandler):
enqueue_asr_report(conn, "嘿,你好呀", [])
await startToChat(conn, "嘿,你好呀")
else:
conn.just_woken_up = True
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
enqueue_asr_report(conn, original_text, [])
# 否则需要LLM对文字内容进行答复
@@ -0,0 +1,45 @@
import json
import time
from typing import Dict, Any
from core.handle.textMessageHandler import TextMessageHandler
from core.handle.textMessageType import TextMessageType
TAG = __name__
class PingMessageHandler(TextMessageHandler):
"""Ping消息处理器,用于保持WebSocket连接"""
@property
def message_type(self) -> TextMessageType:
return TextMessageType.PING
async def handle(self, conn, msg_json: Dict[str, Any]) -> None:
"""
处理PING消息,发送PONG响应
消息格式:{"type": "ping"}
Args:
conn: WebSocket连接对象
msg_json: PING消息的JSON数据
"""
# 检查是否启用了WebSocket心跳功能
enable_websocket_ping = conn.config.get("enable_websocket_ping", False)
if not enable_websocket_ping:
conn.logger.debug(f"WebSocket心跳功能未启用,忽略PING消息")
return
try:
conn.logger.debug(f"收到PING消息,发送PONG响应")
conn.last_activity_time = time.time() * 1000
# 构造PONG响应消息
pong_message = {
"type": "pong",
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
}
# 发送PONG响应
await conn.websocket.send(json.dumps(pong_message))
except Exception as e:
conn.logger.error(f"处理PING消息时发生错误: {e}")
@@ -7,6 +7,7 @@ from core.handle.textHandler.listenMessageHandler import ListenTextMessageHandle
from core.handle.textHandler.mcpMessageHandler import McpTextMessageHandler
from core.handle.textMessageHandler import TextMessageHandler
from core.handle.textHandler.serverMessageHandler import ServerTextMessageHandler
from core.handle.textHandler.pingMessageHandler import PingMessageHandler
TAG = __name__
@@ -27,6 +28,7 @@ class TextMessageHandlerRegistry:
IotTextMessageHandler(),
McpTextMessageHandler(),
ServerTextMessageHandler(),
PingMessageHandler(),
]
for handler in handlers:
@@ -9,3 +9,4 @@ class TextMessageType(Enum):
IOT = "iot"
MCP = "mcp"
SERVER = "server"
PING = "ping"
+49 -27
View File
@@ -33,38 +33,60 @@ class SimpleHttpServer:
return f"ws://{local_ip}:{port}/xiaozhi/v1/"
async def start(self):
server_config = self.config["server"]
read_config_from_api = self.config.get("read_config_from_api", False)
host = server_config.get("ip", "0.0.0.0")
port = int(server_config.get("http_port", 8003))
try:
server_config = self.config["server"]
read_config_from_api = self.config.get("read_config_from_api", False)
host = server_config.get("ip", "0.0.0.0")
port = int(server_config.get("http_port", 8003))
if port:
app = web.Application()
if port:
app = web.Application()
if not read_config_from_api:
# 如果没有开启智控台,只是单模块运行,就需要再添加简单OTA接口,用于下发websocket接口
if not read_config_from_api:
# 如果没有开启智控台,只是单模块运行,就需要再添加简单OTA接口,用于下发websocket接口
app.add_routes(
[
web.get("/xiaozhi/ota/", self.ota_handler.handle_get),
web.post("/xiaozhi/ota/", self.ota_handler.handle_post),
web.options(
"/xiaozhi/ota/", self.ota_handler.handle_options
),
# 下载接口,仅提供 data/bin/*.bin 下载
web.get(
"/xiaozhi/ota/download/{filename}",
self.ota_handler.handle_download,
),
web.options(
"/xiaozhi/ota/download/{filename}",
self.ota_handler.handle_options,
),
]
)
# 添加路由
app.add_routes(
[
web.get("/xiaozhi/ota/", self.ota_handler.handle_get),
web.post("/xiaozhi/ota/", self.ota_handler.handle_post),
web.options("/xiaozhi/ota/", self.ota_handler.handle_post),
web.get("/mcp/vision/explain", self.vision_handler.handle_get),
web.post(
"/mcp/vision/explain", self.vision_handler.handle_post
),
web.options(
"/mcp/vision/explain", self.vision_handler.handle_options
),
]
)
# 添加路由
app.add_routes(
[
web.get("/mcp/vision/explain", self.vision_handler.handle_get),
web.post("/mcp/vision/explain", self.vision_handler.handle_post),
web.options("/mcp/vision/explain", self.vision_handler.handle_post),
]
)
# 运行服务
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
# 运行服务
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
# 保持服务运行
while True:
await asyncio.sleep(3600) # 每隔 1 小时检查一次
# 保持服务运行
while True:
await asyncio.sleep(3600) # 每隔 1 小时检查一次
except Exception as e:
self.logger.bind(tag=TAG).error(f"HTTP服务器启动失败: {e}")
import traceback
self.logger.bind(tag=TAG).error(f"错误堆栈: {traceback.format_exc()}")
raise
@@ -8,8 +8,6 @@ import asyncio
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
@@ -139,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:
@@ -185,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": {
@@ -207,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]: # 音频问题
@@ -227,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:]:
@@ -243,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:
@@ -336,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):
@@ -347,4 +349,11 @@ class ASRProvider(ASRProviderBase):
async def close(self):
"""关闭资源"""
await self._cleanup()
await self._cleanup(None)
if hasattr(self, 'decoder') and self.decoder is not None:
try:
del self.decoder
self.decoder = None
logger.bind(tag=TAG).debug("Aliyun decoder resources released")
except Exception as e:
logger.bind(tag=TAG).debug(f"释放Aliyun decoder资源时出错: {e}")
+57 -83
View File
@@ -9,7 +9,6 @@ import asyncio
import traceback
import threading
import opuslib_next
import concurrent.futures
from abc import ABC, abstractmethod
from config.logger import setup_logging
from typing import Optional, Tuple, List
@@ -53,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)
@@ -241,6 +208,7 @@ class ASRProviderBase(ABC):
@staticmethod
def decode_opus(opus_data: List[bytes]) -> List[bytes]:
"""将Opus音频数据解码为PCM数据"""
decoder = None
try:
decoder = opuslib_next.Decoder(16000, 1)
pcm_data = []
@@ -265,3 +233,9 @@ class ASRProviderBase(ABC):
except Exception as e:
logger.bind(tag=TAG).error(f"音频解码过程发生错误: {e}")
return []
finally:
if decoder is not None:
try:
del decoder
except Exception as e:
logger.bind(tag=TAG).debug(f"释放decoder资源时出错: {e}")
@@ -18,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
@@ -49,6 +47,8 @@ class ASRProvider(ASRProviderBase):
self.channel = config.get("channel", 1)
self.auth_method = config.get("auth_method", "token")
self.secret = config.get("secret", "access_secret")
end_window_size = config.get("end_window_size")
self.end_window_size = int(end_window_size) if end_window_size else 200
async def open_audio_channels(self, conn):
await super().open_audio_channels(conn)
@@ -56,14 +56,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 = []
@@ -179,6 +178,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 = ""
@@ -187,15 +187,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", "未知错误")
@@ -227,8 +256,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:
@@ -236,6 +263,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": {
@@ -252,7 +293,7 @@ class ASRProvider(ASRProviderBase):
"sequence": 1,
"boosting_table_name": self.boosting_table_name,
"correct_table_name": self.correct_table_name,
"end_window_size": 200,
"end_window_size": self.end_window_size,
},
"audio": {
"format": self.format,
@@ -370,6 +411,16 @@ class ASRProvider(ASRProviderBase):
pass
self.forward_task = None
self.is_processing = False
# 显式释放decoder资源
if hasattr(self, 'decoder') and self.decoder is not None:
try:
del self.decoder
self.decoder = None
logger.bind(tag=TAG).debug("Doubao decoder resources released")
except Exception as e:
logger.bind(tag=TAG).debug(f"释放Doubao decoder资源时出错: {e}")
# 清理所有连接的音频缓冲区
if hasattr(self, '_connections'):
for conn in self._connections.values():
@@ -377,5 +428,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
@@ -5,6 +5,7 @@ import hashlib
import asyncio
import websockets
import opuslib_next
import gc
from time import mktime
from datetime import datetime
from urllib.parse import urlencode
@@ -34,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")
@@ -51,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"},
}
@@ -115,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
# 发送当前音频数据
@@ -125,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):
"""开始识别会话"""
@@ -135,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,
@@ -145,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))
# 发送首帧音频
@@ -195,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}")
@@ -235,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
@@ -389,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]):
"""处理语音停止,发送最后一帧并处理识别结果"""
@@ -407,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}")
@@ -436,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:
@@ -482,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):
"""获取识别结果"""
@@ -512,6 +353,16 @@ class ASRProvider(ASRProviderBase):
pass
self.forward_task = None
self.is_processing = False
# 显式释放decoder资源
if hasattr(self, 'decoder') and self.decoder is not None:
try:
del self.decoder
self.decoder = None
logger.bind(tag=TAG).debug("Xunfei decoder resources released")
except Exception as e:
logger.bind(tag=TAG).debug(f"释放Xunfei decoder资源时出错: {e}")
# 清理所有连接的音频缓冲区
if hasattr(self, "_connections"):
for conn in self._connections.values():
@@ -519,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
@@ -24,7 +24,6 @@ class LLMProvider(LLMProviderBase):
"max_tokens": int,
"temperature": lambda x: round(float(x), 1),
"top_p": lambda x: round(float(x), 1),
"top_k": int,
"frequency_penalty": lambda x: round(float(x), 1),
}
@@ -40,7 +39,7 @@ class LLMProvider(LLMProviderBase):
setattr(self, param, None)
logger.debug(
f"意图识别参数初始化: {self.temperature}, {self.max_tokens}, {self.top_p}, {self.top_k}, {self.frequency_penalty}"
f"意图识别参数初始化: {self.temperature}, {self.max_tokens}, {self.top_p}, {self.frequency_penalty}"
)
model_key_msg = check_model_key("LLM", self.api_key)
@@ -71,7 +70,6 @@ class LLMProvider(LLMProviderBase):
"max_tokens": kwargs.get("max_tokens", self.max_tokens),
"temperature": kwargs.get("temperature", self.temperature),
"top_p": kwargs.get("top_p", self.top_p),
"top_k": kwargs.get("top_k", self.top_k),
"frequency_penalty": kwargs.get("frequency_penalty", self.frequency_penalty),
}
@@ -116,7 +114,6 @@ class LLMProvider(LLMProviderBase):
"max_tokens": kwargs.get("max_tokens", self.max_tokens),
"temperature": kwargs.get("temperature", self.temperature),
"top_p": kwargs.get("top_p", self.top_p),
"top_k": kwargs.get("top_k", self.top_k),
"frequency_penalty": kwargs.get("frequency_penalty", self.frequency_penalty),
}
@@ -14,7 +14,7 @@ class MemoryProviderBase(ABC):
self.llm = llm
@abstractmethod
async def save_memory(self, msgs):
async def save_memory(self, msgs, session_id=None):
"""Save a new memory for specific role and return memory ID"""
print("this is base func", msgs)
@@ -28,7 +28,7 @@ class MemoryProvider(MemoryProviderBase):
logger.bind(tag=TAG).error(f"详细错误: {traceback.format_exc()}")
self.use_mem0 = False
async def save_memory(self, msgs):
async def save_memory(self, msgs, session_id=None):
if not self.use_mem0:
return None
if len(msgs) < 2:
@@ -41,9 +41,7 @@ class MemoryProvider(MemoryProviderBase):
for message in msgs
if message.role != "system"
]
result = self.client.add(
messages, user_id=self.role_id
)
result = self.client.add(messages, user_id=self.role_id)
logger.bind(tag=TAG).debug(f"Save memory result: {result}")
except Exception as e:
logger.bind(tag=TAG).error(f"保存记忆失败: {str(e)}")
@@ -4,7 +4,8 @@ import json
import os
import yaml
from config.config_loader import get_project_dir
from config.manage_api_client import save_mem_local_short
from config.manage_api_client import generate_and_save_chat_summary
import asyncio
from core.utils.util import check_model_key
@@ -74,18 +75,6 @@ short_term_memory_prompt = """
```
"""
short_term_memory_prompt_only_content = """
你是一个经验丰富的记忆总结者,擅长将对话内容进行总结摘要,遵循以下规则:
1、总结user的重要信息,以便在未来的对话中提供更个性化的服务
2、不要重复总结,不要遗忘之前记忆,除非原来的记忆超过了1800字内,否则不要遗忘、不要压缩用户的历史记忆
3、用户操控的设备音量、播放音乐、天气、退出、不想对话等和用户本身无关的内容,这些信息不需要加入到总结中
4、聊天内容中的今天的日期时间、今天的天气情况与用户事件无关的数据,这些信息如果当成记忆存储会影响后序对话,这些信息不需要加入到总结中
5、不要把设备操控的成果结果和失败结果加入到总结中,也不要把用户的一些废话加入到总结中
6、不要为了总结而总结,如果用户的聊天没有意义,请返回原来的历史记录也是可以的
7、只需要返回总结摘要,严格控制在1800字内
8、不要包含代码、xml,不需要解释、注释和说明,保存记忆时仅从对话提取信息,不要混入示例内容
"""
def extract_json_data(json_code):
start = json_code.find("```json")
@@ -143,7 +132,7 @@ class MemoryProvider(MemoryProviderBase):
with open(self.memory_path, "w", encoding="utf-8") as f:
yaml.dump(all_memory, f, allow_unicode=True)
async def save_memory(self, msgs):
async def save_memory(self, msgs, session_id=None):
# 打印使用的模型信息
model_info = getattr(self.llm, "model_name", str(self.llm.__class__.__name__))
logger.bind(tag=TAG).debug(f"使用记忆保存模型: {model_info}")
@@ -187,14 +176,12 @@ class MemoryProvider(MemoryProviderBase):
except Exception as e:
print("Error:", e)
else:
result = self.llm.response_no_stream(
short_term_memory_prompt_only_content,
msgStr,
max_tokens=2000,
temperature=0.2,
)
save_mem_local_short(self.role_id, result)
logger.bind(tag=TAG).info(f"Save memory successful - Role: {self.role_id}")
# 当save_to_file为False时,调用Java端的聊天记录总结接口
summary_id = session_id if session_id else self.role_id
await generate_and_save_chat_summary(summary_id)
logger.bind(tag=TAG).info(
f"Save memory successful - Role: {self.role_id}, Session: {session_id}"
)
return self.short_memory
@@ -11,7 +11,7 @@ class MemoryProvider(MemoryProviderBase):
def __init__(self, config, summary_memory=None):
super().__init__(config)
async def save_memory(self, msgs):
async def save_memory(self, msgs, session_id=None):
logger.bind(tag=TAG).debug("nomem mode: No memory saving is performed.")
return None
@@ -3,12 +3,8 @@
import asyncio
import os
import json
from datetime import timedelta
from typing import Dict, Any, List
from mcp import Implementation
from mcp.client.session import SamplingFnT, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT
from mcp.shared.session import ProgressFnT
from mcp.types import LoggingMessageNotificationParams
from config.config_loader import get_project_dir
@@ -33,6 +29,7 @@ class ServerMCPManager:
)
self.clients: Dict[str, ServerMCPClient] = {}
self.tools = []
self._init_lock = asyncio.Lock()
def load_config(self) -> Dict[str, Any]:
"""加载MCP服务配置"""
@@ -49,29 +46,50 @@ class ServerMCPManager:
)
return {}
async def _init_server(self, name: str, srv_config: Dict[str, Any]):
"""初始化单个MCP服务"""
client = None
try:
# 初始化服务端MCP客户端
logger.bind(tag=TAG).info(f"初始化服务端MCP客户端: {name}")
client = ServerMCPClient(srv_config)
# 设置超时时间10秒
await asyncio.wait_for(client.initialize(logging_callback=self.logging_callback), timeout=10)
# 使用锁保护共享状态的修改
async with self._init_lock:
self.clients[name] = client
client_tools = client.get_available_tools()
self.tools.extend(client_tools)
except asyncio.TimeoutError:
logger.bind(tag=TAG).error(
f"Failed to initialize MCP server {name}: Timeout"
)
if client:
await client.cleanup()
except Exception as e:
logger.bind(tag=TAG).error(
f"Failed to initialize MCP server {name}: {e}"
)
if client:
await client.cleanup()
async def initialize_servers(self) -> None:
"""初始化所有MCP服务"""
config = self.load_config()
tasks = []
for name, srv_config in config.items():
if not srv_config.get("command") and not srv_config.get("url"):
logger.bind(tag=TAG).warning(
f"Skipping server {name}: neither command nor url specified"
)
continue
try:
# 初始化服务端MCP客户端
logger.bind(tag=TAG).info(f"初始化服务端MCP客户端: {name}")
client = ServerMCPClient(srv_config)
await client.initialize(logging_callback=self.logging_callback)
self.clients[name] = client
client_tools = client.get_available_tools()
self.tools.extend(client_tools)
except Exception as e:
logger.bind(tag=TAG).error(
f"Failed to initialize MCP server {name}: {e}"
)
tasks.append(self._init_server(name, srv_config))
if tasks:
await asyncio.gather(*tasks)
# 输出当前支持的服务端MCP工具列表
if hasattr(self.conn, "func_handler") and self.conn.func_handler:
@@ -36,7 +36,18 @@ class VADProvider(VADProviderBase):
# 至少要多少帧才算有语音
self.frame_window_threshold = 3
def __del__(self):
if hasattr(self, 'decoder') and self.decoder is not None:
try:
del self.decoder
except Exception:
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) # 将新数据加入缓冲区
@@ -0,0 +1,160 @@
import time
import asyncio
from collections import deque
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class AudioRateController:
"""
音频速率控制器 - 按照60ms帧时长精确控制音频发送
解决高并发下的时间累积误差问题
"""
def __init__(self, frame_duration=60):
"""
Args:
frame_duration: 单个音频帧时长(毫秒),默认60ms
"""
self.frame_duration = frame_duration
self.queue = deque()
self.play_position = 0 # 虚拟播放位置(毫秒)
self.start_timestamp = None # 开始时间戳(只读,不修改)
self.pending_send_task = None
self.logger = logger
self.queue_empty_event = asyncio.Event() # 队列清空事件
self.queue_empty_event.set() # 初始为空状态
self.queue_has_data_event = asyncio.Event() # 队列数据事件
def reset(self):
"""重置控制器状态"""
if self.pending_send_task and not self.pending_send_task.done():
self.pending_send_task.cancel()
# 取消任务后,任务会在下次事件循环时清理,无需阻塞等待
self.queue.clear()
self.play_position = 0
self.start_timestamp = None # 由首个音频包设置
# 相关事件处理
self.queue_empty_event.set()
self.queue_has_data_event.clear()
def add_audio(self, opus_packet):
"""添加音频包到队列"""
self.queue.append(("audio", opus_packet))
# 相关事件处理
self.queue_empty_event.clear()
self.queue_has_data_event.set()
def add_message(self, message_callback):
"""
添加消息到队列(立即发送,不占用播放时间)
Args:
message_callback: 消息发送回调函数 async def()
"""
self.queue.append(("message", message_callback))
# 相关事件处理
self.queue_empty_event.clear()
self.queue_has_data_event.set()
def _get_elapsed_ms(self):
"""获取已经过的时间(毫秒)"""
if self.start_timestamp is None:
return 0
return (time.monotonic() - self.start_timestamp) * 1000
async def check_queue(self, send_audio_callback):
"""
检查队列并按时发送音频/消息
Args:
send_audio_callback: 发送音频的回调函数 async def(opus_packet)
"""
while self.queue:
item = self.queue[0]
item_type = item[0]
if item_type == "message":
# 消息类型:立即发送,不占用播放时间
_, message_callback = item
self.queue.popleft()
try:
await message_callback()
except Exception as e:
self.logger.bind(tag=TAG).error(f"发送消息失败: {e}")
raise
elif item_type == "audio":
if self.start_timestamp is None:
self.start_timestamp = time.monotonic()
_, opus_packet = item
# 循环等待直到时间到达
while True:
# 计算时间差
elapsed_ms = self._get_elapsed_ms()
output_ms = self.play_position
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
# 等待结束后重新检查时间(循环回到 while True)
else:
# 时间已到,跳出等待循环
break
# 时间已到,从队列移除并发送
self.queue.popleft()
self.play_position += self.frame_duration
try:
await send_audio_callback(opus_packet)
except Exception as e:
self.logger.bind(tag=TAG).error(f"发送音频失败: {e}")
raise
# 队列处理完后清除事件
self.queue_empty_event.set()
self.queue_has_data_event.clear()
def start_sending(self, send_audio_callback):
"""
启动异步发送任务
Args:
send_audio_callback: 发送音频的回调函数
Returns:
asyncio.Task: 发送任务
"""
async def _send_loop():
try:
while True:
# 等待队列数据事件,不轮询等待占用CPU
await self.queue_has_data_event.wait()
await self.check_queue(send_audio_callback)
except asyncio.CancelledError:
self.logger.bind(tag=TAG).debug("音频发送循环已停止")
except Exception as e:
self.logger.bind(tag=TAG).error(f"音频发送循环异常: {e}")
self.pending_send_task = asyncio.create_task(_send_loop())
return self.pending_send_task
def stop_sending(self):
"""停止发送任务"""
if self.pending_send_task and not self.pending_send_task.done():
self.pending_send_task.cancel()
self.logger.bind(tag=TAG).debug("已取消音频发送任务")
+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

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