diff --git a/main/manager-api/pom.xml b/main/manager-api/pom.xml index b3798571..5981f4b2 100644 --- a/main/manager-api/pom.xml +++ b/main/manager-api/pom.xml @@ -32,6 +32,7 @@ 4.20.0 4.1.0 3.4.0 + true @@ -275,7 +276,7 @@ org.apache.maven.plugins maven-surefire-plugin - true + ${skipTests} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/Enums/AgentSnapshotField.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/Enums/AgentSnapshotField.java new file mode 100644 index 00000000..166b76fe --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/Enums/AgentSnapshotField.java @@ -0,0 +1,103 @@ +package xiaozhi.modules.agent.Enums; + +import java.util.Arrays; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import lombok.Getter; +import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO; +import xiaozhi.modules.agent.dto.AgentUpdateDTO; +import xiaozhi.modules.agent.entity.AgentEntity; + +@Getter +public enum AgentSnapshotField { + AGENT_CODE("agentCode", AgentSnapshotDataDTO::getAgentCode, AgentUpdateDTO::getAgentCode, + (agent, data) -> agent.setAgentCode(data.getAgentCode())), + AGENT_NAME("agentName", AgentSnapshotDataDTO::getAgentName, AgentUpdateDTO::getAgentName, + (agent, data) -> agent.setAgentName(data.getAgentName())), + ASR_MODEL_ID("asrModelId", AgentSnapshotDataDTO::getAsrModelId, AgentUpdateDTO::getAsrModelId, + (agent, data) -> agent.setAsrModelId(data.getAsrModelId())), + VAD_MODEL_ID("vadModelId", AgentSnapshotDataDTO::getVadModelId, AgentUpdateDTO::getVadModelId, + (agent, data) -> agent.setVadModelId(data.getVadModelId())), + LLM_MODEL_ID("llmModelId", AgentSnapshotDataDTO::getLlmModelId, AgentUpdateDTO::getLlmModelId, + (agent, data) -> agent.setLlmModelId(data.getLlmModelId())), + SLM_MODEL_ID("slmModelId", AgentSnapshotDataDTO::getSlmModelId, AgentUpdateDTO::getSlmModelId, + (agent, data) -> agent.setSlmModelId(data.getSlmModelId())), + VLLM_MODEL_ID("vllmModelId", AgentSnapshotDataDTO::getVllmModelId, AgentUpdateDTO::getVllmModelId, + (agent, data) -> agent.setVllmModelId(data.getVllmModelId())), + TTS_MODEL_ID("ttsModelId", AgentSnapshotDataDTO::getTtsModelId, AgentUpdateDTO::getTtsModelId, + (agent, data) -> agent.setTtsModelId(data.getTtsModelId())), + TTS_VOICE_ID("ttsVoiceId", AgentSnapshotDataDTO::getTtsVoiceId, AgentUpdateDTO::getTtsVoiceId, + (agent, data) -> agent.setTtsVoiceId(data.getTtsVoiceId())), + TTS_LANGUAGE("ttsLanguage", AgentSnapshotDataDTO::getTtsLanguage, AgentUpdateDTO::getTtsLanguage, + (agent, data) -> agent.setTtsLanguage(data.getTtsLanguage())), + TTS_VOLUME("ttsVolume", AgentSnapshotDataDTO::getTtsVolume, AgentUpdateDTO::getTtsVolume, + (agent, data) -> agent.setTtsVolume(data.getTtsVolume())), + TTS_RATE("ttsRate", AgentSnapshotDataDTO::getTtsRate, AgentUpdateDTO::getTtsRate, + (agent, data) -> agent.setTtsRate(data.getTtsRate())), + TTS_PITCH("ttsPitch", AgentSnapshotDataDTO::getTtsPitch, AgentUpdateDTO::getTtsPitch, + (agent, data) -> agent.setTtsPitch(data.getTtsPitch())), + MEM_MODEL_ID("memModelId", AgentSnapshotDataDTO::getMemModelId, AgentUpdateDTO::getMemModelId, + (agent, data) -> agent.setMemModelId(data.getMemModelId())), + INTENT_MODEL_ID("intentModelId", AgentSnapshotDataDTO::getIntentModelId, AgentUpdateDTO::getIntentModelId, + (agent, data) -> agent.setIntentModelId(data.getIntentModelId())), + CHAT_HISTORY_CONF("chatHistoryConf", AgentSnapshotDataDTO::getChatHistoryConf, AgentUpdateDTO::getChatHistoryConf, + (agent, data) -> agent.setChatHistoryConf(data.getChatHistoryConf())), + SYSTEM_PROMPT("systemPrompt", AgentSnapshotDataDTO::getSystemPrompt, AgentUpdateDTO::getSystemPrompt, + (agent, data) -> agent.setSystemPrompt(data.getSystemPrompt())), + SUMMARY_MEMORY("summaryMemory", AgentSnapshotDataDTO::getSummaryMemory, AgentUpdateDTO::getSummaryMemory, + (agent, data) -> agent.setSummaryMemory(data.getSummaryMemory())), + LANG_CODE("langCode", AgentSnapshotDataDTO::getLangCode, AgentUpdateDTO::getLangCode, + (agent, data) -> agent.setLangCode(data.getLangCode())), + LANGUAGE("language", AgentSnapshotDataDTO::getLanguage, AgentUpdateDTO::getLanguage, + (agent, data) -> agent.setLanguage(data.getLanguage())), + SORT("sort", AgentSnapshotDataDTO::getSort, AgentUpdateDTO::getSort, + (agent, data) -> agent.setSort(data.getSort())), + FUNCTIONS("functions", AgentSnapshotDataDTO::getFunctions, AgentUpdateDTO::getFunctions, null), + CONTEXT_PROVIDERS("contextProviders", AgentSnapshotDataDTO::getContextProviders, + AgentUpdateDTO::getContextProviders, null), + CORRECT_WORD_FILE_IDS("correctWordFileIds", AgentSnapshotDataDTO::getCorrectWordFileIds, + AgentUpdateDTO::getCorrectWordFileIds, null), + TAG_NAMES("tagNames", AgentSnapshotDataDTO::getTagNames, AgentUpdateDTO::getTagNames, null); + + private final String fieldName; + private final Function snapshotGetter; + private final Function updateGetter; + private final BiConsumer restoreApplier; + + AgentSnapshotField(String fieldName, Function snapshotGetter, + Function updateGetter, + BiConsumer restoreApplier) { + this.fieldName = fieldName; + this.snapshotGetter = snapshotGetter; + this.updateGetter = updateGetter; + this.restoreApplier = restoreApplier; + } + + public static List names() { + return Arrays.stream(values()).map(AgentSnapshotField::getFieldName).toList(); + } + + public static String canonicalName(String fieldName) { + return "tags".equals(fieldName) ? TAG_NAMES.getFieldName() : fieldName; + } + + public Object snapshotValue(AgentSnapshotDataDTO data) { + return data == null ? null : snapshotGetter.apply(data); + } + + public Object updateValue(AgentUpdateDTO data) { + return data == null ? null : updateGetter.apply(data); + } + + public boolean isRestorableAgentField() { + return restoreApplier != null; + } + + public void applyTo(AgentEntity agent, AgentSnapshotDataDTO data) { + if (restoreApplier != null) { + restoreApplier.accept(agent, data); + } + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java index 429db8ee..407a3e45 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java @@ -342,7 +342,10 @@ public class AgentController { requireAgentPermission(id); List tagIds = (List) params.get("tagIds"); List tagNames = (List) params.get("tagNames"); - agentTagService.saveAgentTags(id, tagIds, tagNames); + AgentUpdateDTO dto = new AgentUpdateDTO(); + dto.setTagIds(tagIds); + dto.setTagNames(tagNames); + agentService.updateAgentById(id, dto); return new Result().ok(null); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java new file mode 100644 index 00000000..255e606d --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java @@ -0,0 +1,75 @@ +package xiaozhi.modules.agent.controller; + +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import xiaozhi.common.exception.RenException; +import xiaozhi.common.page.PageData; +import xiaozhi.common.user.UserDetail; +import xiaozhi.common.utils.Result; +import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO; +import xiaozhi.modules.agent.service.AgentService; +import xiaozhi.modules.agent.service.AgentSnapshotService; +import xiaozhi.modules.agent.vo.AgentSnapshotVO; +import xiaozhi.modules.security.user.SecurityUser; + +@Tag(name = "智能体快照") +@AllArgsConstructor +@RestController +@RequestMapping("/agent/{agentId}/snapshots") +public class AgentSnapshotController { + private final AgentSnapshotService agentSnapshotService; + private final AgentService agentService; + + @GetMapping + @Operation(summary = "获取智能体快照列表") + @RequiresPermissions("sys:role:normal") + public Result> page( + @PathVariable String agentId, + @ParameterObject AgentSnapshotPageDTO params) { + checkPermission(agentId); + return new Result>().ok(agentSnapshotService.page(agentId, params)); + } + + @GetMapping("/{snapshotId}") + @Operation(summary = "获取智能体快照详情") + @RequiresPermissions("sys:role:normal") + public Result getSnapshot(@PathVariable String agentId, @PathVariable String snapshotId) { + checkPermission(agentId); + return new Result().ok(agentSnapshotService.getSnapshot(agentId, snapshotId)); + } + + @PostMapping("/{snapshotId}/restore") + @Operation(summary = "恢复智能体快照") + @RequiresPermissions("sys:role:normal") + public Result restore(@PathVariable String agentId, @PathVariable String snapshotId) { + checkPermission(agentId); + agentSnapshotService.restoreSnapshot(agentId, snapshotId); + return new Result<>(); + } + + @DeleteMapping("/{snapshotId}") + @Operation(summary = "删除智能体历史快照") + @RequiresPermissions("sys:role:normal") + public Result deleteSnapshot(@PathVariable String agentId, @PathVariable String snapshotId) { + checkPermission(agentId); + agentSnapshotService.deleteSnapshot(agentId, snapshotId); + return new Result<>(); + } + + private void checkPermission(String agentId) { + UserDetail user = SecurityUser.getUser(); + if (user == null || !agentService.checkAgentPermission(agentId, user.getId())) { + throw new RenException("没有权限访问该智能体快照"); + } + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java index 03e569cd..4def2b67 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java @@ -36,4 +36,11 @@ public interface AgentDao extends BaseDao { * @param agentId 智能体ID */ AgentInfoVO selectAgentInfoById(@Param("agentId") String agentId); + + /** + * 锁定智能体主记录,用于串行化同一智能体的配置写入 + * + * @param agentId 智能体ID + */ + AgentEntity selectByIdForUpdate(@Param("agentId") String agentId); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java new file mode 100644 index 00000000..ee17f15f --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java @@ -0,0 +1,20 @@ +package xiaozhi.modules.agent.dao; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import xiaozhi.common.dao.BaseDao; +import xiaozhi.modules.agent.entity.AgentSnapshotEntity; + +@Mapper +public interface AgentSnapshotDao extends BaseDao { + Integer selectMaxVersionNo(@Param("agentId") String agentId); + + AgentSnapshotEntity selectLatestSnapshot(@Param("agentId") String agentId); + + AgentSnapshotEntity selectNextSnapshot(@Param("agentId") String agentId, @Param("versionNo") Integer versionNo); + + int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot); + + int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit); +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotDataDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotDataDTO.java new file mode 100644 index 00000000..328c73cf --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotDataDTO.java @@ -0,0 +1,40 @@ +package xiaozhi.modules.agent.dto; + +import java.io.Serializable; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "智能体快照数据") +public class AgentSnapshotDataDTO implements Serializable { + private static final long serialVersionUID = 1L; + + private String agentCode; + private String agentName; + private String asrModelId; + private String vadModelId; + private String llmModelId; + private String slmModelId; + private String vllmModelId; + private String ttsModelId; + private String ttsVoiceId; + private String ttsLanguage; + private Integer ttsVolume; + private Integer ttsRate; + private Integer ttsPitch; + private String memModelId; + private String intentModelId; + private Integer chatHistoryConf; + private String systemPrompt; + private String summaryMemory; + private String langCode; + private String language; + private Integer sort; + private List functions; + private List contextProviders; + private List correctWordFileIds; + private List tagNames; + private List tags; +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotPageDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotPageDTO.java new file mode 100644 index 00000000..3f3f5d65 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotPageDTO.java @@ -0,0 +1,28 @@ +package xiaozhi.modules.agent.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "智能体快照分页查询参数") +public class AgentSnapshotPageDTO { + @Schema(description = "当前页码,从1开始", example = "1") + private Integer page = 1; + + @Schema(description = "每页数量", example = "10") + private Integer limit = 10; + + @Schema(description = "版本锚点,只查询小于等于该版本号的历史快照", example = "20") + private Integer maxVersionNo; + + public int pageOrDefault() { + return page == null || page < 1 ? 1 : page; + } + + public int limitOrDefault() { + if (limit == null || limit < 1) { + return 10; + } + return limit; + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotTagDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotTagDTO.java new file mode 100644 index 00000000..6e93dd0e --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotTagDTO.java @@ -0,0 +1,16 @@ +package xiaozhi.modules.agent.dto; + +import java.io.Serializable; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "智能体快照标签") +public class AgentSnapshotTagDTO implements Serializable { + private static final long serialVersionUID = 1L; + + private String id; + private String tagName; + private Integer sort; +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java index f2053ac5..7c939630 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentUpdateDTO.java @@ -4,9 +4,12 @@ import java.io.Serializable; import java.math.BigDecimal; import java.util.HashMap; import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.core.type.TypeReference; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import xiaozhi.common.utils.JsonUtils; /** * 智能体更新DTO @@ -91,15 +94,50 @@ public class AgentUpdateDTO implements Serializable { @Schema(description = "替换词文件ID列表", nullable = true) private List correctWordFileIds; + @Schema(description = "标签名称列表", nullable = true) + private List tagNames; + + @Schema(description = "标签ID列表", nullable = true) + private List tagIds; + @Data @Schema(description = "插件函数信息") public static class FunctionInfo implements Serializable { + private static final TypeReference> PARAM_INFO_TYPE = new TypeReference<>() { + }; + @Schema(description = "插件ID", example = "plugin_01") private String pluginId; @Schema(description = "函数参数信息", nullable = true) - private HashMap paramInfo; + private HashMap paramInfo = new HashMap<>(); + + public void setParamInfo(Object paramInfo) { + this.paramInfo = normalizeParamInfo(paramInfo); + } + + private static HashMap normalizeParamInfo(Object paramInfo) { + if (paramInfo == null) { + return new HashMap<>(); + } + if (paramInfo instanceof String value) { + if (value.trim().isEmpty()) { + return new HashMap<>(); + } + return JsonUtils.parseObject(value, PARAM_INFO_TYPE); + } + if (paramInfo instanceof Map value) { + HashMap normalized = new HashMap<>(); + value.forEach((key, val) -> { + if (key != null) { + normalized.put(String.valueOf(key), val); + } + }); + return normalized; + } + return JsonUtils.parseObject(JsonUtils.toJsonString(paramInfo), PARAM_INFO_TYPE); + } private static final long serialVersionUID = 1L; } -} \ No newline at end of file +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentContextProviderEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentContextProviderEntity.java index 937556ef..4269b70f 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentContextProviderEntity.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentContextProviderEntity.java @@ -7,11 +7,11 @@ 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; +import xiaozhi.modules.agent.typehandler.ContextProviderListTypeHandler; @Data @TableName(value = "ai_agent_context_provider", autoResultMap = true) @@ -26,7 +26,7 @@ public class AgentContextProviderEntity { private String agentId; @Schema(description = "上下文源配置") - @TableField(typeHandler = JacksonTypeHandler.class) + @TableField(typeHandler = ContextProviderListTypeHandler.class) private List contextProviders; @Schema(description = "创建者") diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentSnapshotEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentSnapshotEntity.java new file mode 100644 index 00000000..0d99fd36 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentSnapshotEntity.java @@ -0,0 +1,50 @@ +package xiaozhi.modules.agent.entity; + +import java.util.Date; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@TableName("ai_agent_snapshot") +@Schema(description = "智能体配置快照") +public class AgentSnapshotEntity { + + @TableId(type = IdType.ASSIGN_UUID) + @Schema(description = "快照ID") + private String id; + + @Schema(description = "智能体ID") + private String agentId; + + @Schema(description = "所属用户ID") + private Long userId; + + @Schema(description = "版本号") + private Integer versionNo; + + @Schema(description = "快照数据JSON") + private String snapshotData; + + @Schema(description = "变更字段JSON") + private String changedFields; + + @Schema(description = "快照来源") + private String source; + + @Schema(description = "恢复来源快照ID") + private String restoreFromSnapshotId; + + @Schema(description = "恢复来源版本号") + private Integer restoreFromVersionNo; + + @Schema(description = "创建者") + private Long creator; + + @Schema(description = "创建时间") + private Date createdAt; +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java index 33fc7659..2ccf12dc 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java @@ -60,6 +60,13 @@ public interface AgentService extends BaseService { */ void deleteAgentByUserId(Long userId); + /** + * 删除智能体及其关联数据 + * + * @param agentId 智能体ID + */ + void deleteAgent(String agentId); + /** * 获取用户智能体列表 * @@ -129,6 +136,15 @@ public interface AgentService extends BaseService { */ void deleteAgentById(String agentId, Long userId); + /** + * 更新智能体 + * + * @param agentId 智能体ID + * @param dto 更新智能体所需的信息 + * @param createSnapshot 是否创建配置快照 + */ + void updateAgentById(String agentId, AgentUpdateDTO dto, boolean createSnapshot); + /** * 创建智能体 * diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java new file mode 100644 index 00000000..f88c47ea --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java @@ -0,0 +1,23 @@ +package xiaozhi.modules.agent.service; + +import xiaozhi.common.page.PageData; +import xiaozhi.common.service.BaseService; +import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO; +import xiaozhi.modules.agent.entity.AgentSnapshotEntity; +import xiaozhi.modules.agent.vo.AgentSnapshotVO; + +public interface AgentSnapshotService extends BaseService { + void createSnapshot(String agentId, String source); + + PageData page(String agentId, AgentSnapshotPageDTO params); + + AgentSnapshotVO getSnapshot(String agentId, String snapshotId); + + void restoreSnapshot(String agentId, String snapshotId); + + void deleteSnapshot(String agentId, String snapshotId); + + Integer getCurrentVersionNo(String agentId); + + void deleteByAgentId(String agentId); +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java index a3b78b18..09cbf062 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentChatSummaryServiceImpl.java @@ -118,7 +118,7 @@ public class AgentChatSummaryServiceImpl implements AgentChatSummaryService { { setSummaryMemory(summaryDTO.getSummary()); } - }); + }, false); log.info("成功保存会话 {} 的聊天记录总结到智能体 {}", sessionId, agentId); } else { log.info("生成总结失败: {}", summaryDTO.getErrorMessage()); @@ -518,4 +518,4 @@ public class AgentChatSummaryServiceImpl implements AgentChatSummaryService { return null; } } -} \ No newline at end of file +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java index cdf7cd0a..3ad213df 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java @@ -15,7 +15,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import lombok.AllArgsConstructor; @@ -44,9 +43,10 @@ import xiaozhi.modules.agent.entity.AgentTagEntity; 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.AgentTagService; +import xiaozhi.modules.agent.service.AgentPluginMappingService; +import xiaozhi.modules.agent.service.AgentService; +import xiaozhi.modules.agent.service.AgentSnapshotService; +import xiaozhi.modules.agent.service.AgentTagService; import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.agent.vo.AgentInfoVO; import xiaozhi.modules.correctword.service.CorrectWordFileService; @@ -74,9 +74,10 @@ public class AgentServiceImpl extends BaseServiceImpl imp private final AgentChatHistoryService agentChatHistoryService; private final AgentTemplateService agentTemplateService; private final ModelProviderService modelProviderService; - private final AgentContextProviderService agentContextProviderService; - private final AgentTagService agentTagService; - private final CorrectWordFileService correctWordFileService; + private final AgentContextProviderService agentContextProviderService; + private final AgentTagService agentTagService; + private final CorrectWordFileService correctWordFileService; + private final AgentSnapshotService agentSnapshotService; @Override public PageData adminAgentList(Map params) { @@ -108,12 +109,13 @@ public class AgentServiceImpl extends BaseServiceImpl imp agent.setContextProviders(contextProviderEntity.getContextProviders()); } - // 查询替换词文件ID列表 - List correctWordFileIds = correctWordFileService.getAgentCorrectWordFileIds(id); - agent.setCorrectWordFileIds(correctWordFileIds); - - // 无需额外查询插件列表,已通过SQL查询出来 - return agent; + // 查询替换词文件ID列表 + List correctWordFileIds = correctWordFileService.getAgentCorrectWordFileIds(id); + agent.setCorrectWordFileIds(correctWordFileIds); + agent.setCurrentVersionNo(agentSnapshotService.getCurrentVersionNo(id)); + + // 无需额外查询插件列表,已通过SQL查询出来 + return agent; } @Override @@ -195,15 +197,35 @@ public class AgentServiceImpl extends BaseServiceImpl imp return super.insert(entity); } - @Override - public void deleteAgentByUserId(Long userId) { - UpdateWrapper wrapper = new UpdateWrapper<>(); - wrapper.eq("user_id", userId); - baseDao.delete(wrapper); - } - - @Override - public List getUserAgents(Long userId, String keyword, String searchType) { + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteAgentByUserId(Long userId) { + List agents = baseDao.selectList(new QueryWrapper() + .select("id") + .eq("user_id", userId)); + for (AgentEntity agent : agents) { + deleteAgent(agent.getId()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteAgent(String agentId) { + if (agentDao.selectByIdForUpdate(agentId) == null) { + return; + } + deviceService.deleteByAgentId(agentId); + agentChatHistoryService.deleteByAgentId(agentId, true, true); + agentPluginMappingService.deleteByAgentId(agentId); + agentContextProviderService.deleteByAgentId(agentId); + correctWordFileService.deleteMappingsByAgentId(agentId); + agentTagService.deleteAgentTags(agentId); + agentSnapshotService.deleteByAgentId(agentId); + deleteById(agentId); + } + + @Override + public List getUserAgents(Long userId, String keyword, String searchType) { QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("user_id", userId).orderByDesc("created_at"); @@ -314,36 +336,55 @@ public class AgentServiceImpl extends BaseServiceImpl imp } @Override - public boolean checkAgentPermission(String agentId, Long userId) { + public boolean checkAgentPermission(String agentId, Long userId) { AgentEntity agent = agentDao.selectById(agentId); return hasAgentPermission(agent, userId); - } - - // 根据id更新智能体信息 - @Override - @Transactional(rollbackFor = Exception.class) - public void updateAgentById(String agentId, AgentUpdateDTO dto) { - AgentEntity existingEntity = getAgentEntityOrThrow(agentId); - requireCurrentUserPermissionIfPresent(existingEntity); - updateAgentEntity(agentId, dto, existingEntity); + } + + // 根据id更新智能体信息 + @Override + @Transactional(rollbackFor = Exception.class) + public void updateAgentById(String agentId, AgentUpdateDTO dto) { + updateAgentById(agentId, dto, true); } @Override @Transactional(rollbackFor = Exception.class) public void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId) { - AgentEntity existingEntity = getAgentEntityOrThrow(agentId); - requireAgentPermission(existingEntity, userId); - updateAgentEntity(agentId, dto, existingEntity); + updateAgentById(agentId, dto, userId, true); } - private void updateAgentEntity(String agentId, AgentUpdateDTO dto, AgentEntity existingEntity) { - // 只更新提供的非空字段 - if (dto.getAgentName() != null) { - existingEntity.setAgentName(dto.getAgentName()); - } - if (dto.getAgentCode() != null) { - existingEntity.setAgentCode(dto.getAgentCode()); - } + // 根据id更新智能体信息 + @Override + @Transactional(rollbackFor = Exception.class) + public void updateAgentById(String agentId, AgentUpdateDTO dto, boolean createSnapshot) { + updateAgentById(agentId, dto, null, createSnapshot); + } + + private void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId, boolean createSnapshot) { + AgentEntity lockedAgent = agentDao.selectByIdForUpdate(agentId); + if (lockedAgent == null) { + throw new RenException(ErrorCode.AGENT_NOT_FOUND); + } + if (userId == null) { + requireCurrentUserPermissionIfPresent(lockedAgent); + } else { + requireAgentPermission(lockedAgent, userId); + } + + // 锁定后查询现有实体和关联配置 + AgentEntity existingEntity = this.getAgentById(agentId); + if (createSnapshot && agentSnapshotService.getCurrentVersionNo(agentId) == 0) { + agentSnapshotService.createSnapshot(agentId, "initial"); + } + + // 只更新提供的非空字段 + if (dto.getAgentName() != null) { + existingEntity.setAgentName(dto.getAgentName()); + } + if (dto.getAgentCode() != null) { + existingEntity.setAgentCode(dto.getAgentCode()); + } if (dto.getAsrModelId() != null) { existingEntity.setAsrModelId(dto.getAsrModelId()); } @@ -481,16 +522,24 @@ public class AgentServiceImpl extends BaseServiceImpl imp } // 更新替换词文件关联 - if (dto.getCorrectWordFileIds() != null) { - correctWordFileService.saveAgentCorrectWords(agentId, dto.getCorrectWordFileIds()); - } - - boolean b = validateLLMIntentParams(dto.getLlmModelId(), dto.getIntentModelId()); - if (!b) { - throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH); - } - this.updateById(existingEntity); - } + if (dto.getCorrectWordFileIds() != null) { + correctWordFileService.saveAgentCorrectWords(agentId, dto.getCorrectWordFileIds()); + } + + // 更新智能体标签 + if (dto.getTagNames() != null || dto.getTagIds() != null) { + agentTagService.saveAgentTags(agentId, dto.getTagIds(), dto.getTagNames()); + } + + boolean b = validateLLMIntentParams(existingEntity.getLlmModelId(), existingEntity.getIntentModelId()); + if (!b) { + throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH); + } + this.updateById(existingEntity); + if (createSnapshot) { + agentSnapshotService.createSnapshot(agentId, "config"); + } + } @Override @Transactional(rollbackFor = Exception.class) @@ -504,7 +553,7 @@ public class AgentServiceImpl extends BaseServiceImpl imp AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO(); agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory()); - updateAgentById(device.getAgentId(), agentUpdateDTO, userId); + updateAgentById(device.getAgentId(), agentUpdateDTO, userId, false); } @Override @@ -512,19 +561,7 @@ public class AgentServiceImpl extends BaseServiceImpl imp public void deleteAgentById(String agentId, Long userId) { AgentEntity agent = getAgentEntityOrThrow(agentId); requireAgentPermission(agent, userId); - - // 先删除关联的设备 - deviceService.deleteByAgentId(agentId); - // 删除关联的聊天记录 - agentChatHistoryService.deleteByAgentId(agentId, true, true); - // 删除关联的插件 - agentPluginMappingService.deleteByAgentId(agentId); - // 删除关联的上下文源配置 - agentContextProviderService.deleteByAgentId(agentId); - // 删除关联的替换词文件关联记录 - correctWordFileService.deleteMappingsByAgentId(agentId); - // 再删除智能体 - baseDao.deleteById(agentId); + deleteAgent(agentId); } /** diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java new file mode 100644 index 00000000..3919b917 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java @@ -0,0 +1,759 @@ +package xiaozhi.modules.agent.service.impl; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.core.type.TypeReference; + +import lombok.AllArgsConstructor; +import xiaozhi.common.constant.Constant; +import xiaozhi.common.exception.ErrorCode; +import xiaozhi.common.exception.RenException; +import xiaozhi.common.page.PageData; +import xiaozhi.common.service.impl.BaseServiceImpl; +import xiaozhi.common.utils.JsonUtils; +import xiaozhi.modules.agent.dao.AgentDao; +import xiaozhi.modules.agent.dao.AgentSnapshotDao; +import xiaozhi.modules.agent.dao.AgentTagDao; +import xiaozhi.modules.agent.dao.AgentTagRelationDao; +import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO; +import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO; +import xiaozhi.modules.agent.dto.AgentSnapshotTagDTO; +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.AgentSnapshotEntity; +import xiaozhi.modules.agent.entity.AgentTagEntity; +import xiaozhi.modules.agent.entity.AgentTagRelationEntity; +import xiaozhi.modules.agent.Enums.AgentSnapshotField; +import xiaozhi.modules.agent.service.AgentChatHistoryService; +import xiaozhi.modules.agent.service.AgentContextProviderService; +import xiaozhi.modules.agent.service.AgentPluginMappingService; +import xiaozhi.modules.agent.service.AgentSnapshotService; +import xiaozhi.modules.agent.service.AgentTagService; +import xiaozhi.modules.agent.vo.AgentInfoVO; +import xiaozhi.modules.agent.vo.AgentSnapshotVO; +import xiaozhi.modules.correctword.service.CorrectWordFileService; +import xiaozhi.modules.model.entity.ModelConfigEntity; +import xiaozhi.modules.model.service.ModelConfigService; +import xiaozhi.modules.security.user.SecurityUser; + +@Service +@AllArgsConstructor +public class AgentSnapshotServiceImpl extends BaseServiceImpl + implements AgentSnapshotService { + private static final int MAX_SNAPSHOTS_PER_AGENT = 100; + private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() { + }; + private static final TypeReference> PARAM_INFO_TYPE = new TypeReference<>() { + }; + private static final TypeReference> OBJECT_MAP_TYPE = new TypeReference<>() { + }; + private static final String SECRET_PLACEHOLDER = "__SNAPSHOT_SECRET_REDACTED__"; + + private final AgentSnapshotDao agentSnapshotDao; + private final AgentDao agentDao; + private final AgentTagDao agentTagDao; + private final AgentTagRelationDao agentTagRelationDao; + private final AgentPluginMappingService agentPluginMappingService; + private final AgentContextProviderService agentContextProviderService; + private final AgentTagService agentTagService; + private final AgentChatHistoryService agentChatHistoryService; + private final ModelConfigService modelConfigService; + private final CorrectWordFileService correctWordFileService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void createSnapshot(String agentId, String source) { + lockAgent(agentId); + AgentInfoVO agent = getAgentInfo(agentId); + AgentSnapshotDataDTO snapshotData = buildSnapshotData(agent); + AgentSnapshotEntity previousSnapshot = agentSnapshotDao.selectLatestSnapshot(agentId); + List changedFields; + if (previousSnapshot == null) { + changedFields = List.of("initial"); + } else { + AgentSnapshotDataDTO previousData = JsonUtils.parseObject(previousSnapshot.getSnapshotData(), + AgentSnapshotDataDTO.class); + changedFields = getChangedFields(previousData, snapshotData); + } + if (changedFields.isEmpty()) { + return; + } + + insertSnapshot(agentId, agent.getUserId(), source, snapshotData, changedFields); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public PageData page(String agentId, AgentSnapshotPageDTO params) { + AgentSnapshotPageDTO pageParams = params == null ? new AgentSnapshotPageDTO() : params; + Page page = new Page<>(pageParams.pageOrDefault(), pageParams.limitOrDefault()); + page.addOrder(OrderItem.desc("version_no")); + IPage result = agentSnapshotDao.selectPage(page, + new QueryWrapper() + .eq("agent_id", agentId) + .le(pageParams.getMaxVersionNo() != null, "version_no", pageParams.getMaxVersionNo())); + List list = result.getRecords().stream() + .map(entity -> toVO(entity, false)) + .toList(); + return new PageData<>(list, result.getTotal()); + } + + @Override + public AgentSnapshotVO getSnapshot(String agentId, String snapshotId) { + AgentSnapshotEntity entity = getSnapshotEntity(agentId, snapshotId); + return toVO(entity, true); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void restoreSnapshot(String agentId, String snapshotId) { + AgentEntity agent = agentDao.selectByIdForUpdate(agentId); + if (agent == null) { + throw new RenException(ErrorCode.AGENT_NOT_FOUND); + } + + AgentSnapshotEntity snapshot = getSnapshotEntity(agentId, snapshotId); + AgentSnapshotDataDTO data = JsonUtils.parseObject(snapshot.getSnapshotData(), AgentSnapshotDataDTO.class); + if (data == null) { + throw new RenException("快照数据为空,无法恢复"); + } + + AgentInfoVO currentAgent = getAgentInfo(agentId); + AgentSnapshotDataDTO currentData = buildSnapshotData(currentAgent); + AgentSnapshotDataDTO restoreData = preserveCurrentSensitiveValues(data, currentData); + List changedFields = getChangedFields(currentData, restoreData); + if (changedFields.isEmpty()) { + return; + } + + applyAgentFields(agent, restoreData); + validateRestoreParams(agent); + applyMemoryPolicy(agent); + agent.setUpdater(SecurityUser.getUserId()); + agent.setUpdatedAt(new Date()); + agentDao.updateById(agent); + + restoreFunctions(agentId, restoreData.getFunctions()); + restoreContextProviders(agentId, restoreData.getContextProviders()); + correctWordFileService.saveAgentCorrectWords(agentId, nullToEmpty(restoreData.getCorrectWordFileIds())); + restoreTags(agentId, restoreData); + insertSnapshot(agentId, currentAgent.getUserId(), "restore", restoreData, changedFields, + snapshot.getId(), snapshot.getVersionNo()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSnapshot(String agentId, String snapshotId) { + AgentSnapshotEntity entity = getSnapshotEntity(agentId, snapshotId); + Integer maxVersionNo = agentSnapshotDao.selectMaxVersionNo(agentId); + if (Objects.equals(entity.getVersionNo(), maxVersionNo)) { + throw new RenException("最新历史版本不能删除"); + } + deleteById(snapshotId); + } + + @Override + public Integer getCurrentVersionNo(String agentId) { + Integer maxVersionNo = agentSnapshotDao.selectMaxVersionNo(agentId); + return maxVersionNo == null ? 0 : maxVersionNo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteByAgentId(String agentId) { + agentSnapshotDao.delete(new QueryWrapper().eq("agent_id", agentId)); + } + + private void insertSnapshot(String agentId, Long userId, String source, AgentSnapshotDataDTO snapshotData, + List changedFields) { + insertSnapshot(agentId, userId, source, snapshotData, changedFields, null, null); + } + + private void insertSnapshot(String agentId, Long userId, String source, AgentSnapshotDataDTO snapshotData, + List changedFields, String restoreFromSnapshotId, Integer restoreFromVersionNo) { + AgentSnapshotEntity entity = new AgentSnapshotEntity(); + entity.setId(UUID.randomUUID().toString().replace("-", "")); + entity.setAgentId(agentId); + entity.setUserId(userId); + entity.setSnapshotData(JsonUtils.toJsonString(redactSnapshotData(snapshotData))); + entity.setChangedFields(JsonUtils.toJsonString(changedFields)); + entity.setSource(StringUtils.defaultIfBlank(source, "config")); + entity.setRestoreFromSnapshotId(restoreFromSnapshotId); + entity.setRestoreFromVersionNo(restoreFromVersionNo); + entity.setCreator(SecurityUser.getUserId()); + entity.setCreatedAt(new Date()); + int inserted = agentSnapshotDao.insertWithNextVersion(entity); + if (inserted != 1) { + throw new RenException("快照版本号生成失败"); + } + pruneSnapshots(agentId); + } + + private void lockAgent(String agentId) { + if (agentDao.selectByIdForUpdate(agentId) == null) { + throw new RenException(ErrorCode.AGENT_NOT_FOUND); + } + } + + private void pruneSnapshots(String agentId) { + agentSnapshotDao.deleteOlderThanKeepLimit(agentId, MAX_SNAPSHOTS_PER_AGENT); + } + + private AgentSnapshotEntity getSnapshotEntity(String agentId, String snapshotId) { + AgentSnapshotEntity entity = selectById(snapshotId); + if (entity == null || !Objects.equals(agentId, entity.getAgentId())) { + throw new RenException("快照不存在"); + } + return entity; + } + + private AgentInfoVO getAgentInfo(String agentId) { + AgentInfoVO agent = agentDao.selectAgentInfoById(agentId); + if (agent == null) { + throw new RenException(ErrorCode.AGENT_NOT_FOUND); + } + return agent; + } + + private AgentSnapshotDataDTO buildSnapshotData(String agentId) { + return buildSnapshotData(getAgentInfo(agentId)); + } + + private AgentSnapshotDataDTO buildSnapshotData(AgentInfoVO agent) { + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + data.setAgentCode(agent.getAgentCode()); + data.setAgentName(agent.getAgentName()); + data.setAsrModelId(agent.getAsrModelId()); + data.setVadModelId(agent.getVadModelId()); + data.setLlmModelId(agent.getLlmModelId()); + data.setSlmModelId(agent.getSlmModelId()); + data.setVllmModelId(agent.getVllmModelId()); + data.setTtsModelId(agent.getTtsModelId()); + data.setTtsVoiceId(agent.getTtsVoiceId()); + data.setTtsLanguage(agent.getTtsLanguage()); + data.setTtsVolume(agent.getTtsVolume()); + data.setTtsRate(agent.getTtsRate()); + data.setTtsPitch(agent.getTtsPitch()); + data.setMemModelId(agent.getMemModelId()); + data.setIntentModelId(agent.getIntentModelId()); + data.setChatHistoryConf(agent.getChatHistoryConf()); + data.setSystemPrompt(agent.getSystemPrompt()); + data.setSummaryMemory(agent.getSummaryMemory()); + data.setLangCode(agent.getLangCode()); + data.setLanguage(agent.getLanguage()); + data.setSort(agent.getSort()); + data.setFunctions(toFunctionInfo(agent.getFunctions())); + data.setContextProviders(getContextProviders(agent.getId())); + data.setCorrectWordFileIds(nullToEmpty(correctWordFileService.getAgentCorrectWordFileIds(agent.getId()))); + List tags = getSnapshotTags(agent.getId()); + data.setTags(tags); + data.setTagNames(tags.stream().map(AgentSnapshotTagDTO::getTagName).toList()); + return data; + } + + private List getSnapshotTags(String agentId) { + List tags = agentTagDao.selectByAgentId(agentId); + if (tags == null || tags.isEmpty()) { + return Collections.emptyList(); + } + List snapshotTags = new ArrayList<>(); + for (int i = 0; i < tags.size(); i++) { + AgentTagEntity tag = tags.get(i); + AgentSnapshotTagDTO snapshotTag = new AgentSnapshotTagDTO(); + snapshotTag.setId(tag.getId()); + snapshotTag.setTagName(tag.getTagName()); + snapshotTag.setSort(i); + snapshotTags.add(snapshotTag); + } + return snapshotTags; + } + + private List toFunctionInfo(List mappings) { + if (mappings == null || mappings.isEmpty()) { + return Collections.emptyList(); + } + return mappings.stream().map(mapping -> { + AgentUpdateDTO.FunctionInfo info = new AgentUpdateDTO.FunctionInfo(); + info.setPluginId(mapping.getPluginId()); + info.setParamInfo(parseParamInfo(mapping.getParamInfo())); + return info; + }).toList(); + } + + private HashMap parseParamInfo(String paramInfo) { + if (StringUtils.isBlank(paramInfo)) { + return new HashMap<>(); + } + return JsonUtils.parseObject(paramInfo, PARAM_INFO_TYPE); + } + + private List getContextProviders(String agentId) { + AgentContextProviderEntity contextEntity = agentContextProviderService.getByAgentId(agentId); + if (contextEntity == null || contextEntity.getContextProviders() == null) { + return Collections.emptyList(); + } + return contextEntity.getContextProviders(); + } + + private List getChangedFields(AgentSnapshotDataDTO current, AgentUpdateDTO pendingUpdate) { + if (pendingUpdate == null) { + return List.of("restore"); + } + + List fields = new ArrayList<>(); + for (AgentSnapshotField field : AgentSnapshotField.values()) { + Object nextValue = field.updateValue(pendingUpdate); + if (nextValue != null && isChanged(field, field.snapshotValue(current), nextValue)) { + fields.add(field.getFieldName()); + } + } + return fields; + } + + private List getChangedFields(AgentSnapshotDataDTO current, AgentSnapshotDataDTO next) { + if (next == null) { + return List.of("restore"); + } + if (current == null) { + current = new AgentSnapshotDataDTO(); + } + + List fields = new ArrayList<>(); + for (AgentSnapshotField field : AgentSnapshotField.values()) { + if (isChanged(field, field.snapshotValue(current), field.snapshotValue(next))) { + fields.add(field.getFieldName()); + } + } + return fields; + } + + private boolean isChanged(AgentSnapshotField field, Object current, Object next) { + return !Objects.equals(normalizeForCompare(field, current), normalizeForCompare(field, next)); + } + + @SuppressWarnings("unchecked") + private Object normalizeForCompare(AgentSnapshotField field, Object value) { + return switch (field) { + case FUNCTIONS -> normalizeFunctions((List) value); + case CONTEXT_PROVIDERS -> normalizeSortedJsonList((List) value); + case CORRECT_WORD_FILE_IDS -> normalizeStringList((List) value); + case TAG_NAMES -> normalizeStringList((List) value); + default -> value; + }; + } + + private Map normalizeFunctions(List functions) { + Map result = new TreeMap<>(); + if (functions == null) { + return result; + } + for (AgentUpdateDTO.FunctionInfo function : functions) { + if (function == null || StringUtils.isBlank(function.getPluginId())) { + continue; + } + result.put(function.getPluginId(), normalizeMap(function.getParamInfo())); + } + return result; + } + + private List normalizeStringList(List values) { + if (values == null) { + return Collections.emptyList(); + } + return values.stream() + .filter(StringUtils::isNotBlank) + .sorted() + .toList(); + } + + private List normalizeSortedJsonList(List values) { + if (values == null) { + return Collections.emptyList(); + } + return values.stream() + .filter(Objects::nonNull) + .map(value -> JsonUtils.toJsonString(normalizeValue(value))) + .sorted() + .toList(); + } + + private Object normalizeValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof Map map) { + return normalizeMap(map); + } + if (value instanceof List list) { + return list.stream().map(this::normalizeValue).toList(); + } + if (isScalarValue(value)) { + return value; + } + return normalizeMap(JsonUtils.parseObject(JsonUtils.toJsonString(value), OBJECT_MAP_TYPE)); + } + + private boolean isScalarValue(Object value) { + return value instanceof CharSequence + || value instanceof Number + || value instanceof Boolean + || value instanceof Character + || value instanceof Enum + || value instanceof Date; + } + + private Map normalizeMap(Map map) { + Map result = new TreeMap<>(); + if (map == null) { + return result; + } + map.forEach((key, value) -> { + if (key != null) { + String keyText = String.valueOf(key); + result.put(keyText, isSensitiveKey(keyText) ? SECRET_PLACEHOLDER : normalizeValue(value)); + } + }); + return result; + } + + private void applyAgentFields(AgentEntity agent, AgentSnapshotDataDTO data) { + for (AgentSnapshotField field : AgentSnapshotField.values()) { + field.applyTo(agent, data); + } + } + + private void validateRestoreParams(AgentEntity agent) { + if (agent == null || StringUtils.isBlank(agent.getLlmModelId())) { + return; + } + + ModelConfigEntity llmModelData = modelConfigService.selectById(agent.getLlmModelId()); + if (llmModelData == null || llmModelData.getConfigJson() == null) { + throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH); + } + Object typeValue = llmModelData.getConfigJson().get("type"); + String type = typeValue == null ? "" : typeValue.toString(); + if ("openai".equals(type) || "ollama".equals(type)) { + return; + } + if ("Intent_function_call".equals(agent.getIntentModelId())) { + throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH); + } + } + + private void applyMemoryPolicy(AgentEntity agent) { + if (agent == null || StringUtils.isBlank(agent.getMemModelId())) { + return; + } + if (Constant.MEMORY_NO_MEM.equals(agent.getMemModelId())) { + agentChatHistoryService.deleteByAgentId(agent.getId(), true, true); + agent.setSummaryMemory(""); + } else if (Constant.MEMORY_MEM_REPORT_ONLY.equals(agent.getMemModelId())) { + agent.setSummaryMemory(""); + } + } + + private void restoreFunctions(String agentId, List functions) { + agentPluginMappingService.deleteByAgentId(agentId); + if (functions == null || functions.isEmpty()) { + return; + } + List mappings = functions.stream().map(info -> { + AgentPluginMapping mapping = new AgentPluginMapping(); + mapping.setAgentId(agentId); + mapping.setPluginId(info.getPluginId()); + mapping.setParamInfo(JsonUtils.toJsonString(info.getParamInfo())); + return mapping; + }).toList(); + agentPluginMappingService.saveBatch(mappings); + } + + private void restoreContextProviders(String agentId, + List contextProviders) { + AgentContextProviderEntity entity = new AgentContextProviderEntity(); + entity.setAgentId(agentId); + entity.setContextProviders(nullToEmpty(contextProviders)); + entity.setUpdater(SecurityUser.getUserId()); + entity.setUpdatedAt(new Date()); + agentContextProviderService.saveOrUpdateByAgentId(entity); + } + + private void restoreTags(String agentId, AgentSnapshotDataDTO data) { + List tags = data.getTags(); + if (tags == null || tags.isEmpty()) { + agentTagService.saveAgentTags(agentId, null, nullToEmpty(data.getTagNames())); + return; + } + + agentTagRelationDao.deleteByAgentId(agentId); + List relations = new ArrayList<>(); + Date now = new Date(); + for (int i = 0; i < tags.size(); i++) { + AgentSnapshotTagDTO snapshotTag = tags.get(i); + String tagId = restoreTag(snapshotTag, now); + if (StringUtils.isBlank(tagId)) { + continue; + } + AgentTagRelationEntity relation = new AgentTagRelationEntity(); + relation.setId(UUID.randomUUID().toString().replace("-", "")); + relation.setAgentId(agentId); + relation.setTagId(tagId); + relation.setSort(i); + relation.setCreator(SecurityUser.getUserId()); + relation.setCreatedAt(now); + relation.setUpdater(SecurityUser.getUserId()); + relation.setUpdatedAt(now); + relations.add(relation); + } + if (!relations.isEmpty()) { + agentTagRelationDao.batchInsertRelation(relations); + } + } + + private String restoreTag(AgentSnapshotTagDTO snapshotTag, Date now) { + if (snapshotTag == null || StringUtils.isBlank(snapshotTag.getTagName())) { + return null; + } + + AgentTagEntity tag = null; + if (StringUtils.isNotBlank(snapshotTag.getId())) { + tag = agentTagDao.selectById(snapshotTag.getId()); + if (tag != null && Objects.equals(tag.getDeleted(), 1)) { + tag = null; + } + } + if (tag == null) { + tag = agentTagDao.selectOne(new QueryWrapper() + .eq("tag_name", snapshotTag.getTagName()) + .eq("deleted", 0) + .last("LIMIT 1")); + } + if (tag != null) { + return tag.getId(); + } + + AgentTagEntity newTag = new AgentTagEntity(); + newTag.setId(UUID.randomUUID().toString().replace("-", "")); + newTag.setTagName(snapshotTag.getTagName()); + newTag.setSort(snapshotTag.getSort() == null ? 0 : snapshotTag.getSort()); + newTag.setDeleted(0); + newTag.setCreator(SecurityUser.getUserId()); + newTag.setCreatedAt(now); + newTag.setUpdater(SecurityUser.getUserId()); + newTag.setUpdatedAt(now); + agentTagDao.insert(newTag); + return newTag.getId(); + } + + private AgentSnapshotVO toVO(AgentSnapshotEntity entity, boolean includeData) { + AgentSnapshotVO vo = new AgentSnapshotVO(); + vo.setId(entity.getId()); + vo.setAgentId(entity.getAgentId()); + vo.setUserId(entity.getUserId()); + vo.setVersionNo(entity.getVersionNo()); + vo.setChangedFields(parseChangedFields(entity.getChangedFields())); + vo.setFieldOrder(AgentSnapshotField.names()); + vo.setSource(entity.getSource()); + vo.setRestoreFromSnapshotId(entity.getRestoreFromSnapshotId()); + vo.setRestoreFromVersionNo(entity.getRestoreFromVersionNo()); + vo.setCreator(entity.getCreator()); + vo.setCreatedAt(entity.getCreatedAt()); + if (includeData) { + vo.setSnapshotData(redactSnapshotData(JsonUtils.parseObject(entity.getSnapshotData(), + AgentSnapshotDataDTO.class))); + vo.setAfterSnapshotData(redactSnapshotData(getAfterSnapshotData(entity))); + } + return vo; + } + + private AgentSnapshotDataDTO getAfterSnapshotData(AgentSnapshotEntity entity) { + AgentSnapshotEntity nextSnapshot = agentSnapshotDao.selectNextSnapshot(entity.getAgentId(), entity.getVersionNo()); + if (nextSnapshot != null) { + return JsonUtils.parseObject(nextSnapshot.getSnapshotData(), AgentSnapshotDataDTO.class); + } + return null; + } + + private List parseChangedFields(String changedFields) { + if (StringUtils.isBlank(changedFields)) { + return Collections.emptyList(); + } + return JsonUtils.parseObject(changedFields, STRING_LIST_TYPE); + } + + private AgentSnapshotDataDTO redactSnapshotData(AgentSnapshotDataDTO data) { + if (data == null) { + return null; + } + AgentSnapshotDataDTO copy = JsonUtils.parseObject(JsonUtils.toJsonString(data), AgentSnapshotDataDTO.class); + if (copy.getFunctions() != null) { + copy.getFunctions().forEach(function -> { + if (function != null) { + function.setParamInfo(redactSensitiveMap(function.getParamInfo())); + } + }); + } + if (copy.getContextProviders() != null) { + copy.getContextProviders().forEach(provider -> { + if (provider != null) { + provider.setHeaders(redactSensitiveMap(provider.getHeaders())); + } + }); + } + return copy; + } + + private AgentSnapshotDataDTO preserveCurrentSensitiveValues(AgentSnapshotDataDTO target, AgentSnapshotDataDTO current) { + if (target == null) { + return null; + } + AgentSnapshotDataDTO copy = JsonUtils.parseObject(JsonUtils.toJsonString(target), AgentSnapshotDataDTO.class); + Map currentFunctions = nullToEmpty(current == null ? null : current.getFunctions()) + .stream() + .filter(function -> function != null && StringUtils.isNotBlank(function.getPluginId())) + .collect(Collectors.toMap(AgentUpdateDTO.FunctionInfo::getPluginId, Function.identity(), + (left, right) -> left)); + if (copy.getFunctions() != null) { + copy.getFunctions().forEach(function -> { + if (function == null) { + return; + } + AgentUpdateDTO.FunctionInfo currentFunction = currentFunctions.get(function.getPluginId()); + function.setParamInfo(preserveCurrentSensitiveMap(function.getParamInfo(), + currentFunction == null ? null : currentFunction.getParamInfo())); + }); + } + + Map currentProviders = nullToEmpty( + current == null ? null : current.getContextProviders()) + .stream() + .filter(provider -> provider != null && StringUtils.isNotBlank(provider.getUrl())) + .collect(Collectors.toMap(xiaozhi.modules.agent.dto.ContextProviderDTO::getUrl, Function.identity(), + (left, right) -> left)); + if (copy.getContextProviders() != null) { + copy.getContextProviders().forEach(provider -> { + if (provider == null) { + return; + } + xiaozhi.modules.agent.dto.ContextProviderDTO currentProvider = currentProviders.get(provider.getUrl()); + provider.setHeaders(preserveCurrentSensitiveMap(provider.getHeaders(), + currentProvider == null ? null : currentProvider.getHeaders())); + }); + } + return copy; + } + + private HashMap redactSensitiveMap(Map map) { + HashMap result = new HashMap<>(); + if (map == null) { + return result; + } + map.forEach((key, value) -> { + if (key != null) { + String keyText = String.valueOf(key); + result.put(keyText, isSensitiveKey(keyText) ? SECRET_PLACEHOLDER : redactSensitiveValue(value)); + } + }); + return result; + } + + private Object redactSensitiveValue(Object value) { + if (value instanceof Map map) { + return redactSensitiveMap(map); + } + if (value instanceof List list) { + return list.stream().map(this::redactSensitiveValue).toList(); + } + return value; + } + + private HashMap preserveCurrentSensitiveMap(Map target, Map current) { + HashMap result = new HashMap<>(); + if (target == null) { + return result; + } + target.forEach((key, value) -> { + if (key == null) { + return; + } + String keyText = String.valueOf(key); + Object currentValue = getMapValue(current, keyText); + if (isSensitiveKey(keyText)) { + result.put(keyText, currentValue == null || SECRET_PLACEHOLDER.equals(currentValue) ? "" : currentValue); + } else { + result.put(keyText, preserveCurrentSensitiveValue(value, currentValue)); + } + }); + return result; + } + + private Object preserveCurrentSensitiveValue(Object target, Object current) { + if (target instanceof Map targetMap) { + return preserveCurrentSensitiveMap(targetMap, current instanceof Map currentMap ? currentMap : null); + } + if (target instanceof List targetList) { + List currentList = current instanceof List list ? list : Collections.emptyList(); + List result = new ArrayList<>(); + for (int i = 0; i < targetList.size(); i++) { + Object currentItem = i < currentList.size() ? currentList.get(i) : null; + result.add(preserveCurrentSensitiveValue(targetList.get(i), currentItem)); + } + return result; + } + return target; + } + + private Object getMapValue(Map map, String key) { + if (map == null) { + return null; + } + if (map.containsKey(key)) { + return map.get(key); + } + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() != null && Objects.equals(key, String.valueOf(entry.getKey()))) { + return entry.getValue(); + } + } + return null; + } + + private boolean isSensitiveKey(String key) { + String normalized = StringUtils.defaultString(key).toLowerCase().replaceAll("[^a-z0-9]", ""); + return normalized.equals("authorization") + || normalized.equals("token") + || normalized.endsWith("token") + || normalized.contains("apikey") + || normalized.contains("appkey") + || normalized.contains("accesskey") + || normalized.contains("privatekey") + || normalized.contains("password") + || normalized.contains("passwd") + || normalized.contains("secret") + || normalized.contains("credential"); + } + + private List nullToEmpty(List list) { + return list == null ? Collections.emptyList() : list; + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/typehandler/ContextProviderListTypeHandler.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/typehandler/ContextProviderListTypeHandler.java new file mode 100644 index 00000000..f0762e35 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/typehandler/ContextProviderListTypeHandler.java @@ -0,0 +1,31 @@ +package xiaozhi.modules.agent.typehandler; + +import java.util.Collections; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler; +import com.fasterxml.jackson.core.type.TypeReference; + +import xiaozhi.common.utils.JsonUtils; +import xiaozhi.modules.agent.dto.ContextProviderDTO; + +public class ContextProviderListTypeHandler extends AbstractJsonTypeHandler> { + private static final TypeReference> CONTEXT_PROVIDER_LIST_TYPE = new TypeReference<>() { + }; + + @Override + protected List parse(String json) { + if (StringUtils.isBlank(json)) { + return Collections.emptyList(); + } + List providers = JsonUtils.parseObject(json, CONTEXT_PROVIDER_LIST_TYPE); + return providers == null ? Collections.emptyList() : providers; + } + + @Override + protected String toJson(List obj) { + return JsonUtils.toJsonString(obj == null ? Collections.emptyList() : obj); + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java index 658ed6f2..ab1ca3fc 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentInfoVO.java @@ -1,7 +1,5 @@ package xiaozhi.modules.agent.vo; -import com.baomidou.mybatisplus.annotation.TableField; -import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -20,7 +18,6 @@ import java.util.List; public class AgentInfoVO extends AgentEntity { @Schema(description = "插件列表Id") - @TableField(typeHandler = JacksonTypeHandler.class) private List functions; @Schema(description = "上下文源配置") @@ -28,4 +25,7 @@ public class AgentInfoVO extends AgentEntity @Schema(description = "替换词文件ID列表") private List correctWordFileIds; + + @Schema(description = "当前配置版本号") + private Integer currentVersionNo; } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentSnapshotVO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentSnapshotVO.java new file mode 100644 index 00000000..69547e58 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentSnapshotVO.java @@ -0,0 +1,30 @@ +package xiaozhi.modules.agent.vo; + +import java.util.Date; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO; + +@Data +@Schema(description = "智能体配置快照") +public class AgentSnapshotVO { + private String id; + private String agentId; + @Schema(description = "所属用户ID,表示该快照归属的智能体所有者") + private Long userId; + private Integer versionNo; + private List changedFields; + private List fieldOrder; + private String source; + @Schema(description = "恢复来源快照ID,仅恢复前自动备份版本有值") + private String restoreFromSnapshotId; + @Schema(description = "恢复来源版本号,仅恢复前自动备份版本有值") + private Integer restoreFromVersionNo; + @Schema(description = "创建者,表示触发本次快照写入的操作人") + private Long creator; + private Date createdAt; + private AgentSnapshotDataDTO snapshotData; + private AgentSnapshotDataDTO afterSnapshotData; +} diff --git a/main/manager-api/src/main/resources/db/changelog/202607071530.sql b/main/manager-api/src/main/resources/db/changelog/202607071530.sql new file mode 100644 index 00000000..be224228 --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202607071530.sql @@ -0,0 +1,20 @@ +-- liquibase formatted sql + +-- changeset tykechen:202607071530 +CREATE TABLE IF NOT EXISTS `ai_agent_snapshot` ( + `id` VARCHAR(32) NOT NULL COMMENT '快照ID', + `agent_id` VARCHAR(32) NOT NULL COMMENT '智能体ID', + `user_id` BIGINT DEFAULT NULL COMMENT '所属用户ID', + `version_no` INT UNSIGNED NOT NULL COMMENT '版本号', + `snapshot_data` JSON NOT NULL DEFAULT (JSON_OBJECT()) COMMENT '快照数据', + `changed_fields` JSON DEFAULT NULL COMMENT '变更字段', + `source` VARCHAR(32) DEFAULT 'config' COMMENT '快照来源', + `restore_from_snapshot_id` VARCHAR(32) DEFAULT NULL COMMENT '恢复来源快照ID', + `restore_from_version_no` INT UNSIGNED DEFAULT NULL COMMENT '恢复来源版本号', + `creator` BIGINT DEFAULT NULL COMMENT '创建者', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_agent_version` (`agent_id`, `version_no`), + INDEX `idx_agent_created_at` (`agent_id`, `created_at`), + INDEX `idx_snapshot_user_created_at` (`user_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体配置快照表'; diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index a1902bf4..27ed05a3 100644 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -697,3 +697,10 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202607011405.sql + - changeSet: + id: 202607071530 + author: tykechen + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202607071530.sql diff --git a/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml b/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml index c250afe3..c779a5e6 100644 --- a/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml +++ b/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml @@ -25,9 +25,6 @@ - - @@ -38,6 +35,13 @@ + + + + + + - \ No newline at end of file + + + + diff --git a/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml b/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml new file mode 100644 index 00000000..ea5650c2 --- /dev/null +++ b/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + INSERT INTO ai_agent_snapshot ( + id, + agent_id, + user_id, + version_no, + snapshot_data, + changed_fields, + source, + restore_from_snapshot_id, + restore_from_version_no, + creator, + created_at + ) + SELECT #{snapshot.id}, + #{snapshot.agentId}, + #{snapshot.userId}, + COALESCE(MAX(version_no), 0) + 1, + #{snapshot.snapshotData}, + #{snapshot.changedFields}, + #{snapshot.source}, + #{snapshot.restoreFromSnapshotId}, + #{snapshot.restoreFromVersionNo}, + #{snapshot.creator}, + #{snapshot.createdAt} + FROM ai_agent_snapshot + WHERE agent_id = #{snapshot.agentId} + + + + DELETE FROM ai_agent_snapshot + WHERE agent_id = #{agentId} + AND id NOT IN ( + SELECT id + FROM ( + SELECT id + FROM ai_agent_snapshot + WHERE agent_id = #{agentId} + ORDER BY version_no DESC + LIMIT #{keepLimit} + ) retained_snapshots + ) + + + diff --git a/main/manager-api/src/test/java/xiaozhi/modules/agent/dto/AgentUpdateDTOTest.java b/main/manager-api/src/test/java/xiaozhi/modules/agent/dto/AgentUpdateDTOTest.java new file mode 100644 index 00000000..8ffe01c4 --- /dev/null +++ b/main/manager-api/src/test/java/xiaozhi/modules/agent/dto/AgentUpdateDTOTest.java @@ -0,0 +1,44 @@ +package xiaozhi.modules.agent.dto; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class AgentUpdateDTOTest { + + @Test + void functionInfoAcceptsJsonStringParamInfo() { + AgentUpdateDTO.FunctionInfo info = new AgentUpdateDTO.FunctionInfo(); + + info.setParamInfo("{\"api_key\":\"abc\",\"max_results\":5}"); + + assertEquals("abc", info.getParamInfo().get("api_key")); + assertEquals(5, info.getParamInfo().get("max_results")); + } + + @Test + void functionInfoNormalizesMapKeysToStrings() { + AgentUpdateDTO.FunctionInfo info = new AgentUpdateDTO.FunctionInfo(); + Map params = new LinkedHashMap<>(); + params.put("api_key", "abc"); + params.put(42, true); + + info.setParamInfo(params); + + assertEquals("abc", info.getParamInfo().get("api_key")); + assertEquals(true, info.getParamInfo().get("42")); + } + + @Test + void functionInfoFallsBackToEmptyMapForBlankParamInfo() { + AgentUpdateDTO.FunctionInfo info = new AgentUpdateDTO.FunctionInfo(); + + info.setParamInfo(" "); + + assertTrue(info.getParamInfo().isEmpty()); + } +} diff --git a/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java new file mode 100644 index 00000000..e3aad1b1 --- /dev/null +++ b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java @@ -0,0 +1,450 @@ +package xiaozhi.modules.agent.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import xiaozhi.common.utils.JsonUtils; +import xiaozhi.modules.agent.Enums.AgentSnapshotField; +import xiaozhi.modules.agent.dao.AgentDao; +import xiaozhi.modules.agent.dao.AgentSnapshotDao; +import xiaozhi.modules.agent.dao.AgentTagDao; +import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO; +import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO; +import xiaozhi.modules.agent.dto.AgentSnapshotTagDTO; +import xiaozhi.modules.agent.dto.AgentUpdateDTO; +import xiaozhi.modules.agent.dto.ContextProviderDTO; +import xiaozhi.modules.agent.entity.AgentEntity; +import xiaozhi.modules.agent.entity.AgentSnapshotEntity; +import xiaozhi.modules.agent.entity.AgentTagEntity; +import xiaozhi.modules.agent.service.AgentContextProviderService; +import xiaozhi.modules.agent.service.AgentSnapshotService; +import xiaozhi.modules.agent.vo.AgentSnapshotVO; +import xiaozhi.modules.agent.vo.AgentInfoVO; +import xiaozhi.modules.correctword.service.CorrectWordFileService; + +class AgentSnapshotServiceImplTest { + + @Test + @SuppressWarnings("unchecked") + void normalizeSortedJsonListTreatsDtoAndMapShapesEqually() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("normalizeSortedJsonList", List.class); + method.setAccessible(true); + + ContextProviderDTO dto = new ContextProviderDTO(); + dto.setUrl("https://example.com/context"); + dto.setHeaders(Map.of("Authorization", "Bearer token")); + + Map map = new LinkedHashMap<>(); + map.put("headers", Map.of("Authorization", "Bearer token")); + map.put("url", "https://example.com/context"); + + assertEquals(method.invoke(service, List.of(dto)), method.invoke(service, List.of(map))); + } + + @Test + void redactSnapshotDataMasksSensitiveFunctionAndHeaderValues() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + method.setAccessible(true); + + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + AgentUpdateDTO.FunctionInfo function = new AgentUpdateDTO.FunctionInfo(); + function.setPluginId("rag"); + function.setParamInfo(Map.of("api_key", "secret-key", "max_tokens", 32)); + ContextProviderDTO provider = new ContextProviderDTO(); + provider.setUrl("https://example.com/context"); + provider.setHeaders(Map.of("Authorization", "Bearer secret-token", "Accept", "application/json")); + data.setFunctions(List.of(function)); + data.setContextProviders(List.of(provider)); + + AgentSnapshotDataDTO redacted = (AgentSnapshotDataDTO) method.invoke(service, data); + String json = JsonUtils.toJsonString(redacted); + + assertFalse(json.contains("secret-key")); + assertFalse(json.contains("secret-token")); + assertTrue(json.contains("__SNAPSHOT_SECRET_REDACTED__")); + assertTrue(json.contains("max_tokens")); + assertTrue(json.contains("application/json")); + } + + @Test + void preserveCurrentSensitiveValuesKeepsCurrentSecretsWhenRestoringSnapshot() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + method.setAccessible(true); + + AgentSnapshotDataDTO snapshot = buildSnapshot("old-key", "old-token", "old-value"); + AgentSnapshotDataDTO current = buildSnapshot("current-key", "current-token", "current-value"); + + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) method.invoke(service, snapshot, current); + + assertEquals("current-key", restored.getFunctions().get(0).getParamInfo().get("api_key")); + assertEquals("old-value", restored.getFunctions().get(0).getParamInfo().get("description")); + assertEquals("current-token", restored.getContextProviders().get(0).getHeaders().get("Authorization")); + } + + @Test + @SuppressWarnings("unchecked") + void getChangedFieldsFollowsCentralFieldOrder() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentUpdateDTO.class); + method.setAccessible(true); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setAgentName("old-name"); + current.setCorrectWordFileIds(List.of("b", "a")); + + AgentUpdateDTO pendingUpdate = new AgentUpdateDTO(); + pendingUpdate.setAgentName("new-name"); + pendingUpdate.setCorrectWordFileIds(List.of("a", "b")); + AgentUpdateDTO.FunctionInfo function = new AgentUpdateDTO.FunctionInfo(); + function.setPluginId("rag"); + function.setParamInfo(Map.of("api_key", "secret-key")); + pendingUpdate.setFunctions(List.of(function)); + + List changedFields = (List) method.invoke(service, current, pendingUpdate); + + assertEquals(List.of(AgentSnapshotField.AGENT_NAME.getFieldName(), + AgentSnapshotField.FUNCTIONS.getFieldName()), changedFields); + } + + @Test + @SuppressWarnings("unchecked") + void tagIdsAreNotExposedAsIndependentSnapshotFields() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentUpdateDTO.class); + method.setAccessible(true); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setTagNames(List.of("alpha", "beta")); + + AgentUpdateDTO pendingUpdate = new AgentUpdateDTO(); + pendingUpdate.setTagNames(List.of("beta", "alpha")); + pendingUpdate.setTagIds(List.of("new-id")); + + List changedFields = (List) method.invoke(service, current, pendingUpdate); + + assertFalse(AgentSnapshotField.names().contains("tagIds")); + assertFalse(changedFields.contains("tagIds")); + assertTrue(changedFields.isEmpty()); + + pendingUpdate.setTagNames(List.of("alpha", "gamma")); + changedFields = (List) method.invoke(service, current, pendingUpdate); + + assertEquals(List.of(AgentSnapshotField.TAG_NAMES.getFieldName()), changedFields); + } + + @Test + void tagOnlySavePathCreatesSnapshot() throws Exception { + String controller = Files.readString(Path.of("src/main/java/xiaozhi/modules/agent/controller/AgentController.java")); + String roleConfig = Files.readString(Path.of("../manager-web/src/views/roleConfig.vue")); + + assertTrue(controller.contains("agentService.updateAgentById(id, dto);")); + assertFalse(controller.contains("agentService.updateAgentById(id, dto, false);")); + assertTrue(roleConfig.contains("configData.tagNames = tagNames;")); + assertFalse(roleConfig.contains("this.handleSaveAgentTags(agentId, tagNames)")); + } + + @Test + void firstAgentSaveKeepsInitialBaselineBeforePersistingNewState() { + AgentDao agentDao = mock(AgentDao.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotService snapshotService = mock(AgentSnapshotService.class); + AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null, + null, null, contextProviderService, null, correctWordFileService, snapshotService); + ReflectionTestUtils.setField(service, "baseDao", agentDao); + + String agentId = "agent-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + AgentInfoVO currentAgent = new AgentInfoVO(); + currentAgent.setId(agentId); + currentAgent.setAgentName("old-name"); + AgentUpdateDTO update = new AgentUpdateDTO(); + update.setAgentName("new-name"); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentAgent); + when(snapshotService.getCurrentVersionNo(agentId)).thenReturn(0); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(agentDao.updateById(any())).thenReturn(1); + + service.updateAgentById(agentId, update); + + InOrder inOrder = inOrder(agentDao, snapshotService); + inOrder.verify(snapshotService).createSnapshot(agentId, "initial"); + inOrder.verify(agentDao).updateById(argThat(agent -> "new-name".equals(agent.getAgentName()))); + inOrder.verify(snapshotService).createSnapshot(agentId, "config"); + } + + @Test + void agentSnapshotFieldAppliesRestorableAgentFields() { + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + data.setAgentName("restored-name"); + data.setLlmModelId("llm-new"); + data.setSystemPrompt("restored prompt"); + data.setSort(7); + + AgentEntity agent = new AgentEntity(); + for (AgentSnapshotField field : AgentSnapshotField.values()) { + field.applyTo(agent, data); + } + + assertEquals("restored-name", agent.getAgentName()); + assertEquals("llm-new", agent.getLlmModelId()); + assertEquals("restored prompt", agent.getSystemPrompt()); + assertEquals(7, agent.getSort()); + } + + @Test + void restoreSnapshotRollsBackForAnyException() throws Exception { + Method method = AgentSnapshotServiceImpl.class.getMethod("restoreSnapshot", String.class, String.class); + + Transactional transactional = method.getAnnotation(Transactional.class); + + assertNotNull(transactional); + assertTrue(List.of(transactional.rollbackFor()).contains(Exception.class)); + } + + @Test + void deleteSnapshotRollsBackForAnyException() throws Exception { + Method method = AgentSnapshotServiceImpl.class.getMethod("deleteSnapshot", String.class, String.class); + + Transactional transactional = method.getAnnotation(Transactional.class); + + assertNotNull(transactional); + assertTrue(List.of(transactional.rollbackFor()).contains(Exception.class)); + } + + @Test + void snapshotInsertAllocatesVersionInSingleSqlStatement() throws Exception { + String xml = normalizeWhitespace(Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml"))); + String dao = Files.readString(Path.of("src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java")); + + assertTrue(dao.contains("insertWithNextVersion")); + assertTrue(xml.contains("")); + assertTrue(xml.contains("INSERT INTO ai_agent_snapshot")); + assertTrue(xml.contains("COALESCE(MAX(version_no), 0) + 1")); + assertFalse(xml.contains("LAST_INSERT_ID")); + assertFalse(xml.contains("ai_agent_snapshot_sequence")); + } + + @Test + void snapshotMigrationIsMergedIntoSingleChangeLog() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/changelog/202607071530.sql")); + String master = Files.readString(Path.of("src/main/resources/db/changelog/db.changelog-master.yaml")); + + assertTrue(sql.contains("restore_from_snapshot_id")); + assertTrue(sql.contains("restore_from_version_no")); + assertTrue(sql.contains("idx_snapshot_user_created_at")); + assertTrue(sql.contains("DEFAULT (JSON_OBJECT())")); + assertTrue(master.contains("202607071530.sql")); + assertFalse(master.contains("202607081150.sql")); + assertFalse(master.contains("202607081230.sql")); + } + + @Test + void selectNextSnapshotLoadsRestoreTraceColumns() throws Exception { + String xml = normalizeWhitespace(Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml"))); + + assertTrue(xml.contains("restore_from_snapshot_id AS restoreFromSnapshotId")); + assertTrue(xml.contains("restore_from_version_no AS restoreFromVersionNo")); + } + + @Test + void retentionPruneSqlKeepsLatestVersions() throws Exception { + String xml = Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml")); + + assertTrue(xml.contains("deleteOlderThanKeepLimit")); + assertTrue(xml.contains("ORDER BY version_no DESC")); + assertTrue(xml.contains("LIMIT #{keepLimit}")); + } + + @Test + void agentInfoMapperLoadsFunctionsWithJoinInsteadOfNestedSelect() throws Exception { + String xml = Files.readString(Path.of("src/main/resources/mapper/agent/AgentDao.xml")); + + assertTrue(xml.contains("LEFT JOIN ai_agent_plugin_mapping f ON f.agent_id = a.id")); + assertFalse(xml.contains("selectAgentFunctionsByAgentId")); + assertFalse(xml.contains("select=\"selectAgentFunctions")); + } + + @Test + void toVOIncludesRestoreTraceFields() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("toVO", + AgentSnapshotEntity.class, boolean.class); + method.setAccessible(true); + + AgentSnapshotEntity entity = new AgentSnapshotEntity(); + entity.setId("snapshot-id"); + entity.setAgentId("agent-id"); + entity.setVersionNo(3); + entity.setChangedFields("[]"); + entity.setSource("restore"); + entity.setRestoreFromSnapshotId("target-id"); + entity.setRestoreFromVersionNo(1); + + AgentSnapshotVO vo = (AgentSnapshotVO) method.invoke(service, entity, false); + + assertEquals("target-id", vo.getRestoreFromSnapshotId()); + assertEquals(1, vo.getRestoreFromVersionNo()); + } + + @Test + void pageDoesNotCreateInitialSnapshotWhenAgentHasNoHistory() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, null, null, null, + null, null, null, null, null); + + when(snapshotDao.selectPage(any(), any())).thenReturn(new Page(1, 10)); + + service.page("agent-id", new AgentSnapshotPageDTO()); + + verify(agentDao, never()).selectByIdForUpdate(any()); + verify(snapshotDao, never()).selectMaxVersionNo(any()); + verify(snapshotDao, never()).insertWithNextVersion(any()); + verify(snapshotDao, never()).deleteOlderThanKeepLimit(any(), anyInt()); + } + + @Test + void getCurrentVersionNoReturnsPersistedMaxVersion() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, null, null, null, null, + null, null, null, null, null); + + when(snapshotDao.selectMaxVersionNo("agent-id")).thenReturn(7); + + assertEquals(7, service.getCurrentVersionNo("agent-id")); + } + + @Test + void createSnapshotStoresCurrentStateAsInitialVersionWhenHistoryIsEmpty() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, null, + contextProviderService, null, null, null, correctWordFileService); + + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId("agent-id"); + AgentInfoVO agentInfo = new AgentInfoVO(); + agentInfo.setId("agent-id"); + agentInfo.setUserId(7L); + agentInfo.setAgentName("agent"); + when(agentDao.selectByIdForUpdate("agent-id")).thenReturn(lockedAgent); + when(agentDao.selectAgentInfoById("agent-id")).thenReturn(agentInfo); + when(snapshotDao.selectLatestSnapshot("agent-id")).thenReturn(null); + when(snapshotDao.insertWithNextVersion(any())).thenReturn(1); + when(correctWordFileService.getAgentCorrectWordFileIds("agent-id")).thenReturn(List.of()); + when(contextProviderService.getByAgentId("agent-id")).thenReturn(null); + when(tagDao.selectByAgentId("agent-id")).thenReturn(List.of()); + + service.createSnapshot("agent-id", "config"); + + verify(snapshotDao).insertWithNextVersion(argThat(snapshot -> "config".equals(snapshot.getSource()) + && snapshot.getVersionNo() == null + && snapshot.getChangedFields().contains("initial"))); + verify(snapshotDao).deleteOlderThanKeepLimit("agent-id", 100); + } + + @Test + void restoreTagDoesNotReviveSoftDeletedSnapshotTagId() throws Exception { + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, tagDao, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("restoreTag", + AgentSnapshotTagDTO.class, Date.class); + method.setAccessible(true); + + AgentTagEntity deletedTag = new AgentTagEntity(); + deletedTag.setId("deleted-tag-id"); + deletedTag.setTagName("archived"); + deletedTag.setDeleted(1); + when(tagDao.selectById("deleted-tag-id")).thenReturn(deletedTag); + when(tagDao.selectOne(any())).thenReturn(null); + AtomicReference insertedTag = new AtomicReference<>(); + when(tagDao.insert(any())).thenAnswer(invocation -> { + insertedTag.set(invocation.getArgument(0)); + return 1; + }); + + AgentSnapshotTagDTO snapshotTag = new AgentSnapshotTagDTO(); + snapshotTag.setId("deleted-tag-id"); + snapshotTag.setTagName("archived"); + + String restoredTagId = (String) method.invoke(service, snapshotTag, new Date()); + + verify(tagDao, never()).updateById(any()); + assertNotEquals("deleted-tag-id", restoredTagId); + assertNotNull(insertedTag.get()); + assertEquals(restoredTagId, insertedTag.get().getId()); + assertEquals(0, insertedTag.get().getDeleted()); + } + + private AgentSnapshotDataDTO buildSnapshot(String apiKey, String authToken, String description) { + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + + AgentUpdateDTO.FunctionInfo function = new AgentUpdateDTO.FunctionInfo(); + function.setPluginId("rag"); + HashMap params = new HashMap<>(); + params.put("api_key", apiKey); + params.put("description", description); + function.setParamInfo(params); + + ContextProviderDTO provider = new ContextProviderDTO(); + provider.setUrl("https://example.com/context"); + provider.setHeaders(Map.of("Authorization", authToken)); + + data.setFunctions(List.of(function)); + data.setContextProviders(List.of(provider)); + return data; + } + + private String normalizeWhitespace(String value) { + return value.replaceAll("\\s+", " ").trim(); + } +} diff --git a/main/manager-api/src/test/java/xiaozhi/modules/agent/typehandler/ContextProviderListTypeHandlerTest.java b/main/manager-api/src/test/java/xiaozhi/modules/agent/typehandler/ContextProviderListTypeHandlerTest.java new file mode 100644 index 00000000..e1d0e6c4 --- /dev/null +++ b/main/manager-api/src/test/java/xiaozhi/modules/agent/typehandler/ContextProviderListTypeHandlerTest.java @@ -0,0 +1,42 @@ +package xiaozhi.modules.agent.typehandler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.apache.ibatis.type.TypeHandler; +import org.apache.ibatis.type.TypeHandlerRegistry; +import org.junit.jupiter.api.Test; + +import xiaozhi.modules.agent.dto.ContextProviderDTO; + +class ContextProviderListTypeHandlerTest { + + private final ContextProviderListTypeHandler handler = new ContextProviderListTypeHandler(); + + @Test + void parseKeepsContextProviderDtoElementType() { + List providers = handler + .parse("[{\"url\":\"https://example.com/context\",\"headers\":{\"Authorization\":\"Bearer token\"}}]"); + + assertEquals(1, providers.size()); + assertInstanceOf(ContextProviderDTO.class, providers.get(0)); + assertEquals("https://example.com/context", providers.get(0).getUrl()); + assertEquals("Bearer token", providers.get(0).getHeaders().get("Authorization")); + } + + @Test + void parseBlankJsonAsEmptyList() { + assertTrue(handler.parse(" ").isEmpty()); + } + + @Test + void myBatisCanInstantiateHandlerForListField() { + TypeHandler typeHandler = new TypeHandlerRegistry().getInstance(List.class, + ContextProviderListTypeHandler.class); + + assertInstanceOf(ContextProviderListTypeHandler.class, typeHandler); + } +} diff --git a/main/manager-mobile/src/pages/agent/edit.vue b/main/manager-mobile/src/pages/agent/edit.vue index 964ad0b2..e33c0211 100644 --- a/main/manager-mobile/src/pages/agent/edit.vue +++ b/main/manager-mobile/src/pages/agent/edit.vue @@ -169,6 +169,28 @@ function handleInputConfirm() { // 是否禁用历史记忆输入框 const isMemoryDisabled = computed(() => formData.value.memModelId !== 'Memory_mem_local_short') +function normalizeFunctionParams(paramInfo: any) { + if (!paramInfo) + return {} + if (typeof paramInfo === 'string') { + try { + const parsed = JSON.parse(paramInfo) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } + catch { + return {} + } + } + return typeof paramInfo === 'object' && !Array.isArray(paramInfo) ? { ...paramInfo } : {} +} + +function normalizeAgentFunctions(functions: any[] = []) { + return functions.map(item => ({ + ...item, + paramInfo: normalizeFunctionParams(item.paramInfo), + })) +} + // 打开上下文源编辑弹窗 function openContextProviderDialog() { uni.navigateTo({ @@ -191,11 +213,12 @@ async function loadAgentDetail() { loading.value = true tempSummaryMemory.value = '' const detail = await getAgentDetail(agentId.value) - formData.value = { ...detail } + const normalizedFunctions = normalizeAgentFunctions(detail.functions || []) + formData.value = { ...detail, functions: normalizedFunctions } // 更新插件store pluginStore.setCurrentAgentId(agentId.value) - pluginStore.setCurrentFunctions(detail.functions || []) + pluginStore.setCurrentFunctions(normalizedFunctions) // 更新语速音调 speedPitchStore.updateSpeedPitch({ @@ -626,6 +649,7 @@ async function saveAgent() { ...speedPitchStore.speedPitch, ttsLanguage: formData.value.language, contextProviders: providerStore.providers, + functions: normalizeAgentFunctions(formData.value.functions || []), } await updateAgent(agentId.value, saveData) loadAgentDetail() @@ -663,7 +687,7 @@ function handleTools() { // 确保store中有最新数据 pluginStore.setCurrentAgentId(agentId.value) - pluginStore.setCurrentFunctions(formData.value.functions || []) + pluginStore.setCurrentFunctions(normalizeAgentFunctions(formData.value.functions || [])) pluginStore.setAllFunctions(allFunctions.value) uni.navigateTo({ @@ -688,7 +712,7 @@ async function handleUpdateAgentTags() { // 监听store中的插件配置变化 watch(() => pluginStore.currentFunctions, (newFunctions) => { - formData.value.functions = newFunctions + formData.value.functions = normalizeAgentFunctions(newFunctions || []) }, { deep: true }) onMounted(async () => { diff --git a/main/manager-mobile/src/pages/agent/tools.vue b/main/manager-mobile/src/pages/agent/tools.vue index fb47deae..6d835a30 100644 --- a/main/manager-mobile/src/pages/agent/tools.vue +++ b/main/manager-mobile/src/pages/agent/tools.vue @@ -41,17 +41,32 @@ const tempParams = ref>({}) const arrayTextCache = ref>({}) const jsonTextCache = ref>({}) +function normalizeFunctionParams(paramInfo: any, fallback: Record = {}) { + if (!paramInfo) + return { ...fallback } + if (typeof paramInfo === 'string') { + try { + const parsed = JSON.parse(paramInfo) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : { ...fallback } + } + catch { + return { ...fallback } + } + } + return typeof paramInfo === 'object' && !Array.isArray(paramInfo) ? { ...fallback, ...paramInfo } : { ...fallback } +} + async function mergeFunctions() { selectedList.value = functions.value.map((mapping) => { const meta = allFunctions.value.find(f => f.id === mapping.pluginId) if (!meta) { - return { id: mapping.pluginId, name: mapping.pluginId, params: {} } + return { id: mapping.pluginId, name: mapping.pluginId, params: normalizeFunctionParams(mapping.paramInfo) } } return { id: mapping.pluginId, name: meta.name, - params: mapping.paramInfo || { ...meta.params }, + params: normalizeFunctionParams(mapping.paramInfo, meta.params), fieldsMeta: meta.fieldsMeta, } }) @@ -91,7 +106,7 @@ function selectFunction(func: any) { selectedList.value = [...selectedList.value, { id: func.id, name: func.name, - params: { ...func.params }, + params: normalizeFunctionParams(func.params), fieldsMeta: func.fieldsMeta, }] @@ -118,7 +133,7 @@ function editFunction(func: any) { currentFunction.value = func // 直接使用当前函数的参数 - tempParams.value = { ...func.params } + tempParams.value = normalizeFunctionParams(func.params) // 初始化文本缓存 if (func.fieldsMeta) { @@ -252,7 +267,7 @@ function getFieldRemark(field: any) { watch(() => selectedList.value, (newSelectedList) => { const finalFunctions = newSelectedList.map(f => ({ pluginId: f.id, - paramInfo: f.params, + paramInfo: normalizeFunctionParams(f.params), })) pluginStore.updateFunctions(finalFunctions) }) diff --git a/main/manager-web/package-lock.json b/main/manager-web/package-lock.json index acc4aec2..44829664 100644 --- a/main/manager-web/package-lock.json +++ b/main/manager-web/package-lock.json @@ -11096,6 +11096,16 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmmirror.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/main/manager-web/src/apis/module/agent.js b/main/manager-web/src/apis/module/agent.js index d79f8da4..3c9522db 100644 --- a/main/manager-web/src/apis/module/agent.js +++ b/main/manager-web/src/apis/module/agent.js @@ -81,6 +81,67 @@ export default { }); }).send(); }, + // 获取智能体配置快照列表 + getAgentSnapshots(agentId, params, callback) { + RequestService.sendRequest() + .url(`${getServiceUrl()}/agent/${agentId}/snapshots`) + .method('GET') + .data(params) + .success((res) => { + RequestService.clearRequestTime(); + callback(res); + }) + .networkFail(() => { + RequestService.reAjaxFun(() => { + this.getAgentSnapshots(agentId, params, callback); + }); + }).send(); + }, + // 获取智能体配置快照详情 + getAgentSnapshot(agentId, snapshotId, callback) { + RequestService.sendRequest() + .url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}`) + .method('GET') + .success((res) => { + RequestService.clearRequestTime(); + callback(res); + }) + .networkFail(() => { + RequestService.reAjaxFun(() => { + this.getAgentSnapshot(agentId, snapshotId, callback); + }); + }).send(); + }, + // 恢复智能体配置快照 + restoreAgentSnapshot(agentId, snapshotId, callback) { + RequestService.sendRequest() + .url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}/restore`) + .method('POST') + .success((res) => { + RequestService.clearRequestTime(); + callback(res); + }) + .networkFail(() => { + RequestService.reAjaxFun(() => { + this.restoreAgentSnapshot(agentId, snapshotId, callback); + }); + }).send(); + }, + // 删除智能体配置快照 + deleteAgentSnapshot(agentId, snapshotId, callback) { + RequestService.sendRequest() + .url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}`) + .method('DELETE') + .success((res) => { + RequestService.clearRequestTime(); + callback(res); + }) + .networkFail(() => { + RequestService.reAjaxFun(() => { + this.deleteAgentSnapshot(agentId, snapshotId, callback); + }); + }).send(); + }, // 新增方法:获取智能体模板 getAgentTemplate(callback) { // 移除templateName参数 RequestService.sendRequest() diff --git a/main/manager-web/src/components/AgentSnapshotDialog.vue b/main/manager-web/src/components/AgentSnapshotDialog.vue new file mode 100644 index 00000000..af3e6e91 --- /dev/null +++ b/main/manager-web/src/components/AgentSnapshotDialog.vue @@ -0,0 +1,2334 @@ + + + + + diff --git a/main/manager-web/src/i18n/de.js b/main/manager-web/src/i18n/de.js index d1a42bb9..eeb2ae4b 100644 --- a/main/manager-web/src/i18n/de.js +++ b/main/manager-web/src/i18n/de.js @@ -806,12 +806,128 @@ export default { 'roleConfig.addTag': 'Neues Label hinzufügen', 'roleConfig.restartNotice': 'Neustart nach Konfigurationsspeichern zur Aktivierung.', 'roleConfig.saveConfig': 'Konfiguration speichern', + 'roleConfig.snapshotHistory': 'Versionsverlauf', + 'roleConfig.currentVersion': 'Aktuelle Version {version}', + 'roleConfig.functionName': 'Funktionsname', 'roleConfig.reset': 'Zurücksetzen', 'roleConfig.agentName': 'Spitzname', 'roleConfig.roleTemplate': 'Vorlage', 'roleConfig.roleIntroduction': 'Einführung', 'roleConfig.languageCode': 'Sprachcode', 'roleConfig.interactionLanguage': 'Interaktionssprache', + 'agentSnapshot.title': 'Versionsverlauf', + 'agentSnapshot.empty': 'Noch keine Versionen', + 'agentSnapshot.version': 'Version', + 'agentSnapshot.createdAt': 'Gespeichert am', + 'agentSnapshot.source': 'Quelle', + 'agentSnapshot.changedFields': 'Änderungen', + 'agentSnapshot.actions': 'Aktionen', + 'agentSnapshot.view': 'Anzeigen', + 'agentSnapshot.restore': 'Wiederherstellen', + 'agentSnapshot.delete': 'Löschen', + 'agentSnapshot.detailTitle': 'Änderungsdetails', + 'agentSnapshot.restorePreviewTitle': 'Wiederherstellungsvorschau', + 'agentSnapshot.confirmRestore': 'Wiederherstellung bestätigen', + 'agentSnapshot.before': 'Vorher', + 'agentSnapshot.after': 'Nachher', + 'agentSnapshot.emptyValue': 'Keine', + 'agentSnapshot.secretRedacted': 'Geheimer Schlüssel maskiert', + 'agentSnapshot.configValue': 'Konfigurationswert', + 'agentSnapshot.noChangedContent': 'Keine anzeigbaren Änderungen', + 'agentSnapshot.noFunctionChange': 'Keine Funktionsänderungen', + 'agentSnapshot.beforeChange': 'Vor der Änderung', + 'agentSnapshot.afterChange': 'Nach der Änderung', + 'agentSnapshot.beforeRestore': 'Vor der Wiederherstellung', + 'agentSnapshot.afterRestore': 'Nach der Wiederherstellung', + 'agentSnapshot.restoreMemoryWarning': 'Beim Wiederherstellen einer Version ohne Speicher wird der Chatverlauf dieses Agenten gelöscht. Fahren Sie nur fort, wenn Sie dieses Risiko bestätigt haben.', + 'agentSnapshot.restoreConfirm': 'Version {version} wiederherstellen? Die aktuelle Konfiguration wird zuerst als neue Version gespeichert.', + 'agentSnapshot.restoreSuccess': 'Version wurde wiederhergestellt', + 'agentSnapshot.restoreFailed': 'Version konnte nicht wiederhergestellt werden', + 'agentSnapshot.restoreFailedRollback': 'Version konnte nicht wiederhergestellt werden. Die Transaktion wurde zurueckgerollt und die aktuelle Konfiguration wurde nicht geaendert', + 'agentSnapshot.deleteConfirm': 'Version {version} löschen? Dies kann nicht rückgängig gemacht werden.', + 'agentSnapshot.deleteSuccess': 'Verlaufsversion gelöscht', + 'agentSnapshot.deleteFailed': 'Verlaufsversion konnte nicht gelöscht werden', + 'agentSnapshot.fetchFailed': 'Versionsverlauf konnte nicht abgerufen werden', + 'agentSnapshot.detailFailed': 'Snapshot-Details konnten nicht abgerufen werden', + 'agentSnapshot.currentVersion': 'Aktuelle Version', + 'agentSnapshot.source.config': 'Konfiguration gespeichert', + 'agentSnapshot.source.current': 'Aktuelle Konfiguration', + 'agentSnapshot.source.restore': 'Vor der Wiederherstellung', + 'agentSnapshot.source.initial': 'Initialversion', + 'agentSnapshot.field.restore': 'Wiederherstellung', + 'agentSnapshot.field.initial': 'Initialer Snapshot', + 'agentSnapshot.field.agentCode': 'Agent-Code', + 'agentSnapshot.field.agentName': 'Spitzname', + 'agentSnapshot.field.asrModelId': 'Spracherkennung', + 'agentSnapshot.field.vadModelId': 'Sprachaktivitätserkennung', + 'agentSnapshot.field.llmModelId': 'Hauptsprachmodell', + 'agentSnapshot.field.slmModelId': 'Kleines Sprachmodell', + 'agentSnapshot.field.vllmModelId': 'Vision-Modell', + 'agentSnapshot.field.ttsModelId': 'Text-zu-Sprache', + 'agentSnapshot.field.ttsVoiceId': 'Stimme', + 'agentSnapshot.field.ttsLanguage': 'Sprache', + 'agentSnapshot.field.ttsVolume': 'Lautstärke', + 'agentSnapshot.field.ttsRate': 'Geschwindigkeit', + 'agentSnapshot.field.ttsPitch': 'Tonhöhe', + 'agentSnapshot.field.memModelId': 'Speichermodell', + 'agentSnapshot.field.intentModelId': 'Absichtserkennung', + 'agentSnapshot.field.chatHistoryConf': 'Chatverlauf', + 'agentSnapshot.field.systemPrompt': 'Einführung', + 'agentSnapshot.field.summaryMemory': 'Speicher', + 'agentSnapshot.field.langCode': 'Sprachcode', + 'agentSnapshot.field.language': 'Interaktionssprache', + 'agentSnapshot.field.sort': 'Sortierung', + 'agentSnapshot.field.functions': 'Funktionen', + 'agentSnapshot.field.contextProviders': 'Kontextquellen', + 'agentSnapshot.field.correctWordFileIds': 'Ersatzwörter', + 'agentSnapshot.field.tagNames': 'Labels', + 'agentSnapshot.field.tagIds': 'Label-IDs', + 'agentSnapshot.function.added': 'Aktiviert', + 'agentSnapshot.function.removed': 'Deaktiviert', + 'agentSnapshot.function.updated': 'Parameter geändert', + 'agentSnapshot.function.enabled': 'Aktiviert', + 'agentSnapshot.function.disabled': 'Deaktiviert', + 'agentSnapshot.function.disabledNote': 'Diese Funktion ist deaktiviert', + 'agentSnapshot.function.noParams': 'Keine Parameter erforderlich', + 'agentSnapshot.chatHistoryConf.none': 'Chatverlauf nicht aufzeichnen', + 'agentSnapshot.chatHistoryConf.text': 'Text melden', + 'agentSnapshot.chatHistoryConf.textVoice': 'Text und Sprache melden', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEATHER': 'Wetter', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': 'Server-Musikplayer', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': 'China News', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'NewsNow-Aggregation', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'HomeAssistant-Statusabfrage', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'HomeAssistant-Statusaktualisierung', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'HomeAssistant-Musikplayer', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': 'Websuche', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': 'Geräteanruf', + 'agentSnapshot.pluginField.url': 'Endpunkt-URL', + 'agentSnapshot.pluginField.news_sources': 'Nachrichtenquellen', + 'agentSnapshot.pluginField.default_rss_url': 'Standard-RSS-URL', + 'agentSnapshot.pluginField.society_rss_url': 'RSS-URL für Gesellschaft', + 'agentSnapshot.pluginField.world_rss_url': 'RSS-URL für Welt', + 'agentSnapshot.pluginField.finance_rss_url': 'RSS-URL für Finanzen', + 'agentSnapshot.pluginField.api_key': 'API-Schlüssel', + 'agentSnapshot.pluginField.default_location': 'Standardstadt', + 'agentSnapshot.pluginField.api_host': 'Entwickler-API-Host', + 'agentSnapshot.pluginField.base_url': 'Server-URL', + 'agentSnapshot.pluginField.devices': 'Geräte', + 'agentSnapshot.pluginField.provider': 'Suchanbieter', + 'agentSnapshot.pluginField.description': 'Werkzeugbeschreibung', + 'agentSnapshot.pluginField.max_results': 'Ergebnisanzahl', + 'agentSnapshot.model.Memory_nomem': 'Kein Speicher', + 'agentSnapshot.model.Memory_mem_local_short': 'Lokaler Kurzzeitspeicher', + 'agentSnapshot.model.Memory_mem0ai': 'Mem0AI-Speicher', + 'agentSnapshot.model.Memory_mem_report_only': 'Nur melden', + 'agentSnapshot.model.Intent_nointent': 'Keine Absichtserkennung', + 'agentSnapshot.model.Intent_intent_llm': 'Externe LLM-Absichtserkennung', + 'agentSnapshot.model.Intent_function_call': 'LLM-Funktionsaufruf', + 'agentSnapshot.voice.TTS_EdgeTTS0001': 'EdgeTTS weiblich - Xiaoxiao', + 'agentSnapshot.voice.TTS_EdgeTTS0002': 'EdgeTTS männlich - Yunyang', + 'agentSnapshot.voice.TTS_EdgeTTS0003': 'EdgeTTS weiblich - Xiaoyi', + 'agentSnapshot.voice.TTS_EdgeTTS0004': 'EdgeTTS männlich - Yunjian', + 'agentSnapshot.voice.TTS_EdgeTTS0005': 'EdgeTTS männlich - Yunxi', + 'agentSnapshot.voice.TTS_EdgeTTS0006': 'EdgeTTS männlich - Yunxia', 'roleConfig.vad': 'VAD', 'roleConfig.asr': 'ASR', 'roleConfig.llm': 'Hauptsprachmodell (LLM)', @@ -1336,10 +1452,6 @@ export default { 'knowledgeFileUpload.viewSlices': 'Segmente anzeigen', 'knowledgeFileUpload.delete': 'Löschen', 'knowledgeFileUpload.itemsPerPage': 'Einträge/Seite', - 'knowledgeFileUpload.firstPage': 'Erste Seite', - 'knowledgeFileUpload.prevPage': 'Vorherige Seite', - 'knowledgeFileUpload.nextPage': 'Nächste Seite', - 'knowledgeFileUpload.totalRecords': 'Insgesamt {total} Datensätze', 'knowledgeFileUpload.uploadDocument': 'Dokument hochladen', 'knowledgeFileUpload.documentNamePlaceholder': 'Bitte Dokumentnamen eingeben', 'knowledgeFileUpload.file': 'Datei', @@ -1378,10 +1490,7 @@ export default { 'knowledgeFileUpload.sliceCount': 'Segmentanzahl', 'knowledgeFileUpload.add': 'Hinzufügen', 'knowledgeFileUpload.retrievalTest': 'Abruftest', - 'knowledgeFileUpload.testQuestion': 'Testfrage', - 'knowledgeFileUpload.testQuestionPlaceholder': 'Bitte geben Sie die zu testende Frage ein', 'knowledgeFileUpload.executeTest': 'Test ausführen', - 'knowledgeFileUpload.testResult': 'Testergebnis:', 'knowledgeFileUpload.selectedFiles': 'Ausgewählte Dateien', 'knowledgeFileUpload.totalSlices': 'Insgesamt {total} Datensätze', 'knowledgeFileUpload.slice': 'Segment', @@ -1457,7 +1566,6 @@ export default { 'addressBookManagement.name': 'Adressbuchname', 'addressBookManagement.description': 'Adressbuchbeschreibung', 'addressBookManagement.contactCount': 'Kontaktanzahl', - 'addressBookManagement.status': 'Aktiviert', 'addressBookManagement.createdAt': 'Erstellt am', 'addressBookManagement.operation': 'Aktion', 'addressBookManagement.add': 'Hinzufügen', @@ -1478,9 +1586,7 @@ export default { 'addressBookManagement.operationCancelled': 'Löschvorgang abgebrochen', 'addressBookManagement.updateSuccess': 'Aktualisierung erfolgreich', 'addressBookManagement.addSuccess': 'Erfolgreich hinzugefügt', - 'addressBookManagement.updateFailed': 'Aktualisierung fehlgeschlagen', 'addressBookManagement.addFailed': 'Hinzufügen fehlgeschlagen', - 'addressBookManagement.selectAll': 'Alle auswählen', 'addressBookManagement.cancelSelectAll': 'Alle abwählen', 'addressBookManagement.deviceName': 'Gerätename', 'addressBookManagement.deviceType': 'Gerätetyp', @@ -1495,9 +1601,7 @@ export default { 'addressBookManagement.addressBookDescPlaceholder': 'Bitte Adressbuchbeschreibung eingeben', 'addressBookManagement.remarksPlaceholder': 'Bitte Bemerkungen eingeben', 'addressBookManagement.selectDevice': 'Bitte ein Gerät auswählen, um Adressbuchdetails anzuzeigen', - 'addressBookManagement.save': 'Speichern', 'addressBookManagement.saveSuccess': 'Speichern erfolgreich', - 'addressBookManagement.saveFailed': 'Speichern fehlgeschlagen', 'addressBookManagement.deviceNotSelected': 'Bitte zuerst ein Gerät auswählen', 'addressBookManagement.addDeviceTip': 'Gerät hinzufügen Funktion ist in Entwicklung', 'addressBookManagement.selectAgentFirst': 'Bitte zuerst einen Agent auswählen', @@ -1554,4 +1658,4 @@ export default { // Header navigation 'header.addressBook': 'Adressbuch', -} \ No newline at end of file +} diff --git a/main/manager-web/src/i18n/en.js b/main/manager-web/src/i18n/en.js index 0248a653..8e258c60 100644 --- a/main/manager-web/src/i18n/en.js +++ b/main/manager-web/src/i18n/en.js @@ -806,6 +806,9 @@ export default { 'roleConfig.addTag': 'Add New Tag', 'roleConfig.restartNotice': 'After saving the configuration, you need to restart the device for the new configuration to take effect.', 'roleConfig.saveConfig': 'Save Configuration', + 'roleConfig.snapshotHistory': 'History', + 'roleConfig.currentVersion': 'Current Version {version}', + 'roleConfig.functionName': 'Function Name', 'roleConfig.reset': 'Reset', 'roleConfig.agentName': 'Nickname', 'roleConfig.roleTemplate': 'Template', @@ -862,6 +865,119 @@ export default { 'roleConfig.audioPlayFailed': 'Audio playback failed', 'roleConfig.cannotPlayAudio': 'Cannot play audio', 'roleConfig.audioPlayError': 'Error occurred during audio playback', + 'agentSnapshot.title': 'Version History', + 'agentSnapshot.empty': 'No versions yet', + 'agentSnapshot.version': 'Version', + 'agentSnapshot.createdAt': 'Saved At', + 'agentSnapshot.source': 'Source', + 'agentSnapshot.changedFields': 'Changes', + 'agentSnapshot.actions': 'Actions', + 'agentSnapshot.view': 'View', + 'agentSnapshot.restore': 'Restore', + 'agentSnapshot.delete': 'Delete', + 'agentSnapshot.detailTitle': 'Change Details', + 'agentSnapshot.restorePreviewTitle': 'Restore Preview', + 'agentSnapshot.confirmRestore': 'Confirm Restore', + 'agentSnapshot.before': 'Before', + 'agentSnapshot.after': 'After', + 'agentSnapshot.emptyValue': 'None', + 'agentSnapshot.secretRedacted': 'Secret redacted', + 'agentSnapshot.configValue': 'Config Value', + 'agentSnapshot.noChangedContent': 'No changes to display', + 'agentSnapshot.noFunctionChange': 'No function changes', + 'agentSnapshot.beforeChange': 'Before Change', + 'agentSnapshot.afterChange': 'After Change', + 'agentSnapshot.beforeRestore': 'Before Restore', + 'agentSnapshot.afterRestore': 'After Restore', + 'agentSnapshot.restoreMemoryWarning': 'Restoring to a no-memory version will clear this agent\'s chat history. Continue only after confirming this risk.', + 'agentSnapshot.restoreConfirm': 'Restore to version {version}? The current configuration will be saved as a new version first.', + 'agentSnapshot.restoreSuccess': 'Version restored', + 'agentSnapshot.restoreFailed': 'Failed to restore version', + 'agentSnapshot.restoreFailedRollback': 'Failed to restore version. The transaction was rolled back and the current configuration was not changed', + 'agentSnapshot.deleteConfirm': 'Delete version {version}? This cannot be undone.', + 'agentSnapshot.deleteSuccess': 'History version deleted', + 'agentSnapshot.deleteFailed': 'Failed to delete history version', + 'agentSnapshot.fetchFailed': 'Failed to fetch versions', + 'agentSnapshot.detailFailed': 'Failed to fetch snapshot details', + 'agentSnapshot.currentVersion': 'Current Version', + 'agentSnapshot.source.config': 'Config Save', + 'agentSnapshot.source.current': 'Current Config', + 'agentSnapshot.source.restore': 'Before Restore', + 'agentSnapshot.source.initial': 'Initial Version', + 'agentSnapshot.field.restore': 'Restore', + 'agentSnapshot.field.initial': 'Initial Snapshot', + 'agentSnapshot.field.agentCode': 'Agent Code', + 'agentSnapshot.field.agentName': 'Nickname', + 'agentSnapshot.field.asrModelId': 'Speech Recognition', + 'agentSnapshot.field.vadModelId': 'Voice Detect', + 'agentSnapshot.field.llmModelId': 'Main Language Model', + 'agentSnapshot.field.slmModelId': 'Small Language Model', + 'agentSnapshot.field.vllmModelId': 'Vision Model', + 'agentSnapshot.field.ttsModelId': 'Text-to-Speech', + 'agentSnapshot.field.ttsVoiceId': 'Voice', + 'agentSnapshot.field.ttsLanguage': 'Language', + 'agentSnapshot.field.ttsVolume': 'Volume', + 'agentSnapshot.field.ttsRate': 'Speed', + 'agentSnapshot.field.ttsPitch': 'Pitch', + 'agentSnapshot.field.memModelId': 'Memory Model', + 'agentSnapshot.field.intentModelId': 'Intent Recognition', + 'agentSnapshot.field.chatHistoryConf': 'Chat History', + 'agentSnapshot.field.systemPrompt': 'Introduction', + 'agentSnapshot.field.summaryMemory': 'Memory', + 'agentSnapshot.field.langCode': 'Language Code', + 'agentSnapshot.field.language': 'Interaction Language', + 'agentSnapshot.field.sort': 'Sort', + 'agentSnapshot.field.functions': 'Functions', + 'agentSnapshot.field.contextProviders': 'Context Providers', + 'agentSnapshot.field.correctWordFileIds': 'Replacement Words', + 'agentSnapshot.field.tagNames': 'Tags', + 'agentSnapshot.field.tagIds': 'Tag IDs', + 'agentSnapshot.function.added': 'Enabled', + 'agentSnapshot.function.removed': 'Disabled', + 'agentSnapshot.function.updated': 'Parameters changed', + 'agentSnapshot.function.enabled': 'Enabled', + 'agentSnapshot.function.disabled': 'Disabled', + 'agentSnapshot.function.disabledNote': 'This function is disabled', + 'agentSnapshot.function.noParams': 'No parameters required', + 'agentSnapshot.chatHistoryConf.none': 'Do not record chat history', + 'agentSnapshot.chatHistoryConf.text': 'Report text', + 'agentSnapshot.chatHistoryConf.textVoice': 'Report text and voice', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEATHER': 'Weather', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': 'Server music player', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': 'China News', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'NewsNow aggregation', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'HomeAssistant state query', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'HomeAssistant state update', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'HomeAssistant music player', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': 'Web search', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': 'Device-to-device call', + 'agentSnapshot.pluginField.url': 'Endpoint URL', + 'agentSnapshot.pluginField.news_sources': 'News sources', + 'agentSnapshot.pluginField.default_rss_url': 'Default RSS URL', + 'agentSnapshot.pluginField.society_rss_url': 'Society RSS URL', + 'agentSnapshot.pluginField.world_rss_url': 'World RSS URL', + 'agentSnapshot.pluginField.finance_rss_url': 'Finance RSS URL', + 'agentSnapshot.pluginField.api_key': 'API key', + 'agentSnapshot.pluginField.default_location': 'Default city', + 'agentSnapshot.pluginField.api_host': 'Developer API host', + 'agentSnapshot.pluginField.base_url': 'Server URL', + 'agentSnapshot.pluginField.devices': 'Devices', + 'agentSnapshot.pluginField.provider': 'Search provider', + 'agentSnapshot.pluginField.description': 'Tool description', + 'agentSnapshot.pluginField.max_results': 'Result count', + 'agentSnapshot.model.Memory_nomem': 'No memory', + 'agentSnapshot.model.Memory_mem_local_short': 'Local short memory', + 'agentSnapshot.model.Memory_mem0ai': 'Mem0AI memory', + 'agentSnapshot.model.Memory_mem_report_only': 'Report only', + 'agentSnapshot.model.Intent_nointent': 'No intent recognition', + 'agentSnapshot.model.Intent_intent_llm': 'External LLM intent recognition', + 'agentSnapshot.model.Intent_function_call': 'LLM function calling', + 'agentSnapshot.voice.TTS_EdgeTTS0001': 'EdgeTTS female - Xiaoxiao', + 'agentSnapshot.voice.TTS_EdgeTTS0002': 'EdgeTTS male - Yunyang', + 'agentSnapshot.voice.TTS_EdgeTTS0003': 'EdgeTTS female - Xiaoyi', + 'agentSnapshot.voice.TTS_EdgeTTS0004': 'EdgeTTS male - Yunjian', + 'agentSnapshot.voice.TTS_EdgeTTS0005': 'EdgeTTS male - Yunxi', + 'agentSnapshot.voice.TTS_EdgeTTS0006': 'EdgeTTS male - Yunxia', // Form field Tooltip descriptions 'roleConfig.tooltip.agentName': 'Set the name of your AI agent for identification and recognition', @@ -1336,10 +1452,6 @@ export default { 'knowledgeFileUpload.viewSlices': 'View Slices', 'knowledgeFileUpload.delete': 'Delete', 'knowledgeFileUpload.itemsPerPage': 'items/page', - 'knowledgeFileUpload.firstPage': 'First Page', - 'knowledgeFileUpload.prevPage': 'Previous Page', - 'knowledgeFileUpload.nextPage': 'Next Page', - 'knowledgeFileUpload.totalRecords': 'Total {total} records', 'knowledgeFileUpload.uploadDocument': 'Upload Document', 'knowledgeFileUpload.documentNamePlaceholder': 'Please enter document name', 'knowledgeFileUpload.file': 'File', @@ -1378,10 +1490,7 @@ export default { 'knowledgeFileUpload.sliceCount': 'Slice Count', 'knowledgeFileUpload.add': 'Add', 'knowledgeFileUpload.retrievalTest': 'Retrieval Test', - 'knowledgeFileUpload.testQuestion': 'Test Question', - 'knowledgeFileUpload.testQuestionPlaceholder': 'Please enter the question to test', 'knowledgeFileUpload.executeTest': 'Execute Test', - 'knowledgeFileUpload.testResult': 'Test Result:', 'knowledgeFileUpload.selectedFiles': 'Selected Files', 'knowledgeFileUpload.totalSlices': 'Total {total} records', 'knowledgeFileUpload.slice': 'Slice', @@ -1457,7 +1566,6 @@ export default { 'addressBookManagement.name': 'Address Book Name', 'addressBookManagement.description': 'Address Book Description', 'addressBookManagement.contactCount': 'Contact Count', - 'addressBookManagement.status': 'Enabled', 'addressBookManagement.createdAt': 'Created Time', 'addressBookManagement.operation': 'Operation', 'addressBookManagement.add': 'Add', @@ -1478,9 +1586,7 @@ export default { 'addressBookManagement.operationCancelled': 'Delete operation cancelled', 'addressBookManagement.updateSuccess': 'Update successful', 'addressBookManagement.addSuccess': 'Add successful', - 'addressBookManagement.updateFailed': 'Update failed', 'addressBookManagement.addFailed': 'Add failed', - 'addressBookManagement.selectAll': 'Select All', 'addressBookManagement.cancelSelectAll': 'Deselect All', 'addressBookManagement.deviceName': 'Device Name', 'addressBookManagement.deviceType': 'Device Type', @@ -1495,9 +1601,7 @@ export default { 'addressBookManagement.addressBookDescPlaceholder': 'Please enter address book description', 'addressBookManagement.remarksPlaceholder': 'Please enter remarks', 'addressBookManagement.selectDevice': 'Please select a device to view address book details', - 'addressBookManagement.save': 'Save', 'addressBookManagement.saveSuccess': 'Save successful', - 'addressBookManagement.saveFailed': 'Save failed', 'addressBookManagement.deviceNotSelected': 'Please select a device first', 'addressBookManagement.addDeviceTip': 'Add device feature is under development', 'addressBookManagement.selectAgentFirst': 'Please select an agent first', @@ -1554,4 +1658,4 @@ export default { // Header navigation 'header.addressBook': 'Address Book', -} \ No newline at end of file +} diff --git a/main/manager-web/src/i18n/pt_BR.js b/main/manager-web/src/i18n/pt_BR.js index 90e9fc87..a53ba976 100644 --- a/main/manager-web/src/i18n/pt_BR.js +++ b/main/manager-web/src/i18n/pt_BR.js @@ -806,12 +806,128 @@ export default { 'roleConfig.addTag': 'Adicionar Novo Rótulo', 'roleConfig.restartNotice': 'Reinicie após salvar a configuração para ativação.', 'roleConfig.saveConfig': 'Salvar Configuração', + 'roleConfig.snapshotHistory': 'Histórico de Versões', + 'roleConfig.currentVersion': 'Versão Atual {version}', + 'roleConfig.functionName': 'Nome da Função', 'roleConfig.reset': 'Redefinir', 'roleConfig.agentName': 'Apelido', 'roleConfig.roleTemplate': 'Modelo', 'roleConfig.roleIntroduction': 'Introdução', 'roleConfig.languageCode': 'Código do Idioma', 'roleConfig.interactionLanguage': 'Idioma de Interação', + 'agentSnapshot.title': 'Histórico de Versões', + 'agentSnapshot.empty': 'Ainda sem versões', + 'agentSnapshot.version': 'Versão', + 'agentSnapshot.createdAt': 'Salvo em', + 'agentSnapshot.source': 'Origem', + 'agentSnapshot.changedFields': 'Alterações', + 'agentSnapshot.actions': 'Ações', + 'agentSnapshot.view': 'Ver', + 'agentSnapshot.restore': 'Restaurar', + 'agentSnapshot.delete': 'Excluir', + 'agentSnapshot.detailTitle': 'Detalhes da Alteração', + 'agentSnapshot.restorePreviewTitle': 'Prévia da Restauração', + 'agentSnapshot.confirmRestore': 'Confirmar Restauração', + 'agentSnapshot.before': 'Antes', + 'agentSnapshot.after': 'Depois', + 'agentSnapshot.emptyValue': 'Nenhum', + 'agentSnapshot.secretRedacted': 'Chave secreta mascarada', + 'agentSnapshot.configValue': 'Valor da configuração', + 'agentSnapshot.noChangedContent': 'Nenhuma alteração para exibir', + 'agentSnapshot.noFunctionChange': 'Sem alterações de função', + 'agentSnapshot.beforeChange': 'Antes da Alteração', + 'agentSnapshot.afterChange': 'Depois da Alteração', + 'agentSnapshot.beforeRestore': 'Antes da Restauração', + 'agentSnapshot.afterRestore': 'Depois da Restauração', + 'agentSnapshot.restoreMemoryWarning': 'Restaurar para uma versão sem memória apagará o histórico de conversa deste agente. Continue somente após confirmar esse risco.', + 'agentSnapshot.restoreConfirm': 'Restaurar para a versão {version}? A configuração atual será salva primeiro como uma nova versão.', + 'agentSnapshot.restoreSuccess': 'Versão restaurada', + 'agentSnapshot.restoreFailed': 'Falha ao restaurar versão', + 'agentSnapshot.restoreFailedRollback': 'Falha ao restaurar versão. A transação foi revertida e a configuração atual não foi alterada', + 'agentSnapshot.deleteConfirm': 'Excluir a versão {version}? Esta ação não pode ser desfeita.', + 'agentSnapshot.deleteSuccess': 'Versão do histórico excluída', + 'agentSnapshot.deleteFailed': 'Falha ao excluir a versão do histórico', + 'agentSnapshot.fetchFailed': 'Falha ao buscar versões', + 'agentSnapshot.detailFailed': 'Falha ao buscar detalhes do snapshot', + 'agentSnapshot.currentVersion': 'Versão Atual', + 'agentSnapshot.source.config': 'Configuração Salva', + 'agentSnapshot.source.current': 'Configuração Atual', + 'agentSnapshot.source.restore': 'Antes da Restauração', + 'agentSnapshot.source.initial': 'Versão inicial', + 'agentSnapshot.field.restore': 'Restauração', + 'agentSnapshot.field.initial': 'Snapshot inicial', + 'agentSnapshot.field.agentCode': 'Código do Agente', + 'agentSnapshot.field.agentName': 'Apelido', + 'agentSnapshot.field.asrModelId': 'Reconhecimento de Fala', + 'agentSnapshot.field.vadModelId': 'Detecção de Atividade de Voz', + 'agentSnapshot.field.llmModelId': 'Modelo de Linguagem Principal', + 'agentSnapshot.field.slmModelId': 'Modelo de Linguagem Pequeno', + 'agentSnapshot.field.vllmModelId': 'Modelo de Visão', + 'agentSnapshot.field.ttsModelId': 'Texto para Fala', + 'agentSnapshot.field.ttsVoiceId': 'Voz', + 'agentSnapshot.field.ttsLanguage': 'Idioma', + 'agentSnapshot.field.ttsVolume': 'Volume', + 'agentSnapshot.field.ttsRate': 'Velocidade', + 'agentSnapshot.field.ttsPitch': 'Tom', + 'agentSnapshot.field.memModelId': 'Modelo de Memória', + 'agentSnapshot.field.intentModelId': 'Reconhecimento de Intenção', + 'agentSnapshot.field.chatHistoryConf': 'Histórico de Conversa', + 'agentSnapshot.field.systemPrompt': 'Introdução', + 'agentSnapshot.field.summaryMemory': 'Memória', + 'agentSnapshot.field.langCode': 'Código do Idioma', + 'agentSnapshot.field.language': 'Idioma de Interação', + 'agentSnapshot.field.sort': 'Ordenação', + 'agentSnapshot.field.functions': 'Funções', + 'agentSnapshot.field.contextProviders': 'Provedores de Contexto', + 'agentSnapshot.field.correctWordFileIds': 'Palavras de Substituição', + 'agentSnapshot.field.tagNames': 'Rótulos', + 'agentSnapshot.field.tagIds': 'IDs dos Rótulos', + 'agentSnapshot.function.added': 'Ativado', + 'agentSnapshot.function.removed': 'Desativado', + 'agentSnapshot.function.updated': 'Parâmetros alterados', + 'agentSnapshot.function.enabled': 'Ativado', + 'agentSnapshot.function.disabled': 'Desativado', + 'agentSnapshot.function.disabledNote': 'Esta função está desativada', + 'agentSnapshot.function.noParams': 'Nenhum parâmetro necessário', + 'agentSnapshot.chatHistoryConf.none': 'Não registrar histórico de conversa', + 'agentSnapshot.chatHistoryConf.text': 'Enviar texto', + 'agentSnapshot.chatHistoryConf.textVoice': 'Enviar texto e voz', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEATHER': 'Clima', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': 'Reprodutor de música do servidor', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': 'China News', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'Agregação NewsNow', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'Consulta de estado do HomeAssistant', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'Atualização de estado do HomeAssistant', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'Reprodutor de música do HomeAssistant', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': 'Pesquisa na web', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': 'Chamada entre dispositivos', + 'agentSnapshot.pluginField.url': 'URL do endpoint', + 'agentSnapshot.pluginField.news_sources': 'Fontes de notícias', + 'agentSnapshot.pluginField.default_rss_url': 'URL RSS padrão', + 'agentSnapshot.pluginField.society_rss_url': 'URL RSS de sociedade', + 'agentSnapshot.pluginField.world_rss_url': 'URL RSS mundial', + 'agentSnapshot.pluginField.finance_rss_url': 'URL RSS financeiro', + 'agentSnapshot.pluginField.api_key': 'Chave de API', + 'agentSnapshot.pluginField.default_location': 'Cidade padrão', + 'agentSnapshot.pluginField.api_host': 'Host da API de desenvolvedor', + 'agentSnapshot.pluginField.base_url': 'URL do servidor', + 'agentSnapshot.pluginField.devices': 'Dispositivos', + 'agentSnapshot.pluginField.provider': 'Provedor de pesquisa', + 'agentSnapshot.pluginField.description': 'Descrição da ferramenta', + 'agentSnapshot.pluginField.max_results': 'Quantidade de resultados', + 'agentSnapshot.model.Memory_nomem': 'Sem memória', + 'agentSnapshot.model.Memory_mem_local_short': 'Memória curta local', + 'agentSnapshot.model.Memory_mem0ai': 'Memória Mem0AI', + 'agentSnapshot.model.Memory_mem_report_only': 'Somente relatório', + 'agentSnapshot.model.Intent_nointent': 'Sem reconhecimento de intenção', + 'agentSnapshot.model.Intent_intent_llm': 'Reconhecimento de intenção por LLM externo', + 'agentSnapshot.model.Intent_function_call': 'Chamada de função por LLM', + 'agentSnapshot.voice.TTS_EdgeTTS0001': 'EdgeTTS feminino - Xiaoxiao', + 'agentSnapshot.voice.TTS_EdgeTTS0002': 'EdgeTTS masculino - Yunyang', + 'agentSnapshot.voice.TTS_EdgeTTS0003': 'EdgeTTS feminino - Xiaoyi', + 'agentSnapshot.voice.TTS_EdgeTTS0004': 'EdgeTTS masculino - Yunjian', + 'agentSnapshot.voice.TTS_EdgeTTS0005': 'EdgeTTS masculino - Yunxi', + 'agentSnapshot.voice.TTS_EdgeTTS0006': 'EdgeTTS masculino - Yunxia', 'roleConfig.vad': 'Detecção de Voz', 'roleConfig.asr': 'Reconhecimento de Fala', 'roleConfig.llm': 'Modelo de Linguagem Principal', @@ -1336,10 +1452,6 @@ export default { 'knowledgeFileUpload.viewSlices': 'Ver Fatias', 'knowledgeFileUpload.delete': 'Excluir', 'knowledgeFileUpload.itemsPerPage': 'itens/página', - 'knowledgeFileUpload.firstPage': 'Primeira Página', - 'knowledgeFileUpload.prevPage': 'Página Anterior', - 'knowledgeFileUpload.nextPage': 'Próxima Página', - 'knowledgeFileUpload.totalRecords': 'Total de {total} registros', 'knowledgeFileUpload.uploadDocument': 'Enviar Documento', 'knowledgeFileUpload.documentNamePlaceholder': 'Por favor, insira o nome do documento', 'knowledgeFileUpload.file': 'Arquivo', @@ -1378,10 +1490,7 @@ export default { 'knowledgeFileUpload.sliceCount': 'Quantidade de Fatias', 'knowledgeFileUpload.add': 'Adicionar', 'knowledgeFileUpload.retrievalTest': 'Teste de Recuperação', - 'knowledgeFileUpload.testQuestion': 'Pergunta de Teste', - 'knowledgeFileUpload.testQuestionPlaceholder': 'Por favor, insira a pergunta para testar', 'knowledgeFileUpload.executeTest': 'Executar Teste', - 'knowledgeFileUpload.testResult': 'Resultado do Teste:', 'knowledgeFileUpload.selectedFiles': 'Arquivos Selecionados', 'knowledgeFileUpload.totalSlices': 'Total de {total} registros', 'knowledgeFileUpload.slice': 'Fatia', @@ -1457,7 +1566,6 @@ export default { 'addressBookManagement.name': 'Nome da Lista de Contatos', 'addressBookManagement.description': 'Descrição da Lista de Contatos', 'addressBookManagement.contactCount': 'Número de Contatos', - 'addressBookManagement.status': 'Ativo', 'addressBookManagement.createdAt': 'Data de Criação', 'addressBookManagement.operation': 'Operação', 'addressBookManagement.add': 'Adicionar', @@ -1478,9 +1586,7 @@ export default { 'addressBookManagement.operationCancelled': 'Operação de exclusão cancelada', 'addressBookManagement.updateSuccess': 'Atualização bem-sucedida', 'addressBookManagement.addSuccess': 'Adição bem-sucedida', - 'addressBookManagement.updateFailed': 'Falha na atualização', 'addressBookManagement.addFailed': 'Falha ao adicionar', - 'addressBookManagement.selectAll': 'Selecionar Todos', 'addressBookManagement.cancelSelectAll': 'Desmarcar Todos', 'addressBookManagement.deviceName': 'Nome do Dispositivo', 'addressBookManagement.deviceType': 'Tipo do Dispositivo', @@ -1495,9 +1601,7 @@ export default { 'addressBookManagement.addressBookDescPlaceholder': 'Digite a descrição da lista de contatos', 'addressBookManagement.remarksPlaceholder': 'Digite as observações', 'addressBookManagement.selectDevice': 'Selecione um dispositivo para ver os detalhes da lista de contatos', - 'addressBookManagement.save': 'Salvar', 'addressBookManagement.saveSuccess': 'Salvo com sucesso', - 'addressBookManagement.saveFailed': 'Falha ao salvar', 'addressBookManagement.deviceNotSelected': 'Selecione um dispositivo primeiro', 'addressBookManagement.addDeviceTip': 'Recurso de adicionar dispositivo em desenvolvimento', 'addressBookManagement.selectAgentFirst': 'Selecione um agente primeiro', @@ -1554,4 +1658,4 @@ export default { // Header navigation 'header.addressBook': 'Lista de Contatos', -} \ No newline at end of file +} diff --git a/main/manager-web/src/i18n/vi.js b/main/manager-web/src/i18n/vi.js index b58023aa..a2565f9f 100644 --- a/main/manager-web/src/i18n/vi.js +++ b/main/manager-web/src/i18n/vi.js @@ -806,12 +806,128 @@ export default { 'roleConfig.addTag': 'Thêm mới nhãn', 'roleConfig.restartNotice': 'Sau khi lưu cấu hình, bạn cần khởi động lại thiết bị để cấu hình mới có hiệu lực.', 'roleConfig.saveConfig': 'Lưu cấu hình', + 'roleConfig.snapshotHistory': 'Lịch sử phiên bản', + 'roleConfig.currentVersion': 'Phiên bản hiện tại {version}', + 'roleConfig.functionName': 'Tên chức năng', 'roleConfig.reset': 'Đặt lại', 'roleConfig.agentName': 'Biệt danh', 'roleConfig.roleTemplate': 'Mẫu', 'roleConfig.roleIntroduction': 'Giới thiệu', 'roleConfig.languageCode': 'Mã ngôn ngữ', 'roleConfig.interactionLanguage': 'Ngôn ngữ tương tác', + 'agentSnapshot.title': 'Lịch sử phiên bản', + 'agentSnapshot.empty': 'Chưa có phiên bản', + 'agentSnapshot.version': 'Phiên bản', + 'agentSnapshot.createdAt': 'Lưu lúc', + 'agentSnapshot.source': 'Nguồn', + 'agentSnapshot.changedFields': 'Thay đổi', + 'agentSnapshot.actions': 'Thao tác', + 'agentSnapshot.view': 'Xem', + 'agentSnapshot.restore': 'Khôi phục', + 'agentSnapshot.delete': 'Xóa', + 'agentSnapshot.detailTitle': 'Chi tiết thay đổi', + 'agentSnapshot.restorePreviewTitle': 'Xem trước khôi phục', + 'agentSnapshot.confirmRestore': 'Xác nhận khôi phục', + 'agentSnapshot.before': 'Trước', + 'agentSnapshot.after': 'Sau', + 'agentSnapshot.emptyValue': 'Không có', + 'agentSnapshot.secretRedacted': 'Khóa bí mật đã được ẩn', + 'agentSnapshot.configValue': 'Giá trị cấu hình', + 'agentSnapshot.noChangedContent': 'Không có thay đổi để hiển thị', + 'agentSnapshot.noFunctionChange': 'Không có thay đổi chức năng', + 'agentSnapshot.beforeChange': 'Trước thay đổi', + 'agentSnapshot.afterChange': 'Sau thay đổi', + 'agentSnapshot.beforeRestore': 'Trước khi khôi phục', + 'agentSnapshot.afterRestore': 'Sau khi khôi phục', + 'agentSnapshot.restoreMemoryWarning': 'Khôi phục về phiên bản không có bộ nhớ sẽ xóa lịch sử trò chuyện của tác nhân này. Chỉ tiếp tục sau khi xác nhận rủi ro này.', + 'agentSnapshot.restoreConfirm': 'Khôi phục về phiên bản {version}? Cấu hình hiện tại sẽ được lưu thành phiên bản mới trước.', + 'agentSnapshot.restoreSuccess': 'Đã khôi phục phiên bản', + 'agentSnapshot.restoreFailed': 'Khôi phục phiên bản thất bại', + 'agentSnapshot.restoreFailedRollback': 'Khôi phục phiên bản thất bại. Giao dịch đã được hoàn tác và cấu hình hiện tại chưa thay đổi', + 'agentSnapshot.deleteConfirm': 'Xóa phiên bản {version}? Không thể hoàn tác thao tác này.', + 'agentSnapshot.deleteSuccess': 'Đã xóa phiên bản lịch sử', + 'agentSnapshot.deleteFailed': 'Xóa phiên bản lịch sử thất bại', + 'agentSnapshot.fetchFailed': 'Không thể tải lịch sử phiên bản', + 'agentSnapshot.detailFailed': 'Không thể tải chi tiết snapshot', + 'agentSnapshot.currentVersion': 'Phiên bản hiện tại', + 'agentSnapshot.source.config': 'Lưu cấu hình', + 'agentSnapshot.source.current': 'Cấu hình hiện tại', + 'agentSnapshot.source.restore': 'Trước khi khôi phục', + 'agentSnapshot.source.initial': 'Phiên bản ban đầu', + 'agentSnapshot.field.restore': 'Khôi phục', + 'agentSnapshot.field.initial': 'Snapshot ban đầu', + 'agentSnapshot.field.agentCode': 'Mã tác nhân', + 'agentSnapshot.field.agentName': 'Biệt danh', + 'agentSnapshot.field.asrModelId': 'Nhận dạng giọng nói', + 'agentSnapshot.field.vadModelId': 'Phát hiện hoạt động giọng nói', + 'agentSnapshot.field.llmModelId': 'Mô hình ngôn ngữ chính', + 'agentSnapshot.field.slmModelId': 'Mô hình ngôn ngữ nhỏ', + 'agentSnapshot.field.vllmModelId': 'Mô hình thị giác', + 'agentSnapshot.field.ttsModelId': 'Văn bản thành giọng nói', + 'agentSnapshot.field.ttsVoiceId': 'Giọng nói', + 'agentSnapshot.field.ttsLanguage': 'Ngôn ngữ', + 'agentSnapshot.field.ttsVolume': 'Âm lượng', + 'agentSnapshot.field.ttsRate': 'Tốc độ', + 'agentSnapshot.field.ttsPitch': 'Cao độ', + 'agentSnapshot.field.memModelId': 'Mô hình bộ nhớ', + 'agentSnapshot.field.intentModelId': 'Nhận dạng ý định', + 'agentSnapshot.field.chatHistoryConf': 'Lịch sử trò chuyện', + 'agentSnapshot.field.systemPrompt': 'Giới thiệu', + 'agentSnapshot.field.summaryMemory': 'Bộ nhớ', + 'agentSnapshot.field.langCode': 'Mã ngôn ngữ', + 'agentSnapshot.field.language': 'Ngôn ngữ tương tác', + 'agentSnapshot.field.sort': 'Sắp xếp', + 'agentSnapshot.field.functions': 'Chức năng', + 'agentSnapshot.field.contextProviders': 'Nguồn ngữ cảnh', + 'agentSnapshot.field.correctWordFileIds': 'Từ thay thế', + 'agentSnapshot.field.tagNames': 'Nhãn', + 'agentSnapshot.field.tagIds': 'ID nhãn', + 'agentSnapshot.function.added': 'Đã bật', + 'agentSnapshot.function.removed': 'Đã tắt', + 'agentSnapshot.function.updated': 'Tham số đã thay đổi', + 'agentSnapshot.function.enabled': 'Đã bật', + 'agentSnapshot.function.disabled': 'Đã tắt', + 'agentSnapshot.function.disabledNote': 'Chức năng này đang tắt', + 'agentSnapshot.function.noParams': 'Không cần cấu hình tham số', + 'agentSnapshot.chatHistoryConf.none': 'Không ghi lịch sử trò chuyện', + 'agentSnapshot.chatHistoryConf.text': 'Báo cáo văn bản', + 'agentSnapshot.chatHistoryConf.textVoice': 'Báo cáo văn bản và giọng nói', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEATHER': 'Thời tiết', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': 'Trình phát nhạc máy chủ', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': 'Tin tức China News', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'Tổng hợp NewsNow', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'Truy vấn trạng thái HomeAssistant', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'Cập nhật trạng thái HomeAssistant', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'Trình phát nhạc HomeAssistant', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': 'Tìm kiếm web', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': 'Gọi thiết bị', + 'agentSnapshot.pluginField.url': 'URL điểm cuối', + 'agentSnapshot.pluginField.news_sources': 'Nguồn tin', + 'agentSnapshot.pluginField.default_rss_url': 'URL RSS mặc định', + 'agentSnapshot.pluginField.society_rss_url': 'URL RSS xã hội', + 'agentSnapshot.pluginField.world_rss_url': 'URL RSS thế giới', + 'agentSnapshot.pluginField.finance_rss_url': 'URL RSS tài chính', + 'agentSnapshot.pluginField.api_key': 'Khóa API', + 'agentSnapshot.pluginField.default_location': 'Thành phố mặc định', + 'agentSnapshot.pluginField.api_host': 'Máy chủ API nhà phát triển', + 'agentSnapshot.pluginField.base_url': 'URL máy chủ', + 'agentSnapshot.pluginField.devices': 'Thiết bị', + 'agentSnapshot.pluginField.provider': 'Nhà cung cấp tìm kiếm', + 'agentSnapshot.pluginField.description': 'Mô tả công cụ', + 'agentSnapshot.pluginField.max_results': 'Số lượng kết quả', + 'agentSnapshot.model.Memory_nomem': 'Không có bộ nhớ', + 'agentSnapshot.model.Memory_mem_local_short': 'Bộ nhớ ngắn cục bộ', + 'agentSnapshot.model.Memory_mem0ai': 'Bộ nhớ Mem0AI', + 'agentSnapshot.model.Memory_mem_report_only': 'Chỉ báo cáo bộ nhớ', + 'agentSnapshot.model.Intent_nointent': 'Không nhận dạng ý định', + 'agentSnapshot.model.Intent_intent_llm': 'Nhận dạng ý định bằng LLM bên ngoài', + 'agentSnapshot.model.Intent_function_call': 'LLM gọi hàm', + 'agentSnapshot.voice.TTS_EdgeTTS0001': 'EdgeTTS nữ - Xiaoxiao', + 'agentSnapshot.voice.TTS_EdgeTTS0002': 'EdgeTTS nam - Yunyang', + 'agentSnapshot.voice.TTS_EdgeTTS0003': 'EdgeTTS nữ - Xiaoyi', + 'agentSnapshot.voice.TTS_EdgeTTS0004': 'EdgeTTS nam - Yunjian', + 'agentSnapshot.voice.TTS_EdgeTTS0005': 'EdgeTTS nam - Yunxi', + 'agentSnapshot.voice.TTS_EdgeTTS0006': 'EdgeTTS nam - Yunxia', 'roleConfig.vad': 'Phát hiện giọng nói', 'roleConfig.asr': 'Nhận dạng giọng nói', 'roleConfig.llm': 'Mô hình ngôn ngữ chính', @@ -1336,10 +1452,6 @@ export default { 'knowledgeFileUpload.viewSlices': 'Xem các phần', 'knowledgeFileUpload.delete': 'Xóa', 'knowledgeFileUpload.itemsPerPage': 'mục/trang', - 'knowledgeFileUpload.firstPage': 'Trang đầu', - 'knowledgeFileUpload.prevPage': 'Trang trước', - 'knowledgeFileUpload.nextPage': 'Trang sau', - 'knowledgeFileUpload.totalRecords': 'Tổng cộng {total} bản ghi', 'knowledgeFileUpload.uploadDocument': 'Tải lên tài liệu', 'knowledgeFileUpload.documentNamePlaceholder': 'Vui lòng nhập tên tài liệu', 'knowledgeFileUpload.file': 'Tệp', @@ -1378,10 +1490,7 @@ export default { 'knowledgeFileUpload.sliceCount': 'Số lượng phần', 'knowledgeFileUpload.add': 'Thêm', 'knowledgeFileUpload.retrievalTest': 'Kiểm tra truy xuất', - 'knowledgeFileUpload.testQuestion': 'Câu hỏi kiểm tra', - 'knowledgeFileUpload.testQuestionPlaceholder': 'Vui lòng nhập câu hỏi để kiểm tra', 'knowledgeFileUpload.executeTest': 'Thực thi kiểm tra', - 'knowledgeFileUpload.testResult': 'Kết quả kiểm tra:', 'knowledgeFileUpload.selectedFiles': 'Tệp đã chọn', 'knowledgeFileUpload.totalSlices': 'Tổng cộng {total} bản ghi', 'knowledgeFileUpload.slice': 'Phần', @@ -1457,7 +1566,6 @@ export default { 'addressBookManagement.name': 'Tên danh bạ', 'addressBookManagement.description': 'Mô tả danh bạ', 'addressBookManagement.contactCount': 'Số liên hệ', - 'addressBookManagement.status': 'Kích hoạt', 'addressBookManagement.createdAt': 'Thời gian tạo', 'addressBookManagement.operation': 'Thao tác', 'addressBookManagement.add': 'Thêm', @@ -1478,9 +1586,7 @@ export default { 'addressBookManagement.operationCancelled': 'Đã hủy thao tác xóa', 'addressBookManagement.updateSuccess': 'Cập nhật thành công', 'addressBookManagement.addSuccess': 'Thêm thành công', - 'addressBookManagement.updateFailed': 'Cập nhật thất bại', 'addressBookManagement.addFailed': 'Thêm thất bại', - 'addressBookManagement.selectAll': 'Chọn tất cả', 'addressBookManagement.cancelSelectAll': 'Bỏ chọn tất cả', 'addressBookManagement.deviceName': 'Tên thiết bị', 'addressBookManagement.deviceType': 'Loại thiết bị', @@ -1495,9 +1601,7 @@ export default { 'addressBookManagement.addressBookDescPlaceholder': 'Vui lòng nhập mô tả danh bạ', 'addressBookManagement.remarksPlaceholder': 'Vui lòng nhập ghi chú', 'addressBookManagement.selectDevice': 'Vui lòng chọn thiết bị để xem chi tiết danh bạ', - 'addressBookManagement.save': 'Lưu', 'addressBookManagement.saveSuccess': 'Lưu thành công', - 'addressBookManagement.saveFailed': 'Lưu thất bại', 'addressBookManagement.deviceNotSelected': 'Vui lòng chọn thiết bị trước', 'addressBookManagement.addDeviceTip': 'Tính năng thêm thiết bị đang phát triển', 'addressBookManagement.selectAgentFirst': 'Vui lòng chọn tác tử trước', @@ -1554,4 +1658,4 @@ export default { // Header navigation 'header.addressBook': 'Danh bạ', -} \ No newline at end of file +} diff --git a/main/manager-web/src/i18n/zh_CN.js b/main/manager-web/src/i18n/zh_CN.js index badd75f3..3a583cea 100644 --- a/main/manager-web/src/i18n/zh_CN.js +++ b/main/manager-web/src/i18n/zh_CN.js @@ -275,7 +275,7 @@ export default { 'voicePrint.closeOperation': '操作已关闭', 'voicePrint.loading': '拼命加载中', - // 手動添加設備對話框相關 + // 手动添加设备对话框相关 'manualAddDeviceDialog.title': '手动添加设备', 'manualAddDeviceDialog.deviceType': '设备型号', 'manualAddDeviceDialog.deviceTypePlaceholder': '请选择设备型号', @@ -806,6 +806,9 @@ export default { 'roleConfig.addTag': '添加新标签', 'roleConfig.restartNotice': '保存配置后,需要重启设备,新的配置才会生效。', 'roleConfig.saveConfig': '保存配置', + 'roleConfig.snapshotHistory': '历史版本', + 'roleConfig.currentVersion': '当前版本 {version}', + 'roleConfig.functionName': '功能名称', 'roleConfig.reset': '重置', 'roleConfig.agentName': '助手昵称', 'roleConfig.roleTemplate': '角色模版', @@ -815,7 +818,7 @@ export default { 'roleConfig.vad': '语音活动检测(VAD)', 'roleConfig.asr': '语音识别(ASR)', 'roleConfig.llm': '主语言模型(LLM)', - "roleConfig.slm": "小参数模型(SLM)", + 'roleConfig.slm': '小参数模型(SLM)', 'roleConfig.vllm': '视觉大模型(VLLM)', 'roleConfig.intent': '意图识别(Intent)', 'roleConfig.memoryHis': '记忆', @@ -862,6 +865,119 @@ export default { 'roleConfig.audioPlayFailed': '音频播放失败', 'roleConfig.cannotPlayAudio': '无法播放音频', 'roleConfig.audioPlayError': '播放音频过程出错', + 'agentSnapshot.title': '历史版本', + 'agentSnapshot.empty': '暂无历史版本', + 'agentSnapshot.version': '版本', + 'agentSnapshot.createdAt': '保存时间', + 'agentSnapshot.source': '来源', + 'agentSnapshot.changedFields': '变更内容', + 'agentSnapshot.actions': '操作', + 'agentSnapshot.view': '查看', + 'agentSnapshot.restore': '恢复', + 'agentSnapshot.delete': '删除', + 'agentSnapshot.detailTitle': '变更详情', + 'agentSnapshot.restorePreviewTitle': '恢复确认', + 'agentSnapshot.confirmRestore': '确认恢复', + 'agentSnapshot.before': '变化前', + 'agentSnapshot.after': '变化后', + 'agentSnapshot.emptyValue': '无', + 'agentSnapshot.secretRedacted': '密钥已脱敏', + 'agentSnapshot.configValue': '配置值', + 'agentSnapshot.noChangedContent': '暂无可展示的变更内容', + 'agentSnapshot.noFunctionChange': '功能配置无变化', + 'agentSnapshot.beforeChange': '变化前', + 'agentSnapshot.afterChange': '变化后', + 'agentSnapshot.beforeRestore': '恢复前', + 'agentSnapshot.afterRestore': '恢复后', + 'agentSnapshot.restoreMemoryWarning': '恢复到无记忆版本会清空该智能体的聊天记录,请确认后再继续。', + 'agentSnapshot.restoreConfirm': '确定恢复到版本 {version} 吗?当前配置会先自动保存为新的历史版本。', + 'agentSnapshot.restoreSuccess': '已恢复历史版本', + 'agentSnapshot.restoreFailed': '恢复历史版本失败', + 'agentSnapshot.restoreFailedRollback': '恢复历史版本失败,事务已回滚,当前配置未改变', + 'agentSnapshot.deleteConfirm': '确定删除版本 {version} 吗?删除后不可恢复。', + 'agentSnapshot.deleteSuccess': '已删除历史版本', + 'agentSnapshot.deleteFailed': '删除历史版本失败', + 'agentSnapshot.fetchFailed': '获取历史版本失败', + 'agentSnapshot.detailFailed': '获取快照详情失败', + 'agentSnapshot.currentVersion': '当前版本', + 'agentSnapshot.source.config': '配置保存', + 'agentSnapshot.source.current': '当前配置', + 'agentSnapshot.source.restore': '恢复前', + 'agentSnapshot.source.initial': '初始版本', + 'agentSnapshot.field.restore': '恢复操作', + 'agentSnapshot.field.initial': '初始快照', + 'agentSnapshot.field.agentCode': '助手编码', + 'agentSnapshot.field.agentName': '助手昵称', + 'agentSnapshot.field.asrModelId': '语音识别', + 'agentSnapshot.field.vadModelId': '语音活动检测', + 'agentSnapshot.field.llmModelId': '主语言模型', + 'agentSnapshot.field.slmModelId': '小参数模型', + 'agentSnapshot.field.vllmModelId': '视觉大模型', + 'agentSnapshot.field.ttsModelId': '语音合成', + 'agentSnapshot.field.ttsVoiceId': '声音音色', + 'agentSnapshot.field.ttsLanguage': '对话语言', + 'agentSnapshot.field.ttsVolume': '音量', + 'agentSnapshot.field.ttsRate': '语速', + 'agentSnapshot.field.ttsPitch': '音调', + 'agentSnapshot.field.memModelId': '记忆模式', + 'agentSnapshot.field.intentModelId': '意图识别', + 'agentSnapshot.field.chatHistoryConf': '聊天记录配置', + 'agentSnapshot.field.systemPrompt': '角色介绍', + 'agentSnapshot.field.summaryMemory': '记忆', + 'agentSnapshot.field.langCode': '语言编码', + 'agentSnapshot.field.language': '交互语种', + 'agentSnapshot.field.sort': '排序', + 'agentSnapshot.field.functions': '功能插件', + 'agentSnapshot.field.contextProviders': '上下文源', + 'agentSnapshot.field.correctWordFileIds': '替换词', + 'agentSnapshot.field.tagNames': '标签', + 'agentSnapshot.field.tagIds': '标签ID', + 'agentSnapshot.function.added': '已开启', + 'agentSnapshot.function.removed': '已关闭', + 'agentSnapshot.function.updated': '参数变更', + 'agentSnapshot.function.enabled': '已开启', + 'agentSnapshot.function.disabled': '未开启', + 'agentSnapshot.function.disabledNote': '未开启此功能', + 'agentSnapshot.function.noParams': '无需配置参数', + 'agentSnapshot.chatHistoryConf.none': '不记录聊天记录', + 'agentSnapshot.chatHistoryConf.text': '上报文字', + 'agentSnapshot.chatHistoryConf.textVoice': '上报文字+语音', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEATHER': '天气查询', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': '服务器音乐播放', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': '中新网新闻', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'newsnow新闻聚合', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'HomeAssistant设备状态查询', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'HomeAssistant设备状态修改', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'HomeAssistant音乐播放', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': '联网搜索', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': '设备呼叫设备', + 'agentSnapshot.pluginField.url': '接口地址', + 'agentSnapshot.pluginField.news_sources': '新闻源配置', + 'agentSnapshot.pluginField.default_rss_url': '默认 RSS 源', + 'agentSnapshot.pluginField.society_rss_url': '社会新闻 RSS 地址', + 'agentSnapshot.pluginField.world_rss_url': '国际新闻 RSS 地址', + 'agentSnapshot.pluginField.finance_rss_url': '财经新闻 RSS 地址', + 'agentSnapshot.pluginField.api_key': 'API 密钥', + 'agentSnapshot.pluginField.default_location': '默认查询城市', + 'agentSnapshot.pluginField.api_host': '开发者 API Host', + 'agentSnapshot.pluginField.base_url': '服务器地址', + 'agentSnapshot.pluginField.devices': '设备列表', + 'agentSnapshot.pluginField.provider': '搜索源', + 'agentSnapshot.pluginField.description': '工具描述', + 'agentSnapshot.pluginField.max_results': '返回数量', + 'agentSnapshot.model.Memory_nomem': '无记忆', + 'agentSnapshot.model.Memory_mem_local_short': '本地短记忆', + 'agentSnapshot.model.Memory_mem0ai': 'Mem0AI记忆', + 'agentSnapshot.model.Memory_mem_report_only': '仅上报记忆', + 'agentSnapshot.model.Intent_nointent': '无意图识别', + 'agentSnapshot.model.Intent_intent_llm': '外挂的大模型意图识别', + 'agentSnapshot.model.Intent_function_call': '大模型自主函数调用', + 'agentSnapshot.voice.TTS_EdgeTTS0001': 'EdgeTTS女声-晓晓', + 'agentSnapshot.voice.TTS_EdgeTTS0002': 'EdgeTTS男声-云扬', + 'agentSnapshot.voice.TTS_EdgeTTS0003': 'EdgeTTS女声-晓伊', + 'agentSnapshot.voice.TTS_EdgeTTS0004': 'EdgeTTS男声-云健', + 'agentSnapshot.voice.TTS_EdgeTTS0005': 'EdgeTTS男声-云希', + 'agentSnapshot.voice.TTS_EdgeTTS0006': 'EdgeTTS男声-云夏', // 表单字段 Tooltip 提示说明 'roleConfig.tooltip.agentName': '设置智能体的名称,用于标识和识别您的AI助手', @@ -874,7 +990,7 @@ export default { 'roleConfig.tooltip.vad': '语音活动检测(Voice Activity Detection):检测用户何时开始或结束说话,用于判断对话的开始和结束,实现打断功能', 'roleConfig.tooltip.asr': '自动语音识别(Automatic Speech Recognition):将用户的语音转换为文字,是人机对话的第一步,支持多语言识别', 'roleConfig.tooltip.llm': '主语言模型(Large Language Model):AI助手的"大脑",负责理解用户意图、生成回答和执行各种任务', - "roleConfig.tooltip.slm": '小参数模型(Small Language Model):用于智能体唤醒,生成记忆总结标题', + 'roleConfig.tooltip.slm': '小参数模型(Small Language Model):用于智能体唤醒,生成记忆总结标题', 'roleConfig.tooltip.vllm': '视觉大语言模型(Visual LLM):处理图像和视频理解,使AI助手能够分析和描述摄像头捕获的画面内容', 'roleConfig.tooltip.intent': '意图识别(Intent Detection):分析用户语音或文本,判断用户的真实意图,如查询、聊天、控制设备等', 'roleConfig.tooltip.memory': '记忆模型(Memory Model):管理对话历史的存储和摘要,决定AI能否记住之前的对话内容,实现长期记忆功能', @@ -1336,10 +1452,6 @@ export default { 'knowledgeFileUpload.viewSlices': '查看切片', 'knowledgeFileUpload.delete': '删除', 'knowledgeFileUpload.itemsPerPage': '条/页', - 'knowledgeFileUpload.firstPage': '首页', - 'knowledgeFileUpload.prevPage': '上一页', - 'knowledgeFileUpload.nextPage': '下一页', - 'knowledgeFileUpload.totalRecords': '共{total}条记录', 'knowledgeFileUpload.uploadDocument': '上传文档', 'knowledgeFileUpload.documentNamePlaceholder': '请输入文档名称', 'knowledgeFileUpload.file': '文件', @@ -1378,10 +1490,7 @@ export default { 'knowledgeFileUpload.sliceCount': '切片数量', 'knowledgeFileUpload.add': '新增', 'knowledgeFileUpload.retrievalTest': '召回测试', - 'knowledgeFileUpload.testQuestion': '测试问题', - 'knowledgeFileUpload.testQuestionPlaceholder': '请输入要测试的问题', 'knowledgeFileUpload.executeTest': '执行测试', - 'knowledgeFileUpload.testResult': '测试结果:', 'knowledgeFileUpload.selectedFiles': '已选择文件', 'knowledgeFileUpload.totalSlices': '共{total}条记录', 'knowledgeFileUpload.slice': '切片', @@ -1401,6 +1510,8 @@ export default { 'knowledgeFileUpload.testQuestionRequired': '请输入测试问题', 'knowledgeBaseDialog.descriptionRequired': '请输入知识库描述', 'knowledgeFileUpload.parsing': '正在解析中...', + + // 知识库管理页面文本 'knowledgeBaseManagement.loading': '加载中...', 'knowledgeBaseManagement.noData': '暂无数据', 'knowledgeBaseManagement.switchKnowledgeBase': '切换知识库', @@ -1455,7 +1566,6 @@ export default { 'addressBookManagement.name': '通讯录名称', 'addressBookManagement.description': '通讯录描述', 'addressBookManagement.contactCount': '联系人数量', - 'addressBookManagement.status': '启用', 'addressBookManagement.createdAt': '创建时间', 'addressBookManagement.operation': '操作', 'addressBookManagement.add': '新增', @@ -1476,9 +1586,7 @@ export default { 'addressBookManagement.operationCancelled': '已取消删除操作', 'addressBookManagement.updateSuccess': '修改成功', 'addressBookManagement.addSuccess': '新增成功', - 'addressBookManagement.updateFailed': '更新失败', 'addressBookManagement.addFailed': '新增失败', - 'addressBookManagement.selectAll': '全选', 'addressBookManagement.cancelSelectAll': '取消全选', 'addressBookManagement.deviceName': '设备名称', 'addressBookManagement.deviceType': '设备类型', @@ -1495,7 +1603,6 @@ export default { 'addressBookManagement.selectDevice': '请选择一个设备查看通讯录详情', 'addressBookManagement.save': '保存权限', 'addressBookManagement.saveSuccess': '保存成功', - 'addressBookManagement.saveFailed': '保存失败', 'addressBookManagement.deviceNotSelected': '请先选择一个设备', 'addressBookManagement.addDeviceTip': '新增设备功能开发中', 'addressBookManagement.selectAgentFirst': '请先选择智能体', @@ -1551,4 +1658,4 @@ export default { 'addressBookManagement.monthsAgo': '{months}个月前', 'addressBookManagement.yearsAgo': '{years}年前', -} \ No newline at end of file +} diff --git a/main/manager-web/src/i18n/zh_TW.js b/main/manager-web/src/i18n/zh_TW.js index cc89ba2f..d2c1f8e5 100644 --- a/main/manager-web/src/i18n/zh_TW.js +++ b/main/manager-web/src/i18n/zh_TW.js @@ -806,12 +806,128 @@ export default { 'roleConfig.addTag': '添加新標籤', 'roleConfig.restartNotice': '保存配置後,需要重啟設備,新的配置才會生效。', 'roleConfig.saveConfig': '保存配置', + 'roleConfig.snapshotHistory': '歷史版本', + 'roleConfig.currentVersion': '目前版本 {version}', + 'roleConfig.functionName': '功能名稱', 'roleConfig.reset': '重置', 'roleConfig.agentName': '助手暱稱', 'roleConfig.roleTemplate': '角色模版', 'roleConfig.roleIntroduction': '角色介紹', 'roleConfig.languageCode': '語言編碼', 'roleConfig.interactionLanguage': '交互語種', + 'agentSnapshot.title': '歷史版本', + 'agentSnapshot.empty': '暫無歷史版本', + 'agentSnapshot.version': '版本', + 'agentSnapshot.createdAt': '保存時間', + 'agentSnapshot.source': '來源', + 'agentSnapshot.changedFields': '變更內容', + 'agentSnapshot.actions': '操作', + 'agentSnapshot.view': '查看', + 'agentSnapshot.restore': '恢復', + 'agentSnapshot.delete': '刪除', + 'agentSnapshot.detailTitle': '變更詳情', + 'agentSnapshot.restorePreviewTitle': '恢復確認', + 'agentSnapshot.confirmRestore': '確認恢復', + 'agentSnapshot.before': '之前', + 'agentSnapshot.after': '之後', + 'agentSnapshot.emptyValue': '無', + 'agentSnapshot.secretRedacted': '密鑰已脫敏', + 'agentSnapshot.configValue': '配置值', + 'agentSnapshot.noChangedContent': '暫無可展示的變更內容', + 'agentSnapshot.noFunctionChange': '功能配置無變化', + 'agentSnapshot.beforeChange': '變化前', + 'agentSnapshot.afterChange': '變化後', + 'agentSnapshot.beforeRestore': '恢復前', + 'agentSnapshot.afterRestore': '恢復後', + 'agentSnapshot.restoreMemoryWarning': '恢復到無記憶版本會清空該智能體的聊天記錄,請確認後再繼續。', + 'agentSnapshot.restoreConfirm': '確定恢復到版本 {version} 嗎?目前配置會先自動保存為新的歷史版本。', + 'agentSnapshot.restoreSuccess': '已恢復歷史版本', + 'agentSnapshot.restoreFailed': '恢復歷史版本失敗', + 'agentSnapshot.restoreFailedRollback': '恢復歷史版本失敗,交易已回滾,目前配置未改變', + 'agentSnapshot.deleteConfirm': '確定刪除版本 {version} 嗎?刪除後無法復原。', + 'agentSnapshot.deleteSuccess': '已刪除歷史版本', + 'agentSnapshot.deleteFailed': '刪除歷史版本失敗', + 'agentSnapshot.fetchFailed': '獲取歷史版本失敗', + 'agentSnapshot.detailFailed': '獲取快照詳情失敗', + 'agentSnapshot.currentVersion': '目前版本', + 'agentSnapshot.source.config': '配置保存', + 'agentSnapshot.source.current': '目前配置', + 'agentSnapshot.source.restore': '恢復前', + 'agentSnapshot.source.initial': '初始版本', + 'agentSnapshot.field.restore': '恢復操作', + 'agentSnapshot.field.initial': '初始快照', + 'agentSnapshot.field.agentCode': '助手編碼', + 'agentSnapshot.field.agentName': '助手暱稱', + 'agentSnapshot.field.asrModelId': '語音識別', + 'agentSnapshot.field.vadModelId': '語音活動檢測', + 'agentSnapshot.field.llmModelId': '主語言模型', + 'agentSnapshot.field.slmModelId': '小參數模型', + 'agentSnapshot.field.vllmModelId': '視覺大模型', + 'agentSnapshot.field.ttsModelId': '語音合成', + 'agentSnapshot.field.ttsVoiceId': '聲音音色', + 'agentSnapshot.field.ttsLanguage': '對話語言', + 'agentSnapshot.field.ttsVolume': '音量', + 'agentSnapshot.field.ttsRate': '語速', + 'agentSnapshot.field.ttsPitch': '音調', + 'agentSnapshot.field.memModelId': '記憶模式', + 'agentSnapshot.field.intentModelId': '意圖識別', + 'agentSnapshot.field.chatHistoryConf': '聊天記錄配置', + 'agentSnapshot.field.systemPrompt': '角色介紹', + 'agentSnapshot.field.summaryMemory': '記憶', + 'agentSnapshot.field.langCode': '語言編碼', + 'agentSnapshot.field.language': '交互語種', + 'agentSnapshot.field.sort': '排序', + 'agentSnapshot.field.functions': '功能插件', + 'agentSnapshot.field.contextProviders': '上下文源', + 'agentSnapshot.field.correctWordFileIds': '替換詞', + 'agentSnapshot.field.tagNames': '標籤', + 'agentSnapshot.field.tagIds': '標籤ID', + 'agentSnapshot.function.added': '已開啟', + 'agentSnapshot.function.removed': '已關閉', + 'agentSnapshot.function.updated': '參數變更', + 'agentSnapshot.function.enabled': '已開啟', + 'agentSnapshot.function.disabled': '未開啟', + 'agentSnapshot.function.disabledNote': '未開啟此功能', + 'agentSnapshot.function.noParams': '無需配置參數', + 'agentSnapshot.chatHistoryConf.none': '不記錄聊天記錄', + 'agentSnapshot.chatHistoryConf.text': '上報文字', + 'agentSnapshot.chatHistoryConf.textVoice': '上報文字+語音', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEATHER': '天氣查詢', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_MUSIC': '伺服器音樂播放', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_CHINANEWS': '中新網新聞', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_NEWS_NEWSNOW': 'newsnow新聞聚合', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_GET_STATE': 'HomeAssistant設備狀態查詢', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_SET_STATE': 'HomeAssistant設備狀態修改', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_HA_PLAY_MUSIC': 'HomeAssistant音樂播放', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_WEB_SEARCH': '聯網搜尋', + 'agentSnapshot.plugin.SYSTEM_PLUGIN_CALL_DEVICE': '設備呼叫設備', + 'agentSnapshot.pluginField.url': '接口地址', + 'agentSnapshot.pluginField.news_sources': '新聞源配置', + 'agentSnapshot.pluginField.default_rss_url': '預設 RSS 源', + 'agentSnapshot.pluginField.society_rss_url': '社會新聞 RSS 地址', + 'agentSnapshot.pluginField.world_rss_url': '國際新聞 RSS 地址', + 'agentSnapshot.pluginField.finance_rss_url': '財經新聞 RSS 地址', + 'agentSnapshot.pluginField.api_key': 'API 密鑰', + 'agentSnapshot.pluginField.default_location': '預設查詢城市', + 'agentSnapshot.pluginField.api_host': '開發者 API Host', + 'agentSnapshot.pluginField.base_url': '伺服器地址', + 'agentSnapshot.pluginField.devices': '設備列表', + 'agentSnapshot.pluginField.provider': '搜尋源', + 'agentSnapshot.pluginField.description': '工具描述', + 'agentSnapshot.pluginField.max_results': '返回數量', + 'agentSnapshot.model.Memory_nomem': '無記憶', + 'agentSnapshot.model.Memory_mem_local_short': '本地短記憶', + 'agentSnapshot.model.Memory_mem0ai': 'Mem0AI記憶', + 'agentSnapshot.model.Memory_mem_report_only': '僅上報記憶', + 'agentSnapshot.model.Intent_nointent': '無意圖識別', + 'agentSnapshot.model.Intent_intent_llm': '外掛的大模型意圖識別', + 'agentSnapshot.model.Intent_function_call': '大模型自主函式呼叫', + 'agentSnapshot.voice.TTS_EdgeTTS0001': 'EdgeTTS女聲-曉曉', + 'agentSnapshot.voice.TTS_EdgeTTS0002': 'EdgeTTS男聲-雲揚', + 'agentSnapshot.voice.TTS_EdgeTTS0003': 'EdgeTTS女聲-曉伊', + 'agentSnapshot.voice.TTS_EdgeTTS0004': 'EdgeTTS男聲-雲健', + 'agentSnapshot.voice.TTS_EdgeTTS0005': 'EdgeTTS男聲-雲希', + 'agentSnapshot.voice.TTS_EdgeTTS0006': 'EdgeTTS男聲-雲夏', 'roleConfig.vad': '語音活動檢測(VAD)', 'roleConfig.asr': '語音識別(ASR)', 'roleConfig.llm': '主語言模型(LLM)', @@ -1336,10 +1452,6 @@ export default { 'knowledgeFileUpload.viewSlices': '查看切片', 'knowledgeFileUpload.delete': '刪除', 'knowledgeFileUpload.itemsPerPage': '條/頁', - 'knowledgeFileUpload.firstPage': '首頁', - 'knowledgeFileUpload.prevPage': '上一頁', - 'knowledgeFileUpload.nextPage': '下一頁', - 'knowledgeFileUpload.totalRecords': '共{total}條記錄', 'knowledgeFileUpload.uploadDocument': '上傳文檔', 'knowledgeFileUpload.documentNamePlaceholder': '請輸入文檔名稱', 'knowledgeFileUpload.file': '文件', @@ -1378,10 +1490,7 @@ export default { 'knowledgeFileUpload.sliceCount': '切片數量', 'knowledgeFileUpload.add': '新增', 'knowledgeFileUpload.retrievalTest': '召回測試', - 'knowledgeFileUpload.testQuestion': '測試問題', - 'knowledgeFileUpload.testQuestionPlaceholder': '請輸入要測試的問題', 'knowledgeFileUpload.executeTest': '執行測試', - 'knowledgeFileUpload.testResult': '測試結果:', 'knowledgeFileUpload.selectedFiles': '已選擇文件', 'knowledgeFileUpload.totalSlices': '共{total}條記錄', 'knowledgeFileUpload.slice': '切片', @@ -1456,7 +1565,6 @@ export default { 'addressBookManagement.name': '通訊錄名稱', 'addressBookManagement.description': '通訊錄描述', 'addressBookManagement.contactCount': '聯繫人數量', - 'addressBookManagement.status': '啟用', 'addressBookManagement.createdAt': '創建時間', 'addressBookManagement.operation': '操作', 'addressBookManagement.add': '新增', @@ -1477,9 +1585,7 @@ export default { 'addressBookManagement.operationCancelled': '已取消刪除操作', 'addressBookManagement.updateSuccess': '修改成功', 'addressBookManagement.addSuccess': '新增成功', - 'addressBookManagement.updateFailed': '更新失敗', 'addressBookManagement.addFailed': '新增失敗', - 'addressBookManagement.selectAll': '全選', 'addressBookManagement.cancelSelectAll': '取消全選', 'addressBookManagement.selectAgent': '請選擇智能體', 'addressBookManagement.deviceName': '設備名稱', @@ -1497,7 +1603,6 @@ export default { 'addressBookManagement.selectDevice': '請選擇一個設備查看通訊錄詳情', 'addressBookManagement.save': '保存權限', 'addressBookManagement.saveSuccess': '儲存成功', - 'addressBookManagement.saveFailed': '儲存失敗', 'addressBookManagement.deviceNotSelected': '請先選擇一個設備', 'addressBookManagement.addDeviceTip': '新增設備功能開發中', 'addressBookManagement.selectAgentFirst': '請先選擇智能體', @@ -1553,4 +1658,4 @@ export default { 'addressBookManagement.monthsAgo': '{months}個月前', 'addressBookManagement.yearsAgo': '{years}年前', -} \ No newline at end of file +} diff --git a/main/manager-web/src/utils/date.js b/main/manager-web/src/utils/date.js index c6210bc0..84b0a927 100755 --- a/main/manager-web/src/utils/date.js +++ b/main/manager-web/src/utils/date.js @@ -39,6 +39,7 @@ function formatDateTool(date, fmt) { const o = { 'M+': date.getMonth() + 1, 'd+': date.getDate(), + 'H+': date.getHours(), 'h+': date.getHours(), 'm+': date.getMinutes(), 's+': date.getSeconds() diff --git a/main/manager-web/src/views/roleConfig.vue b/main/manager-web/src/views/roleConfig.vue index bc1fa900..d6dcf4bb 100644 --- a/main/manager-web/src/views/roleConfig.vue +++ b/main/manager-web/src/views/roleConfig.vue @@ -16,6 +16,9 @@ {{ form.agentName }} + + {{ $t("roleConfig.currentVersion", { version: currentVersionNo }) }} +
{{ $t("roleConfig.restartNotice") }}
+ + {{ $t("roleConfig.snapshotHistory") }} + {{ $t("roleConfig.saveConfig") }} @@ -313,7 +319,7 @@ placement="top" >
-
功能名称: {{ func.name }}
+
{{ $t("roleConfig.functionName") }}: {{ func.name }}
{{ getFunctionDisplayChar(func.name) }} @@ -457,6 +463,13 @@ :checked-replacement-word-ids="checkedReplacementWordIds" @save="handleTtsSettingsSave" /> + @@ -470,6 +483,7 @@ import RequestService from "@/apis/httpRequest"; import FunctionDialog from "@/components/FunctionDialog.vue"; import ContextProviderDialog from "@/components/ContextProviderDialog.vue"; import TtsAdvancedSettings from "@/components/TtsAdvancedSettings.vue"; +import AgentSnapshotDialog from "@/components/AgentSnapshotDialog.vue"; import HeaderBar from "@/components/HeaderBar.vue"; import i18n from "@/i18n"; import featureManager from "@/utils/featureManager"; @@ -477,11 +491,12 @@ import VersionFooter from "@/components/VersionFooter.vue"; export default { name: "RoleConfigPage", - components: { HeaderBar, FunctionDialog, ContextProviderDialog, TtsAdvancedSettings, VersionFooter }, + components: { HeaderBar, FunctionDialog, ContextProviderDialog, TtsAdvancedSettings, AgentSnapshotDialog, VersionFooter }, data() { return { showContextProviderDialog: false, showTtsAdvancedDialog: false, + showSnapshotDialog: false, ttsSettings: { volume: 0, speed: 0, @@ -529,6 +544,7 @@ export default { voiceOptions: [], voiceDetails: {}, // 保存完整的音色信息 showFunctionDialog: false, + currentVersionNo: null, currentFunctions: [], currentContextProviders: [], allFunctions: [], @@ -546,6 +562,7 @@ export default { asr: false, // 语音识别功能状态 }, dynamicTags: [], + originalTagNames: [], inputVisible: false, inputValue: '', checkedReplacementWordIds: [] @@ -555,14 +572,26 @@ export default { goToHome() { this.$router.push("/home"); }, - async saveConfig() { - try { - await this.handleSaveAgentTags(this.$route.query.agentId); - } catch (error) { - console.error('保存标签失败:', error); - return; + normalizeFunctionParams(params, fallback = {}) { + if (params === null || params === undefined || params === '') { + return { ...fallback }; } - + if (typeof params === 'string') { + try { + const parsed = JSON.parse(params); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed + : { ...fallback }; + } catch (error) { + return { ...fallback }; + } + } + if (typeof params === 'object' && !Array.isArray(params)) { + return { ...params }; + } + return { ...fallback }; + }, + async saveConfig() { const configData = { agentCode: this.form.agentCode, agentName: this.form.agentName, @@ -585,12 +614,17 @@ export default { functions: this.currentFunctions.map((item) => { return { pluginId: item.id, - paramInfo: item.params, + paramInfo: this.normalizeFunctionParams(item.params), }; }), contextProviders: this.currentContextProviders, correctWordFileIds: this.checkedReplacementWordIds, }; + const tagNames = this.dynamicTags.map(tag => tag.tagName); + const tagsChanged = !this.isSameStringList(tagNames, this.originalTagNames); + if (tagsChanged) { + configData.tagNames = tagNames; + } // 只在用户设置了TTS参数时才传递(不为null/undefined) if (this.form.ttsVolume !== null && this.form.ttsVolume !== undefined) { @@ -602,12 +636,20 @@ export default { if (this.form.ttsPitch !== null && this.form.ttsPitch !== undefined) { configData.ttsPitch = this.form.ttsPitch; } - Api.agent.updateAgentConfig(this.$route.query.agentId, configData, ({ data }) => { + const agentId = this.$route.query.agentId; + Api.agent.updateAgentConfig(agentId, configData, ({ data }) => { if (data.code === 0) { - this.$message.success({ - message: i18n.t("roleConfig.saveSuccess"), - showClose: true, - }); + const afterSave = () => { + if (tagsChanged) { + this.originalTagNames = [...tagNames]; + } + this.$message.success({ + message: i18n.t("roleConfig.saveSuccess"), + showClose: true, + }); + this.fetchCurrentVersion(agentId); + }; + afterSave(); } else { this.$message.error({ message: data.msg || i18n.t("roleConfig.saveFailed"), @@ -617,6 +659,26 @@ export default { }); }, + handleSnapshotRestored() { + const agentId = this.$route.query.agentId; + if (agentId) { + this.fetchAgentConfig(agentId); + this.getAgentTags(agentId); + this.fetchCurrentVersion(agentId); + } + }, + fetchCurrentVersion(agentId) { + if (!agentId) { + this.currentVersionNo = null; + return; + } + + Api.agent.getDeviceConfig(agentId, ({ data }) => { + if (data.code === 0) { + this.currentVersionNo = data.data?.currentVersionNo || null; + } + }); + }, resetConfig() { this.$confirm(i18n.t("roleConfig.confirmReset"), i18n.t("message.info"), { confirmButtonText: i18n.t("button.ok"), @@ -754,7 +816,7 @@ export default { id: mapping.pluginId, name: meta.name, // 后端如果还有 paramInfo 字段就用 mapping.paramInfo,否则用 meta.params 默认值 - params: mapping.paramInfo || { ...meta.params }, + params: this.normalizeFunctionParams(mapping.paramInfo, meta.params), fieldsMeta: meta.fieldsMeta, // 保留以便对话框渲染 tooltip }; }); @@ -806,7 +868,7 @@ export default { }); this.$set(this.modelOptions, model.type, LLMdata); } else { - this.$message.error(data.msg || "获取LLM模型列表失败"); + this.$message.error(data.msg || i18n.t("roleConfig.fetchModelsFailed")); } }); } @@ -1329,7 +1391,7 @@ export default { handleInputConfirm() { let inputValue = this.inputValue; if (inputValue) { - const tag = { id: new Date().getTime(), tagName: inputValue }; + const tag = { id: `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, tagName: inputValue }; this.dynamicTags.push(tag); } this.inputVisible = false; @@ -1339,14 +1401,21 @@ export default { Api.agent.getAgentTags(agentId, ({ data }) => { if (data.code === 0) { this.dynamicTags = data.data || []; + this.originalTagNames = this.dynamicTags.map(tag => tag.tagName); } }); }, - handleSaveAgentTags(agentId) { + isSameStringList(left, right) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + return left.every((value, index) => value === right[index]); + }, + handleSaveAgentTags(agentId, tagNames = this.dynamicTags.map(tag => tag.tagName)) { return new Promise((resolve, reject) => { - const tagNames = this.dynamicTags.map(tag => tag.tagName); Api.agent.saveAgentTags(agentId, { tagNames }, ({ data }) => { if (data.code === 0) { + this.originalTagNames = [...tagNames]; resolve(); } else { reject(data.msg); @@ -1382,6 +1451,7 @@ export default { this.fetchAgentConfig(agentId); this.getAgentTags(agentId); this.fetchAllFunctions(); + this.fetchCurrentVersion(agentId); } this.fetchModelOptions(); this.fetchTemplates(); @@ -1507,6 +1577,18 @@ export default { flex-shrink: 0; } +.current-version-tag { + flex-shrink: 0; + padding: 3px 9px; + border: 1px solid #dfe7ff; + border-radius: 999px; + background: #f4f7ff; + color: #5778ff; + font-size: 12px; + font-weight: 500; + line-height: 1.5; +} + .more-tag { cursor: pointer; flex-shrink: 0; @@ -1785,6 +1867,16 @@ export default { font-size: 14px; } +.header-actions .history-btn { + background: #ffffff; + color: #4d5b7c; + border: 1px solid #d8dce8; + border-radius: 18px; + padding: 8px 16px; + height: 32px; + font-size: 14px; +} + .header-actions .reset-btn { background: #e6ebff; color: #5778ff; diff --git a/main/xiaozhi-server/app.py b/main/xiaozhi-server/app.py index bdefe42d..9fb3a9ea 100644 --- a/main/xiaozhi-server/app.py +++ b/main/xiaozhi-server/app.py @@ -45,7 +45,7 @@ async def monitor_stdin(): async def main(): check_ffmpeg_installed() - config = load_config() + config = await load_config() # auth_key优先级:配置文件server.auth_key > manager-api.secret > 自动生成 # auth_key用于jwt认证,比如视觉分析接口的jwt认证、ota接口的token生成与websocket认证 diff --git a/main/xiaozhi-server/config/assets/wakeup_words/ed76d459636c2481aec828516c1b4f54.wav b/main/xiaozhi-server/config/assets/wakeup_words/ed76d459636c2481aec828516c1b4f54.wav deleted file mode 100644 index 1e7246be..00000000 Binary files a/main/xiaozhi-server/config/assets/wakeup_words/ed76d459636c2481aec828516c1b4f54.wav and /dev/null differ diff --git a/main/xiaozhi-server/config/config_loader.py b/main/xiaozhi-server/config/config_loader.py index 33a57e72..9e7e1c11 100644 --- a/main/xiaozhi-server/config/config_loader.py +++ b/main/xiaozhi-server/config/config_loader.py @@ -7,7 +7,6 @@ from config.manage_api_client import ( get_server_config, get_agent_models, get_correct_words, - lookup_address_book, DeviceNotFoundException, DeviceBindException, ) @@ -24,7 +23,7 @@ def read_config(config_path): return config -def load_config(): +async def load_config(): """加载配置文件""" from core.utils.cache.manager import cache_manager, CacheType @@ -41,16 +40,7 @@ def load_config(): custom_config = read_config(custom_config_path) if custom_config.get("manager-api", {}).get("url"): - 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)) + config = await get_config_from_api_async(custom_config) else: # 合并配置 config = merge_configs(default_config, custom_config) @@ -139,7 +129,7 @@ def ensure_directories(config): selected_provider = selected_modules.get(module_type) if not selected_provider: continue - if config.get(module) is None: + if config.get(module_type) is None: continue if config.get(selected_provider) is None: continue diff --git a/main/xiaozhi-server/config/logger.py b/main/xiaozhi-server/config/logger.py index ee1b4c36..34737247 100644 --- a/main/xiaozhi-server/config/logger.py +++ b/main/xiaozhi-server/config/logger.py @@ -1,9 +1,10 @@ import os import sys +import asyncio from loguru import logger from config.config_loader import load_config from config.settings import check_config_file -from datetime import datetime +from core.utils.cache.manager import cache_manager, CacheType SERVER_VERSION = "0.9.5" _logger_initialized = False @@ -45,10 +46,15 @@ def formatter(record): return record["message"] -def setup_logging(): - check_config_file() +def setup_logging(config=None): """从配置文件中读取日志配置,并设置日志输出格式和级别""" - config = load_config() + if config is None: + check_config_file() + # 先查缓存,避免在 async 上下文中重复 await load_config + config = cache_manager.get(CacheType.CONFIG, "main_config") + if config is None: + # 缓存也没有(理论上不该发生),才走 asyncio.run + config = asyncio.run(load_config()) log_config = config["log"] global _logger_initialized diff --git a/main/xiaozhi-server/config/manage_api_client.py b/main/xiaozhi-server/config/manage_api_client.py index 7cc4c361..8eb2da20 100644 --- a/main/xiaozhi-server/config/manage_api_client.py +++ b/main/xiaozhi-server/config/manage_api_client.py @@ -73,6 +73,7 @@ class ManageApiClient: }, timeout=cls.config.get("timeout", 30), limits=limits, # 使用限制 + trust_env=False, ) return cls._async_clients[loop_id] except RuntimeError: diff --git a/main/xiaozhi-server/config/settings.py b/main/xiaozhi-server/config/settings.py index fb8868b4..cfca89b6 100644 --- a/main/xiaozhi-server/config/settings.py +++ b/main/xiaozhi-server/config/settings.py @@ -1,4 +1,5 @@ import os +import asyncio from config.config_loader import read_config, get_project_dir, load_config @@ -20,7 +21,7 @@ def check_config_file(): ) # 检查是否从API读取配置 - config = load_config() + config = asyncio.run(load_config()) if config.get("read_config_from_api", False): print("从API读取配置") old_config_origin = read_config(custom_config_file) diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index 8772180c..fcf5183b 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -11,6 +11,8 @@ import threading import traceback import subprocess import websockets +import opuslib_next +import numpy as np from core.utils.util import ( extract_json_from_string, @@ -114,6 +116,7 @@ class ConnectionHandler: self.client_abort = False self.client_is_speaking = False self.client_listen_mode = "auto" + self.client_aec = False # 是否启用了服务端AEC # 线程任务相关 self.loop = None # 在 handle_connection 中获取运行中的事件循环 @@ -153,7 +156,7 @@ class ConnectionHandler: # asr相关变量 # 因为实际部署时可能会用到公共的本地ASR,不能把变量暴露给公共ASR # 所以涉及到ASR的变量,需要在这里定义,属于connection的私有变量 - self.asr_audio = [] + self.asr_audio = [] # 存储PCM帧列表,供VAD和ASR共享 self.asr_audio_queue = queue.Queue() self.current_speaker = None # 存储当前说话人 @@ -231,6 +234,9 @@ class ConnectionHandler: # 启动超时检查任务 self.timeout_task = asyncio.create_task(self._check_timeout()) + # 启动AEC缓存清理任务 + self._aec_cache_cleanup_task = asyncio.create_task(self._check_aec_cache_expiry()) + self.welcome_msg = self.config["xiaozhi"] self.welcome_msg["session_id"] = self.session_id @@ -366,12 +372,14 @@ class ConnectionHandler: if handled: return - # 不需要头部处理或没有头部时,直接处理原始消息 - self.asr_audio_queue.put(message) + # 入口处直接解码PCM,避免VAD和ASR重复解码 + pcm_frame = self._decode_opus_packet(message) + if pcm_frame: + self.asr_audio_queue.put(pcm_frame) async def _process_mqtt_audio_message(self, message): """ - 处理来自MQTT网关的音频消息,解析16字节头部并提取音频数据 + 处理来自MQTT网关的音频消息,解析16字节头部并提取音频数据,在入队前进行AEC处理 Args: message: 包含头部的音频消息 @@ -380,58 +388,192 @@ class ConnectionHandler: bool: 是否成功处理了消息 """ try: - # 提取头部信息 + # 解析timestamp timestamp = int.from_bytes(message[8:12], "big") - audio_length = int.from_bytes(message[12:16], "big") - # 提取音频数据 - if audio_length > 0 and len(message) >= 16 + audio_length: - # 有指定长度,提取精确的音频数据 - audio_data = message[16 : 16 + audio_length] - # 基于时间戳进行排序处理 - self._process_websocket_audio(audio_data, timestamp) - return True - elif len(message) > 16: - # 没有指定长度或长度无效,去掉头部后处理剩余数据 - audio_data = message[16:] - self.asr_audio_queue.put(audio_data) + audio_data = message[16:] + # 入口直接解码PCM + pcm_frame = self._decode_opus_packet(audio_data) + if not pcm_frame: return True + + # AEC处理:如果timestamp>0且启用了AEC + if timestamp > 0 and self.client_aec: + pcm_frame = self._apply_aec(timestamp, pcm_frame) + + self.asr_audio_queue.put(pcm_frame) + return True except Exception as e: self.logger.bind(tag=TAG).error(f"解析WebSocket音频包失败: {e}") # 处理失败,返回False表示需要继续处理 return False - def _process_websocket_audio(self, audio_data, timestamp): - """处理WebSocket格式的音频包""" - # 初始化时间戳序列管理 - if not hasattr(self, "audio_timestamp_buffer"): - self.audio_timestamp_buffer = {} - self.last_processed_timestamp = 0 - self.max_timestamp_buffer_size = 20 + def _apply_aec(self, timestamp: int, pcm_frame: bytes) -> bytes: + """应用AEC处理 - 综合算法:互相关延迟估计 + Wiener滤波 + 频谱减法""" + try: + import numpy as np - # 如果时间戳是递增的,直接处理 - if timestamp >= self.last_processed_timestamp: - self.asr_audio_queue.put(audio_data) - self.last_processed_timestamp = timestamp + if not pcm_frame or len(pcm_frame) == 0: + return pcm_frame - # 处理缓冲区中的后续包 - processed_any = True - while processed_any: - processed_any = False - for ts in sorted(self.audio_timestamp_buffer.keys()): - if ts > self.last_processed_timestamp: - buffered_audio = self.audio_timestamp_buffer.pop(ts) - self.asr_audio_queue.put(buffered_audio) - self.last_processed_timestamp = ts - processed_any = True - break - else: - # 乱序包,暂存 - if len(self.audio_timestamp_buffer) < self.max_timestamp_buffer_size: - self.audio_timestamp_buffer[timestamp] = audio_data + if not hasattr(self, "aec_audio_cache") or not self.aec_audio_cache: + return pcm_frame + + mic_audio = np.frombuffer(pcm_frame, dtype=np.int16).astype(np.float32) + mic_rms = np.sqrt(np.mean(mic_audio ** 2)) + + if mic_rms < 100: + return pcm_frame + + # 初始化 + if not hasattr(self, "_aec_filter_state"): + self._aec_filter_state = np.zeros(512) # 滤波器状态 + self._aec_delay_ms = 200 + self._aec_last_delay = 200 + self._aec_coherence_history = [] + + sorted_timestamps = sorted(self.aec_audio_cache.keys()) + if len(sorted_timestamps) < 2: + return pcm_frame + + # ========== 匹配参考帧(对数功率谱匹配) ========== + n = len(mic_audio) + sorted_timestamps = sorted(self.aec_audio_cache.keys()) + + # 找最接近的timestamp作为起点 + closest_idx = min(range(len(sorted_timestamps)), key=lambda i: abs(sorted_timestamps[i] - timestamp)) + + # 对数功率谱相关性计算 + def log_powerSpectrum_corr(audio1, audio2): + window = np.hanning(len(audio1)) + fft1 = np.fft.rfft(audio1 * window) + fft2 = np.fft.rfft(audio2 * window) + psd1 = np.abs(fft1) ** 2 + psd2 = np.abs(fft2) ** 2 + log1 = 10 * np.log10(psd1 + 1e-8) + log2 = 10 * np.log10(psd2 + 1e-8) + P_xy = np.dot(log1, log2) + P_xx = np.dot(log1, log1) + P_yy = np.dot(log2, log2) + return abs(P_xy) / (np.sqrt(P_xx) * np.sqrt(P_yy) + 1e-8) + + # 用对数功率谱匹配找最佳帧:前后各找2帧 + best_corr = -1 + best_ref_idx = closest_idx + + for offset in range(-2, 3): # T-2, T-1, T, T+1, T+2 + test_idx = closest_idx + offset + if test_idx < 0 or test_idx >= len(sorted_timestamps): + continue + test_ts = sorted_timestamps[test_idx] + test_ref = np.frombuffer(self.aec_audio_cache[test_ts], dtype=np.int16).astype(np.float32) + test_ref_rms = np.sqrt(np.mean(test_ref ** 2)) + if test_ref_rms < 50: + continue + + # 对数功率谱相关性 + corr = log_powerSpectrum_corr(mic_audio, test_ref) + + if corr > best_corr: + best_corr = corr + best_ref_idx = test_idx + + best_ts = sorted_timestamps[best_ref_idx] + + best_ref = np.frombuffer(self.aec_audio_cache[best_ts], dtype=np.int16).astype(np.float32) + ref_rms = np.sqrt(np.mean(best_ref ** 2)) + + if ref_rms < 50: + return pcm_frame + + # 对齐参考信号(直接截取相同长度) + aligned_ref = best_ref[:n] + if len(aligned_ref) < n: + aligned_ref = np.pad(aligned_ref, (0, n - len(aligned_ref))) + + # ========== 频域 AEC 处理(谱减法) ========== + # 时域信号经过声学路径后相位失真,导致时域相关性低且P_xy正负不定 + # 频域幅度谱不受相位影响,对数功率谱相关性稳定在0.97+ + # 公式:result_mag = max(|mic_fft| - |ref_fft| * scale * coef, 0) + + window = np.hanning(n) + mic_fft = np.fft.rfft(mic_audio * window) + ref_fft = np.fft.rfft(aligned_ref * window) + + mic_mag = np.abs(mic_fft) + ref_mag = np.abs(ref_fft) + mic_phase = np.angle(mic_fft) + + # 频域计算回声比例 scale + scale = np.sum(mic_mag * ref_mag) / (np.dot(ref_mag, ref_mag) + 1e-8) + + # 使用对数功率谱相关性作为coh + coh = best_corr + ref_power = ref_rms + + # 自适应系数:根据scale和coh动态调整 + # scale大(回声强)-> coef大;coh高(匹配准)-> coef大 + raw_coef = 1.0 + scale * 3 + (coh - 0.97) * 30 + coef = max(0.5, min(3.0, raw_coef)) + + # 谱减法(过减 + 半波整流) + if ref_power < 50: + output = mic_audio + coef = 0 + gain = 0 else: - self.asr_audio_queue.put(audio_data) + echo_mag = ref_mag * scale * coef + # 过减:使用较大系数抑制回声 + over_subtract = 1.5 + result_mag = np.maximum(mic_mag - echo_mag * over_subtract, mic_mag * 0.1) + + # 保留相位重建信号 + result_fft = result_mag * np.exp(1j * mic_phase) + output = np.fft.irfft(result_fft, n) + gain = scale * coef + + # 高置信度是纯回声时,再压一下确保VAD检测不到 + if coh >= 0.97 and ref_power > 500: + output = output * 0.3 + + # 后处理:限幅 + output = np.clip(output, -32768, 32767) + + # 转换为bytes + result = output.astype(np.int16).tobytes() + + return result + + except Exception as e: + self.logger.bind(tag=TAG).warning(f"[AEC] 处理失败: {e}") + return pcm_frame + + def _decode_opus_packet(self, opus_packet: bytes) -> bytes: + """ + 解码Opus数据包为PCM数据 + + Args: + opus_packet: Opus编码的音频数据 + + Returns: + bytes: 解码后的PCM数据,失败返回None + """ + try: + if not opus_packet or len(opus_packet) == 0: + return None + + self._init_connection_state(self) + pcm_frame = self._connection_opus_decoder.decode(opus_packet, 960) + return pcm_frame + except Exception as e: + self.logger.bind(tag=TAG).debug(f"Opus解码失败: {e}") + return None + + def _init_connection_state(self, conn): + """为连接初始化独立的Opus解码器""" + if not hasattr(conn, "_connection_opus_decoder"): + conn._connection_opus_decoder = opuslib_next.Decoder(16000, 1) async def handle_restart(self, message): """处理服务器重启请求""" @@ -1403,6 +1545,13 @@ class ConnectionHandler: ): self.vad.release_conn_resources(self) + # 清理opus解码器 + if hasattr(self, "_connection_opus_decoder"): + try: + delattr(self, "_connection_opus_decoder") + except Exception: + pass + # 清理音频缓冲区 if hasattr(self, "audio_buffer"): self.audio_buffer.clear() @@ -1416,6 +1565,20 @@ class ConnectionHandler: pass self.timeout_task = None + # 取消AEC缓存清理任务 + if hasattr(self, "_aec_cache_cleanup_task") and self._aec_cache_cleanup_task and not self._aec_cache_cleanup_task.done(): + self._aec_cache_cleanup_task.cancel() + try: + await self._aec_cache_cleanup_task + except asyncio.CancelledError: + pass + self._aec_cache_cleanup_task = None + + # 清理AEC缓存 + if hasattr(self, "aec_audio_cache"): + self.aec_audio_cache.clear() + self.aec_audio_cache_time.clear() + # 清理工具处理器资源 if hasattr(self, "func_handler") and self.func_handler: try: @@ -1579,6 +1742,26 @@ class ConnectionHandler: finally: self.logger.bind(tag=TAG).info("超时检查任务已退出") + async def _check_aec_cache_expiry(self): + """定期清理过期的AEC缓存""" + try: + while not self.stop_event.is_set(): + if hasattr(self, "aec_audio_cache") and self.aec_audio_cache: + current_time = time.time() + expired_keys = [ + ts for ts, cache_time in list(self.aec_audio_cache_time.items()) + if current_time - cache_time > 120 # 2分钟过期 + ] + for ts in expired_keys: + self.aec_audio_cache.pop(ts, None) + self.aec_audio_cache_time.pop(ts, None) + if expired_keys: + self.logger.bind(tag=TAG).debug(f"[AEC] 清理过期缓存 {len(expired_keys)} 条") + # 每30秒检查一次 + await asyncio.sleep(30) + except Exception as e: + self.logger.bind(tag=TAG).error(f"AEC缓存清理任务出错: {e}") + @staticmethod def _extract_direct_answer_response(arguments_str): """从 direct_answer 的参数中提取 response 值。 diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py index 6bad8380..2f84a7b1 100644 --- a/main/xiaozhi-server/core/handle/helloHandle.py +++ b/main/xiaozhi-server/core/handle/helloHandle.py @@ -56,6 +56,9 @@ async def handleHelloMessage(conn: "ConnectionHandler", msg_json): conn.mcp_client = MCPClient() # 发送初始化 asyncio.create_task(send_mcp_initialize_message(conn)) + if features.get("aec"): + conn.logger.bind(tag=TAG).debug("客户端启用了服务端AEC") + conn.client_aec = True await conn.websocket.send(json.dumps(conn.welcome_msg)) diff --git a/main/xiaozhi-server/core/handle/receiveAudioHandle.py b/main/xiaozhi-server/core/handle/receiveAudioHandle.py index ae4673a0..63a9f610 100644 --- a/main/xiaozhi-server/core/handle/receiveAudioHandle.py +++ b/main/xiaozhi-server/core/handle/receiveAudioHandle.py @@ -14,9 +14,9 @@ from core.handle.sendAudioHandle import send_stt_message, SentenceType TAG = __name__ -async def handleAudioMessage(conn: "ConnectionHandler", audio): +async def handleAudioMessage(conn: "ConnectionHandler", pcm_frame): # 当前片段是否有人说话 - have_voice = conn.vad.is_vad(conn, audio) + have_voice = conn.vad.is_vad(conn, pcm_frame) # 如果设备刚刚被唤醒,短暂忽略VAD检测 if hasattr(conn, "just_woken_up") and conn.just_woken_up: have_voice = False @@ -24,10 +24,14 @@ async def handleAudioMessage(conn: "ConnectionHandler", audio): if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done(): conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn)) return + # 服务端AEC功能需要实时触发打断 + if conn.client_aec and have_voice: + if conn.client_is_speaking and conn.client_listen_mode != "manual": + await handleAbortMessage(conn) # 设备长时间空闲检测,用于say goodbye await no_voice_close_connect(conn, have_voice) # 接收音频 - await conn.asr.receive_audio(conn, audio, have_voice) + await conn.asr.receive_audio(conn, pcm_frame, have_voice) async def resume_vad_detection(conn: "ConnectionHandler"): diff --git a/main/xiaozhi-server/core/handle/reportHandle.py b/main/xiaozhi-server/core/handle/reportHandle.py index 97fe8b47..ea621f74 100644 --- a/main/xiaozhi-server/core/handle/reportHandle.py +++ b/main/xiaozhi-server/core/handle/reportHandle.py @@ -50,33 +50,27 @@ async def report(conn: "ConnectionHandler", type, text, opus_data, report_time): conn.logger.bind(tag=TAG).error(f"聊天记录上报失败: {e}") -def opus_to_wav(conn: "ConnectionHandler", opus_data): - """将Opus数据转换为WAV格式的字节流 +def opus_to_wav(conn: "ConnectionHandler", pcm_data): + """将PCM数据转换为WAV格式的字节流 Args: output_dir: 输出目录(保留参数以保持接口兼容) - opus_data: opus音频数据 + pcm_data: PCM音频数据(可能是列表或bytes) Returns: bytes: WAV格式的音频数据 """ - decoder = None try: - decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道 - pcm_data = [] + # 处理可能是列表或bytes的PCM数据 + if isinstance(pcm_data, list): + pcm_data_bytes = b"".join(pcm_data) + else: + pcm_data_bytes = pcm_data - for opus_packet in opus_data: - try: - pcm_frame = decoder.decode(opus_packet, 960) # 960 samples = 60ms - pcm_data.append(pcm_frame) - except opuslib_next.OpusError as e: - conn.logger.bind(tag=TAG).error(f"Opus解码错误: {e}", exc_info=True) - - if not pcm_data: + if not pcm_data_bytes: raise ValueError("没有有效的PCM数据") # 创建WAV文件头 - pcm_data_bytes = b"".join(pcm_data) num_samples = len(pcm_data_bytes) // 2 # 16-bit samples # WAV文件头 @@ -97,12 +91,9 @@ def opus_to_wav(conn: "ConnectionHandler", opus_data): # 返回完整的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}") + except Exception as e: + conn.logger.bind(tag=TAG).error(f"PCM转WAV失败: {e}", exc_info=True) + raise def enqueue_tts_report(conn: "ConnectionHandler", text, opus_data): diff --git a/main/xiaozhi-server/core/handle/sendAudioHandle.py b/main/xiaozhi-server/core/handle/sendAudioHandle.py index 17de11ae..421554d6 100644 --- a/main/xiaozhi-server/core/handle/sendAudioHandle.py +++ b/main/xiaozhi-server/core/handle/sendAudioHandle.py @@ -1,6 +1,7 @@ import json import time import asyncio +import opuslib_next from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -81,13 +82,24 @@ async def _send_to_mqtt_gateway( conn: "ConnectionHandler", opus_packet, timestamp, sequence ): """ - 发送带16字节头部的opus数据包给mqtt_gateway + 发送带16字节头部的opus数据包给mqtt_gateway,同时缓存音频用于AEC处理 Args: conn: 连接对象 opus_packet: opus数据包 timestamp: 时间戳 sequence: 序列号 """ + # 如果启用了服务端AEC,缓存PCM数据用于后续AEC处理 + if conn.client_aec and timestamp > 0: + if not hasattr(conn, "aec_audio_cache"): + conn.aec_audio_cache = {} + conn.aec_audio_cache_time = {} + conn._send_opus_decoder = opuslib_next.Decoder(16000, 1) + # 解码opus为PCM后缓存 + pcm_data = conn._send_opus_decoder.decode(bytes(opus_packet), 960) + conn.aec_audio_cache[timestamp] = bytes(pcm_data) + conn.aec_audio_cache_time[timestamp] = time.time() + # 为opus数据包添加16字节头部 header = bytearray(16) header[0] = 1 # type diff --git a/main/xiaozhi-server/core/providers/asr/aliyun.py b/main/xiaozhi-server/core/providers/asr/aliyun.py index 8824d17b..ce9f4d15 100644 --- a/main/xiaozhi-server/core/providers/asr/aliyun.py +++ b/main/xiaozhi-server/core/providers/asr/aliyun.py @@ -213,7 +213,7 @@ class ASRProvider(ASRProviderBase): return None async def speech_to_text( - self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None + self, opus_data: List[bytes], session_id: str, artifacts=None ) -> Tuple[Optional[str], Optional[str]]: """将语音数据转换为文本""" if self._is_token_expired(): diff --git a/main/xiaozhi-server/core/providers/asr/aliyun_stream.py b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py index 6c0bf093..74bf6137 100644 --- a/main/xiaozhi-server/core/providers/asr/aliyun_stream.py +++ b/main/xiaozhi-server/core/providers/asr/aliyun_stream.py @@ -7,7 +7,6 @@ import hashlib import asyncio import requests import websockets -import opuslib_next from urllib import parse from datetime import datetime from config.logger import setup_logging @@ -74,7 +73,6 @@ class ASRProvider(ASRProviderBase): self.interface_type = InterfaceType.STREAM self.config = config self.text = "" - self.decoder = opuslib_next.Decoder(16000, 1) self.asr_ws = None self.forward_task = None self.is_processing = False @@ -129,9 +127,9 @@ class ASRProvider(ASRProviderBase): async def open_audio_channels(self, conn): await super().open_audio_channels(conn) - async def receive_audio(self, conn, audio, audio_have_voice): + async def receive_audio(self, conn, pcm_frame, audio_have_voice): # 先调用父类方法处理基础逻辑 - await super().receive_audio(conn, audio, audio_have_voice) + await super().receive_audio(conn, pcm_frame, audio_have_voice) # 只在有声音且没有连接时建立连接(排除正在停止的情况) if audio_have_voice and not self.is_processing and not self.asr_ws: @@ -144,7 +142,6 @@ class ASRProvider(ASRProviderBase): if self.asr_ws and self.is_processing and self.server_ready: try: - pcm_frame = self.decoder.decode(audio, 960) await self.asr_ws.send(pcm_frame) except Exception as e: logger.bind(tag=TAG).warning(f"发送音频失败: {str(e)}") @@ -232,10 +229,9 @@ class ASRProvider(ASRProviderBase): # 发送缓存音频 if conn.asr_audio: - for cached_audio in conn.asr_audio[-10:]: + for cached_pcm in conn.asr_audio[-10:]: try: - pcm_frame = self.decoder.decode(cached_audio, 960) - await self.asr_ws.send(pcm_frame) + await self.asr_ws.send(cached_pcm) except Exception as e: logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}") break @@ -328,7 +324,7 @@ class ASRProvider(ASRProviderBase): logger.bind(tag=TAG).debug("ASR会话清理完成") - async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None): + async def speech_to_text(self, opus_data, session_id, artifacts=None): """获取识别结果""" result = self.text self.text = "" @@ -337,10 +333,3 @@ class ASRProvider(ASRProviderBase): async def close(self): """关闭资源""" await self._cleanup() - 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}") diff --git a/main/xiaozhi-server/core/providers/asr/aliyunbl_stream.py b/main/xiaozhi-server/core/providers/asr/aliyunbl_stream.py index 83b79655..a7c2953a 100644 --- a/main/xiaozhi-server/core/providers/asr/aliyunbl_stream.py +++ b/main/xiaozhi-server/core/providers/asr/aliyunbl_stream.py @@ -2,7 +2,6 @@ import json import uuid import asyncio import websockets -import opuslib_next from typing import List, TYPE_CHECKING if TYPE_CHECKING: @@ -22,7 +21,6 @@ class ASRProvider(ASRProviderBase): self.interface_type = InterfaceType.STREAM self.config = config self.text = "" - self.decoder = opuslib_next.Decoder(16000, 1) self.asr_ws = None self.forward_task = None self.is_processing = False @@ -55,9 +53,9 @@ class ASRProvider(ASRProviderBase): async def open_audio_channels(self, conn): await super().open_audio_channels(conn) - async def receive_audio(self, conn, audio, audio_have_voice): + async def receive_audio(self, conn, pcm_frame, audio_have_voice): # 先调用父类方法处理基础逻辑 - await super().receive_audio(conn, audio, audio_have_voice) + await super().receive_audio(conn, pcm_frame, audio_have_voice) # 只在有声音且没有连接时建立连接 if audio_have_voice and not self.is_processing and not self.asr_ws: @@ -71,8 +69,6 @@ class ASRProvider(ASRProviderBase): # 发送音频数据 if self.asr_ws and self.is_processing and self.server_ready: try: - pcm_frame = self.decoder.decode(audio, 960) - # 直接发送PCM音频数据(二进制) await self.asr_ws.send(pcm_frame) except Exception as e: logger.bind(tag=TAG).warning(f"发送音频失败: {str(e)}") @@ -179,10 +175,9 @@ class ASRProvider(ASRProviderBase): # 发送缓存音频 if conn.asr_audio: - for cached_audio in conn.asr_audio[-10:]: + for cached_pcm in conn.asr_audio[-10:]: try: - pcm_frame = self.decoder.decode(cached_audio, 960) - await self.asr_ws.send(pcm_frame) + await self.asr_ws.send(cached_pcm) except Exception as e: logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}") break @@ -312,7 +307,7 @@ class ASRProvider(ASRProviderBase): logger.bind(tag=TAG).debug("ASR会话清理完成") - async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None): + async def speech_to_text(self, opus_data, session_id, artifacts=None): """获取识别结果""" result = self.text self.text = "" @@ -320,11 +315,4 @@ class ASRProvider(ASRProviderBase): async def close(self): """关闭资源""" - await self._cleanup() - if hasattr(self, 'decoder') and self.decoder is not None: - try: - del self.decoder - self.decoder = None - logger.bind(tag=TAG).debug("Aliyun BL decoder resources released") - except Exception as e: - logger.bind(tag=TAG).debug(f"释放Aliyun BL decoder资源时出错: {e}") \ No newline at end of file + await self._cleanup() \ No newline at end of file diff --git a/main/xiaozhi-server/core/providers/asr/baidu.py b/main/xiaozhi-server/core/providers/asr/baidu.py index aa14f443..541bb010 100644 --- a/main/xiaozhi-server/core/providers/asr/baidu.py +++ b/main/xiaozhi-server/core/providers/asr/baidu.py @@ -30,7 +30,7 @@ class ASRProvider(ASRProviderBase): os.makedirs(self.output_dir, exist_ok=True) async def speech_to_text( - self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None + self, opus_data: List[bytes], session_id: str, artifacts=None ) -> Tuple[Optional[str], Optional[str]]: """将语音数据转换为文本""" if not opus_data: diff --git a/main/xiaozhi-server/core/providers/asr/base.py b/main/xiaozhi-server/core/providers/asr/base.py index 986dd275..dcfaf3a7 100644 --- a/main/xiaozhi-server/core/providers/asr/base.py +++ b/main/xiaozhi-server/core/providers/asr/base.py @@ -10,7 +10,6 @@ import asyncio import tempfile import traceback import threading -import opuslib_next from abc import ABC, abstractmethod from config.logger import setup_logging @@ -59,13 +58,13 @@ class ASRProviderBase(ABC): continue # 接收音频 - async def receive_audio(self, conn: "ConnectionHandler", audio, audio_have_voice): + async def receive_audio(self, conn: "ConnectionHandler", pcm_frame, audio_have_voice): if conn.client_listen_mode == "manual": # 手动模式:缓存音频用于ASR识别 - conn.asr_audio.append(audio) + conn.asr_audio.append(pcm_frame) else: # 自动/实时模式:使用VAD检测 - conn.asr_audio.append(audio) + conn.asr_audio.append(pcm_frame) # 如果没有语音,且之前也没有声音,缓存部分音频 if not audio_have_voice and not conn.client_have_voice: @@ -74,24 +73,21 @@ class ASRProviderBase(ABC): # 自动模式下通过VAD检测到语音停止时触发识别 if conn.asr.interface_type != InterfaceType.STREAM and conn.client_voice_stop: - asr_audio_task = conn.asr_audio.copy() + # 直接使用asr_audio中的PCM数据 + pcm_bytes = b"".join(conn.asr_audio) + # 检查是否有足够的音频数据(每帧1920字节,15帧约28800字节) + if len(pcm_bytes) > 1920 * 15: + await self.handle_voice_stop(conn, [pcm_bytes]) conn.reset_audio_states() - if len(asr_audio_task) > 15: - await self.handle_voice_stop(conn, asr_audio_task) - # 处理语音停止 async def handle_voice_stop(self, conn: "ConnectionHandler", 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) - + # 数据已经是PCM直接使用 + pcm_data = asr_audio_task combined_pcm_data = b"".join(pcm_data) # 预先准备WAV数据 @@ -101,7 +97,7 @@ class ASRProviderBase(ABC): # 定义ASR任务 asr_task = self.speech_to_text_wrapper( - asr_audio_task, conn.session_id, conn.audio_format + asr_audio_task, conn.session_id ) if conn.voiceprint_provider and wav_data: @@ -270,15 +266,11 @@ class ASRProviderBase(ABC): return file_path async def speech_to_text_wrapper( - self, opus_data: List[bytes], session_id: str, audio_format="opus" + self, pcm_data: List[bytes], session_id: str ) -> Tuple[Optional[str], Optional[str]]: file_path = None temp_path = None try: - if audio_format == "pcm": - pcm_data = opus_data - else: - pcm_data = self.decode_opus(opus_data) combined_pcm_data = b"".join(pcm_data) free_space = shutil.disk_usage(self.output_dir).free @@ -304,7 +296,7 @@ class ASRProviderBase(ABC): ) text, _ = await self.speech_to_text( - opus_data, session_id, audio_format, artifacts + pcm_data, session_id, artifacts ) return text, file_path except OSError as e: @@ -332,50 +324,13 @@ class ASRProviderBase(ABC): self, opus_data: List[bytes], session_id: str, - audio_format="opus", artifacts: Optional[AudioArtifacts] = None, ) -> Tuple[Optional[str], Optional[str]]: """将语音数据转换为文本 :param opus_data: 输入的Opus音频数据 :param session_id: 会话ID - :param audio_format: 音频格式,默认"opus" :param artifacts: 音频工件,包含PCM数据、文件路径等 :return: 识别结果文本和文件路径(如果有) """ pass - - @staticmethod - def decode_opus(opus_data: List[bytes]) -> List[bytes]: - """将Opus音频数据解码为PCM数据""" - decoder = None - try: - decoder = opuslib_next.Decoder(16000, 1) - pcm_data = [] - buffer_size = 960 # 每次处理960个采样点 (60ms at 16kHz) - - for i, opus_packet in enumerate(opus_data): - try: - if not opus_packet or len(opus_packet) == 0: - continue - - pcm_frame = decoder.decode(opus_packet, buffer_size) - if pcm_frame and len(pcm_frame) > 0: - pcm_data.append(pcm_frame) - - except opuslib_next.OpusError as e: - logger.bind(tag=TAG).warning(f"Opus解码错误,跳过数据包 {i}: {e}") - except Exception as e: - logger.bind(tag=TAG).error(f"音频处理错误,数据包 {i}: {e}") - - return pcm_data - - 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}") diff --git a/main/xiaozhi-server/core/providers/asr/doubao.py b/main/xiaozhi-server/core/providers/asr/doubao.py index 016ac0fd..ee8b7cc8 100644 --- a/main/xiaozhi-server/core/providers/asr/doubao.py +++ b/main/xiaozhi-server/core/providers/asr/doubao.py @@ -232,7 +232,7 @@ class ASRProvider(ASRProviderBase): yield data[offset:data_len], True async def speech_to_text( - self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None + self, opus_data: List[bytes], session_id: str, artifacts=None ) -> Tuple[Optional[str], Optional[str]]: """将语音数据转换为文本""" diff --git a/main/xiaozhi-server/core/providers/asr/doubao_stream.py b/main/xiaozhi-server/core/providers/asr/doubao_stream.py index 5c34aa77..130729ba 100644 --- a/main/xiaozhi-server/core/providers/asr/doubao_stream.py +++ b/main/xiaozhi-server/core/providers/asr/doubao_stream.py @@ -3,7 +3,6 @@ import gzip import uuid import asyncio import websockets -import opuslib_next from core.providers.asr.base import ASRProviderBase from config.logger import setup_logging from core.providers.asr.dto.dto import InterfaceType @@ -22,7 +21,6 @@ class ASRProvider(ASRProviderBase): self.interface_type = InterfaceType.STREAM self.config = config self.text = "" - self.decoder = opuslib_next.Decoder(16000, 1) self.asr_ws = None self.forward_task = None self.is_processing = False # 添加处理状态标志 @@ -68,10 +66,10 @@ class ASRProvider(ASRProviderBase): async def open_audio_channels(self, conn): await super().open_audio_channels(conn) - async def receive_audio(self, conn: "ConnectionHandler", audio, audio_have_voice): + async def receive_audio(self, conn: "ConnectionHandler", pcm_frame, audio_have_voice): # 先调用父类方法处理基础逻辑 - await super().receive_audio(conn, audio, audio_have_voice) - + await super().receive_audio(conn, pcm_frame, audio_have_voice) + # 如果本次有声音,且之前没有建立连接 if audio_have_voice and self.asr_ws is None and not self.is_processing: try: @@ -123,10 +121,9 @@ class ASRProvider(ASRProviderBase): # 发送缓存的音频数据 if conn.asr_audio and len(conn.asr_audio) > 0: - for cached_audio in conn.asr_audio[-10:]: + for cached_pcm in conn.asr_audio[-10:]: try: - pcm_frame = self.decoder.decode(cached_audio, 960) - payload = gzip.compress(pcm_frame) + payload = gzip.compress(cached_pcm) audio_request = bytearray( self.generate_audio_default_header() ) @@ -151,7 +148,6 @@ class ASRProvider(ASRProviderBase): # 发送当前音频数据 if self.asr_ws and self.is_processing and not self._is_stopping: try: - pcm_frame = self.decoder.decode(audio, 960) payload = gzip.compress(pcm_frame) audio_request = bytearray(self.generate_audio_default_header()) audio_request.extend(len(payload).to_bytes(4, "big")) @@ -414,7 +410,7 @@ class ASRProvider(ASRProviderBase): logger.bind(tag=TAG).error(f"原始响应数据: {res.hex()}") raise - async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None): + async def speech_to_text(self, opus_data, session_id, artifacts=None): result = self.text self.text = "" # 清空text return result, None @@ -432,12 +428,3 @@ 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}") diff --git a/main/xiaozhi-server/core/providers/asr/fun_local.py b/main/xiaozhi-server/core/providers/asr/fun_local.py index fa428710..5547104d 100644 --- a/main/xiaozhi-server/core/providers/asr/fun_local.py +++ b/main/xiaozhi-server/core/providers/asr/fun_local.py @@ -43,9 +43,13 @@ class ASRProvider(ASRProviderBase): # 内存检测,要求大于2G min_mem_bytes = 2 * 1024 * 1024 * 1024 - total_mem = psutil.virtual_memory().total - if total_mem < min_mem_bytes: - logger.bind(tag=TAG).error(f"可用内存不足2G,当前仅有 {total_mem / (1024*1024):.2f} MB,可能无法启动FunASR") + try: + total_mem = psutil.virtual_memory().total + except RuntimeError as e: + logger.bind(tag=TAG).warning(f"获取系统内存信息失败,跳过FunASR内存检测: {e}") + else: + if total_mem < min_mem_bytes: + logger.bind(tag=TAG).error(f"可用内存不足2G,当前仅有 {total_mem / (1024*1024):.2f} MB,可能无法启动FunASR") self.interface_type = InterfaceType.LOCAL self.model_dir = config.get("model_dir") @@ -65,7 +69,7 @@ class ASRProvider(ASRProviderBase): ) async def speech_to_text( - self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None + self, opus_data: List[bytes], session_id: str, artifacts=None ) -> Tuple[Optional[str], Optional[str]]: """语音转文本主处理逻辑""" retry_count = 0 diff --git a/main/xiaozhi-server/core/providers/asr/fun_server.py b/main/xiaozhi-server/core/providers/asr/fun_server.py index 19b5022d..d577a18e 100644 --- a/main/xiaozhi-server/core/providers/asr/fun_server.py +++ b/main/xiaozhi-server/core/providers/asr/fun_server.py @@ -101,7 +101,7 @@ class ASRProvider(ASRProviderBase): logger.bind(tag=TAG).debug(f"Sent end message: {end_message}") async def speech_to_text( - self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None + self, opus_data: List[bytes], session_id: str, artifacts=None ) -> Tuple[Optional[str], Optional[str]]: """ Convert speech data to text using FunASR. diff --git a/main/xiaozhi-server/core/providers/asr/openai.py b/main/xiaozhi-server/core/providers/asr/openai.py index 7b215589..c917cce5 100644 --- a/main/xiaozhi-server/core/providers/asr/openai.py +++ b/main/xiaozhi-server/core/providers/asr/openai.py @@ -24,7 +24,7 @@ class ASRProvider(ASRProviderBase): def requires_file(self) -> bool: return True - async def speech_to_text(self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None) -> Tuple[Optional[str], Optional[str]]: + async def speech_to_text(self, opus_data: List[bytes], session_id: str, artifacts=None) -> Tuple[Optional[str], Optional[str]]: file_path = None try: if artifacts is None: diff --git a/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py b/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py index 21438c1c..6444a0a4 100644 --- a/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py +++ b/main/xiaozhi-server/core/providers/asr/qwen3_asr_flash.py @@ -41,7 +41,7 @@ class ASRProvider(ASRProviderBase): return True async def speech_to_text( - self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None + self, opus_data: List[bytes], session_id: str, artifacts=None ) -> Tuple[Optional[str], Optional[str]]: """将语音数据转换为文本""" temp_file_path = None diff --git a/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py b/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py index 742c3567..54c981a6 100644 --- a/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py +++ b/main/xiaozhi-server/core/providers/asr/sherpa_onnx_local.py @@ -124,7 +124,7 @@ class ASRProvider(ASRProviderBase): return True async def speech_to_text( - self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None + self, opus_data: List[bytes], session_id: str, artifacts=None ) -> Tuple[Optional[str], Optional[str]]: """语音转文本主处理逻辑""" file_path = None diff --git a/main/xiaozhi-server/core/providers/asr/tencent.py b/main/xiaozhi-server/core/providers/asr/tencent.py index d873bd19..76a8867c 100644 --- a/main/xiaozhi-server/core/providers/asr/tencent.py +++ b/main/xiaozhi-server/core/providers/asr/tencent.py @@ -32,7 +32,7 @@ class ASRProvider(ASRProviderBase): os.makedirs(self.output_dir, exist_ok=True) async def speech_to_text( - self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None + self, opus_data: List[bytes], session_id: str, artifacts=None ) -> Tuple[Optional[str], Optional[str]]: """将语音数据转换为文本""" if not opus_data: diff --git a/main/xiaozhi-server/core/providers/asr/vosk.py b/main/xiaozhi-server/core/providers/asr/vosk.py index 77cbf986..0871c085 100644 --- a/main/xiaozhi-server/core/providers/asr/vosk.py +++ b/main/xiaozhi-server/core/providers/asr/vosk.py @@ -44,7 +44,7 @@ class ASRProvider(ASRProviderBase): raise async def speech_to_text( - self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None + self, opus_data: List[bytes], session_id: str, artifacts=None ) -> Tuple[Optional[str], Optional[str]]: """将语音数据转换为文本""" try: diff --git a/main/xiaozhi-server/core/providers/asr/xunfei_stream.py b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py index 5667106d..fa59ca34 100644 --- a/main/xiaozhi-server/core/providers/asr/xunfei_stream.py +++ b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py @@ -4,7 +4,6 @@ import base64 import hashlib import asyncio import websockets -import opuslib_next import gc from time import mktime from datetime import datetime @@ -33,7 +32,6 @@ class ASRProvider(ASRProviderBase): self.interface_type = InterfaceType.STREAM self.config = config self.text = "" - self.decoder = opuslib_next.Decoder(16000, 1) self.asr_ws = None self.forward_task = None self.is_processing = False @@ -100,9 +98,9 @@ class ASRProvider(ASRProviderBase): async def open_audio_channels(self, conn: "ConnectionHandler"): await super().open_audio_channels(conn) - async def receive_audio(self, conn: "ConnectionHandler", audio, audio_have_voice): + async def receive_audio(self, conn: "ConnectionHandler", pcm_frame, audio_have_voice): # 先调用父类方法处理基础逻辑 - await super().receive_audio(conn, audio, audio_have_voice) + await super().receive_audio(conn, pcm_frame, audio_have_voice) # 如果本次有声音,且之前没有建立连接 if audio_have_voice and self.asr_ws is None and not self.is_processing: @@ -116,7 +114,6 @@ class ASRProvider(ASRProviderBase): # 发送当前音频数据 if self.asr_ws and self.is_processing and self.server_ready: try: - pcm_frame = self.decoder.decode(audio, 960) await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME) except Exception as e: logger.bind(tag=TAG).warning(f"发送音频数据时发生错误: {e}") @@ -148,19 +145,15 @@ class ASRProvider(ASRProviderBase): # 发送首帧音频 if conn.asr_audio and len(conn.asr_audio) > 0: - first_audio = conn.asr_audio[-1] if conn.asr_audio else b"" - pcm_frame = ( - self.decoder.decode(first_audio, 960) if first_audio else b"" - ) - await self._send_audio_frame(pcm_frame, STATUS_FIRST_FRAME) + first_pcm = conn.asr_audio[-1] if conn.asr_audio else b"" + await self._send_audio_frame(first_pcm, STATUS_FIRST_FRAME) self.server_ready = True logger.bind(tag=TAG).info("已发送首帧,开始识别") # 发送缓存的音频数据 - for cached_audio in conn.asr_audio[-10:]: + for cached_pcm in conn.asr_audio[-10:]: try: - pcm_frame = self.decoder.decode(cached_audio, 960) - await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME) + await self._send_audio_frame(cached_pcm, STATUS_CONTINUE_FRAME) except Exception as e: logger.bind(tag=TAG).info(f"发送缓存音频数据时发生错误: {e}") break @@ -322,7 +315,7 @@ class ASRProvider(ASRProviderBase): logger.bind(tag=TAG).debug("ASR会话清理完成") - async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None): + async def speech_to_text(self, opus_data, session_id, artifacts=None): """获取识别结果""" result = self.text self.text = "" @@ -342,12 +335,3 @@ class ASRProvider(ASRProviderBase): 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}") - diff --git a/main/xiaozhi-server/core/providers/memory/mem0ai/mem0ai.py b/main/xiaozhi-server/core/providers/memory/mem0ai/mem0ai.py index cbf410ae..2dc93358 100644 --- a/main/xiaozhi-server/core/providers/memory/mem0ai/mem0ai.py +++ b/main/xiaozhi-server/core/providers/memory/mem0ai/mem0ai.py @@ -1,4 +1,5 @@ import json +import asyncio import traceback from ..base import MemoryProviderBase, logger @@ -59,7 +60,9 @@ class MemoryProvider(MemoryProviderBase): messages.append({"role": message.role, "content": content}) - result = self.client.add(messages, user_id=self.role_id) + result = await asyncio.to_thread( + 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)}") @@ -84,7 +87,9 @@ class MemoryProvider(MemoryProviderBase): except (json.JSONDecodeError, KeyError): pass - results = self.client.search(search_query, filters=filters) + results = await asyncio.to_thread( + self.client.search, search_query, filters=filters + ) if not results or "results" not in results: return "" diff --git a/main/xiaozhi-server/core/providers/memory/powermem/powermem.py b/main/xiaozhi-server/core/providers/memory/powermem/powermem.py index 3e96fdfd..72d6526f 100644 --- a/main/xiaozhi-server/core/providers/memory/powermem/powermem.py +++ b/main/xiaozhi-server/core/providers/memory/powermem/powermem.py @@ -186,13 +186,19 @@ class MemoryProvider(MemoryProviderBase): messages.append({"role": message.role, "content": content}) # Add memory using PowerMem SDK - result = self.memory_client.add( - messages=messages, - user_id=self.role_id - ) - # Handle both sync and async returns - if asyncio.iscoroutine(result): - result = await result + if self.enable_user_profile: + # UserMemory uses sync add + result = await asyncio.to_thread( + self.memory_client.add, + messages=messages, + user_id=self.role_id + ) + else: + # AsyncMemory uses async add + result = await self.memory_client.add( + messages=messages, + user_id=self.role_id + ) logger.bind(tag=TAG).debug(f"Save memory result: {result}") diff --git a/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py b/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py index 249e040c..37f4fb9d 100644 --- a/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py +++ b/main/xiaozhi-server/core/providers/tools/server_plugins/plugin_executor.py @@ -1,5 +1,6 @@ """服务端插件工具执行器""" +import asyncio from typing import Dict, Any, TYPE_CHECKING if TYPE_CHECKING: @@ -41,6 +42,10 @@ class ServerPluginExecutor(ToolExecutor): # 默认不传conn参数 result = func_item.func(**arguments) + # 兼容 async def 工具函数 + if asyncio.iscoroutine(result): + result = await result + return result except Exception as e: diff --git a/main/xiaozhi-server/core/providers/vad/silero.py b/main/xiaozhi-server/core/providers/vad/silero.py index 3682f3b8..d134f25d 100644 --- a/main/xiaozhi-server/core/providers/vad/silero.py +++ b/main/xiaozhi-server/core/providers/vad/silero.py @@ -1,7 +1,6 @@ import time import os import numpy as np -import opuslib_next import onnxruntime from config.logger import setup_logging from core.providers.vad.base import VADProviderBase @@ -39,8 +38,6 @@ class VADProvider(VADProviderBase): def _init_connection_state(self, conn): """为连接初始化独立的 VAD 状态""" - if not hasattr(conn, "_vad_opus_decoder"): - conn._vad_opus_decoder = opuslib_next.Decoder(16000, 1) if not hasattr(conn, "_vad_state"): conn._vad_state = np.zeros((2, 1, 128), dtype=np.float32) if not hasattr(conn, "_vad_context"): @@ -48,14 +45,14 @@ class VADProvider(VADProviderBase): def release_conn_resources(self, conn): """释放连接的 VAD 资源(连接关闭时调用)""" - for attr in ("_vad_opus_decoder", "_vad_state", "_vad_context"): + for attr in ("_vad_state", "_vad_context"): if hasattr(conn, attr): try: delattr(conn, attr) except Exception: pass - def is_vad(self, conn, opus_packet): + def is_vad(self, conn, pcm_frame): # 手动模式:直接返回True,不进行实时VAD检测,所有音频都缓存 if conn.client_listen_mode == "manual": return True @@ -63,7 +60,7 @@ class VADProvider(VADProviderBase): try: self._init_connection_state(conn) - pcm_frame = conn._vad_opus_decoder.decode(opus_packet, 960) + # pcm_frame已经是处理后的PCM数据 conn.client_audio_buffer.extend(pcm_frame) client_have_voice = False @@ -115,7 +112,5 @@ class VADProvider(VADProviderBase): conn.vad_last_voice_time = time.time() * 1000 return client_have_voice - except opuslib_next.OpusError as e: - logger.bind(tag=TAG).info(f"解码错误: {e}") except Exception as e: logger.bind(tag=TAG).error(f"Error processing audio packet: {e}") diff --git a/main/xiaozhi-server/core/utils/prompt_manager.py b/main/xiaozhi-server/core/utils/prompt_manager.py index af422529..edae6e9a 100644 --- a/main/xiaozhi-server/core/utils/prompt_manager.py +++ b/main/xiaozhi-server/core/utils/prompt_manager.py @@ -4,6 +4,8 @@ """ import os +import asyncio +import threading from typing import Dict, Any, TYPE_CHECKING if TYPE_CHECKING: @@ -169,12 +171,33 @@ class PromptManager: if cached_weather is not None: return cached_weather - # 缓存未命中,调用get_weather函数获取 + # 缓存未命中,调用 async get_weather 函数 + # Windows ProactorEventLoop 不支持 run_coroutine_threadsafe().result() + # 因此用 call_soon_threadsafe 提交任务 + threading.Event 等待结果 + # 注意:Event.wait() 只阻塞当前线程池线程,不阻塞主事件循环 from plugins_func.functions.get_weather import get_weather from plugins_func.register import ActionResponse - # 调用get_weather函数 - result = get_weather(conn, location=location, lang="zh_CN") + result_holder = [] + exception_holder = [] + + async def _call(): + try: + result_holder.append( + await get_weather(conn, location=location, lang="zh_CN") + ) + except Exception as e: + exception_holder.append(e) + finally: + event.set() + + event = threading.Event() + conn.loop.call_soon_threadsafe(lambda: asyncio.ensure_future(_call())) + if not event.wait(timeout=10): + raise TimeoutError("获取天气信息超时") + if exception_holder: + raise exception_holder[0] + result = result_holder[0] if isinstance(result, ActionResponse): weather_report = result.result self.cache_manager.set(self.CacheType.WEATHER, location, weather_report) diff --git a/main/xiaozhi-server/core/websocket_server.py b/main/xiaozhi-server/core/websocket_server.py index 077f7f4d..2ef7cda7 100644 --- a/main/xiaozhi-server/core/websocket_server.py +++ b/main/xiaozhi-server/core/websocket_server.py @@ -42,7 +42,7 @@ TAG = __name__ class WebSocketServer: def __init__(self, config: dict): self.config = config - self.logger = setup_logging() + self.logger = setup_logging(config) self.config_lock = asyncio.Lock() modules = initialize_modules( self.logger, diff --git a/main/xiaozhi-server/plugins_func/functions/call_device.py b/main/xiaozhi-server/plugins_func/functions/call_device.py index cfca1f33..e2b08fb5 100644 --- a/main/xiaozhi-server/plugins_func/functions/call_device.py +++ b/main/xiaozhi-server/plugins_func/functions/call_device.py @@ -1,9 +1,8 @@ """呼叫设备工具""" -import requests -from typing import TYPE_CHECKING - +import httpx from config.logger import setup_logging from plugins_func.register import register_function, ToolType, ActionResponse, Action +from typing import TYPE_CHECKING if TYPE_CHECKING: from core.connection import ConnectionHandler @@ -35,8 +34,9 @@ call_device_function_desc = { } -def _request_api(url: str, params: dict, headers: dict) -> requests.Response: - return requests.get(url, params=params, headers=headers, timeout=10) +async def _request_api(url: str, params: dict, headers: dict): + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: + return await client.get(url, params=params, headers=headers) def _failed_reply(msg: str) -> ActionResponse: @@ -49,7 +49,7 @@ def _is_answering(conn: "ConnectionHandler") -> bool: @register_function("call_device", call_device_function_desc, ToolType.SYSTEM_CTL) -def call_device(conn: "ConnectionHandler", nickname: str): +async def call_device(conn: "ConnectionHandler", nickname: str): caller_mac = conn.headers.get("device-id") if not caller_mac: return _failed_reply("无法获取本机MAC地址") @@ -71,13 +71,13 @@ def call_device(conn: "ConnectionHandler", nickname: str): # 查询通讯录并发起呼叫 try: - resp = _request_api( + resp = await _request_api( f"{api_url}/device/address-book/call", params=params, headers=headers, ) result = resp.json() - except requests.RequestException as e: + except httpx.HTTPError as e: logger.bind(tag=TAG).error(f"呼叫请求失败: {e}") return _failed_reply("呼叫失败,请稍后再试") diff --git a/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py b/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py index cf64825b..94cec718 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py +++ b/main/xiaozhi-server/plugins_func/functions/get_news_from_chinanews.py @@ -1,5 +1,5 @@ import random -import requests +import httpx import xml.etree.ElementTree as ET from bs4 import BeautifulSoup from config.logger import setup_logging @@ -44,11 +44,11 @@ GET_NEWS_FROM_CHINANEWS_FUNCTION_DESC = { } -def fetch_news_from_rss(rss_url): +async def fetch_news_from_rss(rss_url): """从RSS源获取新闻列表""" try: - response = requests.get(rss_url) - response.raise_for_status() + async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client: + response = await client.get(rss_url) # 解析XML root = ET.fromstring(response.content) @@ -86,11 +86,11 @@ def fetch_news_from_rss(rss_url): return [] -def fetch_news_detail(url): +async def fetch_news_detail(url): """获取新闻详情页内容并总结""" try: - response = requests.get(url) - response.raise_for_status() + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: + response = await client.get(url) soup = BeautifulSoup(response.content, "html.parser") @@ -148,7 +148,7 @@ def map_category(category_text): GET_NEWS_FROM_CHINANEWS_FUNCTION_DESC, ToolType.SYSTEM_CTL, ) -def get_news_from_chinanews( +async def get_news_from_chinanews( conn: "ConnectionHandler", category: str = None, detail: bool = False, @@ -180,7 +180,7 @@ def get_news_from_chinanews( logger.bind(tag=TAG).debug(f"获取新闻详情: {title}, URL={link}") # 获取新闻详情 - detail_content = fetch_news_detail(link) + detail_content = await fetch_news_detail(link) if not detail_content or detail_content == "无法获取详细内容": return ActionResponse( @@ -220,7 +220,7 @@ def get_news_from_chinanews( ) # 获取新闻列表 - news_items = fetch_news_from_rss(rss_url) + news_items = await fetch_news_from_rss(rss_url) if not news_items: return ActionResponse( diff --git a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py index cbf48cf0..8f0df016 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py +++ b/main/xiaozhi-server/plugins_func/functions/get_news_from_newsnow.py @@ -1,9 +1,8 @@ import random -import requests -import json +import httpx +from markitdown import MarkItDown from config.logger import setup_logging from plugins_func.register import register_function, ToolType, ActionResponse, Action -from markitdown import MarkItDown from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -110,7 +109,7 @@ GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = { } -def fetch_news_from_api(conn: "ConnectionHandler", source="thepaper"): +async def fetch_news_from_api(conn: "ConnectionHandler", source="thepaper"): """从API获取新闻列表""" try: api_url = f"https://newsnow.busiyi.world/api/s?id={source}" @@ -120,8 +119,8 @@ def fetch_news_from_api(conn: "ConnectionHandler", source="thepaper"): api_url = news_config["url"] + source headers = {"User-Agent": "Mozilla/5.0"} - response = requests.get(api_url, headers=headers, timeout=10) - response.raise_for_status() + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: + response = await client.get(api_url, headers=headers) data = response.json() @@ -136,12 +135,12 @@ def fetch_news_from_api(conn: "ConnectionHandler", source="thepaper"): return [] -def fetch_news_detail(url): +async def fetch_news_detail(url): """获取新闻详情页内容并使用MarkItDown清理HTML""" try: headers = {"User-Agent": "Mozilla/5.0"} - response = requests.get(url, headers=headers, timeout=10) - response.raise_for_status() + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: + response = await client.get(url, headers=headers) # 使用MarkItDown清理HTML内容 md = MarkItDown(enable_plugins=False) @@ -166,7 +165,7 @@ def fetch_news_detail(url): GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC, ToolType.SYSTEM_CTL, ) -def get_news_from_newsnow( +async def get_news_from_newsnow( conn: "ConnectionHandler", source: str = "澎湃新闻", detail: bool = False, @@ -206,7 +205,7 @@ def get_news_from_newsnow( ) # 获取新闻详情 - detail_content = fetch_news_detail(url) + detail_content = await fetch_news_detail(url) if not detail_content or detail_content == "无法获取详细内容": return ActionResponse( @@ -248,7 +247,7 @@ def get_news_from_newsnow( logger.bind(tag=TAG).info(f"获取新闻: 新闻源={source}({english_source_id})") # 获取新闻列表 - news_items = fetch_news_from_api(conn, english_source_id) + news_items = await fetch_news_from_api(conn, english_source_id) if not news_items: return ActionResponse( diff --git a/main/xiaozhi-server/plugins_func/functions/get_weather.py b/main/xiaozhi-server/plugins_func/functions/get_weather.py index 88870529..c0c4118e 100644 --- a/main/xiaozhi-server/plugins_func/functions/get_weather.py +++ b/main/xiaozhi-server/plugins_func/functions/get_weather.py @@ -1,4 +1,4 @@ -import requests +import httpx from bs4 import BeautifulSoup from config.logger import setup_logging from plugins_func.register import register_function, ToolType, ActionResponse, Action @@ -111,20 +111,23 @@ WEATHER_CODE_MAP = { } -def fetch_city_info(location, api_key, api_host): +async def fetch_city_info(location, api_key, api_host): url = f"https://{api_host}/geo/v2/city/lookup?key={api_key}&location={location}&lang=zh" - response = requests.get(url, headers=HEADERS).json() - if response.get("error") is not None: + async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client: + response = await client.get(url, headers=HEADERS) + data = response.json() + if data.get("error") is not None: logger.bind(tag=TAG).error( - f"获取天气失败,原因:{response.get('error', {}).get('detail')}" + f"获取天气失败,原因:{data.get('error', {}).get('detail')}" ) return None - return response.get("location", [])[0] if response.get("location") else None + return data.get("location", [])[0] if data.get("location") else None -def fetch_weather_page(url): - response = requests.get(url, headers=HEADERS) - return BeautifulSoup(response.text, "html.parser") if response.ok else None +async def fetch_weather_page(url): + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: + response = await client.get(url, headers=HEADERS) + return BeautifulSoup(response.text, "html.parser") if response.status_code == 200 else None def parse_weather_info(soup): @@ -159,7 +162,7 @@ def parse_weather_info(soup): @register_function("get_weather", GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL) -def get_weather(conn: "ConnectionHandler", location: str = None, lang: str = "zh_CN"): +async def get_weather(conn: "ConnectionHandler", location: str = None, lang: str = "zh_CN"): from core.utils.cache.manager import cache_manager, CacheType weather_config = conn.config.get("plugins", {}).get("get_weather", {}) @@ -195,12 +198,12 @@ def get_weather(conn: "ConnectionHandler", location: str = None, lang: str = "zh return ActionResponse(Action.REQLLM, cached_weather_report, None) # 缓存未命中,获取实时天气数据 - city_info = fetch_city_info(location, api_key, api_host) + city_info = await fetch_city_info(location, api_key, api_host) if not city_info: return ActionResponse( Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None ) - soup = fetch_weather_page(city_info["fxLink"]) + soup = await fetch_weather_page(city_info["fxLink"]) if not soup: return ActionResponse(Action.REQLLM, None, "请求失败") city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup) diff --git a/main/xiaozhi-server/plugins_func/functions/hass_get_state.py b/main/xiaozhi-server/plugins_func/functions/hass_get_state.py index 86e866c1..e56fe5a8 100644 --- a/main/xiaozhi-server/plugins_func/functions/hass_get_state.py +++ b/main/xiaozhi-server/plugins_func/functions/hass_get_state.py @@ -1,8 +1,7 @@ -from plugins_func.register import register_function, ToolType, ActionResponse, Action -from plugins_func.functions.hass_init import initialize_hass_handler +import httpx from config.logger import setup_logging -import asyncio -import requests +from plugins_func.functions.hass_init import initialize_hass_handler +from plugins_func.register import register_function, ToolType, ActionResponse, Action from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -31,11 +30,11 @@ hass_get_state_function_desc = { @register_function("hass_get_state", hass_get_state_function_desc, ToolType.SYSTEM_CTL) -def hass_get_state(conn: "ConnectionHandler", entity_id=""): +async def hass_get_state(conn: "ConnectionHandler", entity_id=""): try: - ha_response = handle_hass_get_state(conn, entity_id) + ha_response = await handle_hass_get_state(conn, entity_id) return ActionResponse(Action.REQLLM, ha_response, None) - except asyncio.TimeoutError: + except httpx.TimeoutException: logger.bind(tag=TAG).error("获取Home Assistant状态超时") return ActionResponse(Action.ERROR, "请求超时", None) except Exception as e: @@ -44,13 +43,16 @@ def hass_get_state(conn: "ConnectionHandler", entity_id=""): return ActionResponse(Action.ERROR, error_msg, None) -def handle_hass_get_state(conn: "ConnectionHandler", entity_id): +async def handle_hass_get_state(conn: "ConnectionHandler", entity_id): ha_config = initialize_hass_handler(conn) api_key = ha_config.get("api_key") base_url = ha_config.get("base_url") url = f"{base_url}/api/states/{entity_id}" headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} - response = requests.get(url, headers=headers, timeout=5) + + async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client: + response = await client.get(url, headers=headers) + if response.status_code == 200: responsetext = "设备状态:" + response.json()["state"] + " " logger.bind(tag=TAG).info(f"api返回内容: {response.json()}") @@ -92,8 +94,5 @@ def handle_hass_get_state(conn: "ConnectionHandler", entity_id): ) logger.bind(tag=TAG).info(f"查询返回内容: {responsetext}") return responsetext - # return response.json()['attributes'] - # response.attributes - else: return f"切换失败,错误码: {response.status_code}" diff --git a/main/xiaozhi-server/plugins_func/functions/hass_play_music.py b/main/xiaozhi-server/plugins_func/functions/hass_play_music.py index 7bd9f2a1..0d0b3eb7 100644 --- a/main/xiaozhi-server/plugins_func/functions/hass_play_music.py +++ b/main/xiaozhi-server/plugins_func/functions/hass_play_music.py @@ -1,8 +1,7 @@ -from plugins_func.register import register_function, ToolType, ActionResponse, Action -from plugins_func.functions.hass_init import initialize_hass_handler +import httpx from config.logger import setup_logging -import asyncio -import requests +from plugins_func.functions.hass_init import initialize_hass_handler +from plugins_func.register import register_function, ToolType, ActionResponse, Action from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -37,18 +36,17 @@ hass_play_music_function_desc = { @register_function( "hass_play_music", hass_play_music_function_desc, ToolType.SYSTEM_CTL ) -def hass_play_music(conn: "ConnectionHandler", entity_id="", media_content_id="random"): +async def hass_play_music(conn: "ConnectionHandler", entity_id="", media_content_id="random"): try: - # 执行音乐播放命令 - future = asyncio.run_coroutine_threadsafe( - handle_hass_play_music(conn, entity_id, media_content_id), conn.loop - ) - ha_response = future.result() + result = await handle_hass_play_music(conn, entity_id, media_content_id) return ActionResponse( - action=Action.RESPONSE, result="退出意图已处理", response=ha_response + action=Action.RECORD, result="指令已接收", response=result ) except Exception as e: logger.bind(tag=TAG).error(f"处理音乐意图错误: {e}") + return ActionResponse( + action=Action.RESPONSE, result=str(e), response="播放音乐时出错了" + ) async def handle_hass_play_music( @@ -60,7 +58,10 @@ async def handle_hass_play_music( url = f"{base_url}/api/services/music_assistant/play_media" headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} data = {"entity_id": entity_id, "media_id": media_content_id} - response = requests.post(url, headers=headers, json=data) + + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: + response = await client.post(url, headers=headers, json=data) + if response.status_code == 200: return f"正在播放{media_content_id}的音乐" else: diff --git a/main/xiaozhi-server/plugins_func/functions/hass_set_state.py b/main/xiaozhi-server/plugins_func/functions/hass_set_state.py index 62807f9b..293ca9e2 100644 --- a/main/xiaozhi-server/plugins_func/functions/hass_set_state.py +++ b/main/xiaozhi-server/plugins_func/functions/hass_set_state.py @@ -1,8 +1,7 @@ -from plugins_func.register import register_function, ToolType, ActionResponse, Action -from plugins_func.functions.hass_init import initialize_hass_handler +import httpx from config.logger import setup_logging -import asyncio -import requests +from plugins_func.functions.hass_init import initialize_hass_handler +from plugins_func.register import register_function, ToolType, ActionResponse, Action from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -54,13 +53,13 @@ hass_set_state_function_desc = { @register_function("hass_set_state", hass_set_state_function_desc, ToolType.SYSTEM_CTL) -def hass_set_state(conn: "ConnectionHandler", entity_id="", state=None): +async def hass_set_state(conn: "ConnectionHandler", entity_id="", state=None): if state is None: state = {} try: - ha_response = handle_hass_set_state(conn, entity_id, state) + ha_response = await handle_hass_set_state(conn, entity_id, state) return ActionResponse(Action.REQLLM, ha_response, None) - except asyncio.TimeoutError: + except httpx.TimeoutException: logger.bind(tag=TAG).error("设置Home Assistant状态超时") return ActionResponse(Action.ERROR, "请求超时", None) except Exception as e: @@ -69,7 +68,7 @@ def hass_set_state(conn: "ConnectionHandler", entity_id="", state=None): return ActionResponse(Action.ERROR, error_msg, None) -def handle_hass_set_state(conn: "ConnectionHandler", entity_id, state): +async def handle_hass_set_state(conn: "ConnectionHandler", entity_id, state): ha_config = initialize_hass_handler(conn) api_key = ha_config.get("api_key") base_url = ha_config.get("base_url") @@ -169,7 +168,10 @@ def handle_hass_set_state(conn: "ConnectionHandler", entity_id, state): data = {"entity_id": entity_id, arg: value} url = f"{base_url}/api/services/{domain}/{action}" headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} - response = requests.post(url, headers=headers, json=data, timeout=5) # 设置5秒超时 + + async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client: + response = await client.post(url, headers=headers, json=data) + logger.bind(tag=TAG).info( f"设置状态:{description},url:{url},return_code:{response.status_code}" ) diff --git a/main/xiaozhi-server/plugins_func/functions/play_music.py b/main/xiaozhi-server/plugins_func/functions/play_music.py index 18c605d2..fd21f9f8 100644 --- a/main/xiaozhi-server/plugins_func/functions/play_music.py +++ b/main/xiaozhi-server/plugins_func/functions/play_music.py @@ -5,10 +5,8 @@ import random import difflib import traceback from pathlib import Path -from core.handle.sendAudioHandle import send_stt_message -from plugins_func.register import register_function, ToolType, ActionResponse, Action -from core.utils.dialogue import Message from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType, ContentType +from plugins_func.register import register_function, ToolType, ActionResponse, Action from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -38,34 +36,12 @@ play_music_function_desc = { @register_function("play_music", play_music_function_desc, ToolType.SYSTEM_CTL) -def play_music(conn: "ConnectionHandler", song_name: str): +async def play_music(conn: "ConnectionHandler", song_name: str): try: music_intent = ( f"播放音乐 {song_name}" if song_name != "random" else "随机播放音乐" ) - - # 检查事件循环状态 - if not conn.loop.is_running(): - conn.logger.bind(tag=TAG).error("事件循环未运行,无法提交任务") - return ActionResponse( - action=Action.RESPONSE, result="系统繁忙", response="请稍后再试" - ) - - # 提交异步任务 - task = conn.loop.create_task( - handle_music_command(conn, music_intent) # 封装异步逻辑 - ) - - # 非阻塞回调处理 - def handle_done(f): - try: - f.result() # 可在此处理成功逻辑 - conn.logger.bind(tag=TAG).info("播放完成") - except Exception as e: - conn.logger.bind(tag=TAG).error(f"播放失败: {e}") - - task.add_done_callback(handle_done) - + await handle_music_command(conn, music_intent) return ActionResponse( action=Action.RECORD, result="指令已接收", response="正在为您播放音乐" ) diff --git a/main/xiaozhi-server/plugins_func/functions/search_from_ragflow.py b/main/xiaozhi-server/plugins_func/functions/search_from_ragflow.py index 79d546ee..40abf8c4 100644 --- a/main/xiaozhi-server/plugins_func/functions/search_from_ragflow.py +++ b/main/xiaozhi-server/plugins_func/functions/search_from_ragflow.py @@ -1,5 +1,5 @@ -import requests -import sys +import json +import httpx from config.logger import setup_logging from plugins_func.register import register_function, ToolType, ActionResponse, Action from typing import TYPE_CHECKING @@ -28,7 +28,7 @@ SEARCH_FROM_RAGFLOW_FUNCTION_DESC = { @register_function( "search_from_ragflow", SEARCH_FROM_RAGFLOW_FUNCTION_DESC, ToolType.SYSTEM_CTL ) -def search_from_ragflow(conn: "ConnectionHandler", question=None): +async def search_from_ragflow(conn: "ConnectionHandler", question=None): # 确保字符串参数正确处理编码 if question and isinstance(question, str): # 确保问题参数是UTF-8编码的字符串 @@ -49,13 +49,8 @@ def search_from_ragflow(conn: "ConnectionHandler", question=None): try: # 使用ensure_ascii=False确保JSON序列化时正确处理中文 - response = requests.post( - url, - json=payload, - headers=headers, - timeout=5, - verify=False, - ) + async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0), verify=False) as client: + response = await client.post(url, json=payload, headers=headers) # 显式设置响应的编码为utf-8 response.encoding = "utf-8" @@ -64,7 +59,6 @@ def search_from_ragflow(conn: "ConnectionHandler", question=None): # 先获取文本内容,然后手动处理JSON解码 response_text = response.text - import json result = json.loads(response_text) @@ -110,48 +104,30 @@ def search_from_ragflow(conn: "ConnectionHandler", question=None): context_text = "根据知识库查询结果,没有相关信息。" return ActionResponse(Action.REQLLM, context_text, None) - except requests.exceptions.RequestException as e: - # 网络请求异常 - error_type = type(e).__name__ - logger.bind(tag=TAG).error( - f"RAGflow网络请求失败,异常类型:{error_type},详情:{str(e)}" - ) - - # 根据异常类型提供更详细的错误信息和解决方案 - if isinstance(e, requests.exceptions.ConnectTimeout): - error_response = "RAG接口连接超时(5秒)" - error_response += "\n可能原因:RAGflow服务未启动或网络连接问题" - error_response += "\n解决方案:请检查RAGflow服务状态和网络连接" - - elif isinstance(e, requests.exceptions.ConnectionError): - error_response = "无法连接到RAG接口" - error_response += "\n可能原因:RAGflow服务地址错误或服务未运行" - error_response += "\n解决方案:请检查RAGflow服务地址配置和服务状态" - - elif isinstance(e, requests.exceptions.Timeout): - error_response = "RAG接口请求超时" - error_response += "\n可能原因:RAGflow服务响应缓慢或网络延迟" - error_response += "\n解决方案:请稍后重试或检查RAGflow服务性能" - - elif isinstance(e, requests.exceptions.HTTPError): - # 处理HTTP错误状态码 - if hasattr(e.response, "status_code"): - status_code = e.response.status_code - error_response = f"RAG接口HTTP错误(状态码:{status_code})" - - # 尝试获取响应内容中的错误信息 - try: - error_detail = e.response.json().get("error", {}).get("message", "") - if error_detail: - error_response += f"\n错误详情:{error_detail}" - except: - pass - else: - error_response = f"RAG接口HTTP异常:{str(e)}" + except httpx.TimeoutException as e: + error_response = "RAG接口请求超时" + error_response += "\n可能原因:RAGflow服务响应缓慢或网络延迟" + error_response += "\n解决方案:请稍后重试或检查RAGflow服务性能" + return ActionResponse(Action.RESPONSE, None, error_response) + except httpx.HTTPStatusError as e: + if hasattr(e.response, "status_code"): + status_code = e.response.status_code + error_response = f"RAG接口HTTP错误(状态码:{status_code})" + try: + error_detail = e.response.json().get("error", {}).get("message", "") + if error_detail: + error_response += f"\n错误详情:{error_detail}" + except: + pass else: - error_response = f"RAG接口网络异常({error_type}):{str(e)}" + error_response = f"RAG接口HTTP异常:{str(e)}" + return ActionResponse(Action.RESPONSE, None, error_response) + except httpx.HTTPError as e: + error_response = "无法连接到RAG接口" + error_response += "\n可能原因:RAGflow服务地址错误或服务未运行" + error_response += "\n解决方案:请检查RAGflow服务地址配置和服务状态" return ActionResponse(Action.RESPONSE, None, error_response) except Exception as e: diff --git a/main/xiaozhi-server/plugins_func/functions/web_search.py b/main/xiaozhi-server/plugins_func/functions/web_search.py index 6411a0ed..519f8b2b 100644 --- a/main/xiaozhi-server/plugins_func/functions/web_search.py +++ b/main/xiaozhi-server/plugins_func/functions/web_search.py @@ -1,4 +1,4 @@ -import requests +import httpx from config.logger import setup_logging from plugins_func.register import ( register_function, @@ -37,7 +37,7 @@ WEB_SEARCH_FUNCTION_DESC = { } -def _search_metaso(api_key: str, query: str, max_results: int) -> str: +async def _search_metaso(api_key: str, query: str, max_results: int) -> str: """调用秘塔搜索API""" url = "https://metaso.cn/api/v1/search" headers = { @@ -54,8 +54,8 @@ def _search_metaso(api_key: str, query: str, max_results: int) -> str: "conciseSnippet": False, } logger.bind(tag=TAG).debug(f"秘塔搜索请求 | URL: {url} | payload: {payload}") - response = requests.post(url, json=payload, headers=headers, timeout=15) - response.raise_for_status() + async with httpx.AsyncClient(timeout=httpx.Timeout(15.0, connect=3.0)) as client: + response = await client.post(url, json=payload, headers=headers) data = response.json() logger.bind(tag=TAG).debug(f"秘塔搜索响应 | status: {response.status_code}") @@ -77,7 +77,7 @@ def _search_metaso(api_key: str, query: str, max_results: int) -> str: return "\n".join(lines) -def _search_tavily(api_key: str, query: str, max_results: int) -> str: +async def _search_tavily(api_key: str, query: str, max_results: int) -> str: """调用Tavily搜索API""" url = "https://api.tavily.com/search" headers = { @@ -91,8 +91,8 @@ def _search_tavily(api_key: str, query: str, max_results: int) -> str: "include_answer": "advanced", } logger.bind(tag=TAG).debug(f"Tavily搜索请求 | URL: {url} | payload: {payload}") - response = requests.post(url, json=payload, headers=headers, timeout=15) - response.raise_for_status() + async with httpx.AsyncClient(timeout=httpx.Timeout(15.0, connect=3.0)) as client: + response = await client.post(url, json=payload, headers=headers) data = response.json() logger.bind(tag=TAG).debug(f"Tavily搜索响应 | status: {response.status_code} | data: {data}") @@ -113,7 +113,7 @@ def _search_tavily(api_key: str, query: str, max_results: int) -> str: @register_function("web_search", WEB_SEARCH_FUNCTION_DESC, ToolType.SYSTEM_CTL) -def web_search(conn: "ConnectionHandler", query: str = None): +async def web_search(conn: "ConnectionHandler", query: str = None): logger.bind(tag=TAG).info(f"web_search 被调用 | query={query}") if not query: return ActionResponse(Action.REQLLM, "请提供搜索关键词。", None) @@ -131,24 +131,22 @@ def web_search(conn: "ConnectionHandler", query: str = None): None, ) - if provider == "metaso": - search_fn = lambda: _search_metaso(api_key, query, max_results) - elif provider == "tavily": - search_fn = lambda: _search_tavily(api_key, query, max_results) - else: - return ActionResponse( - Action.REQLLM, - f"联网搜索功能未配置或配置的搜索源无效(当前:{provider}),请检查配置。", - None, - ) - try: - result_text = search_fn() + if provider == "metaso": + result_text = await _search_metaso(api_key, query, max_results) + elif provider == "tavily": + result_text = await _search_tavily(api_key, query, max_results) + else: + return ActionResponse( + Action.REQLLM, + f"联网搜索功能未配置或配置的搜索源无效(当前:{provider}),请检查配置。", + None, + ) logger.bind(tag=TAG).info(f"搜索结果组装完成:\n{result_text}") - except requests.exceptions.Timeout: + except httpx.TimeoutException: logger.bind(tag=TAG).error("联网搜索请求超时") result_text = "联网搜索请求超时,请稍后重试。" - except requests.exceptions.RequestException as e: + except httpx.HTTPStatusError as e: logger.bind(tag=TAG).error(f"联网搜索请求失败: {e}") result_text = "联网搜索请求失败,请稍后重试。" except Exception as e: