feat: improve agent snapshot history

This commit is contained in:
Tyke Chen
2026-07-10 11:44:35 +08:00
parent 8ec5851027
commit a5aee109fe
23 changed files with 2833 additions and 130 deletions
@@ -56,9 +56,9 @@ import xiaozhi.modules.model.dto.VoiceDTO;
import xiaozhi.modules.model.entity.ModelConfigEntity;
import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.modules.model.service.ModelProviderService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.enums.SuperAdminEnum;
import xiaozhi.modules.timbre.service.TimbreService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.enums.SuperAdminEnum;
import xiaozhi.modules.timbre.service.TimbreService;
@Service
@AllArgsConstructor
@@ -528,16 +528,22 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
template.setTtsVoiceId(timbre.getId());
}
}
}
entity.setTtsVoiceId(template.getTtsVoiceId());
entity.setMemModelId(template.getMemModelId());
entity.setIntentModelId(template.getIntentModelId());
entity.setSystemPrompt(template.getSystemPrompt());
entity.setSummaryMemory(template.getSummaryMemory());
// 根据记忆模型类型设置默认的chatHistoryConf值
if (template.getMemModelId() != null) {
}
entity.setTtsVoiceId(template.getTtsVoiceId());
entity.setTtsLanguage(defaultIfBlank(template.getTtsLanguage(),
timbreModelService.getDefaultLanguageById(entity.getTtsVoiceId())));
entity.setMemModelId(template.getMemModelId());
entity.setIntentModelId(template.getIntentModelId());
entity.setSystemPrompt(template.getSystemPrompt());
entity.setSummaryMemory(template.getSummaryMemory());
if (Constant.MEMORY_NO_MEM.equals(entity.getMemModelId())
|| Constant.MEMORY_MEM_REPORT_ONLY.equals(entity.getMemModelId())) {
entity.setSummaryMemory("");
}
// 根据记忆模型类型设置默认的chatHistoryConf值
if (template.getMemModelId() != null) {
if (template.getMemModelId().equals("Memory_nomem")) {
// 无记忆功能的模型,默认不记录聊天记录
entity.setChatHistoryConf(0);
@@ -592,13 +598,18 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
mapping.setParamInfo(JsonUtils.toJsonString(paramInfo));
mapping.setAgentId(entity.getId());
toInsert.add(mapping);
}
// 保存默认插件
agentPluginMappingService.saveBatch(toInsert);
return entity.getId();
}
private String getDefaultLLMModelId() {
}
// 保存默认插件
agentPluginMappingService.saveBatch(toInsert);
agentSnapshotService.createSnapshot(entity.getId(), "initial");
return entity.getId();
}
private String defaultIfBlank(String value, String defaultValue) {
return StringUtils.isBlank(value) ? defaultValue : value;
}
private String getDefaultLLMModelId() {
try {
List<ModelConfigEntity> llmConfigs = modelConfigService.getEnabledModelsByType("LLM");
if (llmConfigs == null || llmConfigs.isEmpty()) {
@@ -358,10 +358,38 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl<AgentSnapshotDao,
case CONTEXT_PROVIDERS -> normalizeSortedJsonList((List<?>) value);
case CORRECT_WORD_FILE_IDS -> normalizeStringList((List<String>) value);
case TAG_NAMES -> normalizeStringList((List<String>) value);
case SUMMARY_MEMORY -> normalizeBlankText(value);
case TTS_VOLUME, TTS_RATE, TTS_PITCH -> normalizeDefaultTtsNumber(value);
default -> value;
};
}
private String normalizeBlankText(Object value) {
if (value == null) {
return "";
}
String text = String.valueOf(value);
return StringUtils.isBlank(text) ? "" : text;
}
private Object normalizeDefaultTtsNumber(Object value) {
if (value == null) {
return 0;
}
if (value instanceof Number number) {
return number.intValue();
}
String text = String.valueOf(value);
if (StringUtils.isBlank(text)) {
return 0;
}
try {
return Integer.valueOf(text.trim());
} catch (NumberFormatException ignored) {
return text;
}
}
private Map<String, Object> normalizeFunctions(List<AgentUpdateDTO.FunctionInfo> functions) {
Map<String, Object> result = new TreeMap<>();
if (functions == null) {
@@ -57,6 +57,14 @@ public interface TimbreService extends BaseService<TimbreEntity> {
List<VoiceDTO> getVoiceNames(String ttsModelId, String voiceName);
/**
* 获取普通音色或克隆音色配置的首个有效语言。
*
* @param id 音色ID
* @return 默认语言;音色不存在或未配置有效语言时返回null
*/
String getDefaultLanguageById(String id);
/**
* 根据ID获取音色名称
*
@@ -73,4 +81,4 @@ public interface TimbreService extends BaseService<TimbreEntity> {
* @return 音色信息
*/
VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode);
}
}
@@ -1,6 +1,7 @@
package xiaozhi.modules.timbre.service.impl;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
@@ -41,6 +42,8 @@ import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
@Service
public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity> implements TimbreService {
private static final Pattern LANGUAGE_SEPARATOR = Pattern.compile("[、;;,]");
private final TimbreDao timbreDao;
private final VoiceCloneDao voiceCloneDao;
private final RedisUtils redisUtils;
@@ -158,6 +161,32 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
return CollectionUtil.isEmpty(voiceDTOs) ? null : voiceDTOs;
}
@Override
public String getDefaultLanguageById(String id) {
if (StringUtils.isBlank(id)) {
return null;
}
TimbreEntity timbre = timbreDao.selectById(id);
if (timbre != null) {
return firstNonBlankLanguage(timbre.getLanguages());
}
VoiceCloneEntity voiceClone = voiceCloneDao.selectById(id);
return voiceClone == null ? null : firstNonBlankLanguage(voiceClone.getLanguages());
}
private String firstNonBlankLanguage(String languages) {
if (StringUtils.isBlank(languages)) {
return null;
}
return LANGUAGE_SEPARATOR.splitAsStream(languages)
.map(StringUtils::trimToNull)
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
/**
* 处理是不是tts模型的id
*/
@@ -214,4 +243,4 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
dto.setIsClone(false); // 设置为普通音色
return dto;
}
}
}
@@ -12,6 +12,8 @@ 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.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -35,19 +37,25 @@ 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.AgentCreateDTO;
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.AgentTemplateEntity;
import xiaozhi.modules.agent.entity.AgentSnapshotEntity;
import xiaozhi.modules.agent.entity.AgentTagEntity;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentSnapshotService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.agent.vo.AgentSnapshotVO;
import xiaozhi.modules.agent.vo.AgentInfoVO;
import xiaozhi.modules.correctword.service.CorrectWordFileService;
import xiaozhi.modules.model.service.ModelProviderService;
import xiaozhi.modules.timbre.service.TimbreService;
class AgentSnapshotServiceImplTest {
@@ -171,6 +179,70 @@ class AgentSnapshotServiceImplTest {
assertEquals(List.of(AgentSnapshotField.TAG_NAMES.getFieldName()), changedFields);
}
@Test
@SuppressWarnings("unchecked")
void blankSummaryMemoryValuesAreNotSnapshotChanges() throws Exception {
AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null,
null, null);
Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields",
AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class);
method.setAccessible(true);
AgentSnapshotDataDTO current = new AgentSnapshotDataDTO();
current.setSummaryMemory(null);
AgentSnapshotDataDTO next = new AgentSnapshotDataDTO();
next.setSummaryMemory("");
List<String> changedFields = (List<String>) method.invoke(service, current, next);
assertFalse(changedFields.contains(AgentSnapshotField.SUMMARY_MEMORY.getFieldName()));
assertTrue(changedFields.isEmpty());
}
@Test
@SuppressWarnings("unchecked")
void emptyTtsAdvancedDefaultsAreNotSnapshotChanges() throws Exception {
AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null,
null, null);
Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields",
AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class);
method.setAccessible(true);
AgentSnapshotDataDTO current = new AgentSnapshotDataDTO();
current.setTtsVolume(null);
current.setTtsRate(null);
current.setTtsPitch(null);
AgentSnapshotDataDTO next = new AgentSnapshotDataDTO();
next.setTtsVolume(0);
next.setTtsRate(0);
next.setTtsPitch(0);
List<String> changedFields = (List<String>) method.invoke(service, current, next);
assertTrue(changedFields.isEmpty());
}
@Test
@SuppressWarnings("unchecked")
void explicitTtsLanguageIsStoredAsSnapshotChangeEvenWhenItMatchesVoiceDefault() throws Exception {
AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null,
null, null);
Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields",
AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class);
method.setAccessible(true);
AgentSnapshotDataDTO current = new AgentSnapshotDataDTO();
current.setTtsVoiceId("voice-id");
current.setTtsLanguage(null);
AgentSnapshotDataDTO next = new AgentSnapshotDataDTO();
next.setTtsVoiceId("voice-id");
next.setTtsLanguage("普通话");
List<String> changedFields = (List<String>) method.invoke(service, current, next);
assertEquals(List.of(AgentSnapshotField.TTS_LANGUAGE.getFieldName()), changedFields);
}
@Test
void tagOnlySavePathCreatesSnapshot() throws Exception {
String controller = Files.readString(Path.of("src/main/java/xiaozhi/modules/agent/controller/AgentController.java"));
@@ -216,6 +288,42 @@ class AgentSnapshotServiceImplTest {
inOrder.verify(snapshotService).createSnapshot(agentId, "config");
}
@Test
void createAgentPersistsDisplayDefaultsBeforeInitialSnapshot() {
AgentDao agentDao = mock(AgentDao.class);
TimbreService timbreService = mock(TimbreService.class);
AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class);
AgentTemplateService templateService = mock(AgentTemplateService.class);
ModelProviderService providerService = mock(ModelProviderService.class);
AgentSnapshotService snapshotService = mock(AgentSnapshotService.class);
AgentServiceImpl service = new AgentServiceImpl(agentDao, null, timbreService, null, null, null,
pluginMappingService, null, templateService, providerService, null, null, null, snapshotService);
ReflectionTestUtils.setField(service, "baseDao", agentDao);
AgentTemplateEntity template = new AgentTemplateEntity();
template.setTtsModelId("TTS_EdgeTTS");
template.setTtsVoiceId("TTS_EdgeTTS0001");
template.setMemModelId("Memory_nomem");
template.setSummaryMemory(null);
when(templateService.getDefaultTemplate()).thenReturn(template);
when(timbreService.getDefaultLanguageById("TTS_EdgeTTS0001")).thenReturn("普通话");
when(agentDao.insert(any())).thenReturn(1);
AgentCreateDTO dto = new AgentCreateDTO();
dto.setAgentName("test123");
String agentId = service.createAgent(dto);
InOrder inOrder = inOrder(agentDao, pluginMappingService, snapshotService);
inOrder.verify(agentDao).insert(argThat(agent -> "test123".equals(agent.getAgentName())
&& "普通话".equals(agent.getTtsLanguage())
&& "".equals(agent.getSummaryMemory())
&& Integer.valueOf(0).equals(agent.getChatHistoryConf())));
inOrder.verify(pluginMappingService).saveBatch(any());
inOrder.verify(snapshotService).createSnapshot(agentId, "initial");
}
@Test
void agentSnapshotFieldAppliesRestorableAgentFields() {
AgentSnapshotDataDTO data = new AgentSnapshotDataDTO();
@@ -283,11 +391,15 @@ class AgentSnapshotServiceImplTest {
}
@Test
void selectNextSnapshotLoadsRestoreTraceColumns() throws Exception {
void selectNextSnapshotLoadsRestoreTraceColumnsWithoutPreviousVersionQuery() 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(xml.contains("restore_from_snapshot_id AS restoreFromSnapshotId"));
assertTrue(xml.contains("restore_from_version_no AS restoreFromVersionNo"));
assertTrue(xml.contains("version_no &gt; #{versionNo}"));
assertFalse(xml.contains("selectPreviousSnapshot"));
assertFalse(dao.contains("selectPreviousSnapshot"));
}
@Test
@@ -331,6 +443,52 @@ class AgentSnapshotServiceImplTest {
assertEquals(1, vo.getRestoreFromVersionNo());
}
@Test
void toVOKeepsStoredEventFieldsStableAcrossDeletedVersionGapAndMetadataChanges() throws Exception {
AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class);
AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, null, null, null, null, null, null,
null, null, null);
Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("toVO",
AgentSnapshotEntity.class, boolean.class);
method.setAccessible(true);
AgentSnapshotEntity current = new AgentSnapshotEntity();
current.setId("snapshot-id");
current.setAgentId("agent-id");
current.setVersionNo(3);
current.setChangedFields(JsonUtils.toJsonString(List.of("ttsLanguage", "ttsVolume")));
AgentSnapshotVO vo = (AgentSnapshotVO) method.invoke(service, current, false);
assertEquals(List.of("ttsLanguage", "ttsVolume"), vo.getChangedFields());
verifyNoInteractions(snapshotDao);
}
@Test
void pageUsesPersistedChangedFieldsWithoutPerRecordQueries() {
AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class);
AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, null, null, null, null, null, null,
null, null, null);
AgentSnapshotEntity versionFive = new AgentSnapshotEntity();
versionFive.setVersionNo(5);
versionFive.setChangedFields(JsonUtils.toJsonString(List.of("agentName")));
AgentSnapshotEntity versionThree = new AgentSnapshotEntity();
versionThree.setVersionNo(3);
versionThree.setChangedFields(JsonUtils.toJsonString(List.of("ttsLanguage")));
Page<AgentSnapshotEntity> result = new Page<>(1, 10);
result.setRecords(List.of(versionFive, versionThree));
result.setTotal(2);
when(snapshotDao.selectPage(any(), any())).thenReturn(result);
List<AgentSnapshotVO> records = service.page("agent-id", new AgentSnapshotPageDTO()).getList();
assertEquals(List.of("agentName"), records.get(0).getChangedFields());
assertEquals(List.of("ttsLanguage"), records.get(1).getChangedFields());
verify(snapshotDao).selectPage(any(), any());
verifyNoMoreInteractions(snapshotDao);
}
@Test
void pageDoesNotCreateInitialSnapshotWhenAgentHasNoHistory() {
AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class);
@@ -0,0 +1,57 @@
package xiaozhi.modules.timbre.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
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 org.junit.jupiter.api.Test;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.timbre.dao.TimbreDao;
import xiaozhi.modules.timbre.entity.TimbreEntity;
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
class TimbreServiceImplTest {
@Test
void defaultLanguageUsesFirstValidRegularTimbreLanguageWithoutCloneQuery() {
TimbreDao timbreDao = mock(TimbreDao.class);
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
TimbreEntity timbre = new TimbreEntity();
timbre.setLanguages(",, ; 普通话;粤语");
when(timbreDao.selectById("voice-id")).thenReturn(timbre);
assertEquals("普通话", service.getDefaultLanguageById("voice-id"));
verify(voiceCloneDao, never()).selectById("voice-id");
}
@Test
void defaultLanguageFallsBackToCloneTimbre() {
TimbreDao timbreDao = mock(TimbreDao.class);
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
VoiceCloneEntity voiceClone = new VoiceCloneEntity();
voiceClone.setLanguages("、, English,中文");
when(voiceCloneDao.selectById("clone-id")).thenReturn(voiceClone);
assertEquals("English", service.getDefaultLanguageById("clone-id"));
}
@Test
void delimiterOnlyLanguageConfigurationReturnsNull() {
TimbreDao timbreDao = mock(TimbreDao.class);
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
TimbreEntity timbre = new TimbreEntity();
timbre.setLanguages(",,、;;;,,");
when(timbreDao.selectById("voice-id")).thenReturn(timbre);
assertNull(service.getDefaultLanguageById("voice-id"));
}
}