From 179281e49cf667c6dcac3b2b5db5bb23248855b7 Mon Sep 17 00:00:00 2001 From: Tyke Chen <190473011+chentyke@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:21:44 +0800 Subject: [PATCH] fix: harden agent snapshot restore flow --- .../controller/AgentSnapshotController.java | 8 +- .../xiaozhi/modules/agent/dao/AgentDao.java | 9 + .../modules/agent/dao/AgentSnapshotDao.java | 9 + .../agent/dto/AgentSnapshotRestoreDTO.java | 13 + .../agent/entity/AgentSnapshotEntity.java | 3 + .../agent/service/AgentSnapshotService.java | 4 +- .../agent/service/impl/AgentServiceImpl.java | 5 +- .../impl/AgentSnapshotRedactionRunner.java | 59 + .../impl/AgentSnapshotServiceImpl.java | 1140 ++++++++++++- .../modules/agent/vo/AgentSnapshotVO.java | 8 +- .../resources/db/changelog/202607101200.sql | 8 + .../db/changelog/db.changelog-master.yaml | 7 + .../main/resources/mapper/agent/AgentDao.xml | 28 + .../mapper/agent/AgentSnapshotDao.xml | 39 +- .../AgentSnapshotRedactionRunnerTest.java | 77 + .../impl/AgentSnapshotServiceImplTest.java | 1411 ++++++++++++++++- main/manager-mobile/package.json | 1 + main/manager-mobile/src/api/agent/agent.ts | 8 +- main/manager-mobile/src/api/agent/types.ts | 6 +- main/manager-mobile/src/i18n/de.ts | 18 +- main/manager-mobile/src/i18n/en.ts | 18 +- main/manager-mobile/src/i18n/pt_BR.ts | 18 +- main/manager-mobile/src/i18n/vi.ts | 18 +- main/manager-mobile/src/i18n/zh_CN.ts | 18 +- main/manager-mobile/src/i18n/zh_TW.ts | 18 +- .../agent/components/AgentSnapshotPanel.vue | 443 ++++-- .../agentSnapshotContracts.test.mjs | 103 ++ .../agent/components/agentSnapshotUtils.mjs | 314 ++++ .../components/agentSnapshotUtils.test.mjs | 183 +++ main/manager-mobile/src/pages/agent/edit.vue | 502 +++++- main/manager-mobile/src/wot-design-uni.d.ts | 74 +- main/manager-mobile/tsconfig.json | 1 + main/manager-web/package.json | 1 + main/manager-web/src/apis/module/agent.js | 166 +- .../src/apis/module/agentSnapshotApi.test.mjs | 123 ++ .../src/apis/module/correctWord.js | 49 +- main/manager-web/src/apis/module/model.js | 152 +- .../src/components/AgentSnapshotDialog.vue | 224 ++- .../components/agentSnapshotDisplayUtils.mjs | 186 +++ .../agentSnapshotDisplayUtils.test.mjs | 155 ++ main/manager-web/src/i18n/de.js | 8 +- main/manager-web/src/i18n/en.js | 8 +- main/manager-web/src/i18n/pt_BR.js | 8 +- main/manager-web/src/i18n/vi.js | 8 +- main/manager-web/src/i18n/zh_CN.js | 8 +- main/manager-web/src/i18n/zh_TW.js | 8 +- main/manager-web/src/views/roleConfig.vue | 703 +++++--- 47 files changed, 5701 insertions(+), 677 deletions(-) create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotRestoreDTO.java create mode 100644 main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunner.java create mode 100644 main/manager-api/src/main/resources/db/changelog/202607101200.sql create mode 100644 main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunnerTest.java create mode 100644 main/manager-mobile/src/pages/agent/components/agentSnapshotContracts.test.mjs create mode 100644 main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.mjs create mode 100644 main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.test.mjs create mode 100644 main/manager-web/src/apis/module/agentSnapshotApi.test.mjs create mode 100644 main/manager-web/src/components/agentSnapshotDisplayUtils.mjs create mode 100644 main/manager-web/src/components/agentSnapshotDisplayUtils.test.mjs diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java index 255e606d..de4b57b2 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java @@ -6,17 +6,20 @@ 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.AllArgsConstructor; import xiaozhi.common.exception.RenException; import xiaozhi.common.page.PageData; import xiaozhi.common.user.UserDetail; import xiaozhi.common.utils.Result; import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO; +import xiaozhi.modules.agent.dto.AgentSnapshotRestoreDTO; import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentSnapshotService; import xiaozhi.modules.agent.vo.AgentSnapshotVO; @@ -51,9 +54,10 @@ public class AgentSnapshotController { @PostMapping("/{snapshotId}/restore") @Operation(summary = "恢复智能体快照") @RequiresPermissions("sys:role:normal") - public Result restore(@PathVariable String agentId, @PathVariable String snapshotId) { + public Result restore(@PathVariable String agentId, @PathVariable String snapshotId, + @RequestBody @Valid AgentSnapshotRestoreDTO request) { checkPermission(agentId); - agentSnapshotService.restoreSnapshot(agentId, snapshotId); + agentSnapshotService.restoreSnapshot(agentId, snapshotId, request.getCurrentStateToken()); return new Result<>(); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java index 4def2b67..9ad1536d 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java @@ -43,4 +43,13 @@ public interface AgentDao extends BaseDao { * @param agentId 智能体ID */ AgentEntity selectByIdForUpdate(@Param("agentId") String agentId); + + /** + * 精确写入快照覆盖的智能体字段,包括目标快照中的 null 值。 + * 不更新所属用户、创建信息等不属于快照的字段。 + * + * @param agent 已应用目标快照的智能体 + * @return 受影响行数 + */ + int updateSnapshotFields(@Param("agent") AgentEntity agent); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java index ee17f15f..ec8f39e8 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java @@ -1,5 +1,7 @@ package xiaozhi.modules.agent.dao; +import java.util.List; + import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -17,4 +19,11 @@ public interface AgentSnapshotDao extends BaseDao { int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot); int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit); + + List selectLegacyRedactionBatch(@Param("afterId") String afterId, + @Param("limit") int limit, + @Param("targetRedactionVersion") int targetRedactionVersion); + + int updateRedactedSnapshots(@Param("snapshots") List snapshots, + @Param("redactionVersion") int redactionVersion); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotRestoreDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotRestoreDTO.java new file mode 100644 index 00000000..aa003fa4 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotRestoreDTO.java @@ -0,0 +1,13 @@ +package xiaozhi.modules.agent.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "智能体快照恢复请求") +public class AgentSnapshotRestoreDTO { + @NotBlank + @Schema(description = "预览时由服务端生成的当前配置状态指纹") + private String currentStateToken; +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentSnapshotEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentSnapshotEntity.java index 0d99fd36..c4ad695d 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentSnapshotEntity.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentSnapshotEntity.java @@ -47,4 +47,7 @@ public class AgentSnapshotEntity { @Schema(description = "创建时间") private Date createdAt; + + @Schema(description = "快照数据脱敏规则版本") + private Integer redactionVersion; } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java index f88c47ea..57e03ebf 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java @@ -13,11 +13,13 @@ public interface AgentSnapshotService extends BaseService { AgentSnapshotVO getSnapshot(String agentId, String snapshotId); - void restoreSnapshot(String agentId, String snapshotId); + void restoreSnapshot(String agentId, String snapshotId, String currentStateToken); void deleteSnapshot(String agentId, String snapshotId); Integer getCurrentVersionNo(String agentId); void deleteByAgentId(String agentId); + + long redactLegacySnapshots(); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java index 7562c287..34da49be 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java @@ -311,8 +311,9 @@ public class AgentServiceImpl extends BaseServiceImpl imp // 锁定后查询现有实体和关联配置 AgentEntity existingEntity = this.getAgentById(agentId); - if (createSnapshot && agentSnapshotService.getCurrentVersionNo(agentId) == 0) { - agentSnapshotService.createSnapshot(agentId, "initial"); + if (createSnapshot) { + int currentVersionNo = agentSnapshotService.getCurrentVersionNo(agentId); + agentSnapshotService.createSnapshot(agentId, currentVersionNo == 0 ? "initial" : "current"); } // 只更新提供的非空字段 diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunner.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunner.java new file mode 100644 index 00000000..33ff67f6 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunner.java @@ -0,0 +1,59 @@ +package xiaozhi.modules.agent.service.impl; + +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import xiaozhi.modules.agent.service.AgentSnapshotService; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AgentSnapshotRedactionRunner implements SmartInitializingSingleton { + static final long ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS = 5_000; + static final long ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS = 15_000; + + private final AgentSnapshotService agentSnapshotService; + + @Override + public void afterSingletonsInstantiated() { + redactAndReport("startup"); + } + + @Scheduled(initialDelay = ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS, + fixedDelay = ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS) + public void redactLateRollingDeploymentWrites() { + redactAndReport("rolling-deployment"); + } + + private void redactAndReport(String trigger) { + long startedAt = System.nanoTime(); + try { + long migrated = agentSnapshotService.redactLegacySnapshots(); + long durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); + if (migrated > 0) { + log.warn("Agent snapshot legacy redaction trigger={} migrated={} durationMs={}. Rotate credentials " + + "that may have appeared in historical snapshot URLs, cookies, sessions, or structured " + + "headers.", trigger, migrated, durationMillis); + } else if ("startup".equals(trigger)) { + log.info("Agent snapshot legacy redaction startup pass completed: migrated=0 durationMs={}; " + + "rolling-deployment compensation starts after {} ms and repeats every {} ms.", + durationMillis, ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS, + ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS); + } + } catch (RuntimeException exception) { + if ("startup".equals(trigger)) { + log.error("Agent snapshot legacy redaction failed during startup; blocking application startup " + + "before it can accept traffic.", exception); + } else { + log.error("Agent snapshot legacy redaction failed during rolling-deployment compensation; the " + + "scheduler will retry on its next run.", exception); + } + throw exception; + } + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java index 3ad50e63..5b97122b 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java @@ -1,10 +1,16 @@ package xiaozhi.modules.agent.service.impl; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HexFormat; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.TreeMap; @@ -21,6 +27,8 @@ 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 com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AllArgsConstructor; import xiaozhi.common.constant.Constant; @@ -37,6 +45,7 @@ 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.AgentContextProviderEntity; import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentPluginMapping; @@ -61,13 +70,20 @@ import xiaozhi.modules.security.user.SecurityUser; public class AgentSnapshotServiceImpl extends BaseServiceImpl implements AgentSnapshotService { private static final int MAX_SNAPSHOTS_PER_AGENT = 100; + private static final int LEGACY_REDACTION_BATCH_SIZE = 100; + private static final int CURRENT_REDACTION_VERSION = 2; private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() { }; private static final TypeReference> PARAM_INFO_TYPE = new TypeReference<>() { }; private static final TypeReference> OBJECT_MAP_TYPE = new TypeReference<>() { }; + private static final ObjectMapper SNAPSHOT_OBJECT_MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); private static final String SECRET_PLACEHOLDER = "__SNAPSHOT_SECRET_REDACTED__"; + private static final String SOURCE_CONFIG = "config"; + private static final String SOURCE_CURRENT_BACKUP = "current"; + private static final String SOURCE_RESTORE_RESULT = "restore"; private final AgentSnapshotDao agentSnapshotDao; private final AgentDao agentDao; @@ -91,8 +107,7 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl changedFields = getChangedFields(currentData, restoreData); - if (changedFields.isEmpty()) { + validateSensitiveRestoreIsReversible(currentData, restoreData); + List requestedChangedFields = getChangedFields(currentData, restoreData); + if (requestedChangedFields.isEmpty()) { return; } + AgentSnapshotEntity latestSnapshot = agentSnapshotDao.selectLatestSnapshot(agentId); + AgentSnapshotDataDTO latestData = latestSnapshot == null + ? null + : parseSnapshotData(latestSnapshot.getSnapshotData()); + List backupChangedFields = getChangedFields(latestData, currentData); + boolean backupCreated = !backupChangedFields.isEmpty(); + if (backupCreated) { + insertSnapshot(agentId, currentAgent.getUserId(), SOURCE_CURRENT_BACKUP, currentData, + backupChangedFields, null, null, false); + } + applyAgentFields(agent, restoreData); validateRestoreParams(agent); applyMemoryPolicy(agent); agent.setUpdater(SecurityUser.getUserId()); agent.setUpdatedAt(new Date()); - agentDao.updateById(agent); + int updatedRows = agentDao.updateSnapshotFields(agent); + // The row was already locked and verified above. Some MySQL configurations report 0 changed rows + // when a relation-only restore leaves all scalar columns (including second-precision updated_at) unchanged. + if (updatedRows < 0 || updatedRows > 1) { + throw new RenException("智能体快照恢复失败"); + } restoreFunctions(agentId, restoreData.getFunctions()); restoreContextProviders(agentId, restoreData.getContextProviders()); correctWordFileService.saveAgentCorrectWords(agentId, nullToEmpty(restoreData.getCorrectWordFileIds())); restoreTags(agentId, restoreData); - insertSnapshot(agentId, currentAgent.getUserId(), "restore", restoreData, changedFields, - snapshot.getId(), snapshot.getVersionNo()); + + AgentSnapshotDataDTO restoredData = buildSnapshotData(agentId); + List restoredChangedFields = getChangedFields(currentData, restoredData); + if (!restoredChangedFields.isEmpty()) { + insertSnapshot(agentId, currentAgent.getUserId(), SOURCE_RESTORE_RESULT, restoredData, + restoredChangedFields, snapshot.getId(), snapshot.getVersionNo(), false); + } + if (backupCreated || !restoredChangedFields.isEmpty()) { + pruneSnapshots(agentId); + } } @Override @Transactional(rollbackFor = Exception.class) public void deleteSnapshot(String agentId, String snapshotId) { + lockAgent(agentId); AgentSnapshotEntity entity = getSnapshotEntity(agentId, snapshotId); Integer maxVersionNo = agentSnapshotDao.selectMaxVersionNo(agentId); if (Objects.equals(entity.getVersionNo(), maxVersionNo)) { @@ -184,6 +234,32 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl().eq("agent_id", agentId)); } + @Override + public long redactLegacySnapshots() { + String afterId = null; + long migrated = 0; + while (true) { + List batch = agentSnapshotDao.selectLegacyRedactionBatch(afterId, + LEGACY_REDACTION_BATCH_SIZE, CURRENT_REDACTION_VERSION); + if (batch == null || batch.isEmpty()) { + return migrated; + } + for (AgentSnapshotEntity snapshot : batch) { + Map rawData = JsonUtils.parseObject(snapshot.getSnapshotData(), OBJECT_MAP_TYPE); + if (rawData == null) { + throw new RenException("历史快照数据无法解析,已中止脱敏迁移: " + snapshot.getId()); + } + snapshot.setSnapshotData(JsonUtils.toJsonString(redactSensitiveMap(rawData))); + } + int affected = agentSnapshotDao.updateRedactedSnapshots(batch, CURRENT_REDACTION_VERSION); + if (affected < 0 || affected > batch.size()) { + throw new RenException("历史快照批量脱敏迁移失败"); + } + migrated += affected; + afterId = batch.get(batch.size() - 1).getId(); + } + } + private void insertSnapshot(String agentId, Long userId, String source, AgentSnapshotDataDTO snapshotData, List changedFields) { insertSnapshot(agentId, userId, source, snapshotData, changedFields, null, null); @@ -191,22 +267,32 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl changedFields, String restoreFromSnapshotId, Integer restoreFromVersionNo) { + insertSnapshot(agentId, userId, source, snapshotData, changedFields, restoreFromSnapshotId, + restoreFromVersionNo, true); + } + + private void insertSnapshot(String agentId, Long userId, String source, AgentSnapshotDataDTO snapshotData, + List changedFields, String restoreFromSnapshotId, Integer restoreFromVersionNo, + boolean pruneAfterInsert) { AgentSnapshotEntity entity = new AgentSnapshotEntity(); entity.setId(UUID.randomUUID().toString().replace("-", "")); entity.setAgentId(agentId); entity.setUserId(userId); entity.setSnapshotData(JsonUtils.toJsonString(redactSnapshotData(snapshotData))); entity.setChangedFields(JsonUtils.toJsonString(changedFields)); - entity.setSource(StringUtils.defaultIfBlank(source, "config")); + entity.setSource(StringUtils.defaultIfBlank(source, SOURCE_CONFIG)); entity.setRestoreFromSnapshotId(restoreFromSnapshotId); entity.setRestoreFromVersionNo(restoreFromVersionNo); entity.setCreator(SecurityUser.getUserId()); entity.setCreatedAt(new Date()); + entity.setRedactionVersion(CURRENT_REDACTION_VERSION); int inserted = agentSnapshotDao.insertWithNextVersion(entity); if (inserted != 1) { throw new RenException("快照版本号生成失败"); } - pruneSnapshots(agentId); + if (pruneAfterInsert) { + pruneSnapshots(agentId); + } } private void lockAgent(String agentId) { @@ -347,6 +433,19 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl normalized = new TreeMap<>(); + for (AgentSnapshotField field : AgentSnapshotField.values()) { + normalized.put(field.getFieldName(), normalizeForCompare(field, field.snapshotValue(data))); + } + byte[] payload = JsonUtils.toJsonString(normalized).getBytes(StandardCharsets.UTF_8); + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + private boolean isChanged(AgentSnapshotField field, Object current, Object next) { return !Objects.equals(normalizeForCompare(field, current), normalizeForCompare(field, next)); } @@ -355,11 +454,10 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl normalizeFunctions((List) value); - case CONTEXT_PROVIDERS -> normalizeSortedJsonList((List) value); + case CONTEXT_PROVIDERS -> normalizeJsonList((List) value); case CORRECT_WORD_FILE_IDS -> normalizeStringList((List) value); case TAG_NAMES -> normalizeStringList((List) value); case SUMMARY_MEMORY -> normalizeBlankText(value); - case TTS_VOLUME, TTS_RATE, TTS_PITCH -> normalizeDefaultTtsNumber(value); default -> value; }; } @@ -372,24 +470,6 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl normalizeFunctions(List functions) { Map result = new TreeMap<>(); if (functions == null) { @@ -414,31 +494,37 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl List normalizeSortedJsonList(List values) { + private List normalizeJsonList(List values) { if (values == null) { return Collections.emptyList(); } return values.stream() .filter(Objects::nonNull) .map(value -> JsonUtils.toJsonString(normalizeValue(value))) - .sorted() .toList(); } private Object normalizeValue(Object value) { + return normalizeValue(value, null); + } + + private Object normalizeValue(Object value, String parentKey) { if (value == null) { return null; } + if (value instanceof CharSequence text && shouldTreatAsUrl(parentKey, text.toString())) { + return normalizeUrlForCompare(text.toString(), parentKey); + } if (value instanceof Map map) { - return normalizeMap(map); + return normalizeMap(map, parentKey); } if (value instanceof List list) { - return list.stream().map(this::normalizeValue).toList(); + return list.stream().map(item -> normalizeValue(item, parentKey)).toList(); } if (isScalarValue(value)) { return value; } - return normalizeMap(JsonUtils.parseObject(JsonUtils.toJsonString(value), OBJECT_MAP_TYPE)); + return normalizeMap(JsonUtils.parseObject(JsonUtils.toJsonString(value), OBJECT_MAP_TYPE), parentKey); } private boolean isScalarValue(Object value) { @@ -451,14 +537,27 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl normalizeMap(Map map) { + return normalizeMap(map, null); + } + + private Map normalizeMap(Map map, String parentSemanticKey) { Map result = new TreeMap<>(); if (map == null) { return result; } + String structuredSensitiveKey = getStructuredSensitiveKey(map); map.forEach((key, value) -> { if (key != null) { String keyText = String.valueOf(key); - result.put(keyText, isSensitiveKey(keyText) ? SECRET_PLACEHOLDER : normalizeValue(value)); + String semanticKey = resolveUrlSemanticKey(parentSemanticKey, keyText); + if (isSensitiveKey(keyText) + || (structuredSensitiveKey != null && "value".equalsIgnoreCase(keyText))) { + result.put(keyText, SECRET_PLACEHOLDER); + } else if (value instanceof CharSequence text && shouldTreatAsUrl(semanticKey, text.toString())) { + result.put(keyText, normalizeUrlForCompare(text.toString(), semanticKey)); + } else { + result.put(keyText, normalizeValue(value, semanticKey)); + } } }); return result; @@ -566,17 +665,19 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl() .eq("tag_name", snapshotTag.getTagName()) - .eq("deleted", 0) .last("LIMIT 1")); } if (tag != null) { + if (Objects.equals(tag.getDeleted(), 1)) { + // Tags are global records shared by every agent that references them. Restoring a + // snapshot must not revive a globally deleted tag as a side effect for other agents + // (or tenants); require the user to recreate/select an active tag explicitly. + throw new RenException("快照引用的标签已被删除,无法恢复,请先重新创建或选择标签"); + } return tag.getId(); } @@ -607,8 +708,7 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl { if (function != null) { @@ -644,6 +762,7 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl { if (provider != null) { + provider.setUrl(redactSensitiveUrl(provider.getUrl(), "url")); provider.setHeaders(redactSensitiveMap(provider.getHeaders())); } }); @@ -655,7 +774,7 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl currentFunctions = nullToEmpty(current == null ? null : current.getFunctions()) .stream() .filter(function -> function != null && StringUtils.isNotBlank(function.getPluginId())) @@ -672,85 +791,187 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl 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())); - }); - } + copy.setContextProviders(preserveCurrentContextProviders(copy.getContextProviders(), + current == null ? null : current.getContextProviders())); return copy; } + private void validateSensitiveRestoreIsReversible(AgentSnapshotDataDTO current, + AgentSnapshotDataDTO restored) { + try { + // Simulate restoring the automatic pre-restore backup from the proposed + // result. If a current secret-bearing slot disappears, the reverse + // preservation fails because snapshots intentionally never store secrets. + preserveCurrentSensitiveValues(current, restored); + } catch (RenException exception) { + throw new RenException("目标版本会移除无法写入历史的敏感配置,请先手动处理相关密钥后再恢复"); + } + } + private HashMap redactSensitiveMap(Map map) { + return redactSensitiveMap(map, null); + } + + private HashMap redactSensitiveMap(Map map, String parentSemanticKey) { HashMap result = new HashMap<>(); if (map == null) { return result; } + String structuredSensitiveKey = getStructuredSensitiveKey(map); map.forEach((key, value) -> { if (key != null) { String keyText = String.valueOf(key); - result.put(keyText, isSensitiveKey(keyText) ? SECRET_PLACEHOLDER : redactSensitiveValue(value)); + String semanticKey = resolveUrlSemanticKey(parentSemanticKey, keyText); + if (isSensitiveKey(keyText) + || (structuredSensitiveKey != null && "value".equalsIgnoreCase(keyText))) { + result.put(keyText, SECRET_PLACEHOLDER); + } else if (value instanceof CharSequence text && shouldTreatAsUrl(semanticKey, text.toString())) { + result.put(keyText, redactSensitiveUrl(text.toString(), semanticKey)); + } else { + result.put(keyText, redactSensitiveValue(value, semanticKey)); + } } }); return result; } private Object redactSensitiveValue(Object value) { + return redactSensitiveValue(value, null); + } + + private Object redactSensitiveValue(Object value, String parentKey) { + if (value instanceof CharSequence text && shouldTreatAsUrl(parentKey, text.toString())) { + return redactSensitiveUrl(text.toString(), parentKey); + } if (value instanceof Map map) { - return redactSensitiveMap(map); + return redactSensitiveMap(map, parentKey); } if (value instanceof List list) { - return list.stream().map(this::redactSensitiveValue).toList(); + return list.stream().map(item -> redactSensitiveValue(item, parentKey)).toList(); } return value; } private HashMap preserveCurrentSensitiveMap(Map target, Map current) { + return preserveCurrentSensitiveMap(target, current, null); + } + + private HashMap preserveCurrentSensitiveMap(Map target, Map current, + String parentSemanticKey) { HashMap result = new HashMap<>(); if (target == null) { return result; } + String structuredSensitiveKey = getStructuredSensitiveKey(target); + if (structuredSensitiveKey != null) { + validateStructuredSensitiveDiscriminator(target, current); + } target.forEach((key, value) -> { if (key == null) { return; } String keyText = String.valueOf(key); + String semanticKey = resolveUrlSemanticKey(parentSemanticKey, keyText); Object currentValue = getMapValue(current, keyText); - if (isSensitiveKey(keyText)) { - result.put(keyText, currentValue == null || SECRET_PLACEHOLDER.equals(currentValue) ? "" : currentValue); + if (isSensitiveKey(keyText) + || (structuredSensitiveKey != null && "value".equalsIgnoreCase(keyText))) { + result.put(keyText, preserveSensitiveScalar(value, currentValue)); + } else if (value instanceof CharSequence targetUrl + && shouldTreatAsUrl(semanticKey, targetUrl.toString())) { + result.put(keyText, preserveCurrentSensitiveUrl(targetUrl.toString(), + currentValue instanceof CharSequence currentUrl ? currentUrl.toString() : null, semanticKey)); } else { - result.put(keyText, preserveCurrentSensitiveValue(value, currentValue)); + result.put(keyText, preserveCurrentSensitiveValue(value, currentValue, semanticKey)); } }); return result; } private Object preserveCurrentSensitiveValue(Object target, Object current) { + return preserveCurrentSensitiveValue(target, current, null); + } + + private Object preserveCurrentSensitiveValue(Object target, Object current, String parentKey) { + if (target instanceof CharSequence targetText && shouldTreatAsUrl(parentKey, targetText.toString())) { + return preserveCurrentSensitiveUrl(targetText.toString(), + current instanceof CharSequence currentText ? currentText.toString() : null, parentKey); + } if (target instanceof Map targetMap) { - return preserveCurrentSensitiveMap(targetMap, current instanceof Map currentMap ? currentMap : null); + return preserveCurrentSensitiveMap(targetMap, + current instanceof Map currentMap ? currentMap : null, parentKey); } if (target instanceof List targetList) { List currentList = current instanceof List list ? list : Collections.emptyList(); - List result = new ArrayList<>(); - for (int i = 0; i < targetList.size(); i++) { - Object currentItem = i < currentList.size() ? currentList.get(i) : null; - result.add(preserveCurrentSensitiveValue(targetList.get(i), currentItem)); - } - return result; + return preserveCurrentSensitiveList(targetList, currentList, parentKey); } return target; } + private List preserveCurrentSensitiveList(List target, List current, String parentKey) { + Map> targetByIdentity = indexListByStableIdentity(target, parentKey); + Map> currentByIdentity = indexListByStableIdentity(current, parentKey); + List result = new ArrayList<>(); + for (Object targetItem : target) { + List matchingCurrentItems = stableListIdentities(targetItem, parentKey).stream() + .filter(candidate -> targetByIdentity.getOrDefault(candidate, Collections.emptyList()).size() == 1) + .filter(candidate -> currentByIdentity.getOrDefault(candidate, Collections.emptyList()).size() == 1) + .map(candidate -> currentByIdentity.get(candidate).get(0)) + .distinct() + .toList(); + Object currentItem = matchingCurrentItems.size() == 1 ? matchingCurrentItems.get(0) : null; + if (currentItem == null && containsSensitiveMaterial(targetItem)) { + throw new RenException("快照中的敏感列表项缺少唯一稳定标识,无法安全恢复"); + } + result.add(preserveCurrentSensitiveValue(targetItem, currentItem, parentKey)); + } + return result; + } + + private Map> indexListByStableIdentity(List values, String parentKey) { + Map> result = new HashMap<>(); + for (Object value : values) { + for (String identity : stableListIdentities(value, parentKey)) { + result.computeIfAbsent(identity, ignored -> new ArrayList<>()).add(value); + } + } + return result; + } + + private List stableListIdentities(Object value, String parentKey) { + if (value instanceof CharSequence text && shouldTreatAsUrl(parentKey, text.toString())) { + String identity = normalizeUrlIdentity(text.toString(), parentKey); + return StringUtils.isBlank(identity) ? Collections.emptyList() : List.of("url:" + identity); + } + if (!(value instanceof Map map)) { + return Collections.emptyList(); + } + List identities = new ArrayList<>(); + for (String field : List.of("id", "name", "key", "url")) { + Object identityValue = getMapValue(map, field); + if (identityValue == null || StringUtils.isBlank(String.valueOf(identityValue))) { + continue; + } + String text = String.valueOf(identityValue).trim(); + if ("url".equals(field)) { + text = normalizeUrlIdentity(text, resolveUrlSemanticKey(parentKey, field)); + } else if ("name".equals(field) || "key".equals(field)) { + text = text.toLowerCase(Locale.ROOT); + } + identities.add(field + ":" + text); + } + if (!map.isEmpty()) { + identities.add("fingerprint:" + JsonUtils.toJsonString(normalizeMap(map, parentKey))); + } + return identities; + } + + private Object preserveSensitiveScalar(Object target, Object current) { + if (current != null && !SECRET_PLACEHOLDER.equals(current)) { + return current; + } + throw new RenException("快照敏感值无法与当前配置可靠匹配"); + } + private Object getMapValue(Map map, String key) { if (map == null) { return null; @@ -759,21 +980,784 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl entry : map.entrySet()) { - if (entry.getKey() != null && Objects.equals(key, String.valueOf(entry.getKey()))) { + if (entry.getKey() != null && key.equalsIgnoreCase(String.valueOf(entry.getKey()))) { return entry.getValue(); } } return null; } + private List preserveCurrentContextProviders(List target, + List current) { + if (target == null) { + return null; + } + List currentProviders = nullToEmpty(current); + Map> targetByIdentity = indexProvidersByIdentity(target); + Map> currentByIdentity = indexProvidersByIdentity(currentProviders); + + for (ContextProviderDTO targetProvider : target) { + if (targetProvider == null) { + continue; + } + String identity = normalizeUrlIdentity(targetProvider.getUrl()); + ContextProviderDTO currentProvider = null; + if (StringUtils.isNotBlank(identity) + && targetByIdentity.getOrDefault(identity, Collections.emptyList()).size() == 1 + && currentByIdentity.getOrDefault(identity, Collections.emptyList()).size() == 1) { + currentProvider = currentByIdentity.get(identity).get(0); + } else if (hasSensitiveUrlParts(targetProvider.getUrl()) + || containsSensitiveMaterial(targetProvider.getHeaders())) { + throw new RenException("上下文源敏感配置缺少唯一 URL 标识,无法安全恢复"); + } + + if (targetProvider.getUrl() != null) { + targetProvider.setUrl(preserveCurrentSensitiveUrl(targetProvider.getUrl(), + currentProvider == null ? null : currentProvider.getUrl(), "url")); + } + targetProvider.setHeaders(preserveCurrentSensitiveMap(targetProvider.getHeaders(), + currentProvider == null ? null : currentProvider.getHeaders())); + } + return target; + } + + private Map> indexProvidersByIdentity(List providers) { + Map> result = new HashMap<>(); + for (ContextProviderDTO provider : providers) { + if (provider == null) { + continue; + } + String identity = normalizeUrlIdentity(provider.getUrl()); + if (StringUtils.isNotBlank(identity)) { + result.computeIfAbsent(identity, ignored -> new ArrayList<>()).add(provider); + } + } + return result; + } + + private String getStructuredSensitiveKey(Map map) { + if (map == null) { + return null; + } + for (String discriminator : List.of("key", "name")) { + Object value = getMapValue(map, discriminator); + if (value != null && isSensitiveKey(String.valueOf(value))) { + return String.valueOf(value); + } + } + return null; + } + + private void validateStructuredSensitiveDiscriminator(Map target, Map current) { + List targetIdentities = structuredSensitiveDiscriminatorIdentities(target); + List currentIdentities = structuredSensitiveDiscriminatorIdentities(current); + if (targetIdentities.size() != 1 || currentIdentities.size() != 1 + || !Objects.equals(targetIdentities.get(0), currentIdentities.get(0))) { + throw new RenException("快照结构化敏感值的类型标识无法与当前配置唯一匹配"); + } + } + + private List structuredSensitiveDiscriminatorIdentities(Map map) { + if (map == null) { + return Collections.emptyList(); + } + List identities = new ArrayList<>(); + for (String discriminator : List.of("key", "name")) { + Object value = getMapValue(map, discriminator); + if (value == null || !isSensitiveKey(String.valueOf(value))) { + continue; + } + String identity = String.valueOf(value).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + if (!identities.contains(identity)) { + identities.add(identity); + } + } + return identities; + } + + private boolean containsSensitiveMaterial(Object value) { + return containsSensitiveMaterial(value, null); + } + + private boolean containsSensitiveMaterial(Object value, String parentKey) { + if (SECRET_PLACEHOLDER.equals(value)) { + return true; + } + if (value instanceof Map map) { + if (getStructuredSensitiveKey(map) != null) { + return true; + } + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() == null) { + continue; + } + String key = String.valueOf(entry.getKey()); + String semanticKey = resolveUrlSemanticKey(parentKey, key); + if (isSensitiveKey(key) + || (entry.getValue() instanceof CharSequence text + && shouldTreatAsUrl(semanticKey, text.toString()) + && hasSensitiveUrlParts(text.toString(), semanticKey)) + || containsSensitiveMaterial(entry.getValue(), semanticKey)) { + return true; + } + } + return false; + } + if (value instanceof List list) { + return list.stream().anyMatch(item -> containsSensitiveMaterial(item, parentKey)); + } + if (value instanceof CharSequence text && shouldTreatAsUrl(parentKey, text.toString())) { + return hasSensitiveUrlParts(text.toString(), parentKey); + } + return false; + } + + private boolean isUrlKey(String key) { + String normalized = StringUtils.defaultString(key).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + return normalized.endsWith("url") + || normalized.endsWith("uri") + || normalized.endsWith("endpoint") + || normalized.endsWith("webhook"); + } + + private boolean shouldTreatAsUrl(String key, String value) { + return isUrlKey(key) || isWebhookSemanticKey(key) || looksLikeUrl(value); + } + + private String resolveUrlSemanticKey(String parentSemanticKey, String childKey) { + if (isWebhookSemanticKey(childKey)) { + return childKey; + } + if (isWebhookSemanticKey(parentSemanticKey)) { + // Keep the concrete child name for URL detection while carrying the + // enclosing webhook capability semantics through arbitrary wrapper maps. + return StringUtils.isBlank(childKey) + ? parentSemanticKey + : parentSemanticKey + "." + childKey; + } + return childKey; + } + + private boolean looksLikeUrl(String value) { + String text = StringUtils.defaultString(value); + if (text.startsWith("//")) { + int pathStart = text.indexOf('/', 2); + String authority = pathStart < 0 ? text.substring(2) : text.substring(2, pathStart); + return StringUtils.isNotBlank(authority) && authority.chars().noneMatch(Character::isWhitespace); + } + int schemeEnd = text.indexOf("://"); + if (schemeEnd <= 0 || !Character.isLetter(text.charAt(0))) { + return false; + } + for (int i = 1; i < schemeEnd; i++) { + char character = text.charAt(i); + if (!Character.isLetterOrDigit(character) + && character != '+' + && character != '-' + && character != '.') { + return false; + } + } + return true; + } + + private String normalizeUrlForCompare(String value) { + return normalizeUrlForCompare(value, null); + } + + private String normalizeUrlForCompare(String value, String semanticKey) { + return redactSensitiveUrl(value, semanticKey); + } + + private String redactSensitiveUrl(String value) { + return redactSensitiveUrl(value, null); + } + + private String redactSensitiveUrl(String value, String semanticKey) { + if (value == null) { + return null; + } + SensitiveUrlParts parts = splitSensitiveUrl(value); + return buildSensitiveUrl(analyzeSensitiveUrlPath(parts.base(), semanticKey).redactedBase(), + parts.userInfo() == null ? null : SECRET_PLACEHOLDER, + redactSensitiveUrlParameters(parts.query()), + redactSensitiveUrlFragment(parts.fragment())); + } + + private String preserveCurrentSensitiveUrl(String target, String current) { + return preserveCurrentSensitiveUrl(target, current, null); + } + + private String preserveCurrentSensitiveUrl(String target, String current, String semanticKey) { + if (target == null) { + return null; + } + SensitiveUrlParts targetParts = splitSensitiveUrl(target); + SensitiveUrlParts currentParts = splitSensitiveUrl(StringUtils.defaultString(current)); + SensitivePathAnalysis targetPath = analyzeSensitiveUrlPath(targetParts.base(), semanticKey); + boolean forceCurrentGenericMarker = targetPath.slots().stream() + .anyMatch(slot -> slot.identity().startsWith("webhook-suffix:")); + SensitivePathAnalysis currentPath = analyzeSensitiveUrlPath(currentParts.base(), semanticKey, + forceCurrentGenericMarker); + boolean copiesSensitiveComponent = targetParts.userInfo() != null + || !targetPath.slots().isEmpty() + || containsSensitiveUrlParameter(targetParts.query()) + || (targetParts.fragment() != null + && (!isStructuredUrlComponent(targetParts.fragment()) + || containsSensitiveUrlParameter(targetParts.fragment()))); + if (copiesSensitiveComponent && !Objects.equals(targetPath.redactedBase(), currentPath.redactedBase())) { + throw new RenException("快照 URL 敏感信息无法与当前配置的公开地址标识匹配"); + } + String result = buildSensitiveUrl(preserveSensitiveUrlPath(targetParts.base(), currentParts.base(), semanticKey), + preserveUrlComponent(targetParts.userInfo(), currentParts.userInfo()), + preserveSensitiveUrlParameters(targetParts.query(), currentParts.query()), + preserveSensitiveUrlFragment(targetParts.fragment(), currentParts.fragment())); + if (result.contains(SECRET_PLACEHOLDER)) { + throw new RenException("快照 URL 中仍包含脱敏占位符,无法安全恢复"); + } + return result; + } + + private String preserveUrlComponent(String target, String current) { + if (target == null) { + return null; + } + if (current == null || current.contains(SECRET_PLACEHOLDER)) { + throw new RenException("快照 URL 中的敏感信息无法与当前配置可靠匹配"); + } + return current; + } + + private String redactSensitiveUrlParameters(String value) { + if (value == null) { + return null; + } + return buildUrlParameters(parseUrlParameters(value).stream() + .map(parameter -> isSensitiveUrlParameter(parameter) + ? new UrlParameter(parameter.rawKey(), SECRET_PLACEHOLDER, parameter.hasEquals()) + : parameter) + .toList()); + } + + private String redactSensitiveUrlFragment(String value) { + if (value == null) { + return null; + } + return isStructuredUrlComponent(value) + ? redactSensitiveUrlParameters(value) + : SECRET_PLACEHOLDER; + } + + private String preserveSensitiveUrlParameters(String target, String current) { + if (target == null) { + return null; + } + List targetParameters = parseUrlParameters(target); + List currentParameters = parseUrlParameters(StringUtils.defaultString(current)); + Map> targetSensitiveParameters = indexSensitiveUrlParameters(targetParameters); + Map> currentSensitiveParameters = indexSensitiveUrlParameters(currentParameters); + List result = new ArrayList<>(); + + for (UrlParameter targetParameter : targetParameters) { + if (!isSensitiveUrlParameter(targetParameter)) { + result.add(targetParameter); + continue; + } + String identity = normalizeUrlParameterName(targetParameter.rawKey()); + List matchingTargets = targetSensitiveParameters.getOrDefault(identity, + Collections.emptyList()); + List matchingCurrent = currentSensitiveParameters.getOrDefault(identity, + Collections.emptyList()); + if (matchingTargets.size() != 1 || matchingCurrent.size() != 1) { + throw new RenException("快照 URL 中的敏感参数无法与当前配置唯一匹配"); + } + UrlParameter currentParameter = matchingCurrent.get(0); + if (currentParameter.rawValue().contains(SECRET_PLACEHOLDER)) { + throw new RenException("快照 URL 中的敏感信息无法与当前配置可靠匹配"); + } + result.add(new UrlParameter(targetParameter.rawKey(), currentParameter.rawValue(), + currentParameter.hasEquals())); + } + return buildUrlParameters(result); + } + + private String preserveSensitiveUrlFragment(String target, String current) { + if (target == null) { + return null; + } + return isStructuredUrlComponent(target) + ? preserveSensitiveUrlParameters(target, current) + : preserveUrlComponent(target, current); + } + + private Map> indexSensitiveUrlParameters(List parameters) { + Map> result = new HashMap<>(); + for (UrlParameter parameter : parameters) { + if (isSensitiveUrlParameter(parameter)) { + result.computeIfAbsent(normalizeUrlParameterName(parameter.rawKey()), ignored -> new ArrayList<>()) + .add(parameter); + } + } + return result; + } + + private List parseUrlParameters(String value) { + List result = new ArrayList<>(); + for (String part : StringUtils.defaultString(value).split("&", -1)) { + int equalsIndex = part.indexOf('='); + if (equalsIndex < 0) { + result.add(new UrlParameter(part, "", false)); + } else { + result.add(new UrlParameter(part.substring(0, equalsIndex), part.substring(equalsIndex + 1), true)); + } + } + return result; + } + + private String buildUrlParameters(List parameters) { + return parameters.stream() + .map(parameter -> parameter.rawKey() + + (parameter.hasEquals() ? "=" + parameter.rawValue() : "")) + .collect(Collectors.joining("&")); + } + + private boolean isSensitiveUrlParameter(UrlParameter parameter) { + String decodedName = decodeUrlParameterName(parameter.rawKey()); + String normalized = decodedName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + return isSensitiveKey(decodedName) + || normalized.equals("key") + || normalized.equals("sig") + || normalized.equals("signature") + || normalized.equals("xamzsignature") + || normalized.equals("xgoogsignature") + || normalized.equals("sas"); + } + + private String normalizeUrlParameterName(String value) { + return decodeUrlParameterName(value).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + } + + private String decodeUrlParameterName(String value) { + try { + return URLDecoder.decode(StringUtils.defaultString(value), StandardCharsets.UTF_8); + } catch (IllegalArgumentException ignored) { + return StringUtils.defaultString(value); + } + } + + private boolean isStructuredUrlComponent(String value) { + return value != null && (value.contains("=") || value.contains("&")); + } + + private boolean hasSensitiveUrlParts(String value) { + return hasSensitiveUrlParts(value, null); + } + + private boolean hasSensitiveUrlParts(String value, String semanticKey) { + if (StringUtils.isBlank(value)) { + return false; + } + SensitiveUrlParts parts = splitSensitiveUrl(value); + return parts.userInfo() != null + || !analyzeSensitiveUrlPath(parts.base(), semanticKey).slots().isEmpty() + || containsSensitiveUrlParameter(parts.query()) + || (parts.fragment() != null + && (!isStructuredUrlComponent(parts.fragment()) + || containsSensitiveUrlParameter(parts.fragment()))); + } + + private boolean containsSensitiveUrlParameter(String value) { + return value != null && parseUrlParameters(value).stream().anyMatch(this::isSensitiveUrlParameter); + } + + private String normalizeUrlIdentity(String value) { + return normalizeUrlIdentity(value, null); + } + + private String normalizeUrlIdentity(String value, String semanticKey) { + if (StringUtils.isBlank(value)) { + return ""; + } + SensitiveUrlParts parts = splitSensitiveUrl(value); + return analyzeSensitiveUrlPath(parts.base(), semanticKey).redactedBase(); + } + + private String preserveSensitiveUrlPath(String targetBase, String currentBase, String semanticKey) { + SensitivePathAnalysis target = analyzeSensitiveUrlPath(targetBase, semanticKey); + if (target.slots().isEmpty()) { + return targetBase; + } + boolean forceCurrentGenericMarker = target.slots().stream() + .anyMatch(slot -> slot.identity().startsWith("webhook-suffix:")); + SensitivePathAnalysis current = analyzeSensitiveUrlPath(currentBase, semanticKey, forceCurrentGenericMarker); + if (!Objects.equals(target.redactedBase(), current.redactedBase()) + || target.slots().size() != current.slots().size()) { + throw new RenException("快照 URL 路径敏感信息无法与当前配置的公开标识唯一匹配"); + } + + Map> currentSlots = current.slots().stream() + .collect(Collectors.groupingBy(SensitivePathSlot::identity)); + Map replacements = new HashMap<>(); + for (SensitivePathSlot targetSlot : target.slots()) { + List matches = currentSlots.getOrDefault(targetSlot.identity(), Collections.emptyList()); + if (matches.size() != 1 || matches.get(0).secretSegments().isEmpty() + || matches.get(0).secretSegments().stream() + .anyMatch(secret -> StringUtils.isBlank(secret) || secret.contains(SECRET_PLACEHOLDER))) { + throw new RenException("快照 URL 路径敏感信息无法与当前配置可靠匹配"); + } + if (replacements.put(targetSlot.identity(), matches.get(0)) != null) { + throw new RenException("快照 URL 路径敏感信息存在歧义,无法安全恢复"); + } + } + return rebuildSensitivePath(target, replacements); + } + + private SensitivePathAnalysis analyzeSensitiveUrlPath(String base, String semanticKey) { + return analyzeSensitiveUrlPath(base, semanticKey, false); + } + + private SensitivePathAnalysis analyzeSensitiveUrlPath(String base, String semanticKey, + boolean forceGenericMarker) { + UrlPathParts pathParts = splitUrlPath(base); + if (pathParts == null || pathParts.segments().isEmpty()) { + return new SensitivePathAnalysis(base, base, Collections.emptyList()); + } + List segments = pathParts.segments(); + List slots = new ArrayList<>(); + List claimedMarkers = new ArrayList<>(); + + if (isSlackHooksHost(pathParts.host())) { + for (int i = 0; i + 3 < segments.size(); i++) { + if ("services".equalsIgnoreCase(segments.get(i)) + && allPathSegmentsPresent(segments, i + 1, i + 4)) { + addSensitivePathSlot(slots, new SensitivePathSlot("slack:" + i, i + 3, i + 4, "", + List.of(segments.get(i + 3)))); + } + } + } + + if (isDiscordHost(pathParts.host())) { + for (int i = 0; i + 2 < segments.size(); i++) { + if ("webhooks".equalsIgnoreCase(segments.get(i)) + && isDiscordWebhookPrefix(segments, i) + && allPathSegmentsPresent(segments, i + 1, i + 3)) { + addSensitivePathSlot(slots, new SensitivePathSlot("discord:" + i, i + 2, i + 3, "", + List.of(segments.get(i + 2)))); + claimedMarkers.add(i); + } + } + } + + if (isTelegramHost(pathParts.host())) { + for (int i = 0; i < segments.size(); i++) { + String segment = segments.get(i); + int colon = segment.indexOf(':'); + if (segment.regionMatches(true, 0, "bot", 0, 3) + && colon > 3 && colon < segment.length() - 1) { + String publicPrefix = segment.substring(0, colon + 1); + addSensitivePathSlot(slots, new SensitivePathSlot("telegram:" + i + ":" + publicPrefix, + i, i + 1, publicPrefix, List.of(segment.substring(colon + 1)))); + } + } + } + + for (int i = 0; i < segments.size(); i++) { + if (!isWebhookPathMarker(segments.get(i)) || claimedMarkers.contains(i) || i + 1 >= segments.size()) { + continue; + } + int suffixEnd = segments.size(); + while (suffixEnd > i + 1 && StringUtils.isBlank(segments.get(suffixEnd - 1))) { + suffixEnd--; + } + List capabilitySuffix = new ArrayList<>(segments.subList(i + 1, suffixEnd)); + if (capabilitySuffix.isEmpty()) { + continue; + } + if (capabilitySuffix.stream().anyMatch(StringUtils::isBlank)) { + continue; + } + if (!forceGenericMarker + && !isHighConfidenceWebhookCapability(pathParts, semanticKey, capabilitySuffix)) { + continue; + } + addSensitivePathSlot(slots, new SensitivePathSlot("webhook-suffix:" + i, i + 1, suffixEnd, "", + capabilitySuffix)); + break; + } + + if (slots.isEmpty() && isWebhookSemanticKey(semanticKey)) { + int lastSegment = segments.size() - 1; + while (lastSegment >= 0 && StringUtils.isBlank(segments.get(lastSegment))) { + lastSegment--; + } + if (lastSegment >= 0 && StringUtils.isNotBlank(segments.get(lastSegment))) { + addSensitivePathSlot(slots, new SensitivePathSlot("webhook-key:" + lastSegment, + lastSegment, lastSegment + 1, "", List.of(segments.get(lastSegment)))); + } + } + + if (slots.isEmpty()) { + return new SensitivePathAnalysis(base, base, Collections.emptyList()); + } + SensitivePathAnalysis analysis = new SensitivePathAnalysis(base, null, slots); + return new SensitivePathAnalysis(base, rebuildSensitivePath(analysis, Collections.emptyMap()), slots); + } + + private String rebuildSensitivePath(SensitivePathAnalysis analysis, + Map replacements) { + UrlPathParts pathParts = splitUrlPath(analysis.originalBase()); + if (pathParts == null) { + return analysis.originalBase(); + } + Map slotsByStart = analysis.slots().stream() + .collect(Collectors.toMap(SensitivePathSlot::startIndex, Function.identity(), (left, right) -> left)); + List result = new ArrayList<>(); + for (int i = 0; i < pathParts.segments().size();) { + SensitivePathSlot slot = slotsByStart.get(i); + if (slot == null) { + result.add(pathParts.segments().get(i)); + i++; + continue; + } + SensitivePathSlot replacement = replacements.get(slot.identity()); + if (replacement == null) { + result.add(slot.publicPrefix() + SECRET_PLACEHOLDER); + } else if (StringUtils.isNotEmpty(slot.publicPrefix())) { + result.add(slot.publicPrefix() + replacement.secretSegments().get(0)); + } else { + result.addAll(replacement.secretSegments()); + } + i = slot.endIndex(); + } + return pathParts.prefix() + String.join("/", result); + } + + private UrlPathParts splitUrlPath(String base) { + String value = StringUtils.defaultString(base); + int schemeIndex = value.indexOf("://"); + if (schemeIndex < 0 && !value.startsWith("/")) { + if (StringUtils.isBlank(value) || value.chars().anyMatch(Character::isWhitespace)) { + return null; + } + return new UrlPathParts("", + new ArrayList<>(List.of(value.split("/", -1))), ""); + } + if (schemeIndex < 0 && !value.startsWith("//")) { + return new UrlPathParts("", + new ArrayList<>(List.of(value.split("/", -1))), ""); + } + int authorityStart = schemeIndex < 0 ? 2 : schemeIndex + 3; + int pathStart = value.indexOf('/', authorityStart); + String authority = pathStart < 0 ? value.substring(authorityStart) : value.substring(authorityStart, pathStart); + String host = normalizeAuthorityHost(authority); + if (pathStart < 0) { + return new UrlPathParts(value, Collections.emptyList(), host); + } + return new UrlPathParts(value.substring(0, pathStart), + new ArrayList<>(List.of(value.substring(pathStart).split("/", -1))), host); + } + + private String normalizeAuthorityHost(String authority) { + String value = StringUtils.defaultString(authority).toLowerCase(Locale.ROOT); + if (value.startsWith("[")) { + int closingBracket = value.indexOf(']'); + return closingBracket < 0 ? value : value.substring(0, closingBracket + 1); + } + int portSeparator = value.lastIndexOf(':'); + return portSeparator < 0 ? value : value.substring(0, portSeparator); + } + + private boolean isSlackHooksHost(String host) { + return "hooks.slack.com".equals(host) || "hooks.slack-gov.com".equals(host); + } + + private boolean isDiscordHost(String host) { + return "discord.com".equals(host) || host.endsWith(".discord.com") + || "discordapp.com".equals(host) || host.endsWith(".discordapp.com"); + } + + private boolean isTelegramHost(String host) { + return "api.telegram.org".equals(host); + } + + private boolean isDiscordWebhookPrefix(List segments, int markerIndex) { + if (markerIndex >= 1 && "api".equalsIgnoreCase(segments.get(markerIndex - 1))) { + return true; + } + return markerIndex >= 2 + && "api".equalsIgnoreCase(segments.get(markerIndex - 2)) + && isVersionPathSegment(segments.get(markerIndex - 1)); + } + + private boolean isVersionPathSegment(String value) { + String normalized = StringUtils.defaultString(value).toLowerCase(Locale.ROOT); + return normalized.length() > 1 && normalized.charAt(0) == 'v' + && normalized.substring(1).chars().allMatch(Character::isDigit); + } + + private boolean isWebhookPathMarker(String value) { + String normalized = StringUtils.defaultString(value).toLowerCase(Locale.ROOT); + return normalized.equals("webhook") || normalized.equals("webhooks") + || normalized.equals("hook") || normalized.equals("hooks"); + } + + private boolean isHighConfidenceWebhookCapability(UrlPathParts pathParts, String semanticKey, + List capabilitySuffix) { + return isWebhookSemanticKey(semanticKey) + || hasWebhookHostLabel(pathParts.host()) + || isSlackHooksHost(pathParts.host()) + || isDiscordHost(pathParts.host()) + || isTelegramHost(pathParts.host()) + || capabilitySuffix.stream().anyMatch(this::looksLikeCapabilitySecret); + } + + private boolean hasWebhookHostLabel(String host) { + return List.of(StringUtils.defaultString(host).split("\\.")).stream() + .anyMatch(this::isWebhookPathMarker); + } + + private boolean looksLikeCapabilitySecret(String value) { + String text = StringUtils.defaultString(value); + String normalized = text.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + if (normalized.contains("secret") + || normalized.contains("token") + || normalized.contains("capability") + || normalized.contains("credential") + || normalized.contains("signature") + || normalized.contains("apikey") + || normalized.contains("accesskey") + || normalized.contains("authkey")) { + return true; + } + if (text.length() < 20) { + return false; + } + boolean hasLetter = text.chars().anyMatch(Character::isLetter); + boolean hasDigit = text.chars().anyMatch(Character::isDigit); + boolean hasSymbol = text.chars().anyMatch(character -> !Character.isLetterOrDigit(character)); + long distinctCharacters = text.chars().distinct().count(); + return distinctCharacters >= 10 + && ((hasLetter && hasDigit) || (hasLetter && hasSymbol) || (hasDigit && hasSymbol)); + } + + private boolean isWebhookSemanticKey(String value) { + String normalized = StringUtils.defaultString(value).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + return normalized.contains("webhook") || normalized.equals("hook") || normalized.equals("hooks") + || normalized.endsWith("hook") || normalized.endsWith("hooks"); + } + + private boolean allPathSegmentsPresent(List segments, int fromInclusive, int toExclusive) { + return segments.subList(fromInclusive, toExclusive).stream().noneMatch(StringUtils::isBlank); + } + + private void addSensitivePathSlot(List slots, SensitivePathSlot candidate) { + boolean overlaps = slots.stream().anyMatch(slot -> candidate.startIndex() < slot.endIndex() + && slot.startIndex() < candidate.endIndex()); + if (!overlaps) { + slots.add(candidate); + } + } + + private SensitiveUrlParts splitSensitiveUrl(String value) { + String remaining = StringUtils.defaultString(value); + String fragment = null; + int fragmentIndex = remaining.indexOf('#'); + if (fragmentIndex >= 0) { + fragment = remaining.substring(fragmentIndex + 1); + remaining = remaining.substring(0, fragmentIndex); + } + String query = null; + int queryIndex = remaining.indexOf('?'); + if (queryIndex >= 0) { + query = remaining.substring(queryIndex + 1); + remaining = remaining.substring(0, queryIndex); + } + + String userInfo = null; + int schemeIndex = remaining.indexOf("://"); + int authorityStart = schemeIndex >= 0 + ? schemeIndex + 3 + : remaining.startsWith("//") ? 2 : -1; + if (authorityStart >= 0) { + int pathStart = remaining.indexOf('/', authorityStart); + if (pathStart < 0) { + pathStart = remaining.length(); + } + String authority = remaining.substring(authorityStart, pathStart); + int userInfoEnd = authority.lastIndexOf('@'); + if (userInfoEnd >= 0) { + userInfo = authority.substring(0, userInfoEnd); + remaining = remaining.substring(0, authorityStart) + + authority.substring(userInfoEnd + 1) + + remaining.substring(pathStart); + } + } + return new SensitiveUrlParts(remaining, userInfo, query, fragment); + } + + private String buildSensitiveUrl(String base, String userInfo, String query, String fragment) { + String result = StringUtils.defaultString(base); + if (userInfo != null) { + int schemeIndex = result.indexOf("://"); + int authorityStart = schemeIndex >= 0 + ? schemeIndex + 3 + : result.startsWith("//") ? 2 : -1; + if (authorityStart < 0) { + throw new RenException("带用户凭据的 URL 格式无效,无法安全恢复"); + } + result = result.substring(0, authorityStart) + userInfo + "@" + result.substring(authorityStart); + } + if (query != null) { + result += "?" + query; + } + if (fragment != null) { + result += "#" + fragment; + } + return result; + } + + private record SensitiveUrlParts(String base, String userInfo, String query, String fragment) { + } + + private record UrlPathParts(String prefix, List segments, String host) { + } + + private record SensitivePathAnalysis(String originalBase, String redactedBase, + List slots) { + } + + private record SensitivePathSlot(String identity, int startIndex, int endIndex, + String publicPrefix, List secretSegments) { + } + + private record UrlParameter(String rawKey, String rawValue, boolean hasEquals) { + } + private boolean isSensitiveKey(String key) { - String normalized = StringUtils.defaultString(key).toLowerCase().replaceAll("[^a-z0-9]", ""); + String normalized = StringUtils.defaultString(key).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); return normalized.equals("authorization") + || normalized.contains("authorization") + || normalized.contains("authentication") + || normalized.equals("auth") + || normalized.endsWith("auth") + || normalized.equals("cookie") + || normalized.equals("cookie2") + || normalized.equals("setcookie") + || normalized.equals("setcookie2") + || normalized.endsWith("cookie") + || normalized.equals("session") + || normalized.endsWith("session") + || normalized.contains("sessionid") + || normalized.contains("sessionkey") + || normalized.contains("sessiontoken") + || normalized.contains("sessioncookie") + || normalized.endsWith("sessid") || normalized.equals("token") || normalized.endsWith("token") || normalized.contains("apikey") || normalized.contains("appkey") || normalized.contains("accesskey") + || normalized.contains("subscriptionkey") || normalized.contains("privatekey") || normalized.contains("password") || normalized.contains("passwd") diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentSnapshotVO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentSnapshotVO.java index 69547e58..d5a44e1d 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentSnapshotVO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentSnapshotVO.java @@ -18,13 +18,17 @@ public class AgentSnapshotVO { private List changedFields; private List fieldOrder; private String source; - @Schema(description = "恢复来源快照ID,仅恢复前自动备份版本有值") + @Schema(description = "恢复来源快照ID,仅恢复结果版本有值") private String restoreFromSnapshotId; - @Schema(description = "恢复来源版本号,仅恢复前自动备份版本有值") + @Schema(description = "恢复来源版本号,仅恢复结果版本有值") private Integer restoreFromVersionNo; @Schema(description = "创建者,表示触发本次快照写入的操作人") private Long creator; private Date createdAt; private AgentSnapshotDataDTO snapshotData; private AgentSnapshotDataDTO afterSnapshotData; + @Schema(description = "恢复预览对应的脱敏当前配置,仅详情接口有值") + private AgentSnapshotDataDTO currentSnapshotData; + @Schema(description = "恢复预览对应的当前配置状态指纹,仅详情接口有值") + private String currentStateToken; } diff --git a/main/manager-api/src/main/resources/db/changelog/202607101200.sql b/main/manager-api/src/main/resources/db/changelog/202607101200.sql new file mode 100644 index 00000000..7243f217 --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202607101200.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql + +-- changeset tykechen:202607101200 +ALTER TABLE `ai_agent_snapshot` + ADD COLUMN `redaction_version` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '快照脱敏规则版本' AFTER `created_at`, + ADD INDEX `idx_snapshot_redaction_version_id` (`redaction_version`, `id`); + +-- rollback ALTER TABLE `ai_agent_snapshot` DROP INDEX `idx_snapshot_redaction_version_id`, DROP COLUMN `redaction_version`; diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index 27ed05a3..3ce5d234 100644 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -704,3 +704,10 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202607071530.sql + - changeSet: + id: 202607101200 + author: tykechen + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202607101200.sql diff --git a/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml b/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml index c779a5e6..54a575fb 100644 --- a/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml +++ b/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml @@ -89,4 +89,32 @@ FOR UPDATE + + UPDATE ai_agent + SET agent_code = #{agent.agentCode,jdbcType=VARCHAR}, + agent_name = #{agent.agentName,jdbcType=VARCHAR}, + asr_model_id = #{agent.asrModelId,jdbcType=VARCHAR}, + vad_model_id = #{agent.vadModelId,jdbcType=VARCHAR}, + llm_model_id = #{agent.llmModelId,jdbcType=VARCHAR}, + slm_model_id = #{agent.slmModelId,jdbcType=VARCHAR}, + vllm_model_id = #{agent.vllmModelId,jdbcType=VARCHAR}, + tts_model_id = #{agent.ttsModelId,jdbcType=VARCHAR}, + tts_voice_id = #{agent.ttsVoiceId,jdbcType=VARCHAR}, + tts_language = #{agent.ttsLanguage,jdbcType=VARCHAR}, + tts_volume = #{agent.ttsVolume,jdbcType=INTEGER}, + tts_rate = #{agent.ttsRate,jdbcType=INTEGER}, + tts_pitch = #{agent.ttsPitch,jdbcType=INTEGER}, + mem_model_id = #{agent.memModelId,jdbcType=VARCHAR}, + intent_model_id = #{agent.intentModelId,jdbcType=VARCHAR}, + chat_history_conf = #{agent.chatHistoryConf,jdbcType=INTEGER}, + system_prompt = #{agent.systemPrompt,jdbcType=LONGVARCHAR}, + summary_memory = #{agent.summaryMemory,jdbcType=LONGVARCHAR}, + lang_code = #{agent.langCode,jdbcType=VARCHAR}, + language = #{agent.language,jdbcType=VARCHAR}, + sort = #{agent.sort,jdbcType=INTEGER}, + updater = #{agent.updater,jdbcType=BIGINT}, + updated_at = #{agent.updatedAt,jdbcType=TIMESTAMP} + WHERE id = #{agent.id} + + diff --git a/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml b/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml index ea5650c2..39e3211c 100644 --- a/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml +++ b/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml @@ -19,7 +19,8 @@ restore_from_snapshot_id AS restoreFromSnapshotId, restore_from_version_no AS restoreFromVersionNo, creator, - created_at AS createdAt + created_at AS createdAt, + redaction_version AS redactionVersion FROM ai_agent_snapshot WHERE agent_id = #{agentId} ORDER BY version_no DESC @@ -37,7 +38,8 @@ restore_from_snapshot_id AS restoreFromSnapshotId, restore_from_version_no AS restoreFromVersionNo, creator, - created_at AS createdAt + created_at AS createdAt, + redaction_version AS redactionVersion FROM ai_agent_snapshot WHERE agent_id = #{agentId} AND version_no > #{versionNo} @@ -57,7 +59,8 @@ restore_from_snapshot_id, restore_from_version_no, creator, - created_at + created_at, + redaction_version ) SELECT #{snapshot.id}, #{snapshot.agentId}, @@ -69,7 +72,8 @@ #{snapshot.restoreFromSnapshotId}, #{snapshot.restoreFromVersionNo}, #{snapshot.creator}, - #{snapshot.createdAt} + #{snapshot.createdAt}, + #{snapshot.redactionVersion} FROM ai_agent_snapshot WHERE agent_id = #{snapshot.agentId} @@ -89,4 +93,31 @@ ) + + + + UPDATE ai_agent_snapshot + SET snapshot_data = CASE id + + WHEN #{snapshot.id} THEN #{snapshot.snapshotData} + + ELSE snapshot_data + END, + redaction_version = #{redactionVersion} + WHERE redaction_version < #{redactionVersion} + AND id IN + + #{snapshot.id} + + + diff --git a/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunnerTest.java b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunnerTest.java new file mode 100644 index 00000000..42dd244f --- /dev/null +++ b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunnerTest.java @@ -0,0 +1,77 @@ +package xiaozhi.modules.agent.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.scheduling.annotation.Scheduled; + +import xiaozhi.modules.agent.service.AgentSnapshotService; + +@ExtendWith(OutputCaptureExtension.class) +class AgentSnapshotRedactionRunnerTest { + + @Test + void startupRedactionRunsSynchronouslyAndReportsCompensationWindow(CapturedOutput output) { + assertTrue(SmartInitializingSingleton.class.isAssignableFrom(AgentSnapshotRedactionRunner.class)); + AgentSnapshotService service = mock(AgentSnapshotService.class); + AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service); + when(service.redactLegacySnapshots()).thenReturn(0L); + + runner.afterSingletonsInstantiated(); + + verify(service).redactLegacySnapshots(); + assertTrue(output.getAll().contains("startup pass completed")); + assertTrue(output.getAll().contains("starts after 5000 ms and repeats every 15000 ms")); + } + + @Test + void startupRedactionFailureIsLoggedAndPropagatedToKeepStartupFailClosed(CapturedOutput output) { + AgentSnapshotService service = mock(AgentSnapshotService.class); + AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service); + IllegalStateException failure = new IllegalStateException("database unavailable"); + when(service.redactLegacySnapshots()).thenThrow(failure); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + runner::afterSingletonsInstantiated); + + assertSame(failure, thrown); + assertTrue(output.getAll().contains("blocking application startup before it can accept traffic")); + } + + @Test + void rollingDeploymentRedactionReportsTriggerCountAndCredentialRotation(CapturedOutput output) { + AgentSnapshotService service = mock(AgentSnapshotService.class); + AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service); + when(service.redactLegacySnapshots()).thenReturn(3L); + + runner.redactLateRollingDeploymentWrites(); + + assertTrue(output.getAll().contains("trigger=rolling-deployment migrated=3")); + assertTrue(output.getAll().contains("Rotate credentials")); + } + + @Test + void rollingDeploymentScheduleKeepsLegacyWriteExposureWindowShort() throws Exception { + Method method = AgentSnapshotRedactionRunner.class.getMethod("redactLateRollingDeploymentWrites"); + Scheduled scheduled = method.getAnnotation(Scheduled.class); + + assertNotNull(scheduled); + assertEquals(5_000, scheduled.initialDelay()); + assertEquals(15_000, scheduled.fixedDelay()); + assertTrue(scheduled.initialDelay() <= 10_000); + assertTrue(scheduled.fixedDelay() <= 30_000); + } +} diff --git a/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java index 9efe336c..78fd05b9 100644 --- a/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java +++ b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java @@ -3,11 +3,13 @@ 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -17,26 +19,31 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.core.type.TypeReference; import java.lang.reflect.Method; +import java.lang.reflect.InvocationTargetException; 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.Locale; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; import xiaozhi.common.utils.JsonUtils; +import xiaozhi.common.exception.RenException; 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.dao.AgentTagRelationDao; import xiaozhi.modules.agent.dto.AgentCreateDTO; import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO; import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO; @@ -44,12 +51,15 @@ 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.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentSnapshotEntity; import xiaozhi.modules.agent.entity.AgentTagEntity; +import xiaozhi.modules.agent.service.AgentChatHistoryService; import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentContextProviderService; import xiaozhi.modules.agent.service.AgentSnapshotService; +import xiaozhi.modules.agent.service.AgentTagService; import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.agent.vo.AgentSnapshotVO; import xiaozhi.modules.agent.vo.AgentInfoVO; @@ -61,10 +71,10 @@ class AgentSnapshotServiceImplTest { @Test @SuppressWarnings("unchecked") - void normalizeSortedJsonListTreatsDtoAndMapShapesEqually() throws Exception { + void normalizeJsonListTreatsDtoAndMapShapesEqually() 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 method = AgentSnapshotServiceImpl.class.getDeclaredMethod("normalizeJsonList", List.class); method.setAccessible(true); ContextProviderDTO dto = new ContextProviderDTO(); @@ -78,6 +88,31 @@ class AgentSnapshotServiceImplTest { assertEquals(method.invoke(service, List.of(dto)), method.invoke(service, List.of(map))); } + @Test + @SuppressWarnings("unchecked") + void contextProviderOrderIsARealSnapshotChange() 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); + + ContextProviderDTO first = new ContextProviderDTO(); + first.setUrl("https://example.com/first"); + first.setHeaders(Map.of()); + ContextProviderDTO second = new ContextProviderDTO(); + second.setUrl("https://example.com/second"); + second.setHeaders(Map.of()); + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setContextProviders(List.of(first, second)); + AgentSnapshotDataDTO reordered = new AgentSnapshotDataDTO(); + reordered.setContextProviders(List.of(second, first)); + + List changedFields = (List) method.invoke(service, current, reordered); + + assertEquals(List.of(AgentSnapshotField.CONTEXT_PROVIDERS.getFieldName()), changedFields); + } + @Test void redactSnapshotDataMasksSensitiveFunctionAndHeaderValues() throws Exception { AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, @@ -86,26 +121,131 @@ class AgentSnapshotServiceImplTest { 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 data = buildExtendedSensitiveSnapshot(); AgentSnapshotDataDTO redacted = (AgentSnapshotDataDTO) method.invoke(service, data); String json = JsonUtils.toJsonString(redacted); - assertFalse(json.contains("secret-key")); - assertFalse(json.contains("secret-token")); + assertExtendedSecretsAbsent(json); assertTrue(json.contains("__SNAPSHOT_SECRET_REDACTED__")); assertTrue(json.contains("max_tokens")); assertTrue(json.contains("application/json")); } + @Test + void snapshotDetailMasksCookieProxyAuthorizationAndSessionHeaders() 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 entity = snapshotEntity("snapshot-id", "agent-id", 4, buildExtendedSensitiveSnapshot()); + when(snapshotDao.selectNextSnapshot("agent-id", 4)).thenReturn(null); + + AgentSnapshotVO detail = (AgentSnapshotVO) method.invoke(service, entity, true); + String json = JsonUtils.toJsonString(detail); + + assertExtendedSecretsAbsent(json); + assertTrue(json.contains("__SNAPSHOT_SECRET_REDACTED__")); + assertTrue(json.contains("application/json")); + } + + @Test + void snapshotDetailReturnsCurrentPreviewDataAndTokenFromTheSameServerRead() { + 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); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String snapshotId = "snapshot-id"; + AgentInfoVO current = snapshotAgentInfo(agentId, 7L, "current", "current-summary"); + AgentPluginMapping mapping = new AgentPluginMapping(); + mapping.setPluginId("plugin"); + mapping.setParamInfo("{\"api_key\":\"live-secret\",\"visible\":\"kept\"," + + "\"webhookUrl\":\"https://hooks.slack.com/services/T111/B222/live-path-secret\"}"); + current.setFunctions(List.of(mapping)); + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(snapshotId)) + .thenReturn(snapshotEntity(snapshotId, agentId, 2, snapshotData("target", "target-summary"))); + when(snapshotDao.selectNextSnapshot(agentId, 2)).thenReturn(null); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(current); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + + AgentSnapshotVO detail = service.getSnapshot(agentId, snapshotId); + + assertNotNull(detail.getCurrentSnapshotData()); + assertEquals(64, detail.getCurrentStateToken().length()); + String currentJson = JsonUtils.toJsonString(detail.getCurrentSnapshotData()); + assertFalse(currentJson.contains("live-secret")); + assertFalse(currentJson.contains("live-path-secret")); + assertTrue(currentJson.contains("__SNAPSHOT_SECRET_REDACTED__")); + assertTrue(currentJson.contains("kept")); + } + + @Test + void legacyRedactionMigrationCleansStructuredAndUrlSecretsWithoutDroppingUnknownFields() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, null, null, null, null, null, + null, null, null, null); + AgentSnapshotEntity legacy = new AgentSnapshotEntity(); + legacy.setId("legacy-id"); + legacy.setRedactionVersion(1); + legacy.setSnapshotData(JsonUtils.toJsonString(Map.of( + "futureField", Map.of( + "kept", true, + "primaryEndpoint", "https://api.example.com/hooks/events/status", + "secondaryEndpoint", "https://api.example.com/webhooks/events/status", + "singleHookEndpoint", "https://api.example.com/hooks/events", + "statusEndpoint", "https://api.example.com/webhooks/status", + "webhook", "incoming/legacy-token", + "description", "incoming/token", + "outgoingWebhookUrl", "https://example.com/incoming/legacy-path-secret"), + "functions", List.of(Map.of("paramInfo", Map.of( + "headers", List.of(Map.of("NAME", "Authorization", "VALUE", "Bearer legacy-secret")), + "Cookie", "legacy-cookie", + "protocolRelativeEndpoint", + "//legacy-relative-user:legacy-relative-pass@example.com/public/path", + "endpoint", "https://user:pass@example.com/hook?subscription-key=legacy-key")))))); + when(snapshotDao.selectLegacyRedactionBatch(null, 100, 2)).thenReturn(List.of(legacy)); + when(snapshotDao.selectLegacyRedactionBatch("legacy-id", 100, 2)).thenReturn(List.of()); + when(snapshotDao.updateRedactedSnapshots(any(), eq(2))).thenReturn(1); + + long migrated = service.redactLegacySnapshots(); + + verify(snapshotDao).updateRedactedSnapshots( + argThat(snapshots -> snapshots.size() == 1 && "legacy-id".equals(snapshots.get(0).getId())), eq(2)); + assertEquals(1, migrated); + assertFalse(legacy.getSnapshotData().contains("legacy-secret")); + assertFalse(legacy.getSnapshotData().contains("legacy-cookie")); + assertFalse(legacy.getSnapshotData().contains("legacy-key")); + assertFalse(legacy.getSnapshotData().contains("user:pass")); + assertFalse(legacy.getSnapshotData().contains("legacy-path-secret")); + assertFalse(legacy.getSnapshotData().contains("legacy-relative-user")); + assertFalse(legacy.getSnapshotData().contains("legacy-relative-pass")); + assertFalse(legacy.getSnapshotData().contains("incoming/legacy-token")); + assertTrue(legacy.getSnapshotData().contains("futureField")); + assertTrue(legacy.getSnapshotData().contains("\"kept\":true")); + assertTrue(legacy.getSnapshotData().contains("https://api.example.com/hooks/events/status")); + assertTrue(legacy.getSnapshotData().contains("https://api.example.com/webhooks/events/status")); + assertTrue(legacy.getSnapshotData().contains("https://api.example.com/hooks/events")); + assertTrue(legacy.getSnapshotData().contains("https://api.example.com/webhooks/status")); + assertTrue(legacy.getSnapshotData().contains("//__SNAPSHOT_SECRET_REDACTED__@example.com/public/path")); + assertTrue(legacy.getSnapshotData().contains("incoming/__SNAPSHOT_SECRET_REDACTED__")); + assertTrue(legacy.getSnapshotData().contains("incoming/token")); + assertTrue(legacy.getSnapshotData().contains("__SNAPSHOT_SECRET_REDACTED__")); + } + @Test void preserveCurrentSensitiveValuesKeepsCurrentSecretsWhenRestoringSnapshot() throws Exception { AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, @@ -124,6 +264,658 @@ class AgentSnapshotServiceImplTest { assertEquals("current-token", restored.getContextProviders().get(0).getHeaders().get("Authorization")); } + @Test + void redactSnapshotDataMasksStructuredHeadersAndUrlCredentials() 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); + + Map params = new HashMap<>(); + params.put("headers", List.of( + Map.of("key", "Authorization", "value", "Bearer structured-secret"), + Map.of("name", "Cookie", "value", "cookie=structured-cookie"))); + params.put("callbackUrl", + "https://function-user:function-pass@example.com/hook?access_token=function-token#function-fragment"); + params.put("endpoint", + "https://example.com/signed?locale=zh-CN&X-Amz-Signature=endpoint-signature"); + params.put("transport", + "https://example.com/transport?mode=push&key=transport-key"); + params.put("mirrors", List.of( + "https://example.com/nested?locale=en&token=nested-token")); + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + data.setFunctions(List.of(functionInfo("plugin", params))); + ContextProviderDTO provider = new ContextProviderDTO(); + provider.setUrl("https://provider-user:provider-pass@example.com/context?token=provider-token#provider-fragment"); + provider.setHeaders(Map.of()); + data.setContextProviders(List.of(provider)); + + AgentSnapshotDataDTO redacted = (AgentSnapshotDataDTO) method.invoke(service, data); + String json = JsonUtils.toJsonString(redacted); + + for (String secret : List.of("structured-secret", "structured-cookie", "function-user", "function-pass", + "function-token", "function-fragment", "provider-user", "provider-pass", "provider-token", + "provider-fragment", "endpoint-signature", "transport-key", "nested-token")) { + assertFalse(json.contains(secret), () -> "Sensitive value leaked: " + secret); + } + assertEquals("https://__SNAPSHOT_SECRET_REDACTED__@example.com/context" + + "?token=__SNAPSHOT_SECRET_REDACTED__#__SNAPSHOT_SECRET_REDACTED__", + redacted.getContextProviders().get(0).getUrl()); + assertEquals("https://example.com/signed?locale=zh-CN&X-Amz-Signature=__SNAPSHOT_SECRET_REDACTED__", + redacted.getFunctions().get(0).getParamInfo().get("endpoint")); + assertEquals("https://example.com/transport?mode=push&key=__SNAPSHOT_SECRET_REDACTED__", + redacted.getFunctions().get(0).getParamInfo().get("transport")); + assertTrue(json.contains("__SNAPSHOT_SECRET_REDACTED__")); + } + + @Test + void capabilityUrlPathsAreRedactedPreciselyAndIdempotently() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSensitiveUrl", + String.class, String.class); + method.setAccessible(true); + + Map cases = new LinkedHashMap<>(); + cases.put("https://hooks.slack.com/services/T111/B222/slack-secret", + "https://hooks.slack.com/services/T111/B222/__SNAPSHOT_SECRET_REDACTED__"); + cases.put("https://discord.com/api/v10/webhooks/123456/discord-secret", + "https://discord.com/api/v10/webhooks/123456/__SNAPSHOT_SECRET_REDACTED__"); + cases.put("https://api.telegram.org/bot123456:telegram-secret/sendMessage", + "https://api.telegram.org/bot123456:__SNAPSHOT_SECRET_REDACTED__/sendMessage"); + cases.put("https://hooks.zapier.com/hooks/catch/987/generic-secret", + "https://hooks.zapier.com/hooks/__SNAPSHOT_SECRET_REDACTED__"); + cases.put("/api/webhooks/relative-secret/continuation", + "/api/webhooks/__SNAPSHOT_SECRET_REDACTED__"); + + for (Map.Entry entry : cases.entrySet()) { + String redacted = (String) method.invoke(service, entry.getKey(), "endpoint"); + assertEquals(entry.getValue(), redacted); + assertEquals(redacted, method.invoke(service, redacted, "endpoint")); + } + + assertEquals("https://example.com/incoming/__SNAPSHOT_SECRET_REDACTED__", + method.invoke(service, "https://example.com/incoming/key-secret", "outgoingWebhookUrl")); + assertEquals("https://api.example.com/v1/users/123/preferences", + method.invoke(service, "https://api.example.com/v1/users/123/preferences", "endpoint")); + assertEquals("https://api.example.com/hooks/events/status", + method.invoke(service, "https://api.example.com/hooks/events/status", "endpoint")); + assertEquals("https://api.example.com/webhooks/events/status", + method.invoke(service, "https://api.example.com/webhooks/events/status", "endpoint")); + assertEquals("https://api.example.com/hooks/events", + method.invoke(service, "https://api.example.com/hooks/events", "endpoint")); + assertEquals("https://api.example.com/webhooks/status", + method.invoke(service, "https://api.example.com/webhooks/status", "endpoint")); + assertEquals("https://api.example.com/hooks/__SNAPSHOT_SECRET_REDACTED__", + method.invoke(service, "https://api.example.com/hooks/access-token-value", "endpoint")); + assertEquals("https://api.example.com/webhooks/__SNAPSHOT_SECRET_REDACTED__", + method.invoke(service, "https://api.example.com/webhooks/aB3dE5fG7hI9jK1mN3pQ5rS7", "endpoint")); + assertEquals("/api/v1/agents/42", + method.invoke(service, "/api/v1/agents/42", "endpoint")); + assertEquals("incoming/__SNAPSHOT_SECRET_REDACTED__", + method.invoke(service, "incoming/token", "webhook")); + assertEquals("incoming/token", + method.invoke(service, "incoming/token", "description")); + } + + @Test + @SuppressWarnings("unchecked") + void pathCapabilityRotationDoesNotChangeSnapshotStateButPublicIdentityDoes() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method changedMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + changedMethod.setAccessible(true); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of( + "webhookUrl", "https://hooks.slack.com/services/T111/B222/first-secret", + "endpoint", "/api/webhooks/first-relative-secret/continuation")))); + AgentSnapshotDataDTO rotated = new AgentSnapshotDataDTO(); + rotated.setFunctions(List.of(functionInfo("plugin", Map.of( + "webhookUrl", "https://hooks.slack.com/services/T111/B222/second-secret", + "endpoint", "/api/webhooks/second-relative-secret/continuation")))); + AgentSnapshotDataDTO differentPublicIdentity = new AgentSnapshotDataDTO(); + differentPublicIdentity.setFunctions(List.of(functionInfo("plugin", Map.of( + "webhookUrl", "https://hooks.slack.com/services/T111/B999/second-secret", + "endpoint", "/api/webhooks/second-relative-secret/continuation")))); + + assertTrue(((List) changedMethod.invoke(service, current, rotated)).isEmpty()); + assertEquals(currentStateToken(service, current), currentStateToken(service, rotated)); + assertEquals(List.of(AgentSnapshotField.FUNCTIONS.getFieldName()), + changedMethod.invoke(service, current, differentPublicIdentity)); + assertFalse(currentStateToken(service, current).equals(currentStateToken(service, differentPublicIdentity))); + } + + @Test + @SuppressWarnings("unchecked") + void nestedWebhookSemanticKeysPropagateAcrossRedactionComparisonAndRestore() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method changedMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + changedMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + Method containsMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("containsSensitiveMaterial", + Object.class); + containsMethod.setAccessible(true); + + List> nestedCases = List.of( + Map.of("webhookConfig", Map.of("value", + "https://capability.example.com/public/nested-secret-one")), + Map.of("deliveryWebhook", Map.of("options", Map.of("target", + "https://capability.example.com/public/nested-secret-two"))), + Map.of("config", Map.of("webhookUrl", + "https://capability.example.com/public/nested-secret-three"))); + for (Map params : nestedCases) { + AgentSnapshotDataDTO candidate = new AgentSnapshotDataDTO(); + candidate.setFunctions(List.of(functionInfo("plugin", params))); + AgentSnapshotDataDTO redacted = (AgentSnapshotDataDTO) redactMethod.invoke(service, candidate); + String json = JsonUtils.toJsonString(redacted); + for (String secret : List.of("nested-secret-one", "nested-secret-two", "nested-secret-three")) { + assertFalse(json.contains(secret), () -> "Nested webhook capability leaked: " + secret); + } + assertTrue(json.contains("__SNAPSHOT_SECRET_REDACTED__")); + } + + AgentSnapshotDataDTO first = nestedWebhookSnapshot("first-current-secret"); + AgentSnapshotDataDTO rotated = nestedWebhookSnapshot("rotated-current-secret"); + assertTrue(((List) changedMethod.invoke(service, first, rotated)).isEmpty()); + assertEquals(currentStateToken(service, first), currentStateToken(service, rotated)); + + AgentSnapshotDataDTO target = (AgentSnapshotDataDTO) redactMethod.invoke(service, + nestedWebhookSnapshot("historical-secret")); + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, rotated); + Map webhookConfig = (Map) restored.getFunctions().get(0) + .getParamInfo().get("webhookConfig"); + assertEquals("https://capability.example.com/public/rotated-current-secret", webhookConfig.get("value")); + assertTrue((Boolean) containsMethod.invoke(service, + target.getFunctions().get(0).getParamInfo())); + } + + @Test + void restoringPathCapabilitiesCopiesOnlyCurrentSecretAndRejectsUnsafeIdentity() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSensitiveUrl", + String.class, String.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveUrl", + String.class, String.class, String.class); + preserveMethod.setAccessible(true); + + String target = (String) redactMethod.invoke(service, + "https://hooks.slack.com/services/T111/B222/historical-secret", "webhookUrl"); + assertEquals("https://hooks.slack.com/services/T111/B222/current-secret", + preserveMethod.invoke(service, target, + "https://hooks.slack.com/services/T111/B222/current-secret", "webhookUrl")); + String relativeTarget = (String) redactMethod.invoke(service, + "/api/webhooks/historical-secret/continuation", "endpoint"); + assertEquals("/api/webhooks/current-relative/callback", + preserveMethod.invoke(service, relativeTarget, + "/api/webhooks/current-relative/callback", "endpoint")); + String bareRelativeTarget = (String) redactMethod.invoke(service, + "incoming/historical-token", "webhook"); + assertEquals("incoming/current-token", + preserveMethod.invoke(service, bareRelativeTarget, + "incoming/current-token", "webhook")); + + InvocationTargetException mismatch = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, + "https://hooks.slack.com/services/T111/B999/current-secret", "webhookUrl")); + assertTrue(mismatch.getCause() instanceof RenException); + InvocationTargetException missing = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, null, "webhookUrl")); + assertTrue(missing.getCause() instanceof RenException); + InvocationTargetException placeholder = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, + "https://hooks.slack.com/services/T111/B222/__SNAPSHOT_SECRET_REDACTED__", "webhookUrl")); + assertTrue(placeholder.getCause() instanceof RenException); + InvocationTargetException relativeMismatch = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, relativeTarget, + "/different/webhooks/current-relative/callback", "endpoint")); + assertTrue(relativeMismatch.getCause() instanceof RenException); + InvocationTargetException bareRelativeMismatch = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, bareRelativeTarget, + "outgoing/current-token", "webhook")); + assertTrue(bareRelativeMismatch.getCause() instanceof RenException); + } + + @Test + void duplicatePublicPathIdentitiesAreRejectedInsteadOfGuessingASecret() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + target.setFunctions(List.of(functionInfo("plugin", Map.of("webhooks", List.of( + "https://hooks.slack.com/services/T111/B222/historical-secret"))))); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of("webhooks", List.of( + "https://hooks.slack.com/services/T111/B222/current-one", + "https://hooks.slack.com/services/T111/B222/current-two"))))); + + AgentSnapshotDataDTO redactedTarget = target; + InvocationTargetException ambiguous = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, redactedTarget, current)); + assertTrue(ambiguous.getCause() instanceof RenException); + } + + @Test + @SuppressWarnings("unchecked") + void sensitiveListItemsWithoutNamedIdsUseUniquePublicFingerprint() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + target.setFunctions(List.of(functionInfo("plugin", Map.of("destinations", List.of( + Map.of("host", "alpha.example.com", "api_key", "historical-secret")))))); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of("destinations", List.of( + Map.of("host", "alpha.example.com", "api_key", "current-secret")))))); + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, current); + List> destinations = (List>) restored.getFunctions().get(0) + .getParamInfo().get("destinations"); + assertEquals("current-secret", destinations.get(0).get("api_key")); + + AgentSnapshotDataDTO ambiguousCurrent = new AgentSnapshotDataDTO(); + ambiguousCurrent.setFunctions(List.of(functionInfo("plugin", Map.of("destinations", List.of( + Map.of("host", "alpha.example.com", "api_key", "first-current-secret"), + Map.of("host", "alpha.example.com", "api_key", "second-current-secret")))))); + AgentSnapshotDataDTO redactedTarget = target; + InvocationTargetException ambiguous = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, redactedTarget, ambiguousCurrent)); + assertTrue(ambiguous.getCause() instanceof RenException); + } + + @Test + void contextProvidersMatchPathCapabilitiesByUniquePublicIdentity() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + ContextProviderDTO targetProvider = new ContextProviderDTO(); + targetProvider.setUrl("https://discord.com/api/webhooks/123456/historical-secret"); + targetProvider.setHeaders(Map.of()); + target.setContextProviders(List.of(targetProvider)); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + ContextProviderDTO currentProvider = new ContextProviderDTO(); + currentProvider.setUrl("https://discord.com/api/webhooks/123456/current-secret"); + currentProvider.setHeaders(Map.of()); + current.setContextProviders(List.of(currentProvider)); + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, current); + assertEquals("https://discord.com/api/webhooks/123456/current-secret", + restored.getContextProviders().get(0).getUrl()); + + currentProvider.setUrl("https://discord.com/api/webhooks/999999/current-secret"); + AgentSnapshotDataDTO redactedTarget = target; + InvocationTargetException mismatch = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, redactedTarget, current)); + assertTrue(mismatch.getCause() instanceof RenException); + } + + @Test + void snapshotPayloadReaderIgnoresFutureFieldsWithoutChangingGlobalJsonValidation() { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + String payload = "{\"agentName\":\"future-safe\",\"futureField\":{\"enabled\":true}}"; + + AgentSnapshotDataDTO parsed = ReflectionTestUtils.invokeMethod(service, "parseSnapshotData", payload); + + assertNotNull(parsed); + assertEquals("future-safe", parsed.getAgentName()); + assertThrows(RuntimeException.class, () -> JsonUtils.parseObject(payload, AgentSnapshotDataDTO.class)); + } + + @Test + @SuppressWarnings("unchecked") + void preserveSensitiveUrlListsByUrlIdentityInsteadOfIndex() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + target.setFunctions(List.of(functionInfo("plugin", Map.of("mirrors", List.of( + "https://a.example.com/hook?token=old-a&locale=en", + "https://b.example.com/hook?token=old-b&locale=en"))))); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of("mirrors", List.of( + "https://b.example.com/hook?token=current-b&locale=de", + "https://a.example.com/hook?token=current-a&locale=de"))))); + + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, current); + List mirrors = (List) restored.getFunctions().get(0).getParamInfo().get("mirrors"); + + assertEquals(List.of( + "https://a.example.com/hook?token=current-a&locale=en", + "https://b.example.com/hook?token=current-b&locale=en"), mirrors); + } + + @Test + @SuppressWarnings("unchecked") + void urlComparisonIgnoresOnlySensitiveParameterValues() 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.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://api.example.com/hook?locale=en&api_key=first&X-Amz-Signature=first-signature")))); + AgentSnapshotDataDTO secretOnlyChange = new AgentSnapshotDataDTO(); + secretOnlyChange.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://api.example.com/hook?locale=en&api_key=second&X-Amz-Signature=second-signature")))); + AgentSnapshotDataDTO publicParameterChange = new AgentSnapshotDataDTO(); + publicParameterChange.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://api.example.com/hook?locale=de&api_key=second&X-Amz-Signature=second-signature")))); + + assertTrue(((List) method.invoke(service, current, secretOnlyChange)).isEmpty()); + assertEquals(List.of(AgentSnapshotField.FUNCTIONS.getFieldName()), + method.invoke(service, current, publicParameterChange)); + } + + @Test + @SuppressWarnings("unchecked") + void preserveSensitiveHeaderListsByStableIdentityInsteadOfIndex() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + target.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("key", "Authorization", "value", "old-auth"), + Map.of("name", "Cookie", "value", "old-cookie")))))); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("name", "Cookie", "value", "current-cookie"), + Map.of("key", "Authorization", "value", "current-auth")))))); + + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, current); + List> headers = (List>) restored.getFunctions().get(0) + .getParamInfo().get("headers"); + + assertEquals("Authorization", headers.get(0).get("key")); + assertEquals("current-auth", headers.get(0).get("value")); + assertEquals("Cookie", headers.get(1).get("name")); + assertEquals("current-cookie", headers.get(1).get("value")); + assertFalse(JsonUtils.toJsonString(restored).contains("__SNAPSHOT_SECRET_REDACTED__")); + } + + @Test + @SuppressWarnings("unchecked") + void structuredSensitiveValuesRequireOneEquivalentDiscriminator() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSensitiveMap", Map.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveMap", + Map.class, Map.class); + preserveMethod.setAccessible(true); + + Map target = (Map) redactMethod.invoke(service, + Map.of("key", "Authorization", "value", "historical-secret")); + Map restored = (Map) preserveMethod.invoke(service, target, + Map.of("key", "authorization", "value", "current-secret")); + assertEquals("current-secret", restored.get("value")); + + InvocationTargetException mismatchedType = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, + Map.of("key", "Cookie", "value", "cookie-secret"))); + assertTrue(mismatchedType.getCause() instanceof RenException); + + Map ambiguousTarget = (Map) redactMethod.invoke(service, + Map.of("key", "Authorization", "name", "Cookie", "value", "historical-secret")); + InvocationTargetException ambiguous = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, ambiguousTarget, + Map.of("key", "Authorization", "name", "Cookie", "value", "current-secret"))); + assertTrue(ambiguous.getCause() instanceof RenException); + } + + @Test + void restoringRedactedUrlsUsesTargetLocationAndCurrentCredentials() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + target.setFunctions(List.of(functionInfo("plugin", Map.of("callbackUrl", + "https://old-user:old-pass@old.example.com/hook" + + "?api%5Fkey=old-token&locale=en#token=old-fragment§ion=target")))); + ContextProviderDTO targetProvider = new ContextProviderDTO(); + targetProvider.setUrl("https://old-user:old-pass@api.example.com/context" + + "?api_key=old-token&locale=en#sig=old-fragment§ion=target"); + targetProvider.setHeaders(Map.of()); + target.setContextProviders(List.of(targetProvider)); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of("callbackUrl", + "https://current-user:current-pass@old.example.com/hook" + + "?api_key=current-token&locale=de#token=current-fragment§ion=current")))); + ContextProviderDTO currentProvider = new ContextProviderDTO(); + currentProvider.setUrl( + "https://current-user:current-pass@api.example.com/context" + + "?api_key=current-token&locale=de#sig=current-fragment§ion=current"); + currentProvider.setHeaders(Map.of()); + current.setContextProviders(List.of(currentProvider)); + + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, current); + + assertEquals("https://current-user:current-pass@old.example.com/hook" + + "?api%5Fkey=current-token&locale=en#token=current-fragment§ion=target", + restored.getFunctions().get(0).getParamInfo().get("callbackUrl")); + assertEquals("https://current-user:current-pass@api.example.com/context" + + "?api_key=current-token&locale=en#sig=current-fragment§ion=target", + restored.getContextProviders().get(0).getUrl()); + assertFalse(JsonUtils.toJsonString(restored).contains("__SNAPSHOT_SECRET_REDACTED__")); + } + + @Test + void urlSecretRestoreRequiresTheSamePublicSchemeAuthorityAndPath() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSensitiveUrl", + String.class, String.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveUrl", + String.class, String.class, String.class); + preserveMethod.setAccessible(true); + + String target = (String) redactMethod.invoke(service, + "https://old-user:old-pass@example.com/callback?token=old-token#old-fragment", "callbackUrl"); + assertEquals("https://new-user:new-pass@example.com/callback?token=current-token#current-fragment", + preserveMethod.invoke(service, target, + "https://new-user:new-pass@example.com/callback?token=current-token#current-fragment", + "callbackUrl")); + + for (String mismatch : List.of( + "https://new-user:new-pass@other.example.com/callback?token=current-token#current-fragment", + "https://new-user:new-pass@example.com/other?token=current-token#current-fragment", + "http://new-user:new-pass@example.com/callback?token=current-token#current-fragment")) { + InvocationTargetException error = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, mismatch, "callbackUrl")); + assertTrue(error.getCause() instanceof RenException); + } + } + + @Test + void protocolRelativeUrlsRedactUserInfoAndRequireTheSamePublicBaseOnRestore() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSensitiveUrl", + String.class, String.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveUrl", + String.class, String.class, String.class); + preserveMethod.setAccessible(true); + + String target = (String) redactMethod.invoke(service, + "//old-user:old-pass@example.com/path?token=old-token", "endpoint"); + assertEquals("//__SNAPSHOT_SECRET_REDACTED__@example.com/path" + + "?token=__SNAPSHOT_SECRET_REDACTED__", target); + assertEquals("//current-user:current-pass@example.com/path?token=current-token", + preserveMethod.invoke(service, target, + "//current-user:current-pass@example.com/path?token=current-token", "endpoint")); + + for (String mismatch : List.of( + "//current-user:current-pass@other.example.com/path?token=current-token", + "//current-user:current-pass@example.com/other?token=current-token", + "https://current-user:current-pass@example.com/path?token=current-token")) { + InvocationTargetException error = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, mismatch, "endpoint")); + assertTrue(error.getCause() instanceof RenException); + } + } + + @Test + @SuppressWarnings("unchecked") + void restoringOlderStructureDetectsIrreversibleSensitiveRemovals() 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); + Method validateMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod( + "validateSensitiveRestoreIsReversible", AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + validateMethod.setAccessible(true); + + AgentSnapshotDataDTO noPluginsTarget = new AgentSnapshotDataDTO(); + noPluginsTarget.setFunctions(List.of()); + AgentSnapshotDataDTO currentWithPlugin = new AgentSnapshotDataDTO(); + currentWithPlugin.setFunctions(List.of(functionInfo("new-plugin", Map.of("api_key", "current-secret")))); + + AgentSnapshotDataDTO removedPlugin = (AgentSnapshotDataDTO) method.invoke(service, noPluginsTarget, + currentWithPlugin); + assertTrue(removedPlugin.getFunctions().isEmpty()); + InvocationTargetException removedPluginError = assertThrows(InvocationTargetException.class, + () -> validateMethod.invoke(service, currentWithPlugin, removedPlugin)); + assertTrue(removedPluginError.getCause() instanceof RenException); + + AgentSnapshotDataDTO headerTarget = new AgentSnapshotDataDTO(); + headerTarget.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("key", "Content-Type", "value", "application/json")))))); + AgentSnapshotDataDTO headerCurrent = new AgentSnapshotDataDTO(); + headerCurrent.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("key", "Authorization", "value", "current-secret"), + Map.of("key", "Content-Type", "value", "text/plain")))))); + + AgentSnapshotDataDTO removedHeader = (AgentSnapshotDataDTO) method.invoke(service, headerTarget, + headerCurrent); + List> headers = (List>) removedHeader.getFunctions().get(0) + .getParamInfo().get("headers"); + assertEquals(1, headers.size()); + assertEquals("Content-Type", headers.get(0).get("key")); + assertEquals("application/json", headers.get(0).get("value")); + InvocationTargetException removedHeaderError = assertThrows(InvocationTargetException.class, + () -> validateMethod.invoke(service, headerCurrent, removedHeader)); + assertTrue(removedHeaderError.getCause() instanceof RenException); + + AgentSnapshotDataDTO urlTarget = new AgentSnapshotDataDTO(); + urlTarget.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://example.com/hook?locale=en")))); + AgentSnapshotDataDTO urlCurrent = new AgentSnapshotDataDTO(); + urlCurrent.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://example.com/hook?api_key=current-secret&locale=de")))); + AgentSnapshotDataDTO removedUrlSecret = (AgentSnapshotDataDTO) method.invoke(service, urlTarget, urlCurrent); + assertEquals("https://example.com/hook?locale=en", + removedUrlSecret.getFunctions().get(0).getParamInfo().get("endpoint")); + InvocationTargetException removedUrlError = assertThrows(InvocationTargetException.class, + () -> validateMethod.invoke(service, urlCurrent, removedUrlSecret)); + assertTrue(removedUrlError.getCause() instanceof RenException); + } + + @Test + void ambiguousOrHistoricalRawSensitiveTargetsAreRejectedInsteadOfGuessed() 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 ambiguous = new AgentSnapshotDataDTO(); + ambiguous.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("value", "__SNAPSHOT_SECRET_REDACTED__")))))); + InvocationTargetException ambiguousError = assertThrows(InvocationTargetException.class, + () -> method.invoke(service, ambiguous, new AgentSnapshotDataDTO())); + assertTrue(ambiguousError.getCause() instanceof RenException); + + AgentSnapshotDataDTO conflictingIdentities = new AgentSnapshotDataDTO(); + conflictingIdentities.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("name", "Shared", "key", "Authorization", "value", + "__SNAPSHOT_SECRET_REDACTED__")))))); + AgentSnapshotDataDTO conflictingCurrent = new AgentSnapshotDataDTO(); + conflictingCurrent.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("name", "Shared", "key", "Cookie", "value", "cookie-secret"), + Map.of("name", "Different", "key", "Authorization", "value", "auth-secret")))))); + InvocationTargetException conflictingError = assertThrows(InvocationTargetException.class, + () -> method.invoke(service, conflictingIdentities, conflictingCurrent)); + assertTrue(conflictingError.getCause() instanceof RenException); + + AgentSnapshotDataDTO historicalRaw = new AgentSnapshotDataDTO(); + historicalRaw.setFunctions(List.of(functionInfo("plugin", Map.of("Authorization", "old-raw-secret")))); + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of()))); + InvocationTargetException rawSecretError = assertThrows(InvocationTargetException.class, + () -> method.invoke(service, historicalRaw, current)); + assertTrue(rawSecretError.getCause() instanceof RenException); + + AgentSnapshotDataDTO historicalRawUrl = new AgentSnapshotDataDTO(); + historicalRawUrl.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://example.com/hook?api_key=historical-secret&locale=en")))); + AgentSnapshotDataDTO currentUrlWithoutSecret = new AgentSnapshotDataDTO(); + currentUrlWithoutSecret.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://example.com/hook?locale=de")))); + InvocationTargetException rawUrlSecretError = assertThrows(InvocationTargetException.class, + () -> method.invoke(service, historicalRawUrl, currentUrlWithoutSecret)); + assertTrue(rawUrlSecretError.getCause() instanceof RenException); + } + @Test @SuppressWarnings("unchecked") void getChangedFieldsFollowsCentralFieldOrder() throws Exception { @@ -201,7 +993,7 @@ class AgentSnapshotServiceImplTest { @Test @SuppressWarnings("unchecked") - void emptyTtsAdvancedDefaultsAreNotSnapshotChanges() throws Exception { + void nullTtsAdvancedValuesDifferFromExplicitZero() throws Exception { AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, null, null); Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", @@ -219,7 +1011,33 @@ class AgentSnapshotServiceImplTest { List changedFields = (List) method.invoke(service, current, next); + assertEquals(List.of( + AgentSnapshotField.TTS_VOLUME.getFieldName(), + AgentSnapshotField.TTS_RATE.getFieldName(), + AgentSnapshotField.TTS_PITCH.getFieldName()), changedFields); + } + + @Test + @SuppressWarnings("unchecked") + void redactedSecretsDoNotCreateFalseSnapshotChangeButNonSensitiveValuesDo() 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 stored = buildSnapshot( + "__SNAPSHOT_SECRET_REDACTED__", "__SNAPSHOT_SECRET_REDACTED__", "same-value"); + AgentSnapshotDataDTO current = buildSnapshot("current-key", "current-token", "same-value"); + + List changedFields = (List) method.invoke(service, stored, current); + assertTrue(changedFields.isEmpty()); + + current.getFunctions().get(0).getParamInfo().put("description", "changed-value"); + changedFields = (List) method.invoke(service, stored, current); + + assertEquals(List.of(AgentSnapshotField.FUNCTIONS.getFieldName()), changedFields); } @Test @@ -288,6 +1106,40 @@ class AgentSnapshotServiceImplTest { inOrder.verify(snapshotService).createSnapshot(agentId, "config"); } + @Test + void subsequentAgentSaveCapturesUnversionedCurrentStateBeforePersistingNewState() { + AgentDao agentDao = mock(AgentDao.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotService snapshotService = mock(AgentSnapshotService.class); + AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null, + null, null, contextProviderService, null, correctWordFileService, snapshotService); + ReflectionTestUtils.setField(service, "baseDao", agentDao); + + String agentId = "agent-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + AgentInfoVO currentAgent = new AgentInfoVO(); + currentAgent.setId(agentId); + currentAgent.setAgentName("old-name"); + AgentUpdateDTO update = new AgentUpdateDTO(); + update.setAgentName("new-name"); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentAgent); + when(snapshotService.getCurrentVersionNo(agentId)).thenReturn(4); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(agentDao.updateById(any())).thenReturn(1); + + service.updateAgentById(agentId, update); + + InOrder inOrder = inOrder(agentDao, snapshotService); + inOrder.verify(snapshotService).createSnapshot(agentId, "current"); + inOrder.verify(agentDao).updateById(argThat(agent -> "new-name".equals(agent.getAgentName()))); + inOrder.verify(snapshotService).createSnapshot(agentId, "config"); + } + @Test void createAgentPersistsDisplayDefaultsBeforeInitialSnapshot() { AgentDao agentDao = mock(AgentDao.class); @@ -345,7 +1197,8 @@ class AgentSnapshotServiceImplTest { @Test void restoreSnapshotRollsBackForAnyException() throws Exception { - Method method = AgentSnapshotServiceImpl.class.getMethod("restoreSnapshot", String.class, String.class); + Method method = AgentSnapshotServiceImpl.class.getMethod("restoreSnapshot", String.class, String.class, + String.class); Transactional transactional = method.getAnnotation(Transactional.class); @@ -353,6 +1206,338 @@ class AgentSnapshotServiceImplTest { assertTrue(List.of(transactional.rollbackFor()).contains(Exception.class)); } + @Test + void restoreSnapshotRejectsStalePreviewTokenBeforeAnyMutation() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, + pluginMappingService, contextProviderService, null, null, null, correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)) + .thenReturn(snapshotEntity(targetId, agentId, 2, snapshotData("target", "target-summary"))); + when(agentDao.selectAgentInfoById(agentId)) + .thenReturn(snapshotAgentInfo(agentId, 7L, "changed-after-preview", "live-summary")); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + + RenException error = assertThrows(RenException.class, + () -> service.restoreSnapshot(agentId, targetId, "stale-token")); + + assertTrue(error.getMessage().contains("重新打开恢复预览")); + verify(snapshotDao, never()).selectLatestSnapshot(agentId); + verify(snapshotDao, never()).insertWithNextVersion(any()); + verify(agentDao, never()).updateSnapshotFields(any()); + verify(pluginMappingService, never()).deleteByAgentId(any()); + } + + @Test + void restoreSnapshotBacksUpUnversionedMemoryThenStoresAppliedStateAsLatest() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentTagRelationDao tagRelationDao = mock(AgentTagRelationDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + AgentTagService tagService = mock(AgentTagService.class); + AgentChatHistoryService chatHistoryService = mock(AgentChatHistoryService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, tagRelationDao, + pluginMappingService, contextProviderService, tagService, chatHistoryService, null, + correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + lockedAgent.setUserId(7L); + AgentInfoVO currentInfo = snapshotAgentInfo(agentId, 7L, "current-name", "live-summary"); + AgentInfoVO restoredInfo = snapshotAgentInfo(agentId, 7L, "target-name", "target-summary"); + AgentSnapshotDataDTO currentData = snapshotData("current-name", "live-summary"); + AgentSnapshotDataDTO latestData = snapshotData("current-name", "snapshot-summary"); + AgentSnapshotDataDTO targetData = snapshotData("target-name", "target-summary"); + AgentSnapshotEntity target = snapshotEntity(targetId, agentId, 2, targetData); + AgentSnapshotEntity latest = snapshotEntity("latest-id", agentId, 5, latestData); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)).thenReturn(target); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentInfo, restoredInfo); + when(snapshotDao.selectLatestSnapshot(agentId)).thenReturn(latest); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + when(agentDao.updateSnapshotFields(any())).thenReturn(1); + when(snapshotDao.insertWithNextVersion(any())).thenReturn(1); + + service.restoreSnapshot(agentId, targetId, currentStateToken(service, currentData)); + + ArgumentCaptor snapshotCaptor = ArgumentCaptor.forClass(AgentSnapshotEntity.class); + verify(snapshotDao, org.mockito.Mockito.times(2)).insertWithNextVersion(snapshotCaptor.capture()); + List inserted = snapshotCaptor.getAllValues(); + AgentSnapshotEntity backup = inserted.get(0); + AgentSnapshotEntity restored = inserted.get(1); + + assertEquals("current", backup.getSource()); + assertNull(backup.getRestoreFromSnapshotId()); + assertNull(backup.getRestoreFromVersionNo()); + assertEquals(List.of(AgentSnapshotField.SUMMARY_MEMORY.getFieldName()), + JsonUtils.parseObject(backup.getChangedFields(), new TypeReference>() { + })); + assertEquals("live-summary", + JsonUtils.parseObject(backup.getSnapshotData(), AgentSnapshotDataDTO.class).getSummaryMemory()); + + assertEquals("restore", restored.getSource()); + assertEquals(targetId, restored.getRestoreFromSnapshotId()); + assertEquals(2, restored.getRestoreFromVersionNo()); + assertEquals("target-name", + JsonUtils.parseObject(restored.getSnapshotData(), AgentSnapshotDataDTO.class).getAgentName()); + assertEquals("target-summary", + JsonUtils.parseObject(restored.getSnapshotData(), AgentSnapshotDataDTO.class).getSummaryMemory()); + + InOrder order = inOrder(snapshotDao, agentDao); + order.verify(snapshotDao).insertWithNextVersion(backup); + order.verify(agentDao).updateSnapshotFields(lockedAgent); + order.verify(snapshotDao).insertWithNextVersion(restored); + verify(snapshotDao).deleteOlderThanKeepLimit(agentId, 100); + } + + @Test + void restoreSnapshotSkipsBackupWhenLatestAlreadyRepresentsCurrentState() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + AgentTagService tagService = mock(AgentTagService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, + pluginMappingService, contextProviderService, tagService, null, null, correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + AgentInfoVO currentInfo = snapshotAgentInfo(agentId, 7L, "current-name", "current-summary"); + AgentInfoVO restoredInfo = snapshotAgentInfo(agentId, 7L, "target-name", "target-summary"); + AgentSnapshotDataDTO currentData = snapshotData("current-name", "current-summary"); + AgentSnapshotEntity target = snapshotEntity(targetId, agentId, 2, + snapshotData("target-name", "target-summary")); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)).thenReturn(target); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentInfo, restoredInfo); + when(snapshotDao.selectLatestSnapshot(agentId)).thenReturn(snapshotEntity("latest-id", agentId, 5, currentData)); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + when(agentDao.updateSnapshotFields(any())).thenReturn(1); + when(snapshotDao.insertWithNextVersion(any())).thenReturn(1); + + service.restoreSnapshot(agentId, targetId, currentStateToken(service, currentData)); + + ArgumentCaptor snapshotCaptor = ArgumentCaptor.forClass(AgentSnapshotEntity.class); + verify(snapshotDao).insertWithNextVersion(snapshotCaptor.capture()); + assertEquals("restore", snapshotCaptor.getValue().getSource()); + assertEquals(targetId, snapshotCaptor.getValue().getRestoreFromSnapshotId()); + verify(snapshotDao).deleteOlderThanKeepLimit(agentId, 100); + } + + @Test + void restoreSnapshotContinuesWhenRelationOnlyChangeReportsZeroUpdatedAgentRows() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + AgentTagService tagService = mock(AgentTagService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, + pluginMappingService, contextProviderService, tagService, null, null, correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + lockedAgent.setUserId(7L); + AgentInfoVO currentInfo = snapshotAgentInfo(agentId, 7L, "same-name", "same-summary"); + AgentInfoVO restoredInfo = snapshotAgentInfo(agentId, 7L, "same-name", "same-summary"); + AgentPluginMapping currentMapping = new AgentPluginMapping(); + currentMapping.setPluginId("plugin"); + currentMapping.setParamInfo("{\"description\":\"current\"}"); + currentInfo.setFunctions(List.of(currentMapping)); + AgentPluginMapping restoredMapping = new AgentPluginMapping(); + restoredMapping.setPluginId("plugin"); + restoredMapping.setParamInfo("{\"description\":\"target\"}"); + restoredInfo.setFunctions(List.of(restoredMapping)); + + AgentSnapshotDataDTO currentData = snapshotData("same-name", "same-summary"); + currentData.setFunctions(List.of(functionInfo("plugin", Map.of("description", "current")))); + AgentSnapshotDataDTO targetData = snapshotData("same-name", "same-summary"); + targetData.setFunctions(List.of(functionInfo("plugin", Map.of("description", "target")))); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)).thenReturn(snapshotEntity(targetId, agentId, 2, targetData)); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentInfo, restoredInfo); + when(snapshotDao.selectLatestSnapshot(agentId)) + .thenReturn(snapshotEntity("latest-id", agentId, 5, currentData)); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + when(agentDao.updateSnapshotFields(any())).thenReturn(0); + when(snapshotDao.insertWithNextVersion(any())).thenReturn(1); + + service.restoreSnapshot(agentId, targetId, currentStateToken(service, currentData)); + + verify(pluginMappingService).deleteByAgentId(agentId); + verify(pluginMappingService).saveBatch(argThat(mappings -> { + AgentPluginMapping mapping = mappings.size() == 1 ? mappings.iterator().next() : null; + return mapping != null + && "plugin".equals(mapping.getPluginId()) + && mapping.getParamInfo().contains("target"); + })); + verify(snapshotDao).insertWithNextVersion(argThat(snapshot -> "restore".equals(snapshot.getSource()))); + } + + @Test + void restoreSnapshotExplicitlyPersistsNullableFieldsAsNull() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + AgentTagService tagService = mock(AgentTagService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, + pluginMappingService, contextProviderService, tagService, null, null, correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + lockedAgent.setUserId(7L); + lockedAgent.setTtsLanguage("普通话"); + lockedAgent.setTtsVolume(0); + lockedAgent.setTtsRate(0); + lockedAgent.setTtsPitch(0); + lockedAgent.setSlmModelId("slm-id"); + lockedAgent.setVllmModelId("vllm-id"); + lockedAgent.setIntentModelId("intent-id"); + lockedAgent.setSystemPrompt("old prompt"); + + AgentInfoVO currentInfo = snapshotAgentInfo(agentId, 7L, "same-name", "same-summary"); + currentInfo.setTtsLanguage("普通话"); + currentInfo.setTtsVolume(0); + currentInfo.setTtsRate(0); + currentInfo.setTtsPitch(0); + currentInfo.setSlmModelId("slm-id"); + currentInfo.setVllmModelId("vllm-id"); + currentInfo.setIntentModelId("intent-id"); + currentInfo.setSystemPrompt("old prompt"); + AgentInfoVO restoredInfo = snapshotAgentInfo(agentId, 7L, "same-name", "same-summary"); + + AgentSnapshotDataDTO currentData = snapshotData("same-name", "same-summary"); + currentData.setTtsLanguage("普通话"); + currentData.setTtsVolume(0); + currentData.setTtsRate(0); + currentData.setTtsPitch(0); + currentData.setSlmModelId("slm-id"); + currentData.setVllmModelId("vllm-id"); + currentData.setIntentModelId("intent-id"); + currentData.setSystemPrompt("old prompt"); + AgentSnapshotDataDTO targetData = snapshotData("same-name", "same-summary"); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)).thenReturn(snapshotEntity(targetId, agentId, 2, targetData)); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentInfo, restoredInfo); + when(snapshotDao.selectLatestSnapshot(agentId)) + .thenReturn(snapshotEntity("latest-id", agentId, 5, currentData)); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + when(agentDao.updateSnapshotFields(any())).thenReturn(1); + when(snapshotDao.insertWithNextVersion(any())).thenReturn(1); + + service.restoreSnapshot(agentId, targetId, currentStateToken(service, currentData)); + + verify(agentDao).updateSnapshotFields(argThat(agent -> agent.getTtsLanguage() == null + && agent.getTtsVolume() == null + && agent.getTtsRate() == null + && agent.getTtsPitch() == null + && agent.getSlmModelId() == null + && agent.getVllmModelId() == null + && agent.getIntentModelId() == null + && agent.getSystemPrompt() == null + && Long.valueOf(7L).equals(agent.getUserId()))); + ArgumentCaptor snapshotCaptor = ArgumentCaptor.forClass(AgentSnapshotEntity.class); + verify(snapshotDao).insertWithNextVersion(snapshotCaptor.capture()); + AgentSnapshotDataDTO latestData = JsonUtils.parseObject(snapshotCaptor.getValue().getSnapshotData(), + AgentSnapshotDataDTO.class); + assertEquals("restore", snapshotCaptor.getValue().getSource()); + assertNull(latestData.getTtsLanguage()); + assertNull(latestData.getTtsVolume()); + assertNull(latestData.getTtsRate()); + assertNull(latestData.getTtsPitch()); + assertNull(latestData.getSlmModelId()); + assertNull(latestData.getVllmModelId()); + assertNull(latestData.getIntentModelId()); + assertNull(latestData.getSystemPrompt()); + verify(agentDao, org.mockito.Mockito.times(2)).selectAgentInfoById(agentId); + } + + @Test + void restoreSnapshotDoesNotMutateOrWriteHistoryWhenTargetMatchesCurrentState() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + AgentTagService tagService = mock(AgentTagService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, + pluginMappingService, contextProviderService, tagService, null, null, correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + AgentInfoVO currentInfo = snapshotAgentInfo(agentId, 7L, "same-name", "same-summary"); + AgentSnapshotDataDTO currentData = snapshotData("same-name", "same-summary"); + AgentSnapshotEntity target = snapshotEntity(targetId, agentId, 3, currentData); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)).thenReturn(target); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentInfo); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + + service.restoreSnapshot(agentId, targetId, currentStateToken(service, currentData)); + + verify(snapshotDao, never()).selectLatestSnapshot(agentId); + verify(snapshotDao, never()).insertWithNextVersion(any()); + verify(snapshotDao, never()).deleteOlderThanKeepLimit(any(), anyInt()); + verify(agentDao, never()).updateSnapshotFields(any()); + verify(pluginMappingService, never()).deleteByAgentId(any()); + verify(contextProviderService, never()).saveOrUpdateByAgentId(any()); + verify(correctWordFileService, never()).saveAgentCorrectWords(any(), any()); + verify(tagService, never()).saveAgentTags(any(), any(), any()); + } + @Test void deleteSnapshotRollsBackForAnyException() throws Exception { Method method = AgentSnapshotServiceImpl.class.getMethod("deleteSnapshot", String.class, String.class); @@ -363,6 +1548,37 @@ class AgentSnapshotServiceImplTest { assertTrue(List.of(transactional.rollbackFor()).contains(Exception.class)); } + @Test + void deleteSnapshotLocksAgentBeforeReadingAndDeletingSnapshot() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, null, null, null, null, + null, null, null, null); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String snapshotId = "snapshot-id"; + AgentEntity agent = new AgentEntity(); + agent.setId(agentId); + AgentSnapshotEntity snapshot = new AgentSnapshotEntity(); + snapshot.setId(snapshotId); + snapshot.setAgentId(agentId); + snapshot.setVersionNo(2); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(agent); + when(snapshotDao.selectById(snapshotId)).thenReturn(snapshot); + when(snapshotDao.selectMaxVersionNo(agentId)).thenReturn(3); + when(snapshotDao.deleteById(snapshotId)).thenReturn(1); + + service.deleteSnapshot(agentId, snapshotId); + + InOrder order = inOrder(agentDao, snapshotDao); + order.verify(agentDao).selectByIdForUpdate(agentId); + order.verify(snapshotDao).selectById(snapshotId); + order.verify(snapshotDao).selectMaxVersionNo(agentId); + order.verify(snapshotDao).deleteById(snapshotId); + } + @Test void snapshotInsertAllocatesVersionInSingleSqlStatement() throws Exception { String xml = normalizeWhitespace(Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml"))); @@ -376,6 +1592,36 @@ class AgentSnapshotServiceImplTest { assertFalse(xml.contains("ai_agent_snapshot_sequence")); } + @Test + void restoreUpdateSqlWritesNullableSnapshotFieldsWithoutOwnershipColumns() throws Exception { + String xml = Files.readString(Path.of("src/main/resources/mapper/agent/AgentDao.xml")); + int updateStart = xml.indexOf(""); + int updateEnd = xml.indexOf("", updateStart); + assertTrue(updateStart >= 0); + assertTrue(updateEnd > updateStart); + String updateSql = normalizeWhitespace(xml.substring(updateStart, updateEnd)); + + for (AgentSnapshotField field : AgentSnapshotField.values()) { + if (!field.isRestorableAgentField()) { + continue; + } + String column = field.getFieldName().replaceAll("([a-z0-9])([A-Z])", "$1_$2") + .toLowerCase(Locale.ROOT); + assertTrue(updateSql.contains(column + " = #{agent." + field.getFieldName() + ","), + () -> "Missing explicit restore assignment for " + field.getFieldName()); + } + assertTrue(updateSql.contains("tts_language = #{agent.ttsLanguage,jdbcType=VARCHAR}")); + assertTrue(updateSql.contains("tts_volume = #{agent.ttsVolume,jdbcType=INTEGER}")); + assertTrue(updateSql.contains("slm_model_id = #{agent.slmModelId,jdbcType=VARCHAR}")); + assertTrue(updateSql.contains("vllm_model_id = #{agent.vllmModelId,jdbcType=VARCHAR}")); + assertTrue(updateSql.contains("intent_model_id = #{agent.intentModelId,jdbcType=VARCHAR}")); + assertTrue(updateSql.contains("system_prompt = #{agent.systemPrompt,jdbcType=LONGVARCHAR}")); + assertTrue(updateSql.contains("summary_memory = #{agent.summaryMemory,jdbcType=LONGVARCHAR}")); + assertFalse(updateSql.contains("user_id =")); + assertFalse(updateSql.contains("creator =")); + assertFalse(updateSql.contains("created_at =")); + } + @Test void snapshotMigrationIsMergedIntoSingleChangeLog() throws Exception { String sql = Files.readString(Path.of("src/main/resources/db/changelog/202607071530.sql")); @@ -390,6 +1636,21 @@ class AgentSnapshotServiceImplTest { assertFalse(master.contains("202607081230.sql")); } + @Test + void legacySnapshotRedactionHasItsOwnResumableSchemaVersionChangeLog() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/changelog/202607101200.sql")); + String master = Files.readString(Path.of("src/main/resources/db/changelog/db.changelog-master.yaml")); + String mapper = Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml")); + + assertTrue(sql.contains("redaction_version")); + assertTrue(sql.contains("idx_snapshot_redaction_version_id")); + assertTrue(sql.contains("-- rollback")); + assertTrue(master.contains("202607101200.sql")); + assertTrue(mapper.contains("selectLegacyRedactionBatch")); + assertTrue(mapper.contains("redaction_version < #{targetRedactionVersion}")); + assertTrue(mapper.contains("")); + } + @Test void selectNextSnapshotLoadsRestoreTraceColumnsWithoutPreviousVersionQuery() throws Exception { String xml = normalizeWhitespace(Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml"))); @@ -550,7 +1811,7 @@ class AgentSnapshotServiceImplTest { } @Test - void restoreTagDoesNotReviveSoftDeletedSnapshotTagId() throws Exception { + void restoreTagRejectsSoftDeletedSnapshotTagWithoutMutatingGlobalTag() throws Exception { AgentTagDao tagDao = mock(AgentTagDao.class); AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, tagDao, null, null, null, null, null, null, null); @@ -563,24 +1824,104 @@ class AgentSnapshotServiceImplTest { deletedTag.setTagName("archived"); deletedTag.setDeleted(1); when(tagDao.selectById("deleted-tag-id")).thenReturn(deletedTag); - when(tagDao.selectOne(any())).thenReturn(null); - AtomicReference insertedTag = new AtomicReference<>(); - when(tagDao.insert(any())).thenAnswer(invocation -> { - insertedTag.set(invocation.getArgument(0)); - return 1; - }); AgentSnapshotTagDTO snapshotTag = new AgentSnapshotTagDTO(); snapshotTag.setId("deleted-tag-id"); snapshotTag.setTagName("archived"); - String restoredTagId = (String) method.invoke(service, snapshotTag, new Date()); + InvocationTargetException error = assertThrows(InvocationTargetException.class, + () -> method.invoke(service, snapshotTag, new Date())); + assertTrue(error.getCause() instanceof RenException); + assertEquals("快照引用的标签已被删除,无法恢复,请先重新创建或选择标签", error.getCause().getMessage()); verify(tagDao, never()).updateById(any()); - assertNotEquals("deleted-tag-id", restoredTagId); - assertNotNull(insertedTag.get()); - assertEquals(restoredTagId, insertedTag.get().getId()); - assertEquals(0, insertedTag.get().getDeleted()); + verify(tagDao, never()).insert(any()); + } + + private AgentInfoVO snapshotAgentInfo(String agentId, Long userId, String agentName, String summaryMemory) { + AgentInfoVO info = new AgentInfoVO(); + info.setId(agentId); + info.setUserId(userId); + info.setAgentName(agentName); + info.setSummaryMemory(summaryMemory); + info.setChatHistoryConf(0); + return info; + } + + private AgentSnapshotDataDTO snapshotData(String agentName, String summaryMemory) { + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + data.setAgentName(agentName); + data.setSummaryMemory(summaryMemory); + data.setChatHistoryConf(0); + return data; + } + + private AgentSnapshotDataDTO buildExtendedSensitiveSnapshot() { + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + AgentUpdateDTO.FunctionInfo function = new AgentUpdateDTO.FunctionInfo(); + function.setPluginId("rag"); + Map params = new LinkedHashMap<>(); + params.put("api_key", "secret-api-key"); + params.put("Cookie", "secret-cookie"); + params.put("Set-Cookie", "secret-set-cookie"); + params.put("Proxy-Authorization", "secret-proxy-authorization"); + params.put("X-Session-ID", "secret-session-id"); + params.put("X-Auth", "secret-auth-header"); + params.put("slackWebhookUrl", + "https://hooks.slack.com/services/T111/B222/secret-slack-path"); + params.put("max_tokens", 32); + function.setParamInfo(params); + + ContextProviderDTO provider = new ContextProviderDTO(); + provider.setUrl("https://discord.com/api/webhooks/123456/secret-discord-path"); + Map headers = new LinkedHashMap<>(); + headers.put("Authorization", "secret-authorization"); + headers.put("Cookie", "secret-context-cookie"); + headers.put("PHPSESSID", "secret-php-session"); + headers.put("X-Session", "secret-x-session"); + headers.put("Accept", "application/json"); + provider.setHeaders(headers); + + data.setFunctions(List.of(function)); + data.setContextProviders(List.of(provider)); + return data; + } + + private AgentUpdateDTO.FunctionInfo functionInfo(String pluginId, Map params) { + AgentUpdateDTO.FunctionInfo function = new AgentUpdateDTO.FunctionInfo(); + function.setPluginId(pluginId); + function.setParamInfo(params); + return function; + } + + private void assertExtendedSecretsAbsent(String json) { + for (String secret : List.of( + "secret-api-key", + "secret-cookie", + "secret-set-cookie", + "secret-proxy-authorization", + "secret-session-id", + "secret-auth-header", + "secret-slack-path", + "secret-discord-path", + "secret-authorization", + "secret-context-cookie", + "secret-php-session", + "secret-x-session")) { + assertFalse(json.contains(secret), () -> "Sensitive value leaked: " + secret); + } + } + + private AgentSnapshotEntity snapshotEntity(String snapshotId, String agentId, Integer versionNo, + AgentSnapshotDataDTO data) { + AgentSnapshotEntity entity = new AgentSnapshotEntity(); + entity.setId(snapshotId); + entity.setAgentId(agentId); + entity.setUserId(7L); + entity.setVersionNo(versionNo); + entity.setSnapshotData(JsonUtils.toJsonString(data)); + entity.setChangedFields("[]"); + return entity; } private AgentSnapshotDataDTO buildSnapshot(String apiKey, String authToken, String description) { @@ -602,6 +1943,18 @@ class AgentSnapshotServiceImplTest { return data; } + private AgentSnapshotDataDTO nestedWebhookSnapshot(String secret) { + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + data.setFunctions(List.of(functionInfo("plugin", Map.of( + "webhookConfig", Map.of( + "value", "https://capability.example.com/public/" + secret))))); + return data; + } + + private String currentStateToken(AgentSnapshotServiceImpl service, AgentSnapshotDataDTO data) { + return ReflectionTestUtils.invokeMethod(service, "buildCurrentStateToken", data); + } + private String normalizeWhitespace(String value) { return value.replaceAll("\\s+", " ").trim(); } diff --git a/main/manager-mobile/package.json b/main/manager-mobile/package.json index 9c4ed55f..80aaee8e 100644 --- a/main/manager-mobile/package.json +++ b/main/manager-mobile/package.json @@ -69,6 +69,7 @@ "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei", "build:quickapp-webview-union": "uni build -p quickapp-webview-union", "type-check": "vue-tsc --noEmit", + "test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs", "openapi-ts-request": "openapi-ts", "prepare": "git init && husky", "lint": "eslint", diff --git a/main/manager-mobile/src/api/agent/agent.ts b/main/manager-mobile/src/api/agent/agent.ts index 0732900c..e436bbb1 100644 --- a/main/manager-mobile/src/api/agent/agent.ts +++ b/main/manager-mobile/src/api/agent/agent.ts @@ -253,11 +253,11 @@ export function getAgentSnapshot(agentId: string, snapshotId: string) { } // 恢复智能体历史版本 -export function restoreAgentSnapshot(agentId: string, snapshotId: string) { - return http.Post(`/agent/${agentId}/snapshots/${snapshotId}/restore`, {}, { +export function restoreAgentSnapshot(agentId: string, snapshotId: string, currentStateToken: string) { + return http.Post(`/agent/${agentId}/snapshots/${snapshotId}/restore`, { currentStateToken }, { meta: { ignoreAuth: false, - toast: true, + toast: false, }, }) } @@ -267,7 +267,7 @@ export function deleteAgentSnapshot(agentId: string, snapshotId: string) { return http.Delete(`/agent/${agentId}/snapshots/${snapshotId}`, { meta: { ignoreAuth: false, - toast: true, + toast: false, }, }) } diff --git a/main/manager-mobile/src/api/agent/types.ts b/main/manager-mobile/src/api/agent/types.ts index 9d29ad4b..990098f9 100644 --- a/main/manager-mobile/src/api/agent/types.ts +++ b/main/manager-mobile/src/api/agent/types.ts @@ -87,14 +87,16 @@ export interface AgentSnapshotData extends Partial { export interface AgentSnapshot { id: string agentId: string - userId?: number + userId?: string versionNo: number changedFields?: string[] fieldOrder?: string[] source?: string restoreFromSnapshotId?: string | null restoreFromVersionNo?: number | null - creator?: number + currentStateToken?: string + currentSnapshotData?: AgentSnapshotData + creator?: string createdAt?: string snapshotData?: AgentSnapshotData afterSnapshotData?: AgentSnapshotData diff --git a/main/manager-mobile/src/i18n/de.ts b/main/manager-mobile/src/i18n/de.ts index 26f59918..3a95557c 100644 --- a/main/manager-mobile/src/i18n/de.ts +++ b/main/manager-mobile/src/i18n/de.ts @@ -130,6 +130,7 @@ export default { 'agent.saving': 'Wird gespeichert...', 'agent.saveSuccess': 'Erfolgreich gespeichert', 'agent.saveFail': 'Speichern fehlgeschlagen', + 'agent.ttsOptionsLoadFailed': 'Die Sprachoptionen konnten nicht geladen werden. Die vorherigen Einstellungen wurden beibehalten.', 'agent.loadFail': 'Laden fehlgeschlagen', 'agent.pleaseInputAgentName': 'Bitte Agenten-Namen eingeben', 'agent.pleaseInputRoleDescription': 'Bitte Rollenbeschreibung eingeben', @@ -161,7 +162,7 @@ export default { 'agentSnapshot.detailTitle': 'Änderungsdetails', 'agentSnapshot.restorePreviewTitle': 'Wiederherstellungsvorschau', 'agentSnapshot.confirmRestore': 'Wiederherstellung bestätigen', - 'agentSnapshot.currentVersion': 'Aktuelle Version', + 'agentSnapshot.currentVersion': 'Neuester Snapshot', 'agentSnapshot.beforeChange': 'Vorher', 'agentSnapshot.afterChange': 'Nachher', 'agentSnapshot.beforeRestore': 'Vor der Wiederherstellung', @@ -169,11 +170,22 @@ export default { 'agentSnapshot.configValue': 'Konfigurationswert', 'agentSnapshot.emptyValue': 'Keine', 'agentSnapshot.secretRedacted': 'Geheimnis verborgen', + 'agentSnapshot.redactedValueChanged': 'Der Wert ist ausgeblendet, wurde aber geändert', 'agentSnapshot.noChangedContent': 'Keine anzeigbaren Änderungen', - 'agentSnapshot.restoreConfirm': 'Version #{version} wiederherstellen? Die aktuelle Konfiguration wird zuerst als neue Version gespeichert.', + 'agentSnapshot.recordedChange': 'Bei der Aufzeichnung geändert', + 'agentSnapshot.noRestoreNeeded': 'Die aktuelle Konfiguration entspricht bereits dieser Version', + 'agentSnapshot.unsavedChangesTitle': 'Nicht gespeicherte Änderungen', + 'agentSnapshot.unsavedChangesWarning': 'Beim Fortfahren werden die nicht gespeicherten Änderungen auf dieser Seite verworfen.', + 'agentSnapshot.discardAndRestore': 'Verwerfen und wiederherstellen', + 'agentSnapshot.restoreConfirm': 'Version #{version} wiederherstellen? Die aktuelle Konfiguration bleibt im Verlauf erhalten.', 'agentSnapshot.restoreMemoryWarning': 'Beim Wiederherstellen einer Version ohne Speicher wird der Chatverlauf dieses Agenten gelöscht. Bitte Risiko bestätigen.', + 'agentSnapshot.restoreChatHistoryDestructiveWarning': 'Diese Wiederherstellung löscht den vorhandenen Chatverlauf dieses Agenten dauerhaft. Der Chatverlauf ist nicht in Konfigurations-Snapshots enthalten und kann nicht über den Versionsverlauf wiederhergestellt werden.', 'agentSnapshot.restoreSuccess': 'Version wurde wiederhergestellt', 'agentSnapshot.restoreFailed': 'Version konnte nicht wiederhergestellt werden', + 'agentSnapshot.reloadAfterRestorePending': 'Die wiederhergestellte Konfiguration und die Tags werden neu geladen. Speichern ist bis zum Abschluss deaktiviert.', + 'agentSnapshot.reloadAfterRestoreFailed': 'Die wiederhergestellte Konfiguration oder die Tags konnten nicht neu geladen werden. Speichern bleibt deaktiviert, damit veraltete Daten die Wiederherstellung nicht überschreiben. Bitte erneut versuchen.', + 'agentSnapshot.retryReload': 'Neu laden', + 'agentSnapshot.mutationBusy': 'Die Konfiguration wird gespeichert oder neu geladen. Bitte warten Sie, bevor Sie den Versionsverlauf öffnen oder eine Wiederherstellung starten.', 'agentSnapshot.deleteConfirm': 'Version #{version} löschen? Dies kann nicht rückgängig gemacht werden.', 'agentSnapshot.deleteSuccess': 'Version gelöscht', 'agentSnapshot.deleteFailed': 'Version konnte nicht gelöscht werden', @@ -182,7 +194,7 @@ export default { 'agentSnapshot.correctWordCount': '{count} Ersatzwörter', 'agentSnapshot.source.config': 'Konfiguration gespeichert', 'agentSnapshot.source.current': 'Aktuelle Konfiguration', - 'agentSnapshot.source.restore': 'Vor der Wiederherstellung', + 'agentSnapshot.source.restore': 'Wiederhergestellt', 'agentSnapshot.source.initial': 'Initialversion', 'agentSnapshot.field.initial': 'Initialer Snapshot', 'agentSnapshot.field.agentCode': 'Agent-Code', diff --git a/main/manager-mobile/src/i18n/en.ts b/main/manager-mobile/src/i18n/en.ts index 23eefbb7..14fbe86b 100644 --- a/main/manager-mobile/src/i18n/en.ts +++ b/main/manager-mobile/src/i18n/en.ts @@ -130,6 +130,7 @@ export default { 'agent.saving': 'Saving...', 'agent.saveSuccess': 'Save successful', 'agent.saveFail': 'Save failed', + 'agent.ttsOptionsLoadFailed': 'Voice options could not be loaded. The previous voice settings were kept.', 'agent.loadFail': 'Load failed', 'agent.pleaseInputAgentName': 'Please input agent name', 'agent.pleaseInputRoleDescription': 'Please input role description', @@ -161,7 +162,7 @@ export default { 'agentSnapshot.detailTitle': 'Change Details', 'agentSnapshot.restorePreviewTitle': 'Restore Preview', 'agentSnapshot.confirmRestore': 'Confirm Restore', - 'agentSnapshot.currentVersion': 'Current Version', + 'agentSnapshot.currentVersion': 'Latest Snapshot', 'agentSnapshot.beforeChange': 'Before', 'agentSnapshot.afterChange': 'After', 'agentSnapshot.beforeRestore': 'Before Restore', @@ -169,11 +170,22 @@ export default { 'agentSnapshot.configValue': 'Config Value', 'agentSnapshot.emptyValue': 'None', 'agentSnapshot.secretRedacted': 'Secret hidden', + 'agentSnapshot.redactedValueChanged': 'The value is hidden, but it did change', 'agentSnapshot.noChangedContent': 'No displayable changes', - 'agentSnapshot.restoreConfirm': 'Restore to version #{version}? The current config will be saved as a new version first.', + 'agentSnapshot.recordedChange': 'Changed when recorded', + 'agentSnapshot.noRestoreNeeded': 'The current configuration already matches this version', + 'agentSnapshot.unsavedChangesTitle': 'Unsaved changes', + 'agentSnapshot.unsavedChangesWarning': 'Continuing will discard the unsaved changes on this page.', + 'agentSnapshot.discardAndRestore': 'Discard and restore', + 'agentSnapshot.restoreConfirm': 'Restore to version #{version}? The current configuration will remain available in history.', 'agentSnapshot.restoreMemoryWarning': 'Restoring to a no-memory version will clear this agent\'s chat history. Please confirm the risk.', + 'agentSnapshot.restoreChatHistoryDestructiveWarning': 'This restore will permanently delete this agent\'s existing chat history. Chat history is not included in configuration snapshots and cannot be recovered from version history.', 'agentSnapshot.restoreSuccess': 'Version restored', 'agentSnapshot.restoreFailed': 'Failed to restore version', + 'agentSnapshot.reloadAfterRestorePending': 'Reloading the restored configuration and tags. Saving is disabled until this completes.', + 'agentSnapshot.reloadAfterRestoreFailed': 'The restored configuration or tags could not be reloaded. Saving remains disabled to prevent stale data from overwriting the restore. Please retry.', + 'agentSnapshot.retryReload': 'Reload', + 'agentSnapshot.mutationBusy': 'The configuration is being saved or reloaded. Wait for it to finish before opening version history or restoring.', 'agentSnapshot.deleteConfirm': 'Delete version #{version}? This cannot be undone.', 'agentSnapshot.deleteSuccess': 'Version deleted', 'agentSnapshot.deleteFailed': 'Failed to delete version', @@ -182,7 +194,7 @@ export default { 'agentSnapshot.correctWordCount': '{count} replacement words', 'agentSnapshot.source.config': 'Config Save', 'agentSnapshot.source.current': 'Current Config', - 'agentSnapshot.source.restore': 'Before Restore', + 'agentSnapshot.source.restore': 'Restored', 'agentSnapshot.source.initial': 'Initial Version', 'agentSnapshot.field.initial': 'Initial Snapshot', 'agentSnapshot.field.agentCode': 'Agent Code', diff --git a/main/manager-mobile/src/i18n/pt_BR.ts b/main/manager-mobile/src/i18n/pt_BR.ts index d4137922..928ffc67 100644 --- a/main/manager-mobile/src/i18n/pt_BR.ts +++ b/main/manager-mobile/src/i18n/pt_BR.ts @@ -130,6 +130,7 @@ export default { 'agent.saving': 'Salvando...', 'agent.saveSuccess': 'Salvo com sucesso', 'agent.saveFail': 'Falha ao salvar', + 'agent.ttsOptionsLoadFailed': 'Não foi possível carregar as opções de voz. As configurações anteriores foram mantidas.', 'agent.loadFail': 'Falha ao carregar', 'agent.pleaseInputAgentName': 'Por favor, insira o nome do agente', 'agent.pleaseInputRoleDescription': 'Por favor, insira a descrição do papel', @@ -161,7 +162,7 @@ export default { 'agentSnapshot.detailTitle': 'Detalhes da Alteração', 'agentSnapshot.restorePreviewTitle': 'Prévia da Restauração', 'agentSnapshot.confirmRestore': 'Confirmar Restauração', - 'agentSnapshot.currentVersion': 'Versão Atual', + 'agentSnapshot.currentVersion': 'Snapshot mais recente', 'agentSnapshot.beforeChange': 'Antes', 'agentSnapshot.afterChange': 'Depois', 'agentSnapshot.beforeRestore': 'Antes da Restauração', @@ -169,11 +170,22 @@ export default { 'agentSnapshot.configValue': 'Valor da Configuração', 'agentSnapshot.emptyValue': 'Nenhum', 'agentSnapshot.secretRedacted': 'Segredo oculto', + 'agentSnapshot.redactedValueChanged': 'O valor está oculto, mas foi alterado', 'agentSnapshot.noChangedContent': 'Nenhuma alteração exibível', - 'agentSnapshot.restoreConfirm': 'Restaurar para a versão #{version}? A configuração atual será salva como uma nova versão primeiro.', + 'agentSnapshot.recordedChange': 'Alterado quando registrado', + 'agentSnapshot.noRestoreNeeded': 'A configuração atual já corresponde a esta versão', + 'agentSnapshot.unsavedChangesTitle': 'Alterações não salvas', + 'agentSnapshot.unsavedChangesWarning': 'Continuar descartará as alterações não salvas desta página.', + 'agentSnapshot.discardAndRestore': 'Descartar e restaurar', + 'agentSnapshot.restoreConfirm': 'Restaurar para a versão #{version}? A configuração atual continuará disponível no histórico.', 'agentSnapshot.restoreMemoryWarning': 'Restaurar para uma versão sem memória limpará o histórico de chat deste agente. Confirme o risco.', + 'agentSnapshot.restoreChatHistoryDestructiveWarning': 'Esta restauração excluirá permanentemente o histórico de chat existente deste agente. O histórico de chat não faz parte dos snapshots de configuração e não pode ser recuperado pelo histórico de versões.', 'agentSnapshot.restoreSuccess': 'Versão restaurada', 'agentSnapshot.restoreFailed': 'Falha ao restaurar versão', + 'agentSnapshot.reloadAfterRestorePending': 'Recarregando a configuração e as tags restauradas. Não é possível salvar até a conclusão.', + 'agentSnapshot.reloadAfterRestoreFailed': 'Não foi possível recarregar a configuração ou as tags restauradas. O salvamento permanece desativado para impedir que dados antigos sobrescrevam a restauração. Tente novamente.', + 'agentSnapshot.retryReload': 'Recarregar', + 'agentSnapshot.mutationBusy': 'A configuração está sendo salva ou recarregada. Aguarde a conclusão antes de abrir o histórico de versões ou restaurar.', 'agentSnapshot.deleteConfirm': 'Excluir versão #{version}? Esta ação não pode ser desfeita.', 'agentSnapshot.deleteSuccess': 'Versão excluída', 'agentSnapshot.deleteFailed': 'Falha ao excluir versão', @@ -182,7 +194,7 @@ export default { 'agentSnapshot.correctWordCount': '{count} palavras de substituição', 'agentSnapshot.source.config': 'Configuração Salva', 'agentSnapshot.source.current': 'Configuração Atual', - 'agentSnapshot.source.restore': 'Antes da Restauração', + 'agentSnapshot.source.restore': 'Restaurado', 'agentSnapshot.source.initial': 'Versão Inicial', 'agentSnapshot.field.initial': 'Snapshot Inicial', 'agentSnapshot.field.agentCode': 'Código do Agente', diff --git a/main/manager-mobile/src/i18n/vi.ts b/main/manager-mobile/src/i18n/vi.ts index cbdbe9fd..5c899662 100644 --- a/main/manager-mobile/src/i18n/vi.ts +++ b/main/manager-mobile/src/i18n/vi.ts @@ -130,6 +130,7 @@ export default { 'agent.saving': 'Đang lưu...', 'agent.saveSuccess': 'Lưu thành công', 'agent.saveFail': 'Lưu thất bại', + 'agent.ttsOptionsLoadFailed': 'Không thể tải tùy chọn giọng nói. Cấu hình giọng nói trước đó đã được giữ lại.', 'agent.loadFail': 'Tải thất bại', 'agent.pleaseInputAgentName': 'Vui lòng nhập tên đại lý', 'agent.pleaseInputRoleDescription': 'Vui lòng nhập mô tả vai trò', @@ -161,7 +162,7 @@ export default { 'agentSnapshot.detailTitle': 'Chi tiết thay đổi', 'agentSnapshot.restorePreviewTitle': 'Xem trước khôi phục', 'agentSnapshot.confirmRestore': 'Xác nhận khôi phục', - 'agentSnapshot.currentVersion': 'Phiên bản hiện tại', + 'agentSnapshot.currentVersion': 'Ảnh chụp mới nhất', 'agentSnapshot.beforeChange': 'Trước', 'agentSnapshot.afterChange': 'Sau', 'agentSnapshot.beforeRestore': 'Trước khôi phục', @@ -169,11 +170,22 @@ export default { 'agentSnapshot.configValue': 'Giá trị cấu hình', 'agentSnapshot.emptyValue': 'Không có', 'agentSnapshot.secretRedacted': 'Đã ẩn khóa bí mật', + 'agentSnapshot.redactedValueChanged': 'Giá trị đã được ẩn nhưng thực sự đã thay đổi', 'agentSnapshot.noChangedContent': 'Không có thay đổi để hiển thị', - 'agentSnapshot.restoreConfirm': 'Khôi phục về phiên bản #{version}? Cấu hình hiện tại sẽ được lưu thành phiên bản mới trước.', + 'agentSnapshot.recordedChange': 'Đã thay đổi khi ghi nhận', + 'agentSnapshot.noRestoreNeeded': 'Cấu hình hiện tại đã khớp với phiên bản này', + 'agentSnapshot.unsavedChangesTitle': 'Có thay đổi chưa lưu', + 'agentSnapshot.unsavedChangesWarning': 'Tiếp tục sẽ hủy các thay đổi chưa lưu trên trang này.', + 'agentSnapshot.discardAndRestore': 'Hủy thay đổi và khôi phục', + 'agentSnapshot.restoreConfirm': 'Khôi phục về phiên bản #{version}? Cấu hình hiện tại sẽ vẫn có trong lịch sử.', 'agentSnapshot.restoreMemoryWarning': 'Khôi phục về phiên bản không có bộ nhớ sẽ xóa lịch sử trò chuyện của đại lý này. Vui lòng xác nhận rủi ro.', + 'agentSnapshot.restoreChatHistoryDestructiveWarning': 'Thao tác khôi phục này sẽ xóa vĩnh viễn lịch sử trò chuyện hiện có của tác nhân. Lịch sử trò chuyện không nằm trong bản chụp cấu hình và không thể khôi phục từ lịch sử phiên bản.', 'agentSnapshot.restoreSuccess': 'Đã khôi phục phiên bản', 'agentSnapshot.restoreFailed': 'Khôi phục phiên bản thất bại', + 'agentSnapshot.reloadAfterRestorePending': 'Đang tải lại cấu hình và thẻ đã khôi phục. Không thể lưu cho đến khi hoàn tất.', + 'agentSnapshot.reloadAfterRestoreFailed': 'Không thể tải lại cấu hình hoặc thẻ đã khôi phục. Chức năng lưu vẫn bị tắt để tránh dữ liệu cũ ghi đè kết quả khôi phục. Vui lòng thử lại.', + 'agentSnapshot.retryReload': 'Tải lại', + 'agentSnapshot.mutationBusy': 'Cấu hình đang được lưu hoặc tải lại. Hãy đợi hoàn tất trước khi mở lịch sử phiên bản hoặc khôi phục.', 'agentSnapshot.deleteConfirm': 'Xóa phiên bản #{version}? Không thể hoàn tác thao tác này.', 'agentSnapshot.deleteSuccess': 'Đã xóa phiên bản', 'agentSnapshot.deleteFailed': 'Xóa phiên bản thất bại', @@ -182,7 +194,7 @@ export default { 'agentSnapshot.correctWordCount': '{count} từ thay thế', 'agentSnapshot.source.config': 'Lưu cấu hình', 'agentSnapshot.source.current': 'Cấu hình hiện tại', - 'agentSnapshot.source.restore': 'Trước khôi phục', + 'agentSnapshot.source.restore': 'Đã khôi phục', 'agentSnapshot.source.initial': 'Phiên bản khởi tạo', 'agentSnapshot.field.initial': 'Ảnh chụp ban đầu', 'agentSnapshot.field.agentCode': 'Mã đại lý', diff --git a/main/manager-mobile/src/i18n/zh_CN.ts b/main/manager-mobile/src/i18n/zh_CN.ts index 49485b2d..824bf4c7 100644 --- a/main/manager-mobile/src/i18n/zh_CN.ts +++ b/main/manager-mobile/src/i18n/zh_CN.ts @@ -130,6 +130,7 @@ export default { 'agent.saving': '保存中...', 'agent.saveSuccess': '保存成功', 'agent.saveFail': '保存失败', + 'agent.ttsOptionsLoadFailed': '语音选项加载失败,已保留原来的语音配置', 'agent.loadFail': '加载失败', 'agent.pleaseInputAgentName': '请输入智能体名称', 'agent.pleaseInputRoleDescription': '请输入角色介绍', @@ -161,7 +162,7 @@ export default { 'agentSnapshot.detailTitle': '变更详情', 'agentSnapshot.restorePreviewTitle': '恢复预览', 'agentSnapshot.confirmRestore': '确认恢复', - 'agentSnapshot.currentVersion': '当前版本', + 'agentSnapshot.currentVersion': '最新快照', 'agentSnapshot.beforeChange': '变化前', 'agentSnapshot.afterChange': '变化后', 'agentSnapshot.beforeRestore': '恢复前', @@ -169,11 +170,22 @@ export default { 'agentSnapshot.configValue': '配置值', 'agentSnapshot.emptyValue': '无', 'agentSnapshot.secretRedacted': '密钥已隐藏', + 'agentSnapshot.redactedValueChanged': '值已脱敏,但确有变化', 'agentSnapshot.noChangedContent': '无可显示变更', - 'agentSnapshot.restoreConfirm': '确定恢复到版本 #{version}?当前配置会先保存为新的历史版本。', + 'agentSnapshot.recordedChange': '记录时发生变更', + 'agentSnapshot.noRestoreNeeded': '当前配置与目标版本相同,无需恢复', + 'agentSnapshot.unsavedChangesTitle': '存在未保存修改', + 'agentSnapshot.unsavedChangesWarning': '继续恢复会放弃当前页面中尚未保存的修改。', + 'agentSnapshot.discardAndRestore': '放弃修改并恢复', + 'agentSnapshot.restoreConfirm': '确定恢复到版本 #{version}?当前配置会保留在历史中。', 'agentSnapshot.restoreMemoryWarning': '恢复到无记忆版本会清空该智能体聊天记录,请确认风险。', + 'agentSnapshot.restoreChatHistoryDestructiveWarning': '此恢复会永久删除该智能体现有的聊天记录。聊天记录不包含在配置快照中,删除后无法通过历史版本恢复。', 'agentSnapshot.restoreSuccess': '版本已恢复', 'agentSnapshot.restoreFailed': '版本恢复失败', + 'agentSnapshot.reloadAfterRestorePending': '正在重新加载恢复后的配置和标签,完成前无法保存。', + 'agentSnapshot.reloadAfterRestoreFailed': '无法重新加载恢复后的配置或标签。为防止旧数据覆盖恢复结果,保存已禁用,请重试。', + 'agentSnapshot.retryReload': '重新加载', + 'agentSnapshot.mutationBusy': '配置正在保存或重新加载,请完成后再打开历史版本或恢复。', 'agentSnapshot.deleteConfirm': '确定删除版本 #{version}?此操作不可撤销。', 'agentSnapshot.deleteSuccess': '历史版本已删除', 'agentSnapshot.deleteFailed': '历史版本删除失败', @@ -182,7 +194,7 @@ export default { 'agentSnapshot.correctWordCount': '共 {count} 个替换词', 'agentSnapshot.source.config': '配置保存', 'agentSnapshot.source.current': '当前配置', - 'agentSnapshot.source.restore': '恢复前备份', + 'agentSnapshot.source.restore': '恢复结果', 'agentSnapshot.source.initial': '初始版本', 'agentSnapshot.field.initial': '初始快照', 'agentSnapshot.field.agentCode': '智能体编码', diff --git a/main/manager-mobile/src/i18n/zh_TW.ts b/main/manager-mobile/src/i18n/zh_TW.ts index 6f0a0ee3..7a216926 100644 --- a/main/manager-mobile/src/i18n/zh_TW.ts +++ b/main/manager-mobile/src/i18n/zh_TW.ts @@ -151,6 +151,7 @@ export default { 'agent.saving': '儲存中...', 'agent.saveSuccess': '儲存成功', 'agent.saveFail': '儲存失敗', + 'agent.ttsOptionsLoadFailed': '語音選項載入失敗,已保留原來的語音設定', 'agent.loadFail': '加載失敗', 'agent.pleaseInputAgentName': '請輸入助手暱稱', 'agent.pleaseInputRoleDescription': '請輸入角色介紹', @@ -182,7 +183,7 @@ export default { 'agentSnapshot.detailTitle': '變更詳情', 'agentSnapshot.restorePreviewTitle': '恢復預覽', 'agentSnapshot.confirmRestore': '確認恢復', - 'agentSnapshot.currentVersion': '當前版本', + 'agentSnapshot.currentVersion': '最新快照', 'agentSnapshot.beforeChange': '變化前', 'agentSnapshot.afterChange': '變化後', 'agentSnapshot.beforeRestore': '恢復前', @@ -190,11 +191,22 @@ export default { 'agentSnapshot.configValue': '配置值', 'agentSnapshot.emptyValue': '無', 'agentSnapshot.secretRedacted': '密鑰已隱藏', + 'agentSnapshot.redactedValueChanged': '值已脫敏,但確有變化', 'agentSnapshot.noChangedContent': '無可顯示變更', - 'agentSnapshot.restoreConfirm': '確定恢復到版本 #{version}?當前配置會先保存為新的歷史版本。', + 'agentSnapshot.recordedChange': '記錄時發生變更', + 'agentSnapshot.noRestoreNeeded': '目前設定與目標版本相同,無需恢復', + 'agentSnapshot.unsavedChangesTitle': '存在未儲存修改', + 'agentSnapshot.unsavedChangesWarning': '繼續恢復會放棄目前頁面中尚未儲存的修改。', + 'agentSnapshot.discardAndRestore': '放棄修改並恢復', + 'agentSnapshot.restoreConfirm': '確定恢復到版本 #{version}?目前設定會保留在歷史中。', 'agentSnapshot.restoreMemoryWarning': '恢復到無記憶版本會清空該智能體聊天記錄,請確認風險。', + 'agentSnapshot.restoreChatHistoryDestructiveWarning': '此恢復會永久刪除該智能體現有的聊天記錄。聊天記錄不包含在配置快照中,刪除後無法透過歷史版本恢復。', 'agentSnapshot.restoreSuccess': '版本已恢復', 'agentSnapshot.restoreFailed': '版本恢復失敗', + 'agentSnapshot.reloadAfterRestorePending': '正在重新載入恢復後的配置和標籤,完成前無法儲存。', + 'agentSnapshot.reloadAfterRestoreFailed': '無法重新載入恢復後的配置或標籤。為防止舊資料覆蓋恢復結果,儲存已停用,請重試。', + 'agentSnapshot.retryReload': '重新載入', + 'agentSnapshot.mutationBusy': '配置正在儲存或重新載入,請完成後再開啟歷史版本或恢復。', 'agentSnapshot.deleteConfirm': '確定刪除版本 #{version}?此操作不可復原。', 'agentSnapshot.deleteSuccess': '歷史版本已刪除', 'agentSnapshot.deleteFailed': '歷史版本刪除失敗', @@ -203,7 +215,7 @@ export default { 'agentSnapshot.correctWordCount': '共 {count} 個替換詞', 'agentSnapshot.source.config': '配置保存', 'agentSnapshot.source.current': '當前配置', - 'agentSnapshot.source.restore': '恢復前備份', + 'agentSnapshot.source.restore': '恢復結果', 'agentSnapshot.source.initial': '初始版本', 'agentSnapshot.field.initial': '初始快照', 'agentSnapshot.field.agentCode': '智能體編碼', diff --git a/main/manager-mobile/src/pages/agent/components/AgentSnapshotPanel.vue b/main/manager-mobile/src/pages/agent/components/AgentSnapshotPanel.vue index 6e1c5a48..c4d095a3 100644 --- a/main/manager-mobile/src/pages/agent/components/AgentSnapshotPanel.vue +++ b/main/manager-mobile/src/pages/agent/components/AgentSnapshotPanel.vue @@ -4,18 +4,26 @@ import { computed, ref, watch } from 'vue' import { useMessage } from 'wot-design-uni/components/wd-message-box' import { deleteAgentSnapshot, - getAgentDetail, getAgentSnapshot, getAgentSnapshots, - getAgentTags, getCorrectWordFiles, getModelOptions, getPluginFunctions, getTTSVoices, restoreAgentSnapshot, } from '@/api/agent/agent' -import { t } from '@/i18n' +import { getCurrentLanguage, t } from '@/i18n' import { toast } from '@/utils/toast' +import { + isSensitiveKey, + normalizeSnapshotTtsNumber as normalizeDefaultTtsNumber, + normalizeSnapshotOrderedList, + redactSnapshotDisplayValue, + SNAPSHOT_SECRET_REDACTED, + stablePrettyStringify, + toIntlLocale, + willRestorePermanentlyDeleteChatHistory, +} from './agentSnapshotUtils.mjs' defineOptions({ name: 'AgentSnapshotPanel', @@ -24,17 +32,21 @@ defineOptions({ const props = withDefaults(defineProps(), { visible: false, currentVersionNo: null, + hasUnsavedChanges: false, + mutationBusy: false, }) const emit = defineEmits<{ (event: 'update:visible', value: boolean): void - (event: 'restored'): void + (event: 'restored', context: { agentId: string, actionSequence: number }): void }>() interface Props { visible?: boolean agentId: string currentVersionNo?: number | null + hasUnsavedChanges?: boolean + mutationBusy?: boolean } interface SnapshotRow extends AgentSnapshot { @@ -54,6 +66,7 @@ interface VersionDetail { forceCompare?: boolean beforeVersionNo?: number | null afterVersionNo?: number | null + currentStateToken?: string } interface DetailItem { @@ -62,18 +75,34 @@ interface DetailItem { beforeText: string afterText: string single: boolean + recordedOnly?: boolean + redactedDifference?: boolean +} + +interface RestoreActionContext { + actionSequence: number + agentId: string + snapshotId: string + currentStateToken: string + detailRequestSequence: number + willClearChatHistory: boolean } const message = useMessage() +const restoring = ref(false) const panelVisible = computed({ get: () => props.visible, - set: value => emit('update:visible', value), + set: (value) => { + if (!value && restoring.value) { + return + } + emit('update:visible', value) + }, }) const loading = ref(false) const detailLoading = ref(false) -const restoring = ref(false) const deletingSnapshotId = ref('') const snapshots = ref([]) const page = ref(1) @@ -81,6 +110,15 @@ const limit = 10 const total = ref(0) const historyAnchorVersionNo = ref(null) const detailVisible = ref(false) +const detailPopupVisible = computed({ + get: () => detailVisible.value, + set: (value) => { + if (!value && restoring.value) { + return + } + detailVisible.value = value + }, +}) const currentDetail = ref(null) const pluginNameMap = ref>({}) const modelNameMap = ref>({}) @@ -91,6 +129,9 @@ const metadataLoaded = ref(false) const correctWordMetadataLoaded = ref(false) let detailRequestSequence = 0 let snapshotRequestSequence = 0 +let restoreActionSequence = 0 +let restorePostActionSequence: number | null = null +let restorePreviewRequestSequence: number | null = null const MODEL_TYPES = ['ASR', 'VAD', 'LLM', 'VLLM', 'TTS', 'Memory', 'Intent'] const MODEL_FIELD_TYPES: Record = { @@ -110,7 +151,6 @@ const CHAT_HISTORY_CONF_LABEL_KEYS: Record = { 2: 'agentSnapshot.chatHistoryConf.textVoice', } -const SNAPSHOT_SECRET_REDACTED = '__SNAPSHOT_SECRET_REDACTED__' const SNAPSHOT_FIELD_ORDER = [ 'agentName', 'agentCode', @@ -159,8 +199,18 @@ const restoreWillClearChatHistory = computed(() => { if (!currentDetail.value || currentDetail.value.mode !== 'restore') { return false } - return currentDetail.value.beforeData?.memModelId !== 'Memory_nomem' - && currentDetail.value.afterData?.memModelId === 'Memory_nomem' + return willRestorePermanentlyDeleteChatHistory( + currentDetail.value.beforeData?.memModelId, + currentDetail.value.afterData?.memModelId, + ) +}) +const displayedCurrentVersionNo = computed(() => historyAnchorVersionNo.value || props.currentVersionNo) +const canConfirmRestore = computed(() => { + return !restoring.value + && !props.mutationBusy + && currentDetail.value?.mode === 'restore' + && detailItems.value.length > 0 + && Boolean(currentDetail.value.currentStateToken?.trim()) }) watch(() => props.visible, (visible) => { @@ -168,8 +218,9 @@ watch(() => props.visible, (visible) => { openPanel() } else { + invalidateRestoreAction() invalidateSnapshotRequest() - closeDetail() + closeDetail(true) } }) @@ -185,9 +236,48 @@ watch(() => props.currentVersionNo, () => { } }) +watch(() => props.agentId, (currentAgentId, previousAgentId) => { + if (currentAgentId === previousAgentId) { + return + } + invalidateRestoreAction() + invalidateSnapshotRequest() + closeDetail(true) + if (props.visible) { + openPanel() + } +}, { flush: 'sync' }) + +watch(() => props.mutationBusy, (mutationBusy) => { + if (!mutationBusy) { + return + } + const restorePostInFlight = restoring.value + && restorePostActionSequence === restoreActionSequence + if (restorePostInFlight) { + return + } + const pendingRestorePreview = restorePreviewRequestSequence !== null + || currentDetail.value?.mode === 'restore' + const pendingRestoreConfirmation = restoring.value + && restorePostActionSequence !== restoreActionSequence + if (!pendingRestorePreview && !pendingRestoreConfirmation) { + return + } + if (pendingRestoreConfirmation) { + invalidateRestoreAction() + } + restorePreviewRequestSequence = null + closeDetail(true) + toast.warning(t('agentSnapshot.mutationBusy')) +}, { flush: 'sync' }) + async function openPanel() { const requestId = ++snapshotRequestSequence - historyAnchorVersionNo.value = props.currentVersionNo || null + // The first page must be unanchored so that a stale parent page cannot hide + // versions created by another client. The response establishes a stable + // anchor for all subsequent pages in this panel session. + historyAnchorVersionNo.value = null page.value = 1 snapshots.value = [] await loadSnapshots(true, requestId) @@ -207,14 +297,18 @@ async function loadSnapshots(reset = false, requestId = snapshotRequestSequence) page: nextPage, limit, } - if (historyAnchorVersionNo.value) { + if (!reset && historyAnchorVersionNo.value) { params.maxVersionNo = historyAnchorVersionNo.value } const result = await getAgentSnapshots(props.agentId, params) if (!isActiveSnapshotRequest(requestId)) { return } - const rows = decorateSnapshotRows(result?.list || []) + const responseRows = result?.list || [] + if (reset) { + historyAnchorVersionNo.value = responseRows[0]?.versionNo || null + } + const rows = decorateSnapshotRows(responseRows) const mergedRows = reset ? rows : [...snapshots.value, ...rows] snapshots.value = attachPreviousRows(mergedRows) total.value = result?.total || 0 @@ -244,11 +338,9 @@ function invalidateSnapshotRequest() { } function decorateSnapshotRows(rows: AgentSnapshot[]): SnapshotRow[] { - return rows.map((row, index) => ({ + return rows.map(row => ({ ...row, - isLatestSnapshot: props.currentVersionNo - ? row.versionNo === props.currentVersionNo - : index === 0 && page.value === 1, + isLatestSnapshot: Boolean(historyAnchorVersionNo.value && row.versionNo === historyAnchorVersionNo.value), })) } @@ -277,19 +369,18 @@ async function viewSnapshot(row: SnapshotRow) { detailLoading.value = true currentDetail.value = null try { - await ensureMetadata() - if (!isActiveDetailRequest(requestId)) { - return - } + const metadataRequest = ensureMetadata() const detail = await buildSavedVersionDetail(row) if (!isActiveDetailRequest(requestId)) { return } - await ensureDisplayMetadata(detail.beforeData, detail.afterData) - if (!isActiveDetailRequest(requestId)) { - return - } currentDetail.value = detail + // Names are enhancement metadata. Render the snapshot immediately with + // stable IDs, then update names reactively as metadata arrives. + void Promise.allSettled([ + metadataRequest, + ensureDisplayMetadata(detail.beforeData, detail.afterData), + ]) } catch (error) { if (!isActiveDetailRequest(requestId)) { @@ -307,22 +398,26 @@ async function viewSnapshot(row: SnapshotRow) { } async function previewRestoreSnapshot(row: SnapshotRow) { + if (!canRestoreSnapshot(row)) { + return + } + if (props.mutationBusy) { + toast.warning(t('agentSnapshot.mutationBusy')) + return + } detailVisible.value = true const requestId = ++detailRequestSequence + restorePreviewRequestSequence = requestId detailLoading.value = true currentDetail.value = null try { - await ensureMetadata() + const metadataRequest = ensureMetadata() + const targetSnapshot = await getAgentSnapshot(props.agentId, row.id) if (!isActiveDetailRequest(requestId)) { return } - const [targetSnapshot, currentAgent, currentTags] = await Promise.all([ - getAgentSnapshot(props.agentId, row.id), - getAgentDetail(props.agentId), - getAgentTags(props.agentId), - ]) - if (!isActiveDetailRequest(requestId)) { - return + if (!isPlainObject(targetSnapshot.currentSnapshotData)) { + throw new Error('Snapshot detail is missing the atomic current-state data') } const detail: VersionDetail = { mode: 'restore', @@ -331,23 +426,20 @@ async function previewRestoreSnapshot(row: SnapshotRow) { ...row, id: targetSnapshot.id || row.id, }, - beforeData: { - ...currentAgent, - tags: currentTags || [], - tagNames: (currentTags || []).map(tag => tag?.tagName).filter(Boolean), - } as AgentSnapshotData, + beforeData: targetSnapshot.currentSnapshotData, afterData: targetSnapshot.snapshotData || {}, changedFields: [], fieldOrder: targetSnapshot.fieldOrder || [], forceCompare: true, - beforeVersionNo: props.currentVersionNo, + beforeVersionNo: historyAnchorVersionNo.value || props.currentVersionNo, afterVersionNo: targetSnapshot.versionNo, - } - await ensureDisplayMetadata(detail.beforeData, detail.afterData) - if (!isActiveDetailRequest(requestId)) { - return + currentStateToken: targetSnapshot.currentStateToken, } currentDetail.value = detail + void Promise.allSettled([ + metadataRequest, + ensureDisplayMetadata(detail.beforeData, detail.afterData), + ]) } catch (error) { if (!isActiveDetailRequest(requestId)) { @@ -358,6 +450,9 @@ async function previewRestoreSnapshot(row: SnapshotRow) { detailVisible.value = false } finally { + if (restorePreviewRequestSequence === requestId) { + restorePreviewRequestSequence = null + } if (isActiveDetailRequest(requestId)) { detailLoading.value = false } @@ -374,7 +469,10 @@ function invalidateDetailRequest() { currentDetail.value = null } -function closeDetail() { +function closeDetail(force = false) { + if (restoring.value && !force) { + return + } if (detailVisible.value) { detailVisible.value = false return @@ -437,24 +535,125 @@ async function confirmRestoreSnapshot() { if (restoring.value || !detail || detail.mode !== 'restore' || !detail.row?.id) { return } - const snapshotId = detail.row.id + if (props.mutationBusy) { + toast.warning(t('agentSnapshot.mutationBusy')) + return + } + if (!detailItems.value.length) { + toast.info(t('agentSnapshot.noRestoreNeeded')) + return + } + const currentStateToken = detail.currentStateToken?.trim() + if (!currentStateToken) { + console.error('Cannot restore snapshot without a current-state token') + toast.error(t('agentSnapshot.restoreFailed')) + return + } + const context: RestoreActionContext = { + actionSequence: ++restoreActionSequence, + agentId: props.agentId, + snapshotId: detail.row.id, + currentStateToken, + detailRequestSequence, + willClearChatHistory: restoreWillClearChatHistory.value, + } + // Enter the busy state before either confirmation can yield. This prevents + // rapid taps from opening duplicate dialogs or issuing duplicate restores. restoring.value = true try { - await restoreAgentSnapshot(props.agentId, snapshotId) + if (props.hasUnsavedChanges) { + const confirmed = await requestRestoreConfirmation({ + title: t('agentSnapshot.unsavedChangesTitle'), + msg: t('agentSnapshot.unsavedChangesWarning'), + confirmButtonText: t('agentSnapshot.discardAndRestore'), + cancelButtonText: t('common.cancel'), + }) + if (!confirmed || !isActiveRestoreAction(context)) { + return + } + } + + // This confirmation must be the last user interaction before the POST. + // It is intentionally separate from the unsaved-form confirmation because + // clearing chat history is irreversible and outside snapshot recovery. + if (context.willClearChatHistory) { + const confirmed = await requestRestoreConfirmation({ + title: t('agentSnapshot.confirmRestore'), + msg: t('agentSnapshot.restoreChatHistoryDestructiveWarning'), + confirmButtonText: t('agentSnapshot.confirmRestore'), + cancelButtonText: t('common.cancel'), + }) + if (!confirmed || !isActiveRestoreAction(context)) { + return + } + } + + if (!isActiveRestoreAction(context)) { + return + } + // Mark the irreversible request boundary only after the final mutation + // guard. A later parent busy transition must not pretend this POST was + // canceled; its real response still owns the terminal UI state. + restorePostActionSequence = context.actionSequence + await restoreAgentSnapshot(context.agentId, context.snapshotId, context.currentStateToken) + if (!isActiveRestoreAction(context)) { + return + } toast.success(t('agentSnapshot.restoreSuccess')) - closeDetail() - emit('restored') - await openPanel() + closeDetail(true) + emit('restored', { + agentId: context.agentId, + actionSequence: context.actionSequence, + }) + if (props.visible && props.agentId === context.agentId) { + await openPanel() + } } catch (error) { - console.error('Failed to restore snapshot:', error) - toast.error(t('agentSnapshot.restoreFailed')) + if (isActiveRestoreAction(context)) { + console.error('Failed to restore snapshot:', error) + toast.error(t('agentSnapshot.restoreFailed')) + } } finally { - restoring.value = false + if (restorePostActionSequence === context.actionSequence) { + restorePostActionSequence = null + } + if (context.actionSequence === restoreActionSequence) { + restoring.value = false + } } } +async function requestRestoreConfirmation(options: Parameters[0]) { + try { + await message.confirm(options) + return true + } + catch { + return false + } +} + +function isActiveRestoreAction(context: RestoreActionContext) { + const detail = currentDetail.value + return restoring.value + && context.actionSequence === restoreActionSequence + && (!props.mutationBusy || restorePostActionSequence === context.actionSequence) + && context.agentId === props.agentId + && props.visible + && detailVisible.value + && context.detailRequestSequence === detailRequestSequence + && detail?.mode === 'restore' + && detail.row?.id === context.snapshotId + && detail.currentStateToken?.trim() === context.currentStateToken +} + +function invalidateRestoreAction() { + restoreActionSequence += 1 + restoring.value = false +} + function deleteSnapshot(row: SnapshotRow) { message.confirm({ title: t('agentSnapshot.delete'), @@ -494,6 +693,8 @@ function buildDetailItems(detail: VersionDetail): DetailItem[] { beforeText: '', afterText: formatDisplayValue(field, afterValue, detail.afterData), single: true, + recordedOnly: false, + redactedDifference: false, } }) } @@ -507,12 +708,18 @@ function buildDetailItems(detail: VersionDetail): DetailItem[] { ).map((field) => { const beforeValue = getFieldValue(detail.beforeData, field) const afterValue = getFieldValue(detail.afterData, field) + const beforeText = formatDisplayValue(field, beforeValue, detail.beforeData) + const afterText = formatDisplayValue(field, afterValue, detail.afterData) return { field, label: fieldLabel(field), - beforeText: formatDisplayValue(field, beforeValue, detail.beforeData), - afterText: formatDisplayValue(field, afterValue, detail.afterData), + beforeText, + afterText, single: false, + recordedOnly: !detail.forceCompare && isSameFieldValue(field, beforeValue, afterValue), + redactedDifference: !isSameFieldValue(field, beforeValue, afterValue) + && beforeText === afterText + && beforeText.includes(t('agentSnapshot.secretRedacted')), } }) } @@ -531,7 +738,13 @@ function resolveDiffFields( .map(canonicalField) const candidates = forceCompare ? orderedFields : directFields - return Array.from(new Set(candidates)).filter((field) => { + const uniqueCandidates = Array.from(new Set(candidates)) + if (!forceCompare) { + // changedFields is an immutable fact about the saved event. Re-running + // today's comparison rules would make old events disappear from detail. + return uniqueCandidates + } + return uniqueCandidates.filter((field) => { return !isSameFieldValue( field, getFieldValue(beforeData, field), @@ -596,7 +809,7 @@ function formatDisplayValue(field: string, value: any, rowData: AgentSnapshotDat return formatArrayValue(value) } if (typeof value === 'object') { - return JSON.stringify(value, null, 2) + return stablePrettyStringify(redactDisplayValue(value)) } if (field === 'ttsLanguage' && !value && rowData.ttsVoiceId) { return t('agentSnapshot.emptyValue') @@ -611,9 +824,11 @@ function formatFunctions(value: any) { return value.map((item) => { const pluginId = item?.pluginId || item?.id || '' const name = pluginNameMap.value[pluginId] || translatedFallback(`agentSnapshot.plugin.${pluginId}`, pluginId || t('agentSnapshot.emptyValue')) - const params = item?.paramInfo || item?.params || {} - const keys = params && typeof params === 'object' ? Object.keys(params) : [] - return keys.length ? `${name} (${keys.join(', ')})` : name + const params = parseObjectValue(item?.paramInfo ?? item?.params) + const safeParams = redactDisplayValue(params) + return Object.keys(safeParams).length + ? `${name}\n${stablePrettyStringify(safeParams)}` + : name }).join('\n') } @@ -622,7 +837,7 @@ function formatContextProviders(value: any) { return t('agentSnapshot.emptyValue') } return value.map((item, index) => { - return `${index + 1}. ${item?.url || JSON.stringify(item)}` + return `${index + 1}. ${stablePrettyStringify(redactDisplayValue(item))}` }).join('\n') } @@ -630,14 +845,14 @@ function formatCorrectWordFileIds(value: any) { if (!Array.isArray(value) || value.length === 0) { return t('agentSnapshot.emptyValue') } - return value.map(id => correctWordNameMap.value[id] || id).join('、') + return formatLocalizedList(value.map(id => correctWordNameMap.value[id] || id)) } function formatStringList(value: any) { if (!Array.isArray(value) || value.length === 0) { return t('agentSnapshot.emptyValue') } - return value.join('、') + return formatLocalizedList(value) } function formatArrayValue(value: any[]) { @@ -645,9 +860,9 @@ function formatArrayValue(value: any[]) { return t('agentSnapshot.emptyValue') } if (value.every(item => item === null || ['string', 'number', 'boolean'].includes(typeof item))) { - return value.join('、') + return formatLocalizedList(value) } - return JSON.stringify(value, null, 2) + return stablePrettyStringify(redactDisplayValue(value)) } async function ensureMetadata() { @@ -760,8 +975,34 @@ function formatTime(time?: string) { if (Number.isNaN(date.getTime())) { return String(time) } - const pad = (value: number) => String(value).padStart(2, '0') - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` + try { + return new Intl.DateTimeFormat(toIntlLocale(getCurrentLanguage()), { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(date) + } + catch { + const pad = (value: number) => String(value).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` + } +} + +function formatLocalizedList(values: any[]) { + const items = values.map(value => String(value)) + try { + return new Intl.ListFormat(toIntlLocale(getCurrentLanguage()), { + style: 'short', + type: 'conjunction', + }).format(items) + } + catch { + return items.join(getCurrentLanguage().startsWith('zh_') ? '、' : ', ') + } } function isSameFieldValue( @@ -789,22 +1030,11 @@ function normalizeValueForField(field: string, value: any): any { return normalizeFunctions(value) } if (field === 'contextProviders') { - return normalizeSortedList(value) + return normalizeSnapshotOrderedList(value) } return normalizeValue(value) } -function normalizeDefaultTtsNumber(value: any) { - if (value === undefined || value === null || String(value).trim() === '') { - return 0 - } - if (typeof value === 'number') { - return Math.trunc(value) - } - const text = String(value).trim() - return /^[+-]?\d+$/.test(text) ? Number.parseInt(text, 10) : text -} - function normalizeStringList(value: any) { if (!Array.isArray(value)) { return [] @@ -845,16 +1075,6 @@ function parseObjectValue(value: any) { } } -function normalizeSortedList(value: any) { - if (!Array.isArray(value)) { - return [] - } - return value - .filter(item => item !== undefined && item !== null) - .map(normalizeValue) - .sort((left, right) => stableStringify(left).localeCompare(stableStringify(right))) -} - function isEquivalentValue(left: any, right: any): boolean { if (left === SNAPSHOT_SECRET_REDACTED || right === SNAPSHOT_SECRET_REDACTED) { return true @@ -892,23 +1112,8 @@ function isPlainObject(value: any): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } -function isSensitiveKey(key: string) { - const normalized = String(key).toLowerCase().replace(/[^a-z0-9]/g, '') - return normalized === 'authorization' - || normalized === 'token' - || normalized.endsWith('token') - || normalized.includes('apikey') - || normalized.includes('appkey') - || normalized.includes('accesskey') - || normalized.includes('privatekey') - || normalized.includes('password') - || normalized.includes('passwd') - || normalized.includes('secret') - || normalized.includes('credential') -} - -function stableStringify(value: any) { - return JSON.stringify(value) +function redactDisplayValue(value: any, parentKey = ''): any { + return redactSnapshotDisplayValue(value, t('agentSnapshot.secretRedacted'), parentKey) } @@ -918,6 +1123,7 @@ function stableStringify(value: any) { position="bottom" custom-class="agent-snapshot-popup" custom-style="height: 86vh; max-height: 900px;" + :close-on-click-modal="!restoring" safe-area-inset-bottom > @@ -926,8 +1132,8 @@ function stableStringify(value: any) { {{ t('agentSnapshot.title') }} - - {{ t('agentSnapshot.currentVersion') }} {{ formatVersion(currentVersionNo) }} + + {{ t('agentSnapshot.currentVersion') }} {{ formatVersion(displayedCurrentVersionNo) }} @@ -993,6 +1199,7 @@ function stableStringify(value: any) { v-if="canRestoreSnapshot(snapshot)" size="small" type="primary" + :disabled="mutationBusy" custom-class="agent-snapshot-action-button flex-1 !h-[64rpx] !bg-[#336cff]" @click="previewRestoreSnapshot(snapshot)" > @@ -1027,10 +1234,11 @@ function stableStringify(value: any) { @@ -1038,7 +1246,7 @@ function stableStringify(value: any) { {{ detailTitle }} - + @@ -1060,6 +1268,12 @@ function stableStringify(value: any) { {{ item.label }} + + {{ t('agentSnapshot.recordedChange') }} + + + {{ t('agentSnapshot.redactedValueChanged') }} + @@ -1097,11 +1311,17 @@ function stableStringify(value: any) { > {{ t('agentSnapshot.restoreConfirm', { version: currentDetail.afterVersionNo || '' }) }} + + {{ t('agentSnapshot.unsavedChangesWarning') }} + - {{ t('agentSnapshot.restoreMemoryWarning') }} + {{ t('agentSnapshot.restoreChatHistoryDestructiveWarning') }} @@ -1110,12 +1330,13 @@ function stableStringify(value: any) { v-if="currentDetail?.mode === 'restore'" class="agent-snapshot-footer" > - + {{ t('common.cancel') }} diff --git a/main/manager-mobile/src/pages/agent/components/agentSnapshotContracts.test.mjs b/main/manager-mobile/src/pages/agent/components/agentSnapshotContracts.test.mjs new file mode 100644 index 00000000..a217a99a --- /dev/null +++ b/main/manager-mobile/src/pages/agent/components/agentSnapshotContracts.test.mjs @@ -0,0 +1,103 @@ +/* eslint-disable test/no-import-node-test -- zero-dependency source contract gate */ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const panelSource = await readFile(new URL('./AgentSnapshotPanel.vue', import.meta.url), 'utf8') +const editSource = await readFile(new URL('../edit.vue', import.meta.url), 'utf8') + +function sourceBetween(source, startMarker, endMarker) { + const start = source.indexOf(startMarker) + const end = source.indexOf(endMarker, start + startMarker.length) + assert.notEqual(start, -1, `missing start marker: ${startMarker}`) + assert.notEqual(end, -1, `missing end marker: ${endMarker}`) + return source.slice(start, end) +} + +test('restore enters busy state before confirmations and requires a final destructive confirmation', () => { + const restore = sourceBetween( + panelSource, + 'async function confirmRestoreSnapshot()', + 'async function requestRestoreConfirmation', + ) + const busyIndex = restore.indexOf('restoring.value = true') + const unsavedConfirmIndex = restore.indexOf('title: t(\'agentSnapshot.unsavedChangesTitle\')') + const destructiveConfirmIndex = restore.indexOf('msg: t(\'agentSnapshot.restoreChatHistoryDestructiveWarning\')') + const postIndex = restore.indexOf('await restoreAgentSnapshot(') + + assert.ok(busyIndex >= 0 && busyIndex < unsavedConfirmIndex) + assert.ok(destructiveConfirmIndex > unsavedConfirmIndex && destructiveConfirmIndex < postIndex) + assert.match(restore, /if \(context\.willClearChatHistory\)/) + assert.match(restore.slice(destructiveConfirmIndex, postIndex), /isActiveRestoreAction\(context\)/) + const finalMutationGuardIndex = restore.lastIndexOf('if (!isActiveRestoreAction(context))', postIndex) + const postBoundaryIndex = restore.indexOf('restorePostActionSequence = context.actionSequence') + assert.ok(finalMutationGuardIndex > destructiveConfirmIndex) + assert.ok(finalMutationGuardIndex < postBoundaryIndex && postBoundaryIndex < postIndex) +}) +test('restore guards popup exits and stale action completion', () => { + assert.match(panelSource, /if \(!value && restoring\.value\) \{\s*return\s*\}/) + assert.equal((panelSource.match(/:close-on-click-modal="!restoring"/g) || []).length, 2) + assert.match(panelSource, /function closeDetail\(force = false\) \{\s*if \(restoring\.value && !force\)/) + assert.match(panelSource, /context\.agentId === props\.agentId/) + assert.match(panelSource, /context\.detailRequestSequence === detailRequestSequence/) + assert.match(panelSource, /if \(!isActiveRestoreAction\(context\)\) \{\s*return\s*\}\s*toast\.success/) + assert.match(panelSource, /mutationBusy\?: boolean/) + assert.match(panelSource, /&& !props\.mutationBusy[\s\S]*currentDetail\.value\?\.mode === 'restore'/) + assert.match(panelSource, /&& \(!props\.mutationBusy \|\| restorePostActionSequence === context\.actionSequence\)/) + assert.match(panelSource, /if \(restorePostInFlight\) \{\s*return\s*\}/) +}) + +test('post-restore detail and tag reload fail closed before save', () => { + const save = sourceBetween(editSource, 'async function saveAgent()', 'function loadPluginFunctions()') + const reload = sourceBetween( + editSource, + 'async function reloadAgentAfterSnapshotRestore', + 'function isActiveSnapshotReload', + ) + + assert.ok(save.indexOf('if (snapshotReloadBlocked.value)') < save.indexOf('await updateAgent(')) + assert.match(reload, /snapshotReloadBlocked\.value = true/) + assert.match(reload, /Promise\.all\(\[\s*getAgentDetail\(targetAgentId\),\s*getAgentTags\(targetAgentId\)/) + assert.ok(reload.indexOf('const [detail, tags] = await Promise.all') < reload.indexOf('applyPersistedAgentDetail(')) + assert.match(reload, /snapshotReloadFailed\.value = true/) + assert.match(editSource, /:disabled="saving \|\| ttsOptionsLoading \|\| snapshotReloadBlocked"/) + assert.match(editSource, /v-if="snapshotReloadFailed"[\s\S]*@click="retrySnapshotReload"/) +}) + +test('parent mutations guard history opening and propagate into every restore gate', () => { + const opener = sourceBetween(editSource, 'function openSnapshotPanel()', 'function isSameStringList') + const restore = sourceBetween( + panelSource, + 'async function confirmRestoreSnapshot()', + 'async function requestRestoreConfirmation', + ) + + assert.match(opener, /if \(saving\.value \|\| snapshotReloadBlocked\.value\) \{[\s\S]*return/) + assert.match(editSource, /:disabled="saving \|\| snapshotReloadBlocked"[\s\S]*@click="openSnapshotPanel"/) + assert.match(editSource, /:mutation-busy="saving \|\| snapshotReloadBlocked"/) + assert.match(restore, /if \(props\.mutationBusy\) \{[\s\S]*return/) + const postIndex = restore.indexOf('await restoreAgentSnapshot(') + const finalGate = restore.lastIndexOf('if (!isActiveRestoreAction(context))', postIndex) + assert.ok(finalGate >= 0 && finalGate < postIndex) +}) + +test('the latest snapshot hides restore and delete actions', () => { + const restoreGate = sourceBetween( + panelSource, + 'function canRestoreSnapshot(row: SnapshotRow)', + 'function canDeleteSnapshot(row: SnapshotRow)', + ) + const deleteGate = sourceBetween( + panelSource, + 'function canDeleteSnapshot(row: SnapshotRow)', + 'function buildDetailItems', + ) + + assert.match(restoreGate, /!!row\?\.id && !row\.isLatestSnapshot/) + assert.match(deleteGate, /!!row\?\.id && !row\.isLatestSnapshot/) + assert.match(panelSource, /v-if="canRestoreSnapshot\(snapshot\)"/) + assert.match( + panelSource, + /async function previewRestoreSnapshot\(row: SnapshotRow\) \{\s*if \(!canRestoreSnapshot\(row\)\) \{\s*return/, + ) +}) diff --git a/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.mjs b/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.mjs new file mode 100644 index 00000000..c94a4687 --- /dev/null +++ b/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.mjs @@ -0,0 +1,314 @@ +export const SNAPSHOT_SECRET_REDACTED = '__SNAPSHOT_SECRET_REDACTED__' + +/** @param {unknown} voices */ +export function hasUsableTtsVoiceMetadata(voices) { + return Array.isArray(voices) && voices.length > 0 +} + +/** + * @param {unknown} currentMemModelId + * @param {unknown} targetMemModelId + */ +export function willRestorePermanentlyDeleteChatHistory(currentMemModelId, targetMemModelId) { + return currentMemModelId !== 'Memory_nomem' && targetMemModelId === 'Memory_nomem' +} + +/** + * @param {Array>} voices + * @param {string} language + */ +export function filterTtsVoicesByLanguage(voices, language) { + if (!language) { + return voices + } + return voices.filter((voice) => { + if (typeof voice.languages !== 'string' || !voice.languages.trim()) { + return false + } + return voice.languages + .split(/[、;;,,]/) + .map(item => item.trim()) + .filter(Boolean) + .includes(language) + }) +} + +/** @param {unknown} value */ +export function normalizeSnapshotTtsNumber(value) { + if (value === undefined || value === null || String(value).trim() === '') { + return null + } + if (typeof value === 'number') { + return Math.trunc(value) + } + const text = String(value).trim() + return /^[+-]?\d+$/.test(text) ? Number.parseInt(text, 10) : text +} + +/** + * Normalize object-key order without changing array order. Context providers + * are consumed sequentially by the runtime, so their list order is semantic. + * @param {unknown} value + */ +export function normalizeSnapshotOrderedList(value) { + if (!Array.isArray(value)) { + return [] + } + return value + .filter(item => item !== undefined && item !== null) + .map(normalizeDisplayObject) +} + +/** + * @param {unknown} value + * @param {string} redactedLabel + * @param {string} [parentKey] + * @returns {unknown} + */ +export function redactSnapshotDisplayValue(value, redactedLabel, parentKey = '') { + if (value === SNAPSHOT_SECRET_REDACTED || isSensitiveKey(parentKey)) { + return redactedLabel + } + if (Array.isArray(value)) { + return value.map(item => redactSnapshotDisplayValue(item, redactedLabel, parentKey)) + } + if (isPlainObject(value)) { + const entryNameKey = Object.keys(value).find((key) => { + return ['key', 'name'].includes(key.toLowerCase()) + && typeof value[key] === 'string' + && isSensitiveKey(value[key]) + }) + const entryValueKey = Object.keys(value).find(key => key.toLowerCase() === 'value') + return Object.keys(value).sort().reduce((result, key) => { + const semanticKey = resolveUrlSemanticKey(parentKey, key) + if (entryNameKey && key === entryValueKey) { + result[key] = redactedLabel + } + else { + result[key] = redactSnapshotDisplayValue(value[key], redactedLabel, semanticKey) + } + return result + }, {}) + } + if (typeof value === 'string' && isSnapshotUrlValue(parentKey, value)) { + return redactUrl(value, redactedLabel, parentKey) + } + return value +} + +/** @param {unknown} value */ +export function stablePrettyStringify(value) { + return JSON.stringify(normalizeDisplayObject(value), null, 2) +} + +/** @param {string} language */ +export function toIntlLocale(language) { + return language.replace('_', '-') +} + +/** @param {string} key */ +export function isSensitiveKey(key) { + const normalized = String(key).toLowerCase().replace(/[^a-z0-9]/g, '') + return normalized === 'authorization' + || normalized.includes('authorization') + || normalized.includes('authentication') + || normalized === 'auth' + || normalized.endsWith('auth') + || normalized === 'cookie' + || normalized === 'cookie2' + || normalized === 'setcookie' + || normalized === 'setcookie2' + || normalized.endsWith('cookie') + || normalized === 'session' + || normalized.endsWith('session') + || normalized.includes('sessionid') + || normalized.includes('sessionkey') + || normalized.includes('sessiontoken') + || normalized.includes('sessioncookie') + || normalized.endsWith('sessid') + || normalized === 'token' + || normalized.endsWith('token') + || normalized.includes('apikey') + || normalized.includes('appkey') + || normalized.includes('accesskey') + || normalized.includes('subscriptionkey') + || normalized.includes('privatekey') + || normalized.includes('password') + || normalized.includes('passwd') + || normalized.includes('secret') + || normalized.includes('credential') +} + +/** + * @param {string} value + * @param {string} redactedLabel + * @param {string} parentKey + */ +function redactUrl(value, redactedLabel, parentKey) { + const withoutCredentials = value.replace( + /^((?:[a-z][a-z0-9+.-]*:)*\/\/)[^/?#\s]*@/i, + (match, prefix) => `${prefix}${redactedLabel}@`, + ) + const schemeMatch = /^((?:[a-z][a-z0-9+.-]*:)*\/\/)/i.exec(withoutCredentials) + let pathRedacted = withoutCredentials + if (schemeMatch) { + const scheme = schemeMatch[1] + const remainder = withoutCredentials.slice(scheme.length) + const suffixIndex = remainder.search(/[?#]/) + const authorityAndPath = suffixIndex < 0 ? remainder : remainder.slice(0, suffixIndex) + const suffix = suffixIndex < 0 ? '' : remainder.slice(suffixIndex) + const pathIndex = authorityAndPath.indexOf('/') + const authority = pathIndex < 0 ? authorityAndPath : authorityAndPath.slice(0, pathIndex) + const path = pathIndex < 0 ? '' : authorityAndPath.slice(pathIndex) + let host = authority.slice(authority.lastIndexOf('@') + 1) + if (host.startsWith('[')) { + const closingBracket = host.indexOf(']') + host = closingBracket > 0 ? host.slice(1, closingBracket) : host + } + else { + host = host.replace(/:\d+$/, '') + } + host = host.toLowerCase() + pathRedacted = `${scheme}${authority}${redactCapabilityPath(path, host, parentKey, redactedLabel)}${suffix}` + } + else { + const suffixIndex = withoutCredentials.search(/[?#]/) + const path = suffixIndex < 0 ? withoutCredentials : withoutCredentials.slice(0, suffixIndex) + const suffix = suffixIndex < 0 ? '' : withoutCredentials.slice(suffixIndex) + pathRedacted = `${redactCapabilityPath(path, '', parentKey, redactedLabel)}${suffix}` + } + const queryIndex = pathRedacted.search(/[?#]/) + if (queryIndex < 0) { + return pathRedacted + } + return `${pathRedacted.slice(0, queryIndex)}${pathRedacted[queryIndex]}${redactedLabel}` +} + +/** + * Capability URLs often carry credentials in path segments rather than in + * query parameters. Preserve public provider IDs where their formats are + * known, and fail closed after generic hook markers. + * @param {string} path + * @param {string} host + * @param {string} parentKey + * @param {string} redactedLabel + */ +function redactCapabilityPath(path, host, parentKey, redactedLabel) { + if (!path) { + return path + } + const segments = path.split('/') + const normalizedSegments = segments.map(segment => segment.toLowerCase()) + + if (host === 'hooks.slack.com' || host === 'hooks.slack-gov.com') { + const servicesIndex = normalizedSegments.indexOf('services') + const capabilityIndex = servicesIndex >= 0 ? servicesIndex + 3 : -1 + if (capabilityIndex > 0 && capabilityIndex < segments.length) { + segments[capabilityIndex] = redactedLabel + return segments.join('/') + } + } + + if (host === 'discord.com' + || host.endsWith('.discord.com') + || host === 'discordapp.com' + || host.endsWith('.discordapp.com')) { + const webhooksIndex = normalizedSegments.indexOf('webhooks') + const capabilityIndex = webhooksIndex >= 0 ? webhooksIndex + 2 : -1 + if (capabilityIndex > 0 && capabilityIndex < segments.length) { + segments[capabilityIndex] = redactedLabel + return segments.join('/') + } + } + + if (host === 'api.telegram.org') { + const botIndex = segments.findIndex(segment => /^bot[^:]+:.+$/i.test(segment)) + if (botIndex >= 0) { + segments[botIndex] = segments[botIndex].replace(/^bot([^:]+):.+$/i, `bot$1:${redactedLabel}`) + return segments.join('/') + } + } + + const markerIndex = normalizedSegments.findIndex(segment => /^(?:webhooks?|hooks?)$/.test(segment)) + if (markerIndex >= 0 && markerIndex + 1 < segments.length) { + return [...segments.slice(0, markerIndex + 1), redactedLabel].join('/') + } + + const normalizedKey = String(parentKey).toLowerCase().replace(/[^a-z0-9]/g, '') + if (normalizedKey.includes('webhook')) { + let lastSegmentIndex = -1 + for (let index = segments.length - 1; index >= 0; index -= 1) { + if (segments[index].length > 0) { + lastSegmentIndex = index + break + } + } + if (lastSegmentIndex >= 0) { + segments[lastSegmentIndex] = redactedLabel + return segments.join('/') + } + } + + return path +} + +/** + * Carry webhook capability semantics through wrapper objects such as + * `{ deliveryWebhook: { options: { target: "https://.../secret" } } }`. + * @param {string} parentKey + * @param {string} childKey + */ +function resolveUrlSemanticKey(parentKey, childKey) { + if (isWebhookSemanticKey(childKey)) { + return childKey + } + if (isWebhookSemanticKey(parentKey)) { + return childKey ? `${parentKey}.${childKey}` : parentKey + } + return childKey +} + +/** @param {string} value */ +function isWebhookSemanticKey(value) { + const normalized = String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '') + return normalized.includes('webhook') + || normalized === 'hook' + || normalized === 'hooks' + || normalized.endsWith('hook') + || normalized.endsWith('hooks') +} + +/** + * @param {string} parentKey + * @param {string} value + */ +function isSnapshotUrlValue(parentKey, value) { + const normalizedKey = String(parentKey).toLowerCase().replace(/[^a-z0-9]/g, '') + return normalizedKey.includes('url') + || normalizedKey.endsWith('uri') + || normalizedKey.includes('endpoint') + || isWebhookSemanticKey(parentKey) + || /^(?:[a-z][a-z0-9+.-]*:)*\/\//i.test(value) +} + +/** @param {unknown} value */ +function normalizeDisplayObject(value) { + if (Array.isArray(value)) { + return value.map(normalizeDisplayObject) + } + if (isPlainObject(value)) { + return Object.keys(value).sort().reduce((result, key) => { + result[key] = normalizeDisplayObject(value[key]) + return result + }, {}) + } + return value +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.test.mjs b/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.test.mjs new file mode 100644 index 00000000..a75e25dc --- /dev/null +++ b/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.test.mjs @@ -0,0 +1,183 @@ +/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + filterTtsVoicesByLanguage, + hasUsableTtsVoiceMetadata, + isSensitiveKey, + normalizeSnapshotOrderedList, + normalizeSnapshotTtsNumber, + redactSnapshotDisplayValue, + SNAPSHOT_SECRET_REDACTED, + stablePrettyStringify, + toIntlLocale, + willRestorePermanentlyDeleteChatHistory, +} from './agentSnapshotUtils.mjs' + +test('rejects empty TTS metadata while keeping language-less voices selectable without a filter', () => { + const voices = [{ id: 'voice-without-language', languages: '' }] + assert.equal(hasUsableTtsVoiceMetadata([]), false) + assert.equal(hasUsableTtsVoiceMetadata(voices), true) + assert.deepEqual(filterTtsVoicesByLanguage(voices, ''), voices) + assert.deepEqual(filterTtsVoicesByLanguage(voices, 'zh-CN'), []) +}) + +test('keeps inherited TTS values distinct from explicit zero', () => { + assert.equal(normalizeSnapshotTtsNumber(null), null) + assert.equal(normalizeSnapshotTtsNumber(''), null) + assert.equal(normalizeSnapshotTtsNumber(0), 0) + assert.equal(normalizeSnapshotTtsNumber('0'), 0) +}) + +test('warns only when restoring from a memory mode to no-memory mode', () => { + assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_mem_local_short', 'Memory_nomem'), true) + assert.equal(willRestorePermanentlyDeleteChatHistory(undefined, 'Memory_nomem'), true) + assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_nomem', 'Memory_nomem'), false) + assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_mem_local_short', 'Memory_mem0ai'), false) +}) + +test('keeps context-provider order while stabilizing object keys', () => { + const first = normalizeSnapshotOrderedList([ + { url: 'https://one.example', headers: { z: 'last', a: 'first' } }, + { url: 'https://two.example', headers: {} }, + ]) + const reversed = normalizeSnapshotOrderedList([ + { url: 'https://two.example', headers: {} }, + { headers: { a: 'first', z: 'last' }, url: 'https://one.example' }, + ]) + + assert.deepEqual(first[0], { + headers: { a: 'first', z: 'last' }, + url: 'https://one.example', + }) + assert.notDeepEqual(first, reversed) +}) + +test('redacts nested credentials, header entries, URL credentials and query values', () => { + const redacted = redactSnapshotDisplayValue({ + 'apiKey': 'api-secret', + 'Authentication': 'authentication-secret', + 'X-Auth': 'auth-secret', + 'PHPSESSID': 'php-session-secret', + 'Session-Key': 'session-key-secret', + 'headers': [ + { key: 'Authorization', value: 'Bearer secret' }, + { key: 'Proxy-Authorization', value: 'Basic secret' }, + { key: 'Cookie', value: 'sid=secret' }, + { key: 'Set-Cookie', value: 'session=secret' }, + { key: 'Content-Type', value: 'application/json' }, + ], + 'marker': SNAPSHOT_SECRET_REDACTED, + 'session_id': 'session-secret', + 'url': 'https://user:pass@example.com/context?access_token=secret#fragment', + 'endpoint': 'https://example.com/callback?signature=secret', + }, '[hidden]') + + assert.deepEqual(redacted, { + 'apiKey': '[hidden]', + 'Authentication': '[hidden]', + 'X-Auth': '[hidden]', + 'PHPSESSID': '[hidden]', + 'Session-Key': '[hidden]', + 'headers': [ + { key: 'Authorization', value: '[hidden]' }, + { key: 'Proxy-Authorization', value: '[hidden]' }, + { key: 'Cookie', value: '[hidden]' }, + { key: 'Set-Cookie', value: '[hidden]' }, + { key: 'Content-Type', value: 'application/json' }, + ], + 'marker': '[hidden]', + 'session_id': '[hidden]', + 'url': 'https://[hidden]@example.com/context?[hidden]', + 'endpoint': 'https://example.com/callback?[hidden]', + }) +}) + +test('redacts structured key/name entries without relying on property casing', () => { + const entries = [ + { KEY: 'Authentication', VALUE: 'key secret' }, + { Name: 'X-Auth', Value: 'name secret' }, + { key: 'public-label', NAME: 'Authorization', value: 'secret selected from either marker' }, + { NAME: 'Content-Type', VALUE: 'application/json; charset=utf-8' }, + ] + + assert.deepEqual(redactSnapshotDisplayValue(entries, '[hidden]'), [ + { KEY: 'Authentication', VALUE: '[hidden]' }, + { Name: 'X-Auth', Value: '[hidden]' }, + { key: 'public-label', NAME: 'Authorization', value: '[hidden]' }, + { NAME: 'Content-Type', VALUE: 'application/json; charset=utf-8' }, + ]) +}) + +test('redacts capability path values without hiding ordinary REST paths', () => { + const redacted = redactSnapshotDisplayValue({ + slack: 'https://hooks.slack.com/services/T111/B222/capability-value', + slackGov: 'https://hooks.slack-gov.com/services/T111/B222/gov-capability-value', + discord: 'https://discord.com/api/webhooks/123456/capability-value', + telegram: 'https://api.telegram.org/bot123456:capability-value/sendMessage', + genericHook: 'https://example.com/api/hook/capability/continuation', + genericHooks: 'https://example.com/api/hooks/capability/continuation', + genericWebhook: 'https://example.com/api/webhook/capability/continuation', + genericWebhooks: 'https://example.com/api/webhooks/capability/continuation', + relativeUrl: '/api/webhooks/capability/continuation', + callbackWebhookUrl: 'https://example.com/incoming/capability-value', + nested: { + deliveryWebhook: { + options: { + target: 'https://example.com/incoming/nested-capability-value', + relativeTarget: '/incoming/nested-relative-capability-value', + }, + }, + }, + protocolRelativeUrl: '//protocol-user:protocol-pass@example.com/context', + jdbcUrl: 'jdbc:mysql://jdbc-user:jdbc-pass@example.com/database', + ordinary: 'https://example.com/api/v1/agents/42', + }, '[hidden]') + + assert.deepEqual(redacted, { + slack: 'https://hooks.slack.com/services/T111/B222/[hidden]', + slackGov: 'https://hooks.slack-gov.com/services/T111/B222/[hidden]', + discord: 'https://discord.com/api/webhooks/123456/[hidden]', + telegram: 'https://api.telegram.org/bot123456:[hidden]/sendMessage', + genericHook: 'https://example.com/api/hook/[hidden]', + genericHooks: 'https://example.com/api/hooks/[hidden]', + genericWebhook: 'https://example.com/api/webhook/[hidden]', + genericWebhooks: 'https://example.com/api/webhooks/[hidden]', + relativeUrl: '/api/webhooks/[hidden]', + callbackWebhookUrl: 'https://example.com/incoming/[hidden]', + nested: { + deliveryWebhook: { + options: { + target: 'https://example.com/incoming/[hidden]', + relativeTarget: '/incoming/[hidden]', + }, + }, + }, + protocolRelativeUrl: '//[hidden]@example.com/context', + jdbcUrl: 'jdbc:mysql://[hidden]@example.com/database', + ordinary: 'https://example.com/api/v1/agents/42', + }) +}) + +test('matches the backend sensitive key variants', () => { + for (const key of [ + 'Authentication', + 'X-Auth', + 'Proxy-Authorization', + 'Cookie', + 'Set-Cookie', + 'PHPSESSID', + 'Session-Key', + 'session_token', + 'Ocp-Apim-Subscription-Key', + ]) { + assert.equal(isSensitiveKey(key), true, `${key} should be redacted`) + } + assert.equal(isSensitiveKey('Content-Type'), false) + assert.equal(isSensitiveKey('publicKey'), false) +}) + +test('formats objects stably and maps app locale names to Intl locales', () => { + assert.equal(stablePrettyStringify({ z: 1, a: { d: 2, c: 1 } }), '{\n "a": {\n "c": 1,\n "d": 2\n },\n "z": 1\n}') + assert.equal(toIntlLocale('pt_BR'), 'pt-BR') +}) diff --git a/main/manager-mobile/src/pages/agent/edit.vue b/main/manager-mobile/src/pages/agent/edit.vue index 6d77256c..e3cf4e91 100644 --- a/main/manager-mobile/src/pages/agent/edit.vue +++ b/main/manager-mobile/src/pages/agent/edit.vue @@ -6,6 +6,7 @@ import { t } from '@/i18n' import { usePluginStore, useProvider, useSpeedPitch } from '@/store' import { toast } from '@/utils/toast' import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue' +import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs' defineOptions({ name: 'AgentEdit', @@ -67,6 +68,8 @@ const loading = ref(false) const saving = ref(false) const showSnapshotPanel = ref(false) const currentVersionNo = ref(null) +const snapshotReloadBlocked = ref(false) +const snapshotReloadFailed = ref(false) // 模型选项数据 const modelOptions = ref<{ @@ -120,8 +123,18 @@ const selectedTtsLanguage = ref('') const ttsLanguageTouched = ref(false) const ttsVoiceTouched = ref(false) const ttsOptionsLoading = ref(false) +const ttsOptionsModelId = ref('') const originalTagNames = ref([]) +const originalAgentDetail = ref(null) let ttsOptionsRequestSequence = 0 +let agentDetailRequestSequence = 0 +let agentTagRequestSequence = 0 +let snapshotReloadSequence = 0 + +interface SnapshotRestoreContext { + agentId: string + actionSequence: number +} // 音频播放相关 const audioRef = ref(null) @@ -132,6 +145,91 @@ const pluginStore = usePluginStore() const speedPitchStore = useSpeedPitch() const providerStore = useProvider() +const EDITABLE_AGENT_FIELDS: Array = [ + 'agentName', + 'systemPrompt', + 'summaryMemory', + 'vadModelId', + 'asrModelId', + 'llmModelId', + 'slmModelId', + 'vllmModelId', + 'intentModelId', + 'memModelId', + 'ttsModelId', + 'chatHistoryConf', + 'langCode', + 'language', + 'sort', +] + +const hasUnsavedChanges = computed(() => { + if (loading.value || !originalAgentDetail.value) { + return false + } + return stableSerialize(buildCurrentEditableState()) !== stableSerialize(buildOriginalEditableState()) +}) + +function buildCurrentEditableState() { + const original = originalAgentDetail.value as AgentDetail + const current = formData.value as Record + const changedTtsFields = new Set(speedPitchStore.changedFields) + return { + ...pickEditableFields(current), + ttsLanguage: ttsLanguageTouched.value ? selectedTtsLanguage.value : original.ttsLanguage, + ttsVoiceId: ttsVoiceTouched.value ? current.ttsVoiceId : original.ttsVoiceId, + ttsVolume: changedTtsFields.has('ttsVolume') ? speedPitchStore.speedPitch.ttsVolume : original.ttsVolume, + ttsRate: changedTtsFields.has('ttsRate') ? speedPitchStore.speedPitch.ttsRate : original.ttsRate, + ttsPitch: changedTtsFields.has('ttsPitch') ? speedPitchStore.speedPitch.ttsPitch : original.ttsPitch, + functions: normalizeAgentFunctions(current.functions || []), + contextProviders: providerStore.providers, + tagNames: dynamicTags.value.map((tag: any) => tag.tagName).filter(Boolean).sort(), + } +} + +function buildOriginalEditableState() { + const original = originalAgentDetail.value as AgentDetail + return { + ...pickEditableFields(original as unknown as Record), + ttsLanguage: original.ttsLanguage, + ttsVoiceId: original.ttsVoiceId, + ttsVolume: original.ttsVolume, + ttsRate: original.ttsRate, + ttsPitch: original.ttsPitch, + functions: normalizeAgentFunctions(original.functions || []), + contextProviders: original.contextProviders || [], + tagNames: [...originalTagNames.value].sort(), + } +} + +function pickEditableFields(data: Record) { + return EDITABLE_AGENT_FIELDS.reduce>((result, field) => { + result[field] = data[field] + return result + }, {}) +} + +function stableSerialize(value: any) { + return JSON.stringify(sortObjectKeys(value)) +} + +function sortObjectKeys(value: any): any { + if (Array.isArray(value)) { + return value.map(sortObjectKeys) + } + if (value && typeof value === 'object') { + return Object.keys(value).sort().reduce>((result, key) => { + result[key] = sortObjectKeys(value[key]) + return result + }, {}) + } + return value +} + +function cloneSerializable(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + // tabs const tabList = [ { @@ -214,38 +312,63 @@ function handleRegulate() { } // 加载智能体详情 -async function loadAgentDetail() { - if (!agentId.value) - return +async function loadAgentDetail(targetAgentId = agentId.value) { + if (!targetAgentId) + return false + const requestId = ++agentDetailRequestSequence + invalidateTtsMetadataRequest() try { loading.value = true - tempSummaryMemory.value = '' - ttsLanguageTouched.value = false - ttsVoiceTouched.value = false - ttsOptionsRequestSequence += 1 - ttsOptionsLoading.value = false - const detail = await getAgentDetail(agentId.value) - const normalizedFunctions = normalizeAgentFunctions(detail.functions || []) - formData.value = { ...detail, functions: normalizedFunctions } - currentVersionNo.value = detail.currentVersionNo || null + const detail = await getAgentDetail(targetAgentId) + if (!isActiveAgentDetailRequest(targetAgentId, requestId)) { + return false + } + applyPersistedAgentDetail(detail, targetAgentId) + await enhanceAgentDetailMetadata(detail, targetAgentId, requestId) + return isActiveAgentDetailRequest(targetAgentId, requestId) + } + catch (error) { + if (isActiveAgentDetailRequest(targetAgentId, requestId)) { + console.error('加载智能体详情失败:', error) + toast.error(t('agent.loadFail')) + } + return false + } + finally { + if (isActiveAgentDetailRequest(targetAgentId, requestId)) { + loading.value = false + } + } +} - // 更新插件store - pluginStore.setCurrentAgentId(agentId.value) - pluginStore.setCurrentFunctions(normalizedFunctions) +function applyPersistedAgentDetail(detail: AgentDetail, targetAgentId: string) { + const normalizedFunctions = normalizeAgentFunctions(detail.functions || []) + tempSummaryMemory.value = '' + ttsLanguageTouched.value = false + ttsVoiceTouched.value = false + ttsOptionsModelId.value = '' + voiceOptions.value = [] + voiceDetails.value = {} + languageOptions.value = [] + formData.value = { ...detail, functions: normalizedFunctions } + originalAgentDetail.value = cloneSerializable({ ...detail, functions: normalizedFunctions }) + currentVersionNo.value = detail.currentVersionNo || null + selectedTtsLanguage.value = detail.ttsLanguage || '' - // 更新语速音调 - speedPitchStore.updateSpeedPitch({ - ttsVolume: detail.ttsVolume ?? 0, - ttsRate: detail.ttsRate ?? 0, - ttsPitch: detail.ttsPitch ?? 0, - }) - speedPitchStore.resetChangedFields() + pluginStore.setCurrentAgentId(targetAgentId) + pluginStore.setCurrentFunctions(normalizedFunctions) + speedPitchStore.updateSpeedPitch({ + ttsVolume: detail.ttsVolume ?? 0, + ttsRate: detail.ttsRate ?? 0, + ttsPitch: detail.ttsPitch ?? 0, + }) + speedPitchStore.resetChangedFields() + providerStore.updateProviders(detail.contextProviders || []) +} - // 加载上下文配置 - providerStore.updateProviders(detail.contextProviders || []) - - // 如果有TTS模型,加载对应的音色选项 +async function enhanceAgentDetailMetadata(detail: AgentDetail, targetAgentId: string, requestId: number) { + try { if (detail.ttsModelId) { await fetchAllLanguag(detail.ttsModelId, { preferredLanguage: detail.ttsLanguage, @@ -258,20 +381,28 @@ async function loadAgentDetail() { languageOptions.value = [] selectedTtsLanguage.value = '' } - - // 等待模型选项加载完成后再更新显示名称 await nextTick() - updateDisplayNames() + if (isActiveAgentDetailRequest(targetAgentId, requestId)) { + updateDisplayNames() + } } catch (error) { - console.error('加载智能体详情失败:', error) - toast.error(t('agent.loadFail')) - } - finally { - loading.value = false + // Persisted agent detail has already loaded successfully. Voice metadata is + // display enhancement only and must not keep the post-restore save barrier. + console.warn('Failed to enhance agent detail metadata:', error) } } +function isActiveAgentDetailRequest(targetAgentId: string, requestId: number) { + return targetAgentId === agentId.value && requestId === agentDetailRequestSequence +} + +function invalidateTtsMetadataRequest() { + ttsOptionsRequestSequence += 1 + ttsOptionsLoading.value = false + ttsOptionsModelId.value = '' +} + // 获取音色显示名称 function getVoiceDisplayName(ttsVoiceId: string) { if (!ttsVoiceId) @@ -374,6 +505,60 @@ interface VoiceSelectionOptions { preferredVoiceId?: string | null } +interface TtsSelectionState { + modelId: string + voiceId: string + language: string + selectedLanguage: string + languageTouched: boolean + voiceTouched: boolean + optionsModelId: string + voiceOptions: any[] + voiceDetails: Record + languageOptions: any[] + displayNames: { + tts: string + voiceprint: string + language: string + } +} + +function captureTtsSelectionState(): TtsSelectionState { + return { + modelId: formData.value.ttsModelId || '', + voiceId: formData.value.ttsVoiceId || '', + language: formData.value.ttsLanguage || '', + selectedLanguage: selectedTtsLanguage.value, + languageTouched: ttsLanguageTouched.value, + voiceTouched: ttsVoiceTouched.value, + optionsModelId: ttsOptionsModelId.value, + voiceOptions: voiceOptions.value, + voiceDetails: voiceDetails.value, + languageOptions: languageOptions.value, + displayNames: { + tts: displayNames.value.tts, + voiceprint: displayNames.value.voiceprint, + language: displayNames.value.language, + }, + } +} + +function restoreTtsSelectionState(state: TtsSelectionState) { + formData.value.ttsModelId = state.modelId + formData.value.ttsVoiceId = state.voiceId + formData.value.ttsLanguage = state.language + selectedTtsLanguage.value = state.selectedLanguage + ttsLanguageTouched.value = state.languageTouched + ttsVoiceTouched.value = state.voiceTouched + ttsOptionsModelId.value = state.optionsModelId + voiceOptions.value = state.voiceOptions + voiceDetails.value = state.voiceDetails + languageOptions.value = state.languageOptions + displayNames.value.tts = state.displayNames.tts + displayNames.value.voiceprint = state.displayNames.voiceprint + displayNames.value.language = state.displayNames.language +} + function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) { if (!voiceDetails.value || Object.keys(voiceDetails.value).length === 0) { voiceOptions.value = [] @@ -383,13 +568,7 @@ function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) { const allVoices = Object.values(voiceDetails.value) as any[] // 根据选中的语言筛选音色 - const filteredVoices = allVoices.filter((voice) => { - if (!voice.languages) { - return false - } - const languagesArray = voice.languages.split(/[、;;,,]/).map(lang => lang.trim()).filter(lang => lang) - return languagesArray.includes(selectedTtsLanguage.value) - }) + const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value) voiceOptions.value = filteredVoices.map(voice => ({ value: voice.id, @@ -427,13 +606,22 @@ function getVoiceDefaultLanguage(ttsVoiceId: string) { } // 根据语音合成模型加载语言 -async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOptions = {}) { +async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOptions = {}): Promise<'loaded' | 'failed' | 'stale'> { const requestId = ++ttsOptionsRequestSequence ttsOptionsLoading.value = true try { const res = await getAllLanguage(ttsModelId) if (requestId !== ttsOptionsRequestSequence) { - return + return 'stale' + } + if (!Array.isArray(res)) { + throw new TypeError('Invalid TTS voice metadata') + } + // An empty response cannot prove that the newly selected model accepts an + // empty voice. Until the API exposes an explicit "voice optional" + // capability, keep the previous tuple instead of persisting a guess. + if (!hasUsableTtsVoiceMetadata(res)) { + throw new Error('No TTS voice metadata is available') } // 保存完整的音色信息 voiceDetails.value = res.reduce((acc, voice) => { @@ -457,6 +645,10 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption const preferredVoiceLanguage = options.preferredVoiceId ? getVoiceDefaultLanguage(options.preferredVoiceId) : '' + // Do not carry a language from the previous model into a provider which + // exposes no language dimension. + selectedTtsLanguage.value = '' + displayNames.value.language = '' // 优先使用调用方指定的语言或音色默认语言,再回退到智能体当前配置 if (requestedLanguage && languageOptions.value.some(option => option.value === requestedLanguage)) { selectedTtsLanguage.value = requestedLanguage @@ -481,13 +673,15 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption // 根据选中的语言筛选音色 filterVoicesByLanguage(options) + ttsOptionsModelId.value = ttsModelId + return 'loaded' } - catch { + catch (error) { if (requestId === ttsOptionsRequestSequence) { - voiceOptions.value = [] - voiceDetails.value = {} - languageOptions.value = [] + console.error('Failed to load TTS options:', error) + ttsOptionsModelId.value = '' } + return requestId === ttsOptionsRequestSequence ? 'failed' : 'stale' } finally { if (requestId === ttsOptionsRequestSequence) { @@ -514,7 +708,10 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption // } // 选择角色模板 -function selectRoleTemplate(templateId: string) { +async function selectRoleTemplate(templateId: string) { + if (ttsOptionsLoading.value) { + return + } if (selectedTemplateId.value === templateId) { selectedTemplateId.value = '' return @@ -523,6 +720,7 @@ function selectRoleTemplate(templateId: string) { selectedTemplateId.value = templateId const template = roleTemplates.value.find(t => t.id === templateId) if (template) { + const previousTtsState = captureTtsSelectionState() const templateTtsLanguage = template.ttsLanguage?.trim() || '' const hasTemplateTtsLanguage = Boolean(templateTtsLanguage) formData.value = { @@ -548,14 +746,20 @@ function selectRoleTemplate(templateId: string) { displayNames.value.language = templateTtsLanguage } if (template.ttsModelId || template.ttsVoiceId || hasTemplateTtsLanguage) { - ttsLanguageTouched.value = true - ttsVoiceTouched.value = true + const result = await fetchAllLanguag(template.ttsModelId || formData.value.ttsModelId, { + autoSelectVoice: true, + preferredLanguage: hasTemplateTtsLanguage ? templateTtsLanguage : '', + preferredVoiceId: template.ttsVoiceId, + }) + if (result === 'failed') { + restoreTtsSelectionState(previousTtsState) + toast.warning(t('agent.ttsOptionsLoadFailed')) + } + else if (result === 'loaded') { + ttsLanguageTouched.value = true + ttsVoiceTouched.value = true + } } - fetchAllLanguag(template.ttsModelId || formData.value.ttsModelId, { - autoSelectVoice: true, - preferredLanguage: hasTemplateTtsLanguage ? templateTtsLanguage : '', - preferredVoiceId: template.ttsVoiceId, - }) updateDisplayNames() } } @@ -572,6 +776,7 @@ function openPicker(type: string) { async function onPickerConfirm(type: string, value: any, name: string) { console.log('选择器确认:', type, value, name) + const previousTtsState = type === 'tts' ? captureTtsSelectionState() : null // 保存显示名称 displayNames.value[type] = name @@ -613,10 +818,16 @@ async function onPickerConfirm(type: string, value: any, name: string) { case 'tts': { const preferredLanguage = selectedTtsLanguage.value formData.value.ttsModelId = value - ttsLanguageTouched.value = true - ttsVoiceTouched.value = true formData.value.ttsVoiceId = '' - await fetchAllLanguag(value, { autoSelectVoice: true, preferredLanguage }) + const result = await fetchAllLanguag(value, { autoSelectVoice: true, preferredLanguage }) + if (result === 'failed' && previousTtsState) { + restoreTtsSelectionState(previousTtsState) + toast.warning(t('agent.ttsOptionsLoadFailed')) + } + else if (result === 'loaded') { + ttsLanguageTouched.value = true + ttsVoiceTouched.value = true + } break } case 'language': @@ -719,9 +930,28 @@ function getModelDisplayName(modelType: string, modelId: string) { // 保存智能体 async function saveAgent() { + if (saving.value) { + return + } + if (snapshotReloadBlocked.value) { + toast.error(t(snapshotReloadFailed.value + ? 'agentSnapshot.reloadAfterRestoreFailed' + : 'agentSnapshot.reloadAfterRestorePending')) + return + } if (ttsOptionsLoading.value) { return } + const ttsSelectionTouched = ttsLanguageTouched.value || ttsVoiceTouched.value + const hasLanguageOptions = languageOptions.value.length > 0 + const hasVoiceOptions = Object.keys(voiceDetails.value).length > 0 + if (ttsSelectionTouched + && (ttsOptionsModelId.value !== formData.value.ttsModelId + || (hasLanguageOptions && !selectedTtsLanguage.value) + || (hasVoiceOptions && !formData.value.ttsVoiceId))) { + toast.warning(t('agent.ttsOptionsLoadFailed')) + return + } if (!formData.value.agentName?.trim()) { toast.warning(t('agent.pleaseInputAgentName')) return @@ -815,20 +1045,116 @@ function handleTools() { } // 获取智能体标签 -async function loadAgentTags() { +async function loadAgentTags(targetAgentId = agentId.value) { + if (!targetAgentId) { + return false + } + const requestId = ++agentTagRequestSequence try { - const res = await getAgentTags(agentId.value) + const res = await getAgentTags(targetAgentId) + if (!isActiveAgentTagRequest(targetAgentId, requestId)) { + return false + } dynamicTags.value = res || [] originalTagNames.value = dynamicTags.value.map(tag => tag.tagName) + return true + } + catch (error) { + if (isActiveAgentTagRequest(targetAgentId, requestId)) { + console.error('加载智能体标签失败:', error) + } + return false } - catch (error) {} } -async function handleSnapshotRestored() { - await Promise.all([ - loadAgentDetail(), - loadAgentTags(), - ]) +function isActiveAgentTagRequest(targetAgentId: string, requestId: number) { + return targetAgentId === agentId.value && requestId === agentTagRequestSequence +} + +async function handleSnapshotRestored(context: SnapshotRestoreContext) { + if (!context || context.agentId !== agentId.value) { + return + } + await reloadAgentAfterSnapshotRestore(context.agentId) +} + +async function reloadAgentAfterSnapshotRestore(targetAgentId = agentId.value) { + if (!targetAgentId || targetAgentId !== agentId.value) { + return false + } + const reloadId = ++snapshotReloadSequence + const detailRequestId = ++agentDetailRequestSequence + const tagRequestId = ++agentTagRequestSequence + snapshotReloadBlocked.value = true + snapshotReloadFailed.value = false + loading.value = true + invalidateTtsMetadataRequest() + + try { + // Apply detail and tags only after both persisted reads succeed. If either + // fails, the old form may remain visible but cannot be saved until retry. + const [detail, tags] = await Promise.all([ + getAgentDetail(targetAgentId), + getAgentTags(targetAgentId), + ]) + if (!isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) { + return false + } + + applyPersistedAgentDetail(detail, targetAgentId) + dynamicTags.value = tags || [] + originalTagNames.value = dynamicTags.value.map(tag => tag.tagName) + + // Voice metadata is an optional display enhancement. Its own failure must + // not turn a successful persisted detail+tag reload into a blocked form. + await enhanceAgentDetailMetadata(detail, targetAgentId, detailRequestId) + if (!isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) { + return false + } + snapshotReloadBlocked.value = false + snapshotReloadFailed.value = false + return true + } + catch (error) { + if (isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) { + console.error('恢复后重新加载智能体失败:', error) + snapshotReloadFailed.value = true + toast.error(t('agentSnapshot.reloadAfterRestoreFailed')) + } + return false + } + finally { + if (isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) { + loading.value = false + } + } +} + +function isActiveSnapshotReload( + targetAgentId: string, + reloadId: number, + detailRequestId: number, + tagRequestId: number, +) { + return targetAgentId === agentId.value + && reloadId === snapshotReloadSequence + && detailRequestId === agentDetailRequestSequence + && tagRequestId === agentTagRequestSequence +} + +function retrySnapshotReload() { + if (!snapshotReloadFailed.value || !agentId.value) { + return + } + void reloadAgentAfterSnapshotRestore(agentId.value) +} + +function openSnapshotPanel() { + if (saving.value || snapshotReloadBlocked.value) { + toast.warning(t('agentSnapshot.mutationBusy')) + return + } + showSnapshotPanel.value = true } function isSameStringList(left: string[], right: string[]) { @@ -843,6 +1169,24 @@ watch(() => pluginStore.currentFunctions, (newFunctions) => { formData.value.functions = normalizeAgentFunctions(newFunctions || []) }, { deep: true }) +watch(agentId, (currentAgentId, previousAgentId) => { + if (currentAgentId === previousAgentId) { + return + } + agentDetailRequestSequence += 1 + agentTagRequestSequence += 1 + snapshotReloadSequence += 1 + invalidateTtsMetadataRequest() + loading.value = false + snapshotReloadFailed.value = false + snapshotReloadBlocked.value = Boolean(currentAgentId) + originalAgentDetail.value = null + originalTagNames.value = [] + if (currentAgentId) { + void reloadAgentAfterSnapshotRestore(currentAgentId) + } +}, { flush: 'sync' }) + onMounted(async () => { loadAgentTags() @@ -854,7 +1198,7 @@ onMounted(async () => { ]) // 然后加载智能体详情,这样可以正确映射显示名称 - if (agentId.value) { + if (agentId.value && !snapshotReloadBlocked.value) { await loadAgentDetail() } }) @@ -874,8 +1218,9 @@ onMounted(async () => { {{ t('agentSnapshot.title') }} @@ -1152,10 +1497,27 @@ onMounted(async () => { + + + {{ t(snapshotReloadFailed ? 'agentSnapshot.reloadAfterRestoreFailed' : 'agentSnapshot.reloadAfterRestorePending') }} + + + {{ t('agentSnapshot.retryReload') }} + + @@ -1260,6 +1622,8 @@ onMounted(async () => { v-model:visible="showSnapshotPanel" :agent-id="agentId" :current-version-no="currentVersionNo" + :has-unsaved-changes="hasUnsavedChanges" + :mutation-busy="saving || snapshotReloadBlocked" @restored="handleSnapshotRestored" /> diff --git a/main/manager-mobile/src/wot-design-uni.d.ts b/main/manager-mobile/src/wot-design-uni.d.ts index dc9438f2..c2c00726 100644 --- a/main/manager-mobile/src/wot-design-uni.d.ts +++ b/main/manager-mobile/src/wot-design-uni.d.ts @@ -1,30 +1,56 @@ -import type { Component } from 'vue' +import type { ExtractPropTypes } from 'vue' +import type { actionSheetProps } from 'wot-design-uni/components/wd-action-sheet/types' +import type { buttonProps } from 'wot-design-uni/components/wd-button/types' +import type { checkboxProps } from 'wot-design-uni/components/wd-checkbox/types' +import type { configProviderProps } from 'wot-design-uni/components/wd-config-provider/types' +import type { fabProps } from 'wot-design-uni/components/wd-fab/types' +import type { iconProps } from 'wot-design-uni/components/wd-icon/types' +import type { imgProps } from 'wot-design-uni/components/wd-img/types' +import type { inputProps } from 'wot-design-uni/components/wd-input/types' +import type { loadingProps } from 'wot-design-uni/components/wd-loading/types' +import type { messageBoxProps } from 'wot-design-uni/components/wd-message-box/types' +import type { navbarProps } from 'wot-design-uni/components/wd-navbar/types' +import type { pickerProps } from 'wot-design-uni/components/wd-picker/types' +import type { popupProps } from 'wot-design-uni/components/wd-popup/types' +import type { segmentedProps } from 'wot-design-uni/components/wd-segmented/types' +import type { sliderProps } from 'wot-design-uni/components/wd-slider/types' +import type { statusTipProps } from 'wot-design-uni/components/wd-status-tip/types' +import type { swipeActionProps } from 'wot-design-uni/components/wd-swipe-action/types' +import type { switchProps } from 'wot-design-uni/components/wd-switch/types' +import type { tabbarItemProps } from 'wot-design-uni/components/wd-tabbar-item/types' +import type { tabbarProps } from 'wot-design-uni/components/wd-tabbar/types' +import type { tagProps } from 'wot-design-uni/components/wd-tag/types' +import type { toastProps } from 'wot-design-uni/components/wd-toast/types' + +type TypedGlobalComponent = new () => { $props: Partial } // wot-design-uni 1.9.1 exposes raw Vue source through its global declarations. -// Keep the components used by this app recognizable without pulling that source into vue-tsc. +// Rebuild lightweight global components from the published prop objects without +// pulling the package's raw Vue source into vue-tsc. Partial preserves Vue's +// runtime defaults while retaining prop names and value types. declare module 'vue' { export interface GlobalComponents { - WdActionSheet: Component - WdButton: Component - WdCheckbox: Component - WdConfigProvider: Component - WdFab: Component - WdIcon: Component - WdImg: Component - WdInput: Component - WdLoading: Component - WdMessageBox: Component - WdNavbar: Component - WdPicker: Component - WdPopup: Component - WdSegmented: Component - WdSlider: Component - WdStatusTip: Component - WdSwipeAction: Component - WdSwitch: Component - WdTabbar: Component - WdTabbarItem: Component - WdTag: Component - WdToast: Component + WdActionSheet: TypedGlobalComponent> + WdButton: TypedGlobalComponent> + WdCheckbox: TypedGlobalComponent> + WdConfigProvider: TypedGlobalComponent> + WdFab: TypedGlobalComponent> + WdIcon: TypedGlobalComponent> + WdImg: TypedGlobalComponent> + WdInput: TypedGlobalComponent> + WdLoading: TypedGlobalComponent> + WdMessageBox: TypedGlobalComponent> + WdNavbar: TypedGlobalComponent> + WdPicker: TypedGlobalComponent> + WdPopup: TypedGlobalComponent> + WdSegmented: TypedGlobalComponent> + WdSlider: TypedGlobalComponent> + WdStatusTip: TypedGlobalComponent> + WdSwipeAction: TypedGlobalComponent> + WdSwitch: TypedGlobalComponent> + WdTabbar: TypedGlobalComponent> + WdTabbarItem: TypedGlobalComponent> + WdTag: TypedGlobalComponent> + WdToast: TypedGlobalComponent> } } diff --git a/main/manager-mobile/tsconfig.json b/main/manager-mobile/tsconfig.json index 39750568..2de63b96 100644 --- a/main/manager-mobile/tsconfig.json +++ b/main/manager-mobile/tsconfig.json @@ -30,6 +30,7 @@ "include": [ "src/**/*.ts", "src/**/*.js", + "src/**/*.mjs", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.jsx", diff --git a/main/manager-web/package.json b/main/manager-web/package.json index 064d27d2..0dce6f99 100644 --- a/main/manager-web/package.json +++ b/main/manager-web/package.json @@ -5,6 +5,7 @@ "scripts": { "serve": "vue-cli-service serve", "check:i18n": "node scripts/check-i18n.js", + "test:snapshot": "node --test src/components/agentSnapshotDisplayUtils.test.mjs src/apis/module/agentSnapshotApi.test.mjs", "build": "vue-cli-service build", "analyze": "cross-env ANALYZE=true vue-cli-service build" }, diff --git a/main/manager-web/src/apis/module/agent.js b/main/manager-web/src/apis/module/agent.js index 3c9522db..ae397924 100644 --- a/main/manager-web/src/apis/module/agent.js +++ b/main/manager-web/src/apis/module/agent.js @@ -1,6 +1,41 @@ import { getServiceUrl } from '../api'; import RequestService from '../httpRequest'; +const CALLBACK_RETRY_LIMIT = 10; +const CALLBACK_RETRY_DELAY_MS = 2000; +const CALLBACK_RETRY_WINDOW_MS = CALLBACK_RETRY_LIMIT * CALLBACK_RETRY_DELAY_MS; + +function retryCallbackRequest(retry, retryCount, onTerminalFailure, error, retryStartedAt) { + if (!onTerminalFailure) { + RequestService.reAjaxFun(() => retry(retryCount + 1)) + return + } + const startedAt = retryStartedAt || Date.now() + if (retryCount >= CALLBACK_RETRY_LIMIT || Date.now() - startedAt >= CALLBACK_RETRY_WINDOW_MS) { + RequestService.clearRequestTime() + onTerminalFailure(error) + return + } + setTimeout(() => retry(retryCount + 1, startedAt), CALLBACK_RETRY_DELAY_MS) +} + +function attachTerminalFailure(request, onTerminalFailure) { + if (onTerminalFailure) { + request.fail((error) => { + RequestService.clearRequestTime() + onTerminalFailure(error) + }) + } + return request +} + +function terminateCallbackRequest(onTerminalFailure, error) { + RequestService.clearRequestTime() + if (onTerminalFailure) { + onTerminalFailure(error) + } +} + export default { // 获取智能体列表 @@ -50,8 +85,9 @@ export default { }).send(); }, // 获取智能体配置 - getDeviceConfig(agentId, callback) { - RequestService.sendRequest() + getDeviceConfig(agentId, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) { + const retryWindowStartedAt = retryStartedAt || Date.now() + const request = RequestService.sendRequest() .url(`${getServiceUrl()}/agent/${agentId}`) .method('GET') .success((res) => { @@ -60,10 +96,21 @@ export default { }) .networkFail((err) => { console.error('获取配置失败:', err); - RequestService.reAjaxFun(() => { - this.getDeviceConfig(agentId, callback); - }); - }).send(); + retryCallbackRequest( + (nextRetryCount, nextRetryStartedAt) => this.getDeviceConfig( + agentId, + callback, + onTerminalFailure, + nextRetryCount, + nextRetryStartedAt + ), + retryCount, + onTerminalFailure, + err, + retryWindowStartedAt + ) + }) + attachTerminalFailure(request, onTerminalFailure).send(); }, // 配置智能体 updateAgentConfig(agentId, configData, callback) { @@ -82,8 +129,9 @@ export default { }).send(); }, // 获取智能体配置快照列表 - getAgentSnapshots(agentId, params, callback) { - RequestService.sendRequest() + getAgentSnapshots(agentId, params, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) { + const retryWindowStartedAt = retryStartedAt || Date.now() + const request = RequestService.sendRequest() .url(`${getServiceUrl()}/agent/${agentId}/snapshots`) .method('GET') .data(params) @@ -91,56 +139,80 @@ export default { RequestService.clearRequestTime(); callback(res); }) - .networkFail(() => { - RequestService.reAjaxFun(() => { - this.getAgentSnapshots(agentId, params, callback); - }); - }).send(); + .networkFail((error) => { + retryCallbackRequest( + (nextRetryCount, nextRetryStartedAt) => this.getAgentSnapshots( + agentId, + params, + callback, + onTerminalFailure, + nextRetryCount, + nextRetryStartedAt + ), + retryCount, + onTerminalFailure, + error, + retryWindowStartedAt + ) + }) + attachTerminalFailure(request, onTerminalFailure).send(); }, // 获取智能体配置快照详情 - getAgentSnapshot(agentId, snapshotId, callback) { - RequestService.sendRequest() + getAgentSnapshot(agentId, snapshotId, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) { + const retryWindowStartedAt = retryStartedAt || Date.now() + const request = RequestService.sendRequest() .url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}`) .method('GET') .success((res) => { RequestService.clearRequestTime(); callback(res); }) - .networkFail(() => { - RequestService.reAjaxFun(() => { - this.getAgentSnapshot(agentId, snapshotId, callback); - }); - }).send(); + .networkFail((error) => { + retryCallbackRequest( + (nextRetryCount, nextRetryStartedAt) => this.getAgentSnapshot( + agentId, + snapshotId, + callback, + onTerminalFailure, + nextRetryCount, + nextRetryStartedAt + ), + retryCount, + onTerminalFailure, + error, + retryWindowStartedAt + ) + }) + attachTerminalFailure(request, onTerminalFailure).send(); }, // 恢复智能体配置快照 - restoreAgentSnapshot(agentId, snapshotId, callback) { - RequestService.sendRequest() + restoreAgentSnapshot(agentId, snapshotId, currentStateToken, callback, onTerminalFailure) { + const request = RequestService.sendRequest() .url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}/restore`) .method('POST') + .data({ currentStateToken }) .success((res) => { RequestService.clearRequestTime(); callback(res); }) - .networkFail(() => { - RequestService.reAjaxFun(() => { - this.restoreAgentSnapshot(agentId, snapshotId, callback); - }); - }).send(); + .networkFail((error) => { + terminateCallbackRequest(onTerminalFailure, error) + }) + attachTerminalFailure(request, onTerminalFailure).send(); }, // 删除智能体配置快照 - deleteAgentSnapshot(agentId, snapshotId, callback) { - RequestService.sendRequest() + deleteAgentSnapshot(agentId, snapshotId, callback, onTerminalFailure) { + const request = RequestService.sendRequest() .url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}`) .method('DELETE') .success((res) => { RequestService.clearRequestTime(); callback(res); }) - .networkFail(() => { - RequestService.reAjaxFun(() => { - this.deleteAgentSnapshot(agentId, snapshotId, callback); - }); - }).send(); + .networkFail((error) => { + terminateCallbackRequest(onTerminalFailure, error) + }) + attachTerminalFailure(request, onTerminalFailure).send(); }, // 新增方法:获取智能体模板 getAgentTemplate(callback) { // 移除templateName参数 @@ -463,19 +535,31 @@ export default { }).send(); }, // 获取智能体标签 - getAgentTags(agentId, callback) { - RequestService.sendRequest() + getAgentTags(agentId, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) { + const retryWindowStartedAt = retryStartedAt || Date.now() + const request = RequestService.sendRequest() .url(`${getServiceUrl()}/agent/${agentId}/tags`) .method('GET') .success((res) => { RequestService.clearRequestTime(); callback(res); }) - .networkFail(() => { - RequestService.reAjaxFun(() => { - this.getAgentTags(agentId, callback); - }); - }).send(); + .networkFail((error) => { + retryCallbackRequest( + (nextRetryCount, nextRetryStartedAt) => this.getAgentTags( + agentId, + callback, + onTerminalFailure, + nextRetryCount, + nextRetryStartedAt + ), + retryCount, + onTerminalFailure, + error, + retryWindowStartedAt + ) + }) + attachTerminalFailure(request, onTerminalFailure).send(); }, // 保存智能体标签 saveAgentTags(agentId, tags, callback) { diff --git a/main/manager-web/src/apis/module/agentSnapshotApi.test.mjs b/main/manager-web/src/apis/module/agentSnapshotApi.test.mjs new file mode 100644 index 00000000..5103a796 --- /dev/null +++ b/main/manager-web/src/apis/module/agentSnapshotApi.test.mjs @@ -0,0 +1,123 @@ +/* eslint-disable test/no-import-node-test -- zero-dependency API regression gate */ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const agentApiSource = await readFile(new URL("./agent.js", import.meta.url), "utf8"); +const snapshotDialogSource = await readFile( + new URL("../../components/AgentSnapshotDialog.vue", import.meta.url), + "utf8" +); + +function sourceBetween(source, startMarker, endMarker) { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + assert.notEqual(start, -1, `missing source marker: ${startMarker}`); + assert.notEqual(end, -1, `missing source marker: ${endMarker}`); + return source.slice(start, end); +} + +test("snapshot restore sends the preview token once without an automatic replay", () => { + const source = sourceBetween( + agentApiSource, + "restoreAgentSnapshot(agentId", + "// 删除智能体配置快照" + ); + + assert.match(source, /restoreAgentSnapshot\(agentId, snapshotId, currentStateToken, callback, onTerminalFailure\)/); + assert.match(source, /\.method\('POST'\)/); + assert.match(source, /\.data\(\{ currentStateToken \}\)/); + assert.match(source, /terminateCallbackRequest\(onTerminalFailure, error\)/); + assert.doesNotMatch(source, /retryCallbackRequest|RequestService\.reAjaxFun|this\.restoreAgentSnapshot/); +}); + +test("snapshot deletion terminates on network failure without an automatic replay", () => { + const source = sourceBetween( + agentApiSource, + "deleteAgentSnapshot(agentId", + "// 新增方法:获取智能体模板" + ); + + assert.match(source, /deleteAgentSnapshot\(agentId, snapshotId, callback, onTerminalFailure\)/); + assert.match(source, /\.method\('DELETE'\)/); + assert.match(source, /terminateCallbackRequest\(onTerminalFailure, error\)/); + assert.doesNotMatch(source, /retryCallbackRequest|RequestService\.reAjaxFun|this\.deleteAgentSnapshot/); +}); + +test("restore preview uses the state data and token from one snapshot-detail response", () => { + const source = sourceBetween( + snapshotDialogSource, + "restoreSnapshot(row)", + "decorateSnapshotRows(rows)" + ); + + assert.match(source, /this\.fetchSnapshotDetail\(row\.id\)\.then\(\(targetSnapshot\)/); + assert.match(source, /beforeSnapshotData: targetSnapshot\.currentSnapshotData/); + assert.match(source, /hasValidCurrentStateToken\(targetSnapshot\.currentStateToken\)/); + assert.doesNotMatch(source, /fetchCurrentAgentData|fetchCurrentAgentTags|Promise\.all/); +}); + +test("snapshot dialogs cannot close while a restore request is in flight", () => { + const guardedDialogs = snapshotDialogSource.match(/:before-close="guardRestoreInFlightClose"/g) || []; + const hiddenCloseButtons = snapshotDialogSource.match(/:show-close="!restoring"/g) || []; + assert.equal(guardedDialogs.length, 2); + assert.equal(hiddenCloseButtons.length, 2); + assert.match( + snapshotDialogSource, + /class="snapshot-footer-button snapshot-footer-cancel"[\s\S]*?:disabled="restoring"[\s\S]*?@click="closeRestorePreview"/ + ); + + const closeMethods = sourceBetween( + snapshotDialogSource, + " close() {", + " open() {" + ); + assert.match(closeMethods, /close\(\) \{\s+if \(this\.restoring\) \{\s+return;/); + assert.match(closeMethods, /guardRestoreInFlightClose\(done\) \{\s+if \(this\.restoring\) \{\s+return;[\s\S]*?done\(\);/); + assert.match(closeMethods, /closeRestorePreview\(\) \{\s+if \(this\.restoring\) \{\s+return;[\s\S]*?this\.restorePreviewVisible = false;/); +}); + +test("destructive restore requires a second explicit warning before the POST", () => { + const source = sourceBetween( + snapshotDialogSource, + " confirmRestoreSnapshot() {", + " deleteSnapshot(row) {" + ); + + assert.match( + source, + /if \(!this\.restoreWillClearChatHistory\) \{\s+this\.submitRestoreSnapshot\(snapshotId, currentStateToken\);\s+return;\s+\}/ + ); + assert.match( + source, + /this\.\$confirm\(\s+this\.\$t\("agentSnapshot\.restoreMemoryDestructiveWarning"\),[\s\S]*?type: "error"[\s\S]*?\)\.then\(\(\) => \{[\s\S]*?this\.submitRestoreSnapshot\(snapshotId, currentStateToken, requestSeq\);/ + ); + assert.match(source, /if \(this\.restoring \|\| !this\.restorePreviewRow\) \{\s+return;/); +}); + +test("a terminal restore failure invalidates the atomic preview instead of replaying it", () => { + const source = sourceBetween( + snapshotDialogSource, + " submitRestoreSnapshot(snapshotId", + " deleteSnapshot(row) {" + ); + + assert.match(source, /else \{\s+this\.invalidateRestorePreview\(\);\s+this\.\$message\.error\(this\.restoreFailedMessage\(data\)\);/); + assert.match(source, /\}, \(\) => \{[\s\S]*?this\.invalidateRestorePreview\(\);\s+this\.\$message\.error\(this\.\$t\("agentSnapshot\.restoreFailed"\)\);/); + assert.match(source, /invalidateRestorePreview\(\) \{[\s\S]*?this\.restorePreviewSnapshot = null;[\s\S]*?this\.restorePreviewRow = null;/); +}); + +test("the latest snapshot does not expose a restore action", () => { + const source = sourceBetween( + snapshotDialogSource, + " canRestoreSnapshot(row) {", + " canDeleteSnapshot(row) {" + ); + + assert.match(source, /!!row\?\.id && !row\.isLatestSnapshot/); + assert.match(snapshotDialogSource, /v-if="canRestoreSnapshot\(scope\.row\)"/); + assert.match( + snapshotDialogSource, + /restoreSnapshot\(row\) \{\s+if \(!this\.canRestoreSnapshot\(row\)\) \{\s+return;/ + ); +}); diff --git a/main/manager-web/src/apis/module/correctWord.js b/main/manager-web/src/apis/module/correctWord.js index 3bbb5ce2..a2a95b93 100644 --- a/main/manager-web/src/apis/module/correctWord.js +++ b/main/manager-web/src/apis/module/correctWord.js @@ -1,6 +1,26 @@ import { getServiceUrl } from '../api'; import RequestService from '../httpRequest'; +const CALLBACK_RETRY_LIMIT = 10; +const CALLBACK_RETRY_DELAY_MS = 2000; +const CALLBACK_RETRY_WINDOW_MS = CALLBACK_RETRY_LIMIT * CALLBACK_RETRY_DELAY_MS; + +function retryCallbackRequest(retry, retryCount, onTerminalFailure, error, retryStartedAt) { + if (!onTerminalFailure) { + RequestService.reAjaxFun(() => retry(retryCount + 1)) + return + } + const startedAt = retryStartedAt || Date.now() + if (retryCount >= CALLBACK_RETRY_LIMIT || Date.now() - startedAt >= CALLBACK_RETRY_WINDOW_MS) { + RequestService.clearRequestTime() + if (onTerminalFailure) { + onTerminalFailure(error) + } + return + } + setTimeout(() => retry(retryCount + 1, startedAt), CALLBACK_RETRY_DELAY_MS) +} + export default { // 获取替换词文件列表 @@ -26,8 +46,9 @@ export default { }, // 获取所有替换词文件(不分页) - selectAll(callback) { - RequestService.sendRequest() + selectAll(callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) { + const retryWindowStartedAt = retryStartedAt || Date.now() + const request = RequestService.sendRequest() .url(`${getServiceUrl()}/correct-word/file/select`) .method('GET') .success((res) => { @@ -36,10 +57,26 @@ export default { }) .networkFail((err) => { console.error('获取所有替换词文件失败:', err) - RequestService.reAjaxFun(() => { - this.selectAll(callback) - }) - }).send() + retryCallbackRequest( + (nextRetryCount, nextRetryStartedAt) => this.selectAll( + callback, + onTerminalFailure, + nextRetryCount, + nextRetryStartedAt + ), + retryCount, + onTerminalFailure, + err, + retryWindowStartedAt + ) + }) + if (onTerminalFailure) { + request.fail((error) => { + RequestService.clearRequestTime() + onTerminalFailure(error) + }) + } + request.send() }, // 下载替换词文件 diff --git a/main/manager-web/src/apis/module/model.js b/main/manager-web/src/apis/module/model.js index df879919..86e0c911 100644 --- a/main/manager-web/src/apis/module/model.js +++ b/main/manager-web/src/apis/module/model.js @@ -1,6 +1,26 @@ import { getServiceUrl } from '../api'; import RequestService from '../httpRequest'; +const CALLBACK_RETRY_LIMIT = 10; +const CALLBACK_RETRY_DELAY_MS = 2000; +const CALLBACK_RETRY_WINDOW_MS = CALLBACK_RETRY_LIMIT * CALLBACK_RETRY_DELAY_MS; + +function retryCallbackRequest(retry, retryCount, onTerminalFailure, error, retryStartedAt) { + if (!onTerminalFailure) { + RequestService.reAjaxFun(() => retry(retryCount + 1)); + return; + } + const startedAt = retryStartedAt || Date.now(); + if (retryCount >= CALLBACK_RETRY_LIMIT || Date.now() - startedAt >= CALLBACK_RETRY_WINDOW_MS) { + RequestService.clearRequestTime(); + if (onTerminalFailure) { + onTerminalFailure(error); + } + return; + } + setTimeout(() => retry(retryCount + 1, startedAt), CALLBACK_RETRY_DELAY_MS); +} + export default { // 获取模型配置列表 getModelList(params, callback) { @@ -92,8 +112,9 @@ export default { }).send() }, // 获取模型名称列表 - getModelNames(modelType, modelName, callback) { - RequestService.sendRequest() + getModelNames(modelType, modelName, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) { + const retryWindowStartedAt = retryStartedAt || Date.now(); + const request = RequestService.sendRequest() .url(`${getServiceUrl()}/models/names`) .method('GET') .data({ modelType, modelName }) @@ -101,15 +122,34 @@ export default { RequestService.clearRequestTime(); callback(res); }) - .networkFail(() => { - RequestService.reAjaxFun(() => { - this.getModelNames(modelType, modelName, callback); - }); - }).send(); + .networkFail((error) => { + retryCallbackRequest( + (nextRetryCount, nextRetryStartedAt) => this.getModelNames( + modelType, + modelName, + callback, + onTerminalFailure, + nextRetryCount, + nextRetryStartedAt + ), + retryCount, + onTerminalFailure, + error, + retryWindowStartedAt + ); + }); + if (onTerminalFailure) { + request.fail((error) => { + RequestService.clearRequestTime(); + onTerminalFailure(error); + }); + } + request.send(); }, // 获取LLM模型名称列表 - getLlmModelCodeList(modelName, callback) { - RequestService.sendRequest() + getLlmModelCodeList(modelName, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) { + const retryWindowStartedAt = retryStartedAt || Date.now(); + const request = RequestService.sendRequest() .url(`${getServiceUrl()}/models/llm/names`) .method('GET') .data({ modelName }) @@ -117,29 +157,65 @@ export default { RequestService.clearRequestTime(); callback(res); }) - .networkFail(() => { - RequestService.reAjaxFun(() => { - this.getLlmModelCodeList(modelName, callback); - }); - }).send(); + .networkFail((error) => { + retryCallbackRequest( + (nextRetryCount, nextRetryStartedAt) => this.getLlmModelCodeList( + modelName, + callback, + onTerminalFailure, + nextRetryCount, + nextRetryStartedAt + ), + retryCount, + onTerminalFailure, + error, + retryWindowStartedAt + ); + }); + if (onTerminalFailure) { + request.fail((error) => { + RequestService.clearRequestTime(); + onTerminalFailure(error); + }); + } + request.send(); }, // 获取模型音色列表 - getModelVoices(modelId, voiceName, callback) { + getModelVoices(modelId, voiceName, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) { + const retryWindowStartedAt = retryStartedAt || Date.now(); const queryParams = new URLSearchParams({ voiceName: voiceName || '' }).toString(); - RequestService.sendRequest() + const request = RequestService.sendRequest() .url(`${getServiceUrl()}/models/${modelId}/voices?${queryParams}`) .method('GET') .success((res) => { RequestService.clearRequestTime(); callback(res); }) - .networkFail(() => { - RequestService.reAjaxFun(() => { - this.getModelVoices(modelId, voiceName, callback); - }); - }).send(); + .networkFail((error) => { + retryCallbackRequest( + (nextRetryCount, nextRetryStartedAt) => this.getModelVoices( + modelId, + voiceName, + callback, + onTerminalFailure, + nextRetryCount, + nextRetryStartedAt + ), + retryCount, + onTerminalFailure, + error, + retryWindowStartedAt + ); + }); + if (onTerminalFailure) { + request.fail((error) => { + RequestService.clearRequestTime(); + onTerminalFailure(error); + }); + } + request.send(); }, // 获取单个模型配置 getModelConfig(id, callback) { @@ -323,8 +399,9 @@ export default { }).send() }, // 获取插件列表 - getPluginFunctionList(params, callback) { - RequestService.sendRequest() + getPluginFunctionList(params, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) { + const retryWindowStartedAt = retryStartedAt || Date.now(); + const request = RequestService.sendRequest() .url(`${getServiceUrl()}/models/provider/plugin/names`) .method('GET') .success((res) => { @@ -332,11 +409,30 @@ export default { callback(res) }) .networkFail((err) => { - this.$message.error(err.msg || '获取插件列表失败') - RequestService.reAjaxFun(() => { - this.getPluginFunctionList(params, callback) - }) - }).send() + if (!onTerminalFailure && this.$message) { + this.$message.error(err.msg || '获取插件列表失败'); + } + retryCallbackRequest( + (nextRetryCount, nextRetryStartedAt) => this.getPluginFunctionList( + params, + callback, + onTerminalFailure, + nextRetryCount, + nextRetryStartedAt + ), + retryCount, + onTerminalFailure, + err, + retryWindowStartedAt + ); + }); + if (onTerminalFailure) { + request.fail((error) => { + RequestService.clearRequestTime(); + onTerminalFailure(error); + }); + } + request.send() }, // 获取RAG模型列表 diff --git a/main/manager-web/src/components/AgentSnapshotDialog.vue b/main/manager-web/src/components/AgentSnapshotDialog.vue index cb1c4fe2..b5427c80 100644 --- a/main/manager-web/src/components/AgentSnapshotDialog.vue +++ b/main/manager-web/src/components/AgentSnapshotDialog.vue @@ -5,6 +5,10 @@ :visible="visible" width="760px" class="agent-snapshot-dialog" + :before-close="guardRestoreInFlightClose" + :close-on-click-modal="!restoring" + :close-on-press-escape="!restoring" + :show-close="!restoring" @open="open" @close="close" > @@ -302,6 +306,10 @@ :visible.sync="restorePreviewVisible" width="860px" class="snapshot-detail-dialog" + :before-close="guardRestoreInFlightClose" + :close-on-click-modal="!restoring" + :close-on-press-escape="!restoring" + :show-close="!restoring" >