mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-23 23:53:55 +08:00
Fix agent snapshot history issues
This commit is contained in:
@@ -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<AgentSnapshotDataDTO, Object> snapshotGetter;
|
||||
private final Function<AgentUpdateDTO, Object> updateGetter;
|
||||
private final BiConsumer<AgentEntity, AgentSnapshotDataDTO> restoreApplier;
|
||||
|
||||
AgentSnapshotField(String fieldName, Function<AgentSnapshotDataDTO, Object> snapshotGetter,
|
||||
Function<AgentUpdateDTO, Object> updateGetter,
|
||||
BiConsumer<AgentEntity, AgentSnapshotDataDTO> restoreApplier) {
|
||||
this.fieldName = fieldName;
|
||||
this.snapshotGetter = snapshotGetter;
|
||||
this.updateGetter = updateGetter;
|
||||
this.restoreApplier = restoreApplier;
|
||||
}
|
||||
|
||||
public static List<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-18
@@ -48,11 +48,8 @@ import xiaozhi.modules.agent.service.AgentTagService;
|
||||
import xiaozhi.modules.agent.service.AgentChatAudioService;
|
||||
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||
import xiaozhi.modules.agent.service.AgentChatSummaryService;
|
||||
import xiaozhi.modules.agent.service.AgentContextProviderService;
|
||||
import xiaozhi.modules.agent.service.AgentPluginMappingService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
import xiaozhi.modules.correctword.service.CorrectWordFileService;
|
||||
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
|
||||
import xiaozhi.modules.agent.vo.AgentInfoVO;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
@@ -69,12 +66,9 @@ public class AgentController {
|
||||
private final DeviceService deviceService;
|
||||
private final AgentChatHistoryService agentChatHistoryService;
|
||||
private final AgentChatAudioService agentChatAudioService;
|
||||
private final AgentPluginMappingService agentPluginMappingService;
|
||||
private final AgentContextProviderService agentContextProviderService;
|
||||
private final AgentChatSummaryService agentChatSummaryService;
|
||||
private final RedisUtils redisUtils;
|
||||
private final AgentTagService agentTagService;
|
||||
private final CorrectWordFileService correctWordFileService;
|
||||
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "获取用户智能体列表")
|
||||
@@ -171,18 +165,7 @@ public class AgentController {
|
||||
@Operation(summary = "删除智能体")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> delete(@PathVariable String id) {
|
||||
// 先删除关联的设备
|
||||
deviceService.deleteByAgentId(id);
|
||||
// 删除关联的聊天记录
|
||||
agentChatHistoryService.deleteByAgentId(id, true, true);
|
||||
// 删除关联的插件
|
||||
agentPluginMappingService.deleteByAgentId(id);
|
||||
// 删除关联的上下文源配置
|
||||
agentContextProviderService.deleteByAgentId(id);
|
||||
// 删除关联的替换词文件关联记录
|
||||
correctWordFileService.deleteMappingsByAgentId(id);
|
||||
// 再删除智能体
|
||||
agentService.deleteById(id);
|
||||
agentService.deleteAgent(id);
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
|
||||
+13
-4
@@ -1,13 +1,12 @@
|
||||
package xiaozhi.modules.agent.controller;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -17,6 +16,7 @@ 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;
|
||||
@@ -35,7 +35,7 @@ public class AgentSnapshotController {
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<PageData<AgentSnapshotVO>> page(
|
||||
@PathVariable String agentId,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
@ParameterObject AgentSnapshotPageDTO params) {
|
||||
checkPermission(agentId);
|
||||
return new Result<PageData<AgentSnapshotVO>>().ok(agentSnapshotService.page(agentId, params));
|
||||
}
|
||||
@@ -57,6 +57,15 @@ public class AgentSnapshotController {
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{snapshotId}")
|
||||
@Operation(summary = "删除智能体历史快照")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> 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())) {
|
||||
|
||||
@@ -36,4 +36,11 @@ public interface AgentDao extends BaseDao<AgentEntity> {
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
AgentInfoVO selectAgentInfoById(@Param("agentId") String agentId);
|
||||
|
||||
/**
|
||||
* 锁定智能体主记录,用于串行化同一智能体的配置写入
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
AgentEntity selectByIdForUpdate(@Param("agentId") String agentId);
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ public interface AgentSnapshotDao extends BaseDao<AgentSnapshotEntity> {
|
||||
Integer selectMaxVersionNo(@Param("agentId") String agentId);
|
||||
|
||||
AgentSnapshotEntity selectNextSnapshot(@Param("agentId") String agentId, @Param("versionNo") Integer versionNo);
|
||||
|
||||
int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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<ContextProviderDTO> contextProviders;
|
||||
|
||||
@Schema(description = "创建者")
|
||||
|
||||
@@ -36,6 +36,12 @@ public class AgentSnapshotEntity {
|
||||
@Schema(description = "快照来源")
|
||||
private String source;
|
||||
|
||||
@Schema(description = "恢复来源快照ID")
|
||||
private String restoreFromSnapshotId;
|
||||
|
||||
@Schema(description = "恢复来源版本号")
|
||||
private Integer restoreFromVersionNo;
|
||||
|
||||
@Schema(description = "创建者")
|
||||
private Long creator;
|
||||
|
||||
|
||||
@@ -50,6 +50,13 @@ public interface AgentService extends BaseService<AgentEntity> {
|
||||
*/
|
||||
void deleteAgentByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 删除智能体及其关联数据
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
void deleteAgent(String agentId);
|
||||
|
||||
/**
|
||||
* 获取用户智能体列表
|
||||
*
|
||||
|
||||
+8
-3
@@ -1,9 +1,8 @@
|
||||
package xiaozhi.modules.agent.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.BaseService;
|
||||
import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentSnapshotEntity;
|
||||
import xiaozhi.modules.agent.vo.AgentSnapshotVO;
|
||||
@@ -11,9 +10,15 @@ import xiaozhi.modules.agent.vo.AgentSnapshotVO;
|
||||
public interface AgentSnapshotService extends BaseService<AgentSnapshotEntity> {
|
||||
void createSnapshot(String agentId, String source, AgentUpdateDTO pendingUpdate);
|
||||
|
||||
PageData<AgentSnapshotVO> page(String agentId, Map<String, Object> params);
|
||||
PageData<AgentSnapshotVO> 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);
|
||||
}
|
||||
|
||||
+41
-20
@@ -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;
|
||||
@@ -108,12 +107,13 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
agent.setContextProviders(contextProviderEntity.getContextProviders());
|
||||
}
|
||||
|
||||
// 查询替换词文件ID列表
|
||||
List<String> correctWordFileIds = correctWordFileService.getAgentCorrectWordFileIds(id);
|
||||
agent.setCorrectWordFileIds(correctWordFileIds);
|
||||
|
||||
// 无需额外查询插件列表,已通过SQL查询出来
|
||||
return agent;
|
||||
// 查询替换词文件ID列表
|
||||
List<String> correctWordFileIds = correctWordFileService.getAgentCorrectWordFileIds(id);
|
||||
agent.setCorrectWordFileIds(correctWordFileIds);
|
||||
agent.setCurrentVersionNo(agentSnapshotService.getCurrentVersionNo(id));
|
||||
|
||||
// 无需额外查询插件列表,已通过SQL查询出来
|
||||
return agent;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -136,15 +136,35 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
return super.insert(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAgentByUserId(Long userId) {
|
||||
UpdateWrapper<AgentEntity> wrapper = new UpdateWrapper<>();
|
||||
wrapper.eq("user_id", userId);
|
||||
baseDao.delete(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AgentDTO> getUserAgents(Long userId, String keyword, String searchType) {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteAgentByUserId(Long userId) {
|
||||
List<AgentEntity> agents = baseDao.selectList(new QueryWrapper<AgentEntity>()
|
||||
.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<AgentDTO> getUserAgents(Long userId, String keyword, String searchType) {
|
||||
QueryWrapper<AgentEntity> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("user_id", userId).orderByDesc("created_at");
|
||||
|
||||
@@ -285,12 +305,13 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateAgentById(String agentId, AgentUpdateDTO dto, boolean createSnapshot) {
|
||||
// 先查询现有实体
|
||||
AgentEntity existingEntity = this.getAgentById(agentId);
|
||||
if (existingEntity == null) {
|
||||
if (agentDao.selectByIdForUpdate(agentId) == null) {
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 锁定后查询现有实体和关联配置
|
||||
AgentEntity existingEntity = this.getAgentById(agentId);
|
||||
|
||||
if (createSnapshot) {
|
||||
agentSnapshotService.createSnapshot(agentId, "config", dto);
|
||||
}
|
||||
@@ -448,7 +469,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
agentTagService.saveAgentTags(agentId, dto.getTagIds(), dto.getTagNames());
|
||||
}
|
||||
|
||||
boolean b = validateLLMIntentParams(dto.getLlmModelId(), dto.getIntentModelId());
|
||||
boolean b = validateLLMIntentParams(existingEntity.getLlmModelId(), existingEntity.getIntentModelId());
|
||||
if (!b) {
|
||||
throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH);
|
||||
}
|
||||
|
||||
+396
-108
@@ -7,15 +7,19 @@ 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.dao.DuplicateKeyException;
|
||||
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;
|
||||
@@ -30,6 +34,7 @@ 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;
|
||||
@@ -38,6 +43,7 @@ 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;
|
||||
@@ -54,10 +60,14 @@ import xiaozhi.modules.security.user.SecurityUser;
|
||||
@AllArgsConstructor
|
||||
public class AgentSnapshotServiceImpl extends BaseServiceImpl<AgentSnapshotDao, AgentSnapshotEntity>
|
||||
implements AgentSnapshotService {
|
||||
private static final int MAX_SNAPSHOTS_PER_AGENT = 100;
|
||||
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {
|
||||
};
|
||||
private static final TypeReference<HashMap<String, Object>> PARAM_INFO_TYPE = new TypeReference<>() {
|
||||
};
|
||||
private static final TypeReference<Map<String, Object>> OBJECT_MAP_TYPE = new TypeReference<>() {
|
||||
};
|
||||
private static final String SECRET_PLACEHOLDER = "__SNAPSHOT_SECRET_REDACTED__";
|
||||
|
||||
private final AgentSnapshotDao agentSnapshotDao;
|
||||
private final AgentDao agentDao;
|
||||
@@ -73,33 +83,27 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl<AgentSnapshotDao,
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void createSnapshot(String agentId, String source, AgentUpdateDTO pendingUpdate) {
|
||||
AgentSnapshotDataDTO snapshotData = buildSnapshotData(agentId);
|
||||
AgentInfoVO agent = getAgentInfo(agentId);
|
||||
AgentSnapshotDataDTO snapshotData = buildSnapshotData(agent);
|
||||
List<String> changedFields = getChangedFields(snapshotData, pendingUpdate);
|
||||
if (pendingUpdate != null && changedFields.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
AgentInfoVO agent = agentDao.selectAgentInfoById(agentId);
|
||||
if (agent == null) {
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
AgentSnapshotEntity entity = new AgentSnapshotEntity();
|
||||
entity.setAgentId(agentId);
|
||||
entity.setUserId(agent.getUserId());
|
||||
entity.setSnapshotData(JsonUtils.toJsonString(snapshotData));
|
||||
entity.setChangedFields(JsonUtils.toJsonString(changedFields));
|
||||
entity.setSource(StringUtils.defaultIfBlank(source, "config"));
|
||||
entity.setCreator(SecurityUser.getUserId());
|
||||
entity.setCreatedAt(new Date());
|
||||
insertSnapshotWithRetry(entity);
|
||||
insertSnapshot(agentId, agent.getUserId(), source, snapshotData, changedFields);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageData<AgentSnapshotVO> page(String agentId, Map<String, Object> params) {
|
||||
IPage<AgentSnapshotEntity> page = getPage(params, "created_at", false);
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public PageData<AgentSnapshotVO> page(String agentId, AgentSnapshotPageDTO params) {
|
||||
ensureInitialSnapshot(agentId);
|
||||
AgentSnapshotPageDTO pageParams = params == null ? new AgentSnapshotPageDTO() : params;
|
||||
Page<AgentSnapshotEntity> page = new Page<>(pageParams.pageOrDefault(), pageParams.limitOrDefault());
|
||||
page.addOrder(OrderItem.desc("version_no"));
|
||||
IPage<AgentSnapshotEntity> result = agentSnapshotDao.selectPage(page,
|
||||
new QueryWrapper<AgentSnapshotEntity>().eq("agent_id", agentId));
|
||||
new QueryWrapper<AgentSnapshotEntity>()
|
||||
.eq("agent_id", agentId)
|
||||
.le(pageParams.getMaxVersionNo() != null, "version_no", pageParams.getMaxVersionNo()));
|
||||
List<AgentSnapshotVO> list = result.getRecords().stream()
|
||||
.map(entity -> toVO(entity, false))
|
||||
.toList();
|
||||
@@ -115,44 +119,109 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl<AgentSnapshotDao,
|
||||
@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("快照数据为空,无法恢复");
|
||||
}
|
||||
|
||||
createSnapshot(agentId, "restore", null);
|
||||
AgentInfoVO currentAgent = getAgentInfo(agentId);
|
||||
AgentSnapshotDataDTO currentData = buildSnapshotData(currentAgent);
|
||||
AgentSnapshotDataDTO restoreData = preserveCurrentSensitiveValues(data, currentData);
|
||||
List<String> changedFields = getChangedFields(currentData, restoreData);
|
||||
insertSnapshot(agentId, currentAgent.getUserId(), "restore", currentData, changedFields,
|
||||
snapshot.getId(), snapshot.getVersionNo());
|
||||
|
||||
AgentEntity agent = agentDao.selectById(agentId);
|
||||
if (agent == null) {
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
applyAgentFields(agent, data);
|
||||
applyAgentFields(agent, restoreData);
|
||||
validateRestoreParams(agent);
|
||||
applyMemoryPolicy(agent);
|
||||
agent.setUpdater(SecurityUser.getUserId());
|
||||
agent.setUpdatedAt(new Date());
|
||||
agentDao.updateById(agent);
|
||||
|
||||
restoreFunctions(agentId, data.getFunctions());
|
||||
restoreContextProviders(agentId, data.getContextProviders());
|
||||
correctWordFileService.saveAgentCorrectWords(agentId, nullToEmpty(data.getCorrectWordFileIds()));
|
||||
restoreTags(agentId, data);
|
||||
restoreFunctions(agentId, restoreData.getFunctions());
|
||||
restoreContextProviders(agentId, restoreData.getContextProviders());
|
||||
correctWordFileService.saveAgentCorrectWords(agentId, nullToEmpty(restoreData.getCorrectWordFileIds()));
|
||||
restoreTags(agentId, restoreData);
|
||||
}
|
||||
|
||||
private void insertSnapshotWithRetry(AgentSnapshotEntity entity) {
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
Integer maxVersionNo = agentSnapshotDao.selectMaxVersionNo(entity.getAgentId());
|
||||
entity.setVersionNo((maxVersionNo == null ? 0 : maxVersionNo) + 1);
|
||||
try {
|
||||
agentSnapshotDao.insert(entity);
|
||||
return;
|
||||
} catch (DuplicateKeyException e) {
|
||||
if (attempt == 2) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@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) + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteByAgentId(String agentId) {
|
||||
agentSnapshotDao.delete(new QueryWrapper<AgentSnapshotEntity>().eq("agent_id", agentId));
|
||||
}
|
||||
|
||||
private void insertSnapshot(String agentId, Long userId, String source, AgentSnapshotDataDTO snapshotData,
|
||||
List<String> changedFields) {
|
||||
insertSnapshot(agentId, userId, source, snapshotData, changedFields, null, null);
|
||||
}
|
||||
|
||||
private void insertSnapshot(String agentId, Long userId, String source, AgentSnapshotDataDTO snapshotData,
|
||||
List<String> changedFields, String restoreFromSnapshotId, Integer restoreFromVersionNo) {
|
||||
AgentSnapshotEntity entity = new AgentSnapshotEntity();
|
||||
entity.setAgentId(agentId);
|
||||
entity.setUserId(userId);
|
||||
entity.setVersionNo(allocateVersionNo(agentId));
|
||||
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());
|
||||
agentSnapshotDao.insert(entity);
|
||||
pruneSnapshots(agentId);
|
||||
}
|
||||
|
||||
private void ensureInitialSnapshot(String agentId) {
|
||||
Integer maxVersionNo = agentSnapshotDao.selectMaxVersionNo(agentId);
|
||||
if (maxVersionNo != null && maxVersionNo > 0) {
|
||||
return;
|
||||
}
|
||||
AgentEntity agent = agentDao.selectByIdForUpdate(agentId);
|
||||
if (agent == null) {
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
maxVersionNo = agentSnapshotDao.selectMaxVersionNo(agentId);
|
||||
if (maxVersionNo != null && maxVersionNo > 0) {
|
||||
return;
|
||||
}
|
||||
AgentInfoVO agentInfo = getAgentInfo(agentId);
|
||||
insertSnapshot(agentId, agentInfo.getUserId(), "initial", buildSnapshotData(agentInfo), List.of("initial"));
|
||||
}
|
||||
|
||||
private void pruneSnapshots(String agentId) {
|
||||
agentSnapshotDao.deleteOlderThanKeepLimit(agentId, MAX_SNAPSHOTS_PER_AGENT);
|
||||
}
|
||||
|
||||
private Integer allocateVersionNo(String agentId) {
|
||||
Integer maxVersionNo = agentSnapshotDao.selectMaxVersionNo(agentId);
|
||||
if (maxVersionNo == null || maxVersionNo < 0) {
|
||||
throw new RenException("快照版本号生成失败");
|
||||
}
|
||||
return maxVersionNo + 1;
|
||||
}
|
||||
|
||||
private AgentSnapshotEntity getSnapshotEntity(String agentId, String snapshotId) {
|
||||
@@ -163,12 +232,19 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl<AgentSnapshotDao,
|
||||
return entity;
|
||||
}
|
||||
|
||||
private AgentSnapshotDataDTO buildSnapshotData(String agentId) {
|
||||
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());
|
||||
@@ -192,9 +268,9 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl<AgentSnapshotDao,
|
||||
data.setLanguage(agent.getLanguage());
|
||||
data.setSort(agent.getSort());
|
||||
data.setFunctions(toFunctionInfo(agent.getFunctions()));
|
||||
data.setContextProviders(getContextProviders(agentId));
|
||||
data.setCorrectWordFileIds(nullToEmpty(correctWordFileService.getAgentCorrectWordFileIds(agentId)));
|
||||
List<AgentSnapshotTagDTO> tags = getSnapshotTags(agentId);
|
||||
data.setContextProviders(getContextProviders(agent.getId()));
|
||||
data.setCorrectWordFileIds(nullToEmpty(correctWordFileService.getAgentCorrectWordFileIds(agent.getId())));
|
||||
List<AgentSnapshotTagDTO> tags = getSnapshotTags(agent.getId());
|
||||
data.setTags(tags);
|
||||
data.setTagNames(tags.stream().map(AgentSnapshotTagDTO::getTagName).toList());
|
||||
return data;
|
||||
@@ -250,64 +326,124 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl<AgentSnapshotDao,
|
||||
}
|
||||
|
||||
List<String> fields = new ArrayList<>();
|
||||
compare(fields, "agentCode", current.getAgentCode(), pendingUpdate.getAgentCode());
|
||||
compare(fields, "agentName", current.getAgentName(), pendingUpdate.getAgentName());
|
||||
compare(fields, "asrModelId", current.getAsrModelId(), pendingUpdate.getAsrModelId());
|
||||
compare(fields, "vadModelId", current.getVadModelId(), pendingUpdate.getVadModelId());
|
||||
compare(fields, "llmModelId", current.getLlmModelId(), pendingUpdate.getLlmModelId());
|
||||
compare(fields, "slmModelId", current.getSlmModelId(), pendingUpdate.getSlmModelId());
|
||||
compare(fields, "vllmModelId", current.getVllmModelId(), pendingUpdate.getVllmModelId());
|
||||
compare(fields, "ttsModelId", current.getTtsModelId(), pendingUpdate.getTtsModelId());
|
||||
compare(fields, "ttsVoiceId", current.getTtsVoiceId(), pendingUpdate.getTtsVoiceId());
|
||||
compare(fields, "ttsLanguage", current.getTtsLanguage(), pendingUpdate.getTtsLanguage());
|
||||
compare(fields, "ttsVolume", current.getTtsVolume(), pendingUpdate.getTtsVolume());
|
||||
compare(fields, "ttsRate", current.getTtsRate(), pendingUpdate.getTtsRate());
|
||||
compare(fields, "ttsPitch", current.getTtsPitch(), pendingUpdate.getTtsPitch());
|
||||
compare(fields, "memModelId", current.getMemModelId(), pendingUpdate.getMemModelId());
|
||||
compare(fields, "intentModelId", current.getIntentModelId(), pendingUpdate.getIntentModelId());
|
||||
compare(fields, "chatHistoryConf", current.getChatHistoryConf(), pendingUpdate.getChatHistoryConf());
|
||||
compare(fields, "systemPrompt", current.getSystemPrompt(), pendingUpdate.getSystemPrompt());
|
||||
compare(fields, "summaryMemory", current.getSummaryMemory(), pendingUpdate.getSummaryMemory());
|
||||
compare(fields, "langCode", current.getLangCode(), pendingUpdate.getLangCode());
|
||||
compare(fields, "language", current.getLanguage(), pendingUpdate.getLanguage());
|
||||
compare(fields, "sort", current.getSort(), pendingUpdate.getSort());
|
||||
compare(fields, "functions", current.getFunctions(), pendingUpdate.getFunctions());
|
||||
compare(fields, "contextProviders", current.getContextProviders(), pendingUpdate.getContextProviders());
|
||||
compare(fields, "correctWordFileIds", current.getCorrectWordFileIds(), pendingUpdate.getCorrectWordFileIds());
|
||||
compare(fields, "tagNames", current.getTagNames(), pendingUpdate.getTagNames());
|
||||
compare(fields, "tagIds", current.getTags().stream().map(AgentSnapshotTagDTO::getId).toList(),
|
||||
pendingUpdate.getTagIds());
|
||||
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 <T> void compare(List<String> fields, String name, T current, T next) {
|
||||
if (next != null && !Objects.equals(current, next)) {
|
||||
fields.add(name);
|
||||
private List<String> getChangedFields(AgentSnapshotDataDTO current, AgentSnapshotDataDTO next) {
|
||||
if (next == null) {
|
||||
return List.of("restore");
|
||||
}
|
||||
if (current == null) {
|
||||
current = new AgentSnapshotDataDTO();
|
||||
}
|
||||
|
||||
List<String> 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<AgentUpdateDTO.FunctionInfo>) value);
|
||||
case CONTEXT_PROVIDERS -> normalizeSortedJsonList((List<?>) value);
|
||||
case CORRECT_WORD_FILE_IDS -> normalizeStringList((List<String>) value);
|
||||
case TAG_NAMES -> normalizeStringList((List<String>) value);
|
||||
default -> value;
|
||||
};
|
||||
}
|
||||
|
||||
private Map<String, Object> normalizeFunctions(List<AgentUpdateDTO.FunctionInfo> functions) {
|
||||
Map<String, Object> 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<String> normalizeStringList(List<String> values) {
|
||||
if (values == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return values.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private <T> List<String> normalizeSortedJsonList(List<T> 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<String, Object> normalizeMap(Map<?, ?> map) {
|
||||
Map<String, Object> result = new TreeMap<>();
|
||||
if (map == null) {
|
||||
return result;
|
||||
}
|
||||
map.forEach((key, value) -> {
|
||||
if (key != null) {
|
||||
result.put(String.valueOf(key), normalizeValue(value));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private void applyAgentFields(AgentEntity agent, AgentSnapshotDataDTO data) {
|
||||
agent.setAgentCode(data.getAgentCode());
|
||||
agent.setAgentName(data.getAgentName());
|
||||
agent.setAsrModelId(data.getAsrModelId());
|
||||
agent.setVadModelId(data.getVadModelId());
|
||||
agent.setLlmModelId(data.getLlmModelId());
|
||||
agent.setSlmModelId(data.getSlmModelId());
|
||||
agent.setVllmModelId(data.getVllmModelId());
|
||||
agent.setTtsModelId(data.getTtsModelId());
|
||||
agent.setTtsVoiceId(data.getTtsVoiceId());
|
||||
agent.setTtsLanguage(data.getTtsLanguage());
|
||||
agent.setTtsVolume(data.getTtsVolume());
|
||||
agent.setTtsRate(data.getTtsRate());
|
||||
agent.setTtsPitch(data.getTtsPitch());
|
||||
agent.setMemModelId(data.getMemModelId());
|
||||
agent.setIntentModelId(data.getIntentModelId());
|
||||
agent.setChatHistoryConf(data.getChatHistoryConf());
|
||||
agent.setSystemPrompt(data.getSystemPrompt());
|
||||
agent.setSummaryMemory(data.getSummaryMemory());
|
||||
agent.setLangCode(data.getLangCode());
|
||||
agent.setLanguage(data.getLanguage());
|
||||
agent.setSort(data.getSort());
|
||||
for (AgentSnapshotField field : AgentSnapshotField.values()) {
|
||||
field.applyTo(agent, data);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRestoreParams(AgentEntity agent) {
|
||||
@@ -406,26 +542,22 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl<AgentSnapshotDao,
|
||||
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<AgentTagEntity>()
|
||||
.eq("tag_name", snapshotTag.getTagName())
|
||||
.eq("deleted", 0)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
if (tag != null) {
|
||||
if (Objects.equals(tag.getDeleted(), 1)) {
|
||||
tag.setDeleted(0);
|
||||
tag.setUpdater(SecurityUser.getUserId());
|
||||
tag.setUpdatedAt(now);
|
||||
agentTagDao.updateById(tag);
|
||||
}
|
||||
return tag.getId();
|
||||
}
|
||||
|
||||
AgentTagEntity newTag = new AgentTagEntity();
|
||||
if (StringUtils.isNotBlank(snapshotTag.getId())) {
|
||||
newTag.setId(snapshotTag.getId());
|
||||
}
|
||||
newTag.setId(UUID.randomUUID().toString().replace("-", ""));
|
||||
newTag.setTagName(snapshotTag.getTagName());
|
||||
newTag.setSort(snapshotTag.getSort() == null ? 0 : snapshotTag.getSort());
|
||||
newTag.setDeleted(0);
|
||||
@@ -444,12 +576,16 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl<AgentSnapshotDao,
|
||||
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(JsonUtils.parseObject(entity.getSnapshotData(), AgentSnapshotDataDTO.class));
|
||||
vo.setAfterSnapshotData(getAfterSnapshotData(entity));
|
||||
vo.setSnapshotData(redactSnapshotData(JsonUtils.parseObject(entity.getSnapshotData(),
|
||||
AgentSnapshotDataDTO.class)));
|
||||
vo.setAfterSnapshotData(redactSnapshotData(getAfterSnapshotData(entity)));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
@@ -459,7 +595,7 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl<AgentSnapshotDao,
|
||||
if (nextSnapshot != null) {
|
||||
return JsonUtils.parseObject(nextSnapshot.getSnapshotData(), AgentSnapshotDataDTO.class);
|
||||
}
|
||||
return buildSnapshotData(entity.getAgentId());
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> parseChangedFields(String changedFields) {
|
||||
@@ -469,6 +605,158 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl<AgentSnapshotDao,
|
||||
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<String, AgentUpdateDTO.FunctionInfo> 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<String, xiaozhi.modules.agent.dto.ContextProviderDTO> 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<String, Object> redactSensitiveMap(Map<?, ?> map) {
|
||||
HashMap<String, Object> 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<String, Object> preserveCurrentSensitiveMap(Map<?, ?> target, Map<?, ?> current) {
|
||||
HashMap<String, Object> 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<Object> 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 <T> List<T> nullToEmpty(List<T> list) {
|
||||
return list == null ? Collections.emptyList() : list;
|
||||
}
|
||||
|
||||
+31
@@ -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<List<ContextProviderDTO>> {
|
||||
private static final TypeReference<List<ContextProviderDTO>> CONTEXT_PROVIDER_LIST_TYPE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
@Override
|
||||
protected List<ContextProviderDTO> parse(String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<ContextProviderDTO> providers = JsonUtils.parseObject(json, CONTEXT_PROVIDER_LIST_TYPE);
|
||||
return providers == null ? Collections.emptyList() : providers;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String toJson(List<ContextProviderDTO> obj) {
|
||||
return JsonUtils.toJsonString(obj == null ? Collections.emptyList() : obj);
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,7 @@ public class AgentInfoVO extends AgentEntity
|
||||
|
||||
@Schema(description = "替换词文件ID列表")
|
||||
private List<String> correctWordFileIds;
|
||||
|
||||
@Schema(description = "当前配置版本号")
|
||||
private Integer currentVersionNo;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,17 @@ import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO;
|
||||
public class AgentSnapshotVO {
|
||||
private String id;
|
||||
private String agentId;
|
||||
@Schema(description = "所属用户ID,表示该快照归属的智能体所有者")
|
||||
private Long userId;
|
||||
private Integer versionNo;
|
||||
private List<String> changedFields;
|
||||
private List<String> 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;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- changeset codex:202607081150
|
||||
ALTER TABLE `ai_agent_snapshot`
|
||||
ADD COLUMN `restore_from_snapshot_id` VARCHAR(32) DEFAULT NULL COMMENT '恢复来源快照ID' AFTER `source`,
|
||||
ADD COLUMN `restore_from_version_no` INT UNSIGNED DEFAULT NULL COMMENT '恢复来源版本号' AFTER `restore_from_snapshot_id`,
|
||||
ADD INDEX `idx_snapshot_user_created_at` (`user_id`, `created_at`);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- changeset codex:202607081230
|
||||
ALTER TABLE `ai_agent_snapshot`
|
||||
ALTER COLUMN `snapshot_data` SET DEFAULT (JSON_OBJECT());
|
||||
@@ -704,3 +704,17 @@ databaseChangeLog:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607071530.sql
|
||||
- changeSet:
|
||||
id: 202607081150
|
||||
author: Codex
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607081150.sql
|
||||
- changeSet:
|
||||
id: 202607081230
|
||||
author: Codex
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607081230.sql
|
||||
|
||||
@@ -36,9 +36,12 @@
|
||||
<result column="updater" property="updater"/>
|
||||
<result column="updatedAt" property="updatedAt"/>
|
||||
<collection property="functions"
|
||||
ofType="xiaozhi.modules.agent.entity.AgentPluginMapping"
|
||||
column="{agentId=id}"
|
||||
select="selectAgentFunctionsByAgentId"/>
|
||||
ofType="xiaozhi.modules.agent.entity.AgentPluginMapping">
|
||||
<id column="functionId" property="id"/>
|
||||
<result column="functionAgentId" property="agentId"/>
|
||||
<result column="pluginId" property="pluginId"/>
|
||||
<result column="paramInfo" property="paramInfo"/>
|
||||
</collection>
|
||||
</resultMap>
|
||||
|
||||
<select id="selectAgentInfoById" resultMap="AgentInfoMap">
|
||||
@@ -68,17 +71,22 @@
|
||||
a.creator,
|
||||
a.created_at AS createdAt,
|
||||
a.updater,
|
||||
a.updated_at AS updatedAt
|
||||
a.updated_at AS updatedAt,
|
||||
f.id AS functionId,
|
||||
f.agent_id AS functionAgentId,
|
||||
f.plugin_id AS pluginId,
|
||||
f.param_info AS paramInfo
|
||||
FROM ai_agent a
|
||||
LEFT JOIN ai_agent_plugin_mapping f ON f.agent_id = a.id
|
||||
WHERE a.id = #{agentId}
|
||||
ORDER BY f.id ASC
|
||||
</select>
|
||||
|
||||
<select id="selectAgentFunctionsByAgentId" resultType="xiaozhi.modules.agent.entity.AgentPluginMapping">
|
||||
SELECT id,
|
||||
agent_id AS agentId,
|
||||
plugin_id AS pluginId,
|
||||
param_info AS paramInfo
|
||||
FROM ai_agent_plugin_mapping
|
||||
WHERE agent_id = #{agentId}
|
||||
<select id="selectByIdForUpdate" resultType="xiaozhi.modules.agent.entity.AgentEntity">
|
||||
SELECT *
|
||||
FROM ai_agent
|
||||
WHERE id = #{agentId}
|
||||
FOR UPDATE
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
snapshot_data AS snapshotData,
|
||||
changed_fields AS changedFields,
|
||||
source,
|
||||
restore_from_snapshot_id AS restoreFromSnapshotId,
|
||||
restore_from_version_no AS restoreFromVersionNo,
|
||||
creator,
|
||||
created_at AS createdAt
|
||||
FROM ai_agent_snapshot
|
||||
@@ -25,4 +27,19 @@
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<delete id="deleteOlderThanKeepLimit">
|
||||
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
|
||||
)
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -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<Object, Object> 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());
|
||||
}
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
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.argThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
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.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.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<String, Object> 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<String> changedFields = (List<String>) 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<String> changedFields = (List<String>) 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<String>) 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 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 versionAllocationDoesNotDependOnSequenceTable() throws Exception {
|
||||
String xml = 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"));
|
||||
String createMigration = 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"));
|
||||
|
||||
assertFalse(xml.contains("ai_agent_snapshot_sequence"));
|
||||
assertFalse(xml.contains("LAST_INSERT_ID"));
|
||||
assertFalse(dao.contains("allocateVersionNo"));
|
||||
assertFalse(dao.contains("selectLastInsertId"));
|
||||
assertFalse(createMigration.contains("ai_agent_snapshot_sequence"));
|
||||
assertFalse(master.contains("202607081430.sql"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotMigrationAddsRestoreTraceAndUserIndex() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/changelog/202607081150.sql"));
|
||||
String defaultSql = Files.readString(Path.of("src/main/resources/db/changelog/202607081230.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(master.contains("202607081150.sql"));
|
||||
assertTrue(defaultSql.contains("DEFAULT (JSON_OBJECT())"));
|
||||
assertTrue(master.contains("202607081230.sql"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectNextSnapshotLoadsRestoreTraceColumns() throws Exception {
|
||||
String xml = 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 pageCreatesInitialSnapshotWhenAgentHasNoHistory() {
|
||||
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(snapshotDao.selectMaxVersionNo("agent-id")).thenReturn(0, 0, 0);
|
||||
when(agentDao.selectByIdForUpdate("agent-id")).thenReturn(lockedAgent);
|
||||
when(agentDao.selectAgentInfoById("agent-id")).thenReturn(agentInfo);
|
||||
when(correctWordFileService.getAgentCorrectWordFileIds("agent-id")).thenReturn(List.of());
|
||||
when(tagDao.selectByAgentId("agent-id")).thenReturn(List.of());
|
||||
when(snapshotDao.selectPage(any(), any())).thenReturn(new Page<AgentSnapshotEntity>(1, 10));
|
||||
|
||||
service.page("agent-id", new AgentSnapshotPageDTO());
|
||||
|
||||
verify(snapshotDao, times(3)).selectMaxVersionNo("agent-id");
|
||||
verify(snapshotDao).insert(argThat(snapshot -> Integer.valueOf(1).equals(snapshot.getVersionNo())));
|
||||
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<AgentTagEntity> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
+42
@@ -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<ContextProviderDTO> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user