mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-21 22:53:56 +08:00
Merge pull request #3278 from xinnan-tech/agent-snapshot-history-fixes
fix: 完善智能体配置历史与恢复逻辑
This commit is contained in:
@@ -112,12 +112,17 @@ celerybeat.pid
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
/.venv-*/
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Repository-local runtimes and package-manager caches
|
||||
/.runtime/
|
||||
/main/manager-web/.npm-cache/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
+6
-2
@@ -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<Void> restore(@PathVariable String agentId, @PathVariable String snapshotId) {
|
||||
public Result<Void> 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<>();
|
||||
}
|
||||
|
||||
|
||||
@@ -43,4 +43,13 @@ public interface AgentDao extends BaseDao<AgentEntity> {
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
AgentEntity selectByIdForUpdate(@Param("agentId") String agentId);
|
||||
|
||||
/**
|
||||
* 精确写入快照覆盖的智能体字段,包括目标快照中的 null 值。
|
||||
* 不更新所属用户、创建信息等不属于快照的字段。
|
||||
*
|
||||
* @param agent 已应用目标快照的智能体
|
||||
* @return 受影响行数
|
||||
*/
|
||||
int updateSnapshotFields(@Param("agent") AgentEntity agent);
|
||||
}
|
||||
|
||||
@@ -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<AgentSnapshotEntity> {
|
||||
int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot);
|
||||
|
||||
int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit);
|
||||
|
||||
List<AgentSnapshotEntity> selectLegacyRedactionBatch(@Param("afterId") String afterId,
|
||||
@Param("limit") int limit,
|
||||
@Param("targetRedactionVersion") int targetRedactionVersion);
|
||||
|
||||
int updateRedactedSnapshots(@Param("snapshots") List<AgentSnapshotEntity> snapshots,
|
||||
@Param("redactionVersion") int redactionVersion);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -47,4 +47,7 @@ public class AgentSnapshotEntity {
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private Date createdAt;
|
||||
|
||||
@Schema(description = "快照数据脱敏规则版本")
|
||||
private Integer redactionVersion;
|
||||
}
|
||||
|
||||
+3
-1
@@ -13,11 +13,13 @@ public interface AgentSnapshotService extends BaseService<AgentSnapshotEntity> {
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
+34
-22
@@ -57,9 +57,9 @@ import xiaozhi.modules.model.dto.VoiceDTO;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
import xiaozhi.modules.model.service.ModelProviderService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.sys.enums.SuperAdminEnum;
|
||||
import xiaozhi.modules.timbre.service.TimbreService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.sys.enums.SuperAdminEnum;
|
||||
import xiaozhi.modules.timbre.service.TimbreService;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
@@ -374,8 +374,9 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> 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");
|
||||
}
|
||||
|
||||
// 只更新提供的非空字段
|
||||
@@ -614,16 +615,22 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
template.setTtsVoiceId(timbre.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entity.setTtsVoiceId(template.getTtsVoiceId());
|
||||
entity.setMemModelId(template.getMemModelId());
|
||||
entity.setIntentModelId(template.getIntentModelId());
|
||||
entity.setSystemPrompt(template.getSystemPrompt());
|
||||
entity.setSummaryMemory(template.getSummaryMemory());
|
||||
|
||||
// 根据记忆模型类型设置默认的chatHistoryConf值
|
||||
if (template.getMemModelId() != null) {
|
||||
}
|
||||
|
||||
entity.setTtsVoiceId(template.getTtsVoiceId());
|
||||
entity.setTtsLanguage(defaultIfBlank(template.getTtsLanguage(),
|
||||
timbreModelService.getDefaultLanguageById(entity.getTtsVoiceId())));
|
||||
entity.setMemModelId(template.getMemModelId());
|
||||
entity.setIntentModelId(template.getIntentModelId());
|
||||
entity.setSystemPrompt(template.getSystemPrompt());
|
||||
entity.setSummaryMemory(template.getSummaryMemory());
|
||||
if (Constant.MEMORY_NO_MEM.equals(entity.getMemModelId())
|
||||
|| Constant.MEMORY_MEM_REPORT_ONLY.equals(entity.getMemModelId())) {
|
||||
entity.setSummaryMemory("");
|
||||
}
|
||||
|
||||
// 根据记忆模型类型设置默认的chatHistoryConf值
|
||||
if (template.getMemModelId() != null) {
|
||||
if (template.getMemModelId().equals("Memory_nomem")) {
|
||||
// 无记忆功能的模型,默认不记录聊天记录
|
||||
entity.setChatHistoryConf(0);
|
||||
@@ -678,13 +685,18 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
mapping.setParamInfo(JsonUtils.toJsonString(paramInfo));
|
||||
mapping.setAgentId(entity.getId());
|
||||
toInsert.add(mapping);
|
||||
}
|
||||
// 保存默认插件
|
||||
agentPluginMappingService.saveBatch(toInsert);
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
private String getDefaultLLMModelId() {
|
||||
}
|
||||
// 保存默认插件
|
||||
agentPluginMappingService.saveBatch(toInsert);
|
||||
agentSnapshotService.createSnapshot(entity.getId(), "initial");
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
private String defaultIfBlank(String value, String defaultValue) {
|
||||
return StringUtils.isBlank(value) ? defaultValue : value;
|
||||
}
|
||||
|
||||
private String getDefaultLLMModelId() {
|
||||
try {
|
||||
List<ModelConfigEntity> llmConfigs = modelConfigService.getEnabledModelsByType("LLM");
|
||||
if (llmConfigs == null || llmConfigs.isEmpty()) {
|
||||
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1071
-59
File diff suppressed because it is too large
Load Diff
@@ -18,13 +18,17 @@ public class AgentSnapshotVO {
|
||||
private List<String> changedFields;
|
||||
private List<String> 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;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ public interface TimbreService extends BaseService<TimbreEntity> {
|
||||
|
||||
List<VoiceDTO> getVoiceNames(String ttsModelId, String voiceName);
|
||||
|
||||
/**
|
||||
* 获取普通音色或克隆音色配置的首个有效语言。
|
||||
*
|
||||
* @param id 音色ID
|
||||
* @return 默认语言;音色不存在或未配置有效语言时返回null
|
||||
*/
|
||||
String getDefaultLanguageById(String id);
|
||||
|
||||
/**
|
||||
* 根据ID获取音色名称
|
||||
*
|
||||
@@ -73,4 +81,4 @@ public interface TimbreService extends BaseService<TimbreEntity> {
|
||||
* @return 音色信息
|
||||
*/
|
||||
VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -1,6 +1,7 @@
|
||||
package xiaozhi.modules.timbre.service.impl;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -41,6 +42,8 @@ import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
@Service
|
||||
public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity> implements TimbreService {
|
||||
|
||||
private static final Pattern LANGUAGE_SEPARATOR = Pattern.compile("[、;;,,]");
|
||||
|
||||
private final TimbreDao timbreDao;
|
||||
private final VoiceCloneDao voiceCloneDao;
|
||||
private final RedisUtils redisUtils;
|
||||
@@ -158,6 +161,32 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
return CollectionUtil.isEmpty(voiceDTOs) ? null : voiceDTOs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDefaultLanguageById(String id) {
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
TimbreEntity timbre = timbreDao.selectById(id);
|
||||
if (timbre != null) {
|
||||
return firstNonBlankLanguage(timbre.getLanguages());
|
||||
}
|
||||
|
||||
VoiceCloneEntity voiceClone = voiceCloneDao.selectById(id);
|
||||
return voiceClone == null ? null : firstNonBlankLanguage(voiceClone.getLanguages());
|
||||
}
|
||||
|
||||
private String firstNonBlankLanguage(String languages) {
|
||||
if (StringUtils.isBlank(languages)) {
|
||||
return null;
|
||||
}
|
||||
return LANGUAGE_SEPARATOR.splitAsStream(languages)
|
||||
.map(StringUtils::trimToNull)
|
||||
.filter(Objects::nonNull)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理是不是tts模型的id
|
||||
*/
|
||||
@@ -214,4 +243,4 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
dto.setIsClone(false); // 设置为普通音色
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
@@ -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
|
||||
|
||||
@@ -89,4 +89,32 @@
|
||||
FOR UPDATE
|
||||
</select>
|
||||
|
||||
<update id="updateSnapshotFields">
|
||||
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}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -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}
|
||||
</insert>
|
||||
@@ -89,4 +93,31 @@
|
||||
)
|
||||
</delete>
|
||||
|
||||
<select id="selectLegacyRedactionBatch" resultType="xiaozhi.modules.agent.entity.AgentSnapshotEntity">
|
||||
SELECT id,
|
||||
snapshot_data AS snapshotData,
|
||||
redaction_version AS redactionVersion
|
||||
FROM ai_agent_snapshot
|
||||
WHERE redaction_version < #{targetRedactionVersion}
|
||||
AND (#{afterId} IS NULL OR id > #{afterId})
|
||||
ORDER BY id ASC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<update id="updateRedactedSnapshots">
|
||||
UPDATE ai_agent_snapshot
|
||||
SET snapshot_data = CASE id
|
||||
<foreach collection="snapshots" item="snapshot">
|
||||
WHEN #{snapshot.id} THEN #{snapshot.snapshotData}
|
||||
</foreach>
|
||||
ELSE snapshot_data
|
||||
END,
|
||||
redaction_version = #{redactionVersion}
|
||||
WHERE redaction_version < #{redactionVersion}
|
||||
AND id IN
|
||||
<foreach collection="snapshots" item="snapshot" open="(" separator="," close=")">
|
||||
#{snapshot.id}
|
||||
</foreach>
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
+77
@@ -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);
|
||||
}
|
||||
}
|
||||
+1540
-29
File diff suppressed because it is too large
Load Diff
+57
@@ -0,0 +1,57 @@
|
||||
package xiaozhi.modules.timbre.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.timbre.dao.TimbreDao;
|
||||
import xiaozhi.modules.timbre.entity.TimbreEntity;
|
||||
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
|
||||
class TimbreServiceImplTest {
|
||||
|
||||
@Test
|
||||
void defaultLanguageUsesFirstValidRegularTimbreLanguageWithoutCloneQuery() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
|
||||
TimbreEntity timbre = new TimbreEntity();
|
||||
timbre.setLanguages(",, ; 普通话;粤语");
|
||||
when(timbreDao.selectById("voice-id")).thenReturn(timbre);
|
||||
|
||||
assertEquals("普通话", service.getDefaultLanguageById("voice-id"));
|
||||
|
||||
verify(voiceCloneDao, never()).selectById("voice-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultLanguageFallsBackToCloneTimbre() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
|
||||
VoiceCloneEntity voiceClone = new VoiceCloneEntity();
|
||||
voiceClone.setLanguages("、, English,中文");
|
||||
when(voiceCloneDao.selectById("clone-id")).thenReturn(voiceClone);
|
||||
|
||||
assertEquals("English", service.getDefaultLanguageById("clone-id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delimiterOnlyLanguageConfigurationReturnsNull() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
|
||||
TimbreEntity timbre = new TimbreEntity();
|
||||
timbre.setLanguages(",,、;;;,,");
|
||||
when(timbreDao.selectById("voice-id")).thenReturn(timbre);
|
||||
|
||||
assertNull(service.getDefaultLanguageById("voice-id"));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
|
||||
import { watch, onMounted } from 'vue'
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { usePageAuth } from '@/hooks/usePageAuth'
|
||||
import { useConfigStore } from '@/store'
|
||||
import { t } from '@/i18n'
|
||||
import { useConfigStore } from '@/store'
|
||||
import { useLangStore } from '@/store/lang'
|
||||
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
|
||||
|
||||
@@ -37,9 +37,9 @@ function updateTabBarText() {
|
||||
success: () => {},
|
||||
fail: (err) => {
|
||||
console.log('设置首页tabBar文本失败:', err)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// 设置配网tabBar文本
|
||||
uni.setTabBarItem({
|
||||
index: 1,
|
||||
@@ -47,9 +47,9 @@ function updateTabBarText() {
|
||||
success: () => {},
|
||||
fail: (err) => {
|
||||
console.log('设置配网tabBar文本失败:', err)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// 设置系统tabBar文本
|
||||
uni.setTabBarItem({
|
||||
index: 2,
|
||||
@@ -57,9 +57,10 @@ function updateTabBarText() {
|
||||
success: () => {},
|
||||
fail: (err) => {
|
||||
console.log('设置系统tabBar文本失败:', err)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
console.log('更新tabBar文本时出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import type {
|
||||
Agent,
|
||||
AgentCreateData,
|
||||
AgentDetail,
|
||||
AgentSnapshot,
|
||||
AgentSnapshotPageParams,
|
||||
CorrectWordFile,
|
||||
ModelOption,
|
||||
PageData,
|
||||
RoleTemplate,
|
||||
} from './types'
|
||||
import { http } from '@/http/request/alova'
|
||||
@@ -100,7 +104,7 @@ export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
|
||||
}
|
||||
|
||||
// 更新智能体
|
||||
export function updateAgent(id: string, data: Partial<AgentDetail>) {
|
||||
export function updateAgent(id: string, data: Partial<AgentDetail> & { tagNames?: string[] }) {
|
||||
return http.Put(`/agent/${id}`, data, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
@@ -220,3 +224,63 @@ export function getAllLanguage(modelId: string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取智能体历史版本列表
|
||||
export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) {
|
||||
return http.Get<PageData<AgentSnapshot>>(`/agent/${agentId}/snapshots`, {
|
||||
params,
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
cacheFor: {
|
||||
expire: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取智能体历史版本详情
|
||||
export function getAgentSnapshot(agentId: string, snapshotId: string) {
|
||||
return http.Get<AgentSnapshot>(`/agent/${agentId}/snapshots/${snapshotId}`, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
cacheFor: {
|
||||
expire: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 恢复智能体历史版本
|
||||
export function restoreAgentSnapshot(agentId: string, snapshotId: string, currentStateToken: string) {
|
||||
return http.Post(`/agent/${agentId}/snapshots/${snapshotId}/restore`, { currentStateToken }, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 删除智能体历史版本
|
||||
export function deleteAgentSnapshot(agentId: string, snapshotId: string) {
|
||||
return http.Delete(`/agent/${agentId}/snapshots/${snapshotId}`, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取所有替换词文件
|
||||
export function getCorrectWordFiles() {
|
||||
return http.Get<CorrectWordFile[]>('/correct-word/file/select', {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
cacheFor: {
|
||||
expire: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,10 +44,12 @@ export interface AgentDetail {
|
||||
createdAt: string
|
||||
updater: string
|
||||
updatedAt: string
|
||||
ttsLanguage: string
|
||||
ttsVolume: number
|
||||
ttsRate: number
|
||||
ttsPitch: number
|
||||
ttsLanguage: string | null
|
||||
ttsVolume: number | null
|
||||
ttsRate: number | null
|
||||
ttsPitch: number | null
|
||||
currentVersionNo?: number | null
|
||||
tagNames?: string[]
|
||||
functions: AgentFunction[]
|
||||
contextProviders: Providers[]
|
||||
}
|
||||
@@ -67,6 +69,51 @@ export interface AgentFunction {
|
||||
paramInfo: Record<string, string | number | boolean> | null
|
||||
}
|
||||
|
||||
export interface PageData<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AgentSnapshotData extends Partial<AgentDetail> {
|
||||
correctWordFileIds?: string[]
|
||||
tagNames?: string[]
|
||||
tags?: Array<{
|
||||
tagName?: string
|
||||
[key: string]: any
|
||||
}>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface AgentSnapshot {
|
||||
id: string
|
||||
agentId: string
|
||||
userId?: string
|
||||
versionNo: number
|
||||
changedFields?: string[]
|
||||
fieldOrder?: string[]
|
||||
source?: string
|
||||
restoreFromSnapshotId?: string | null
|
||||
restoreFromVersionNo?: number | null
|
||||
currentStateToken?: string
|
||||
currentSnapshotData?: AgentSnapshotData
|
||||
creator?: string
|
||||
createdAt?: string
|
||||
snapshotData?: AgentSnapshotData
|
||||
afterSnapshotData?: AgentSnapshotData
|
||||
}
|
||||
|
||||
export interface AgentSnapshotPageParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
maxVersionNo?: number
|
||||
}
|
||||
|
||||
export interface CorrectWordFile {
|
||||
id: string
|
||||
fileName: string
|
||||
wordCount?: number
|
||||
}
|
||||
|
||||
// 角色模板数据类型
|
||||
export interface RoleTemplate {
|
||||
id: string
|
||||
@@ -78,6 +125,7 @@ export interface RoleTemplate {
|
||||
vllmModelId: string
|
||||
ttsModelId: string
|
||||
ttsVoiceId: string
|
||||
ttsLanguage?: string | null
|
||||
memModelId: string
|
||||
intentModelId: string
|
||||
chatHistoryConf: number
|
||||
|
||||
+560
-473
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
@@ -146,6 +147,92 @@ export default {
|
||||
'agent.speedHint': '-100=Slower, 0=Standard, 100=Faster',
|
||||
'agent.pitchHint': '-100=Lowest, 0=Standard, 100=Highest',
|
||||
|
||||
// Agent snapshots
|
||||
'agentSnapshot.title': 'Version History',
|
||||
'agentSnapshot.empty': 'No versions yet',
|
||||
'agentSnapshot.emptyTip': 'Saved configs will appear here',
|
||||
'agentSnapshot.version': 'Version',
|
||||
'agentSnapshot.createdAt': 'Saved At',
|
||||
'agentSnapshot.source': 'Source',
|
||||
'agentSnapshot.changedFields': 'Changes',
|
||||
'agentSnapshot.view': 'View',
|
||||
'agentSnapshot.restore': 'Restore',
|
||||
'agentSnapshot.delete': 'Delete',
|
||||
'agentSnapshot.loadMore': 'Load More',
|
||||
'agentSnapshot.detailTitle': 'Change Details',
|
||||
'agentSnapshot.restorePreviewTitle': 'Restore Preview',
|
||||
'agentSnapshot.confirmRestore': 'Confirm Restore',
|
||||
'agentSnapshot.currentVersion': 'Latest Snapshot',
|
||||
'agentSnapshot.beforeChange': 'Before',
|
||||
'agentSnapshot.afterChange': 'After',
|
||||
'agentSnapshot.beforeRestore': 'Before Restore',
|
||||
'agentSnapshot.afterRestore': 'After Restore',
|
||||
'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.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',
|
||||
'agentSnapshot.fetchFailed': 'Failed to fetch versions',
|
||||
'agentSnapshot.detailFailed': 'Failed to fetch version details',
|
||||
'agentSnapshot.correctWordCount': '{count} replacement words',
|
||||
'agentSnapshot.source.config': 'Config Save',
|
||||
'agentSnapshot.source.current': 'Current Config',
|
||||
'agentSnapshot.source.restore': 'Restored',
|
||||
'agentSnapshot.source.initial': 'Initial Version',
|
||||
'agentSnapshot.field.initial': 'Initial Snapshot',
|
||||
'agentSnapshot.field.agentCode': 'Agent Code',
|
||||
'agentSnapshot.field.agentName': 'Nickname',
|
||||
'agentSnapshot.field.asrModelId': 'Speech Recognition',
|
||||
'agentSnapshot.field.vadModelId': 'Voice Activity Detection',
|
||||
'agentSnapshot.field.llmModelId': 'Main Language Model',
|
||||
'agentSnapshot.field.slmModelId': 'Small Language Model',
|
||||
'agentSnapshot.field.vllmModelId': 'Vision Model',
|
||||
'agentSnapshot.field.ttsModelId': 'Text-to-Speech',
|
||||
'agentSnapshot.field.ttsVoiceId': 'Voice',
|
||||
'agentSnapshot.field.ttsLanguage': 'Language',
|
||||
'agentSnapshot.field.ttsVolume': 'Volume',
|
||||
'agentSnapshot.field.ttsRate': 'Speed',
|
||||
'agentSnapshot.field.ttsPitch': 'Pitch',
|
||||
'agentSnapshot.field.memModelId': 'Memory Mode',
|
||||
'agentSnapshot.field.intentModelId': 'Intent Recognition',
|
||||
'agentSnapshot.field.chatHistoryConf': 'Chat History Config',
|
||||
'agentSnapshot.field.systemPrompt': 'Role Description',
|
||||
'agentSnapshot.field.summaryMemory': 'Memory',
|
||||
'agentSnapshot.field.langCode': 'Language Code',
|
||||
'agentSnapshot.field.language': 'Interaction Language',
|
||||
'agentSnapshot.field.sort': 'Sort',
|
||||
'agentSnapshot.field.functions': 'Plugins',
|
||||
'agentSnapshot.field.contextProviders': 'Context Sources',
|
||||
'agentSnapshot.field.correctWordFileIds': 'Replacement Words',
|
||||
'agentSnapshot.field.tagNames': 'Agent Tags',
|
||||
'agentSnapshot.chatHistoryConf.none': 'Do not record chat history',
|
||||
'agentSnapshot.chatHistoryConf.text': 'Report text',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': 'Report text and voice',
|
||||
'agentSnapshot.model.Memory_nomem': 'No memory',
|
||||
'agentSnapshot.model.Memory_mem_local_short': 'Local short memory',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Mem0AI memory',
|
||||
'agentSnapshot.model.Memory_mem_report_only': 'Report only',
|
||||
'agentSnapshot.model.Intent_nointent': 'No intent recognition',
|
||||
'agentSnapshot.model.Intent_intent_llm': 'External LLM intent recognition',
|
||||
'agentSnapshot.model.Intent_function_call': 'LLM function calling',
|
||||
|
||||
// Context provider dialog related
|
||||
'contextProviderDialog.title': 'Edit Source',
|
||||
'contextProviderDialog.noContextApi': 'No Context API',
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import type { Language } from '@/store/lang'
|
||||
import { ref } from 'vue'
|
||||
import { useLangStore } from '@/store/lang'
|
||||
import type { Language } from '@/store/lang'
|
||||
|
||||
import de from './de'
|
||||
import en from './en'
|
||||
import pt_BR from './pt_BR'
|
||||
import vi from './vi'
|
||||
// 导入各个语言的翻译文件
|
||||
import zh_CN from './zh_CN'
|
||||
import en from './en'
|
||||
import zh_TW from './zh_TW'
|
||||
import de from './de'
|
||||
import vi from './vi'
|
||||
import pt_BR from './pt_BR'
|
||||
|
||||
// 语言包映射
|
||||
const messages = {
|
||||
zh_CN: zh_CN,
|
||||
zh_CN,
|
||||
en,
|
||||
zh_TW: zh_TW,
|
||||
zh_TW,
|
||||
de,
|
||||
vi,
|
||||
pt_BR: pt_BR,
|
||||
pt_BR,
|
||||
}
|
||||
|
||||
// 当前使用的语言
|
||||
@@ -42,7 +42,7 @@ export function t(key: string, params?: Record<string, string | number>): string
|
||||
|
||||
// 直接查找扁平键名
|
||||
if (langMessages && typeof langMessages === 'object' && key in langMessages) {
|
||||
let value = langMessages[key]
|
||||
const value = langMessages[key]
|
||||
if (typeof value === 'string') {
|
||||
// 处理参数替换
|
||||
if (params) {
|
||||
@@ -76,4 +76,4 @@ export function getSupportedLanguages(): { code: Language, name: string }[] {
|
||||
{ code: 'vi', name: 'Tiếng Việt' },
|
||||
{ code: 'pt_BR', name: 'Português (Brasil)' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@ export default {
|
||||
'login.requiredCaptcha': 'O código de verificação não pode estar vazio',
|
||||
'login.requiredMobile': 'Por favor, insira um número de telefone válido',
|
||||
'login.captchaError': 'Erro no código de verificação gráfico',
|
||||
'login.forgotPassword': 'Esqueceu a Senha',
|
||||
'login.userAgreement': 'Termos de Uso',
|
||||
'login.privacyPolicy': 'Política de Privacidade',
|
||||
'login.forgotPassword': 'Esqueceu a Senha',
|
||||
'login.userAgreement': 'Termos de Uso',
|
||||
'login.privacyPolicy': 'Política de Privacidade',
|
||||
|
||||
// Register page
|
||||
'register.pageTitle': 'Cadastro',
|
||||
@@ -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',
|
||||
@@ -146,6 +147,92 @@ export default {
|
||||
'agent.speedHint': '-100=Slower, 0=Standard, 100=Faster',
|
||||
'agent.pitchHint': '-100=Lowest, 0=Standard, 100=Highest',
|
||||
|
||||
// Histórico de versões do agente
|
||||
'agentSnapshot.title': 'Histórico de Versões',
|
||||
'agentSnapshot.empty': 'Ainda sem versões',
|
||||
'agentSnapshot.emptyTip': 'Configurações salvas aparecerão aqui',
|
||||
'agentSnapshot.version': 'Versão',
|
||||
'agentSnapshot.createdAt': 'Salvo em',
|
||||
'agentSnapshot.source': 'Origem',
|
||||
'agentSnapshot.changedFields': 'Alterações',
|
||||
'agentSnapshot.view': 'Ver',
|
||||
'agentSnapshot.restore': 'Restaurar',
|
||||
'agentSnapshot.delete': 'Excluir',
|
||||
'agentSnapshot.loadMore': 'Carregar Mais',
|
||||
'agentSnapshot.detailTitle': 'Detalhes da Alteração',
|
||||
'agentSnapshot.restorePreviewTitle': 'Prévia da Restauração',
|
||||
'agentSnapshot.confirmRestore': 'Confirmar Restauração',
|
||||
'agentSnapshot.currentVersion': 'Snapshot mais recente',
|
||||
'agentSnapshot.beforeChange': 'Antes',
|
||||
'agentSnapshot.afterChange': 'Depois',
|
||||
'agentSnapshot.beforeRestore': 'Antes da Restauração',
|
||||
'agentSnapshot.afterRestore': 'Depois da Restauração',
|
||||
'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.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',
|
||||
'agentSnapshot.fetchFailed': 'Falha ao buscar versões',
|
||||
'agentSnapshot.detailFailed': 'Falha ao buscar detalhes da versão',
|
||||
'agentSnapshot.correctWordCount': '{count} palavras de substituição',
|
||||
'agentSnapshot.source.config': 'Configuração Salva',
|
||||
'agentSnapshot.source.current': 'Configuração Atual',
|
||||
'agentSnapshot.source.restore': 'Restaurado',
|
||||
'agentSnapshot.source.initial': 'Versão Inicial',
|
||||
'agentSnapshot.field.initial': 'Snapshot Inicial',
|
||||
'agentSnapshot.field.agentCode': 'Código do Agente',
|
||||
'agentSnapshot.field.agentName': 'Apelido',
|
||||
'agentSnapshot.field.asrModelId': 'Reconhecimento de Fala',
|
||||
'agentSnapshot.field.vadModelId': 'Detecção de Voz',
|
||||
'agentSnapshot.field.llmModelId': 'Modelo Principal',
|
||||
'agentSnapshot.field.slmModelId': 'Modelo Pequeno',
|
||||
'agentSnapshot.field.vllmModelId': 'Modelo Visual',
|
||||
'agentSnapshot.field.ttsModelId': 'Texto para Fala',
|
||||
'agentSnapshot.field.ttsVoiceId': 'Voz',
|
||||
'agentSnapshot.field.ttsLanguage': 'Idioma',
|
||||
'agentSnapshot.field.ttsVolume': 'Volume',
|
||||
'agentSnapshot.field.ttsRate': 'Velocidade',
|
||||
'agentSnapshot.field.ttsPitch': 'Tonalidade',
|
||||
'agentSnapshot.field.memModelId': 'Modo de Memória',
|
||||
'agentSnapshot.field.intentModelId': 'Reconhecimento de Intenção',
|
||||
'agentSnapshot.field.chatHistoryConf': 'Configuração do Histórico',
|
||||
'agentSnapshot.field.systemPrompt': 'Descrição do Papel',
|
||||
'agentSnapshot.field.summaryMemory': 'Memória',
|
||||
'agentSnapshot.field.langCode': 'Código do Idioma',
|
||||
'agentSnapshot.field.language': 'Idioma de Interação',
|
||||
'agentSnapshot.field.sort': 'Ordenação',
|
||||
'agentSnapshot.field.functions': 'Plugins',
|
||||
'agentSnapshot.field.contextProviders': 'Fontes de Contexto',
|
||||
'agentSnapshot.field.correctWordFileIds': 'Palavras de Substituição',
|
||||
'agentSnapshot.field.tagNames': 'Tags do Agente',
|
||||
'agentSnapshot.chatHistoryConf.none': 'Não registrar histórico de chat',
|
||||
'agentSnapshot.chatHistoryConf.text': 'Reportar texto',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': 'Reportar texto e voz',
|
||||
'agentSnapshot.model.Memory_nomem': 'Sem memória',
|
||||
'agentSnapshot.model.Memory_mem_local_short': 'Memória curta local',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Memória Mem0AI',
|
||||
'agentSnapshot.model.Memory_mem_report_only': 'Apenas reportar',
|
||||
'agentSnapshot.model.Intent_nointent': 'Sem reconhecimento de intenção',
|
||||
'agentSnapshot.model.Intent_intent_llm': 'Reconhecimento por LLM externo',
|
||||
'agentSnapshot.model.Intent_function_call': 'Chamada de função por LLM',
|
||||
|
||||
// Diálogo de provedor de contexto
|
||||
'contextProviderDialog.title': 'Editar Fonte',
|
||||
'contextProviderDialog.noContextApi': 'Sem API de Contexto',
|
||||
|
||||
+561
-474
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,7 @@ export default {
|
||||
'agent.saving': '保存中...',
|
||||
'agent.saveSuccess': '保存成功',
|
||||
'agent.saveFail': '保存失败',
|
||||
'agent.ttsOptionsLoadFailed': '语音选项加载失败,已保留原来的语音配置',
|
||||
'agent.loadFail': '加载失败',
|
||||
'agent.pleaseInputAgentName': '请输入智能体名称',
|
||||
'agent.pleaseInputRoleDescription': '请输入角色介绍',
|
||||
@@ -146,6 +147,92 @@ export default {
|
||||
'agent.speedHint': '-100=最慢, 0=标准, 100=最快',
|
||||
'agent.pitchHint': '-100=最低, 0=标准, 100=最高',
|
||||
|
||||
// 智能体历史版本
|
||||
'agentSnapshot.title': '历史版本',
|
||||
'agentSnapshot.empty': '暂无历史版本',
|
||||
'agentSnapshot.emptyTip': '保存配置后会生成历史版本',
|
||||
'agentSnapshot.version': '版本',
|
||||
'agentSnapshot.createdAt': '保存时间',
|
||||
'agentSnapshot.source': '来源',
|
||||
'agentSnapshot.changedFields': '变更内容',
|
||||
'agentSnapshot.view': '查看',
|
||||
'agentSnapshot.restore': '恢复',
|
||||
'agentSnapshot.delete': '删除',
|
||||
'agentSnapshot.loadMore': '加载更多',
|
||||
'agentSnapshot.detailTitle': '变更详情',
|
||||
'agentSnapshot.restorePreviewTitle': '恢复预览',
|
||||
'agentSnapshot.confirmRestore': '确认恢复',
|
||||
'agentSnapshot.currentVersion': '最新快照',
|
||||
'agentSnapshot.beforeChange': '变化前',
|
||||
'agentSnapshot.afterChange': '变化后',
|
||||
'agentSnapshot.beforeRestore': '恢复前',
|
||||
'agentSnapshot.afterRestore': '恢复后',
|
||||
'agentSnapshot.configValue': '配置值',
|
||||
'agentSnapshot.emptyValue': '无',
|
||||
'agentSnapshot.secretRedacted': '密钥已隐藏',
|
||||
'agentSnapshot.redactedValueChanged': '值已脱敏,但确有变化',
|
||||
'agentSnapshot.noChangedContent': '无可显示变更',
|
||||
'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': '历史版本删除失败',
|
||||
'agentSnapshot.fetchFailed': '获取历史版本失败',
|
||||
'agentSnapshot.detailFailed': '获取版本详情失败',
|
||||
'agentSnapshot.correctWordCount': '共 {count} 个替换词',
|
||||
'agentSnapshot.source.config': '配置保存',
|
||||
'agentSnapshot.source.current': '当前配置',
|
||||
'agentSnapshot.source.restore': '恢复结果',
|
||||
'agentSnapshot.source.initial': '初始版本',
|
||||
'agentSnapshot.field.initial': '初始快照',
|
||||
'agentSnapshot.field.agentCode': '智能体编码',
|
||||
'agentSnapshot.field.agentName': '助手昵称',
|
||||
'agentSnapshot.field.asrModelId': '语音识别',
|
||||
'agentSnapshot.field.vadModelId': '语音活动检测',
|
||||
'agentSnapshot.field.llmModelId': '主语言模型',
|
||||
'agentSnapshot.field.slmModelId': '小参数模型',
|
||||
'agentSnapshot.field.vllmModelId': '视觉大模型',
|
||||
'agentSnapshot.field.ttsModelId': '语音合成',
|
||||
'agentSnapshot.field.ttsVoiceId': '声音音色',
|
||||
'agentSnapshot.field.ttsLanguage': '对话语言',
|
||||
'agentSnapshot.field.ttsVolume': '音量',
|
||||
'agentSnapshot.field.ttsRate': '语速',
|
||||
'agentSnapshot.field.ttsPitch': '音调',
|
||||
'agentSnapshot.field.memModelId': '记忆模式',
|
||||
'agentSnapshot.field.intentModelId': '意图识别',
|
||||
'agentSnapshot.field.chatHistoryConf': '聊天记录配置',
|
||||
'agentSnapshot.field.systemPrompt': '角色介绍',
|
||||
'agentSnapshot.field.summaryMemory': '记忆',
|
||||
'agentSnapshot.field.langCode': '语言编码',
|
||||
'agentSnapshot.field.language': '交互语种',
|
||||
'agentSnapshot.field.sort': '排序',
|
||||
'agentSnapshot.field.functions': '插件',
|
||||
'agentSnapshot.field.contextProviders': '上下文源',
|
||||
'agentSnapshot.field.correctWordFileIds': '替换词',
|
||||
'agentSnapshot.field.tagNames': '智能体标签',
|
||||
'agentSnapshot.chatHistoryConf.none': '不记录聊天记录',
|
||||
'agentSnapshot.chatHistoryConf.text': '上报文字',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': '上报文字+语音',
|
||||
'agentSnapshot.model.Memory_nomem': '无记忆',
|
||||
'agentSnapshot.model.Memory_mem_local_short': '本地短期记忆',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Mem0AI 记忆',
|
||||
'agentSnapshot.model.Memory_mem_report_only': '仅上报',
|
||||
'agentSnapshot.model.Intent_nointent': '无意图识别',
|
||||
'agentSnapshot.model.Intent_intent_llm': '外部大模型意图识别',
|
||||
'agentSnapshot.model.Intent_function_call': '大模型函数调用',
|
||||
|
||||
// 上下文源对话框相关
|
||||
'contextProviderDialog.title': '编辑源',
|
||||
'contextProviderDialog.noContextApi': '暂无上下文API',
|
||||
|
||||
@@ -29,9 +29,9 @@ export default {
|
||||
'login.requiredCaptcha': '驗證碼不能為空',
|
||||
'login.requiredMobile': '請輸入正確的手機號碼',
|
||||
'login.captchaError': '圖形驗證碼錯誤',
|
||||
'login.forgotPassword': '忘記密碼',
|
||||
'login.userAgreement': '用戶協議',
|
||||
'login.privacyPolicy': '隱私政策',
|
||||
'login.forgotPassword': '忘記密碼',
|
||||
'login.userAgreement': '用戶協議',
|
||||
'login.privacyPolicy': '隱私政策',
|
||||
|
||||
// 忘記密碼頁面
|
||||
'retrievePassword.title': '重置密碼',
|
||||
@@ -151,6 +151,7 @@ export default {
|
||||
'agent.saving': '儲存中...',
|
||||
'agent.saveSuccess': '儲存成功',
|
||||
'agent.saveFail': '儲存失敗',
|
||||
'agent.ttsOptionsLoadFailed': '語音選項載入失敗,已保留原來的語音設定',
|
||||
'agent.loadFail': '加載失敗',
|
||||
'agent.pleaseInputAgentName': '請輸入助手暱稱',
|
||||
'agent.pleaseInputRoleDescription': '請輸入角色介紹',
|
||||
@@ -167,6 +168,92 @@ export default {
|
||||
'agent.speedHint': '-100=最慢, 0=標準, 100=最快',
|
||||
'agent.pitchHint': '-100=最低, 0=標準, 100=最高',
|
||||
|
||||
// 智能體歷史版本
|
||||
'agentSnapshot.title': '歷史版本',
|
||||
'agentSnapshot.empty': '暫無歷史版本',
|
||||
'agentSnapshot.emptyTip': '保存配置後會生成歷史版本',
|
||||
'agentSnapshot.version': '版本',
|
||||
'agentSnapshot.createdAt': '保存時間',
|
||||
'agentSnapshot.source': '來源',
|
||||
'agentSnapshot.changedFields': '變更內容',
|
||||
'agentSnapshot.view': '查看',
|
||||
'agentSnapshot.restore': '恢復',
|
||||
'agentSnapshot.delete': '刪除',
|
||||
'agentSnapshot.loadMore': '載入更多',
|
||||
'agentSnapshot.detailTitle': '變更詳情',
|
||||
'agentSnapshot.restorePreviewTitle': '恢復預覽',
|
||||
'agentSnapshot.confirmRestore': '確認恢復',
|
||||
'agentSnapshot.currentVersion': '最新快照',
|
||||
'agentSnapshot.beforeChange': '變化前',
|
||||
'agentSnapshot.afterChange': '變化後',
|
||||
'agentSnapshot.beforeRestore': '恢復前',
|
||||
'agentSnapshot.afterRestore': '恢復後',
|
||||
'agentSnapshot.configValue': '配置值',
|
||||
'agentSnapshot.emptyValue': '無',
|
||||
'agentSnapshot.secretRedacted': '密鑰已隱藏',
|
||||
'agentSnapshot.redactedValueChanged': '值已脫敏,但確有變化',
|
||||
'agentSnapshot.noChangedContent': '無可顯示變更',
|
||||
'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': '歷史版本刪除失敗',
|
||||
'agentSnapshot.fetchFailed': '獲取歷史版本失敗',
|
||||
'agentSnapshot.detailFailed': '獲取版本詳情失敗',
|
||||
'agentSnapshot.correctWordCount': '共 {count} 個替換詞',
|
||||
'agentSnapshot.source.config': '配置保存',
|
||||
'agentSnapshot.source.current': '當前配置',
|
||||
'agentSnapshot.source.restore': '恢復結果',
|
||||
'agentSnapshot.source.initial': '初始版本',
|
||||
'agentSnapshot.field.initial': '初始快照',
|
||||
'agentSnapshot.field.agentCode': '智能體編碼',
|
||||
'agentSnapshot.field.agentName': '助手暱稱',
|
||||
'agentSnapshot.field.asrModelId': '語音識別',
|
||||
'agentSnapshot.field.vadModelId': '語音活動檢測',
|
||||
'agentSnapshot.field.llmModelId': '主語言模型',
|
||||
'agentSnapshot.field.slmModelId': '小參數模型',
|
||||
'agentSnapshot.field.vllmModelId': '視覺大模型',
|
||||
'agentSnapshot.field.ttsModelId': '語音合成',
|
||||
'agentSnapshot.field.ttsVoiceId': '聲音音色',
|
||||
'agentSnapshot.field.ttsLanguage': '對話語言',
|
||||
'agentSnapshot.field.ttsVolume': '音量',
|
||||
'agentSnapshot.field.ttsRate': '語速',
|
||||
'agentSnapshot.field.ttsPitch': '音調',
|
||||
'agentSnapshot.field.memModelId': '記憶模式',
|
||||
'agentSnapshot.field.intentModelId': '意圖識別',
|
||||
'agentSnapshot.field.chatHistoryConf': '聊天記錄配置',
|
||||
'agentSnapshot.field.systemPrompt': '角色介紹',
|
||||
'agentSnapshot.field.summaryMemory': '記憶',
|
||||
'agentSnapshot.field.langCode': '語言編碼',
|
||||
'agentSnapshot.field.language': '交互語種',
|
||||
'agentSnapshot.field.sort': '排序',
|
||||
'agentSnapshot.field.functions': '插件',
|
||||
'agentSnapshot.field.contextProviders': '上下文源',
|
||||
'agentSnapshot.field.correctWordFileIds': '替換詞',
|
||||
'agentSnapshot.field.tagNames': '智能體標籤',
|
||||
'agentSnapshot.chatHistoryConf.none': '不記錄聊天記錄',
|
||||
'agentSnapshot.chatHistoryConf.text': '上報文字',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': '上報文字+語音',
|
||||
'agentSnapshot.model.Memory_nomem': '無記憶',
|
||||
'agentSnapshot.model.Memory_mem_local_short': '本地短期記憶',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Mem0AI 記憶',
|
||||
'agentSnapshot.model.Memory_mem_report_only': '僅上報',
|
||||
'agentSnapshot.model.Intent_nointent': '無意圖識別',
|
||||
'agentSnapshot.model.Intent_intent_llm': '外部大模型意圖識別',
|
||||
'agentSnapshot.model.Intent_function_call': '大模型函數調用',
|
||||
|
||||
// 上下文源对话框相关
|
||||
'contextProviderDialog.title': '編輯源',
|
||||
'contextProviderDialog.noContextApi': '暫無上下文API',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ConfigProviderThemeVars } from 'wot-design-uni'
|
||||
import type { ConfigProviderThemeVars } from 'wot-design-uni/components/wd-config-provider/types'
|
||||
|
||||
const themeVars: ConfigProviderThemeVars = {
|
||||
// colorTheme: 'red',
|
||||
|
||||
@@ -38,10 +38,8 @@ onLoad(() => {
|
||||
<wd-tabbar
|
||||
v-if="customTabbarEnable"
|
||||
v-model="tabbarStore.curIdx"
|
||||
bordered
|
||||
safe-area-inset-bottom
|
||||
placeholder
|
||||
fixed
|
||||
|
||||
safe-area-inset-bottom bordered placeholder fixed
|
||||
@change="selectTabBar"
|
||||
>
|
||||
<block v-for="(item, idx) in tabbarList" :key="item.path">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ConfigProviderThemeVars } from 'wot-design-uni'
|
||||
import type { ConfigProviderThemeVars } from 'wot-design-uni/components/wd-config-provider/types'
|
||||
import FgTabbar from './fg-tabbar/fg-tabbar.vue'
|
||||
|
||||
const themeVars: ConfigProviderThemeVars = {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { routeInterceptor } from './router/interceptor'
|
||||
|
||||
import store from './store'
|
||||
import '@/style/index.scss'
|
||||
import 'virtual:uno.css'
|
||||
|
||||
// 导入国际化相关功能
|
||||
import { initI18n } from './i18n'
|
||||
import { useLangStore } from './store/lang'
|
||||
|
||||
import { routeInterceptor } from './router/interceptor'
|
||||
import store from './store'
|
||||
|
||||
import '@/style/index.scss'
|
||||
import 'virtual:uno.css'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"globalStyle": {
|
||||
"navigationStyle": "default",
|
||||
"navigationBarTitleText": "智控台",
|
||||
"navigationBarTitleText": "小智",
|
||||
"navigationBarBackgroundColor": "#f8f8f8",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#FFFFFF"
|
||||
@@ -74,6 +74,24 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agent/provider",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationBarTitleText": "编辑源",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agent/speedPitch",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationBarTitleText": "语音设置",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agent/tools",
|
||||
"type": "page",
|
||||
@@ -126,6 +144,42 @@
|
||||
"navigationBarTitleText": "Login"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/privacy-policy-en",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "Privacy Policy"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/privacy-policy-zh",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "隐私政策"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/user-agreement-en",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "User Agreement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/user-agreement-zh",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "用户协议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/register/index",
|
||||
"type": "page",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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/,
|
||||
)
|
||||
})
|
||||
@@ -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<Record<string, any>>} 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<string, unknown>}
|
||||
*/
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, updateAgent, updateAgentTags } from '@/api/agent/agent'
|
||||
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, updateAgent } from '@/api/agent/agent'
|
||||
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',
|
||||
@@ -64,6 +66,10 @@ const selectedTemplateId = ref('')
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const showSnapshotPanel = ref(false)
|
||||
const currentVersionNo = ref<number | null>(null)
|
||||
const snapshotReloadBlocked = ref(false)
|
||||
const snapshotReloadFailed = ref(false)
|
||||
|
||||
// 模型选项数据
|
||||
const modelOptions = ref<{
|
||||
@@ -79,9 +85,9 @@ const modelOptions = ref<{
|
||||
})
|
||||
|
||||
// 音色选项数据
|
||||
const voiceOptions = ref([])
|
||||
const voiceOptions = ref<any[]>([])
|
||||
// 保存完整的音色信息
|
||||
const voiceDetails = ref({})
|
||||
const voiceDetails = ref<Record<string, any>>({})
|
||||
|
||||
// 上报模式选项数据
|
||||
const reportOptions = [
|
||||
@@ -110,9 +116,25 @@ const allFunctions = ref<PluginDefinition[]>([])
|
||||
const dynamicTags = ref([])
|
||||
const inputValue = ref('')
|
||||
const inputVisible = ref(false)
|
||||
const languageOptions = ref([])
|
||||
const languageOptions = ref<any[]>([])
|
||||
const isVisibleReport = ref(false)
|
||||
const tempSummaryMemory = ref('')
|
||||
const selectedTtsLanguage = ref('')
|
||||
const ttsLanguageTouched = ref(false)
|
||||
const ttsVoiceTouched = ref(false)
|
||||
const ttsOptionsLoading = ref(false)
|
||||
const ttsOptionsModelId = ref('')
|
||||
const originalTagNames = ref<string[]>([])
|
||||
const originalAgentDetail = ref<AgentDetail | null>(null)
|
||||
let ttsOptionsRequestSequence = 0
|
||||
let agentDetailRequestSequence = 0
|
||||
let agentTagRequestSequence = 0
|
||||
let snapshotReloadSequence = 0
|
||||
|
||||
interface SnapshotRestoreContext {
|
||||
agentId: string
|
||||
actionSequence: number
|
||||
}
|
||||
|
||||
// 音频播放相关
|
||||
const audioRef = ref<UniApp.InnerAudioContext | null>(null)
|
||||
@@ -123,6 +145,91 @@ const pluginStore = usePluginStore()
|
||||
const speedPitchStore = useSpeedPitch()
|
||||
const providerStore = useProvider()
|
||||
|
||||
const EDITABLE_AGENT_FIELDS: Array<keyof AgentDetail> = [
|
||||
'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<string, any>
|
||||
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<string, any>),
|
||||
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<string, any>) {
|
||||
return EDITABLE_AGENT_FIELDS.reduce<Record<string, any>>((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<Record<string, any>>((result, key) => {
|
||||
result[key] = sortObjectKeys(value[key])
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function cloneSerializable<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
|
||||
// tabs
|
||||
const tabList = [
|
||||
{
|
||||
@@ -205,49 +312,97 @@ 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 = ''
|
||||
const detail = await getAgentDetail(agentId.value)
|
||||
const normalizedFunctions = normalizeAgentFunctions(detail.functions || [])
|
||||
formData.value = { ...detail, functions: normalizedFunctions }
|
||||
|
||||
// 更新插件store
|
||||
pluginStore.setCurrentAgentId(agentId.value)
|
||||
pluginStore.setCurrentFunctions(normalizedFunctions)
|
||||
|
||||
// 更新语速音调
|
||||
speedPitchStore.updateSpeedPitch({
|
||||
ttsVolume: detail.ttsVolume || 0,
|
||||
ttsRate: detail.ttsRate || 0,
|
||||
ttsPitch: detail.ttsPitch || 0,
|
||||
})
|
||||
|
||||
// 加载上下文配置
|
||||
providerStore.updateProviders(detail.contextProviders || [])
|
||||
|
||||
// 如果有TTS模型,加载对应的音色选项
|
||||
if (detail.ttsModelId) {
|
||||
await fetchAllLanguag(detail.ttsModelId)
|
||||
const detail = await getAgentDetail(targetAgentId)
|
||||
if (!isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 等待模型选项加载完成后再更新显示名称
|
||||
await nextTick()
|
||||
updateDisplayNames()
|
||||
applyPersistedAgentDetail(detail, targetAgentId)
|
||||
await enhanceAgentDetailMetadata(detail, targetAgentId, requestId)
|
||||
return isActiveAgentDetailRequest(targetAgentId, requestId)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载智能体详情失败:', error)
|
||||
toast.error(t('agent.loadFail'))
|
||||
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
console.error('加载智能体详情失败:', error)
|
||||
toast.error(t('agent.loadFail'))
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 || ''
|
||||
|
||||
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 || [])
|
||||
}
|
||||
|
||||
async function enhanceAgentDetailMetadata(detail: AgentDetail, targetAgentId: string, requestId: number) {
|
||||
try {
|
||||
if (detail.ttsModelId) {
|
||||
await fetchAllLanguag(detail.ttsModelId, {
|
||||
preferredLanguage: detail.ttsLanguage,
|
||||
preferredVoiceId: detail.ttsVoiceId,
|
||||
})
|
||||
}
|
||||
else {
|
||||
voiceOptions.value = []
|
||||
voiceDetails.value = {}
|
||||
languageOptions.value = []
|
||||
selectedTtsLanguage.value = ''
|
||||
}
|
||||
await nextTick()
|
||||
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
updateDisplayNames()
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// 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)
|
||||
@@ -344,7 +499,67 @@ async function loadModelOptions() {
|
||||
}
|
||||
|
||||
// 根据语言筛选音色
|
||||
function filterVoicesByLanguage() {
|
||||
interface VoiceSelectionOptions {
|
||||
autoSelectVoice?: boolean
|
||||
preferredLanguage?: string | null
|
||||
preferredVoiceId?: string | null
|
||||
}
|
||||
|
||||
interface TtsSelectionState {
|
||||
modelId: string
|
||||
voiceId: string
|
||||
language: string
|
||||
selectedLanguage: string
|
||||
languageTouched: boolean
|
||||
voiceTouched: boolean
|
||||
optionsModelId: string
|
||||
voiceOptions: any[]
|
||||
voiceDetails: Record<string, any>
|
||||
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 = []
|
||||
return
|
||||
@@ -353,13 +568,7 @@ function filterVoicesByLanguage() {
|
||||
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(formData.value.language)
|
||||
})
|
||||
const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
|
||||
|
||||
voiceOptions.value = filteredVoices.map(voice => ({
|
||||
value: voice.id,
|
||||
@@ -374,26 +583,46 @@ function filterVoicesByLanguage() {
|
||||
const currentVoiceSupportsLanguage = formData.value.ttsVoiceId
|
||||
&& filteredVoices.some(voice => voice.id === formData.value.ttsVoiceId)
|
||||
|
||||
if (!currentVoiceSupportsLanguage) {
|
||||
if (!currentVoiceSupportsLanguage && options.autoSelectVoice) {
|
||||
formData.value.ttsVoiceId = filteredVoices.length > 0 ? filteredVoices[0].id : ''
|
||||
displayNames.value.voiceprint = filteredVoices.length > 0 ? filteredVoices[0].name : ''
|
||||
ttsVoiceTouched.value = true
|
||||
}
|
||||
else {
|
||||
displayNames.value.voiceprint = filteredVoices.find(item => item.id === formData.value.ttsVoiceId)?.name
|
||||
|| getVoiceDisplayName(formData.value.ttsVoiceId)
|
||||
}
|
||||
}
|
||||
|
||||
// 同步到ttsSettings(如果值为null,使用0作为显示默认值,但不修改form中的值)
|
||||
speedPitchStore.updateSpeedPitch({
|
||||
ttsVolume: formData.value.ttsVolume !== null && formData.value.ttsVolume !== undefined ? formData.value.ttsVolume : 0,
|
||||
ttsRate: formData.value.ttsRate !== null && formData.value.ttsRate !== undefined ? formData.value.ttsRate : 0,
|
||||
ttsPitch: formData.value.ttsPitch !== null && formData.value.ttsPitch !== undefined ? formData.value.ttsPitch : 0,
|
||||
})
|
||||
function getVoiceDefaultLanguage(ttsVoiceId: string) {
|
||||
if (!ttsVoiceId || !voiceDetails.value?.[ttsVoiceId]?.languages) {
|
||||
return ''
|
||||
}
|
||||
const languages = voiceDetails.value[ttsVoiceId].languages
|
||||
.split(/[、;;,,]/)
|
||||
.map(lang => lang.trim())
|
||||
.filter(Boolean)
|
||||
return languages[0] || ''
|
||||
}
|
||||
|
||||
// 根据语音合成模型加载语言
|
||||
async function fetchAllLanguag(ttsModelId: string) {
|
||||
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 '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) => {
|
||||
acc[voice.id] = voice
|
||||
@@ -412,21 +641,52 @@ async function fetchAllLanguag(ttsModelId: string) {
|
||||
name: lang,
|
||||
}))
|
||||
|
||||
// 使用后端返回的用户选择的语言,如果没有则使用第一个语言选项
|
||||
if (formData.value.ttsLanguage && languageOptions.value.some(option => option.value === formData.value.ttsLanguage)) {
|
||||
formData.value.language = formData.value.ttsLanguage
|
||||
const requestedLanguage = options.preferredLanguage
|
||||
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
|
||||
displayNames.value.language = requestedLanguage
|
||||
}
|
||||
else if (preferredVoiceLanguage && languageOptions.value.some(option => option.value === preferredVoiceLanguage)) {
|
||||
selectedTtsLanguage.value = preferredVoiceLanguage
|
||||
displayNames.value.language = preferredVoiceLanguage
|
||||
}
|
||||
else if (formData.value.ttsLanguage && languageOptions.value.some(option => option.value === formData.value.ttsLanguage)) {
|
||||
selectedTtsLanguage.value = formData.value.ttsLanguage
|
||||
displayNames.value.language = formData.value.ttsLanguage
|
||||
}
|
||||
else if (getVoiceDefaultLanguage(formData.value.ttsVoiceId)) {
|
||||
selectedTtsLanguage.value = getVoiceDefaultLanguage(formData.value.ttsVoiceId)
|
||||
displayNames.value.language = selectedTtsLanguage.value
|
||||
}
|
||||
else if (languageOptions.value.length > 0) {
|
||||
formData.value.language = languageOptions.value[0].value
|
||||
selectedTtsLanguage.value = languageOptions.value[0].value
|
||||
displayNames.value.language = languageOptions.value[0].value
|
||||
}
|
||||
|
||||
// 根据选中的语言筛选音色
|
||||
filterVoicesByLanguage()
|
||||
filterVoicesByLanguage(options)
|
||||
ttsOptionsModelId.value = ttsModelId
|
||||
return 'loaded'
|
||||
}
|
||||
catch {
|
||||
languageOptions.value = []
|
||||
catch (error) {
|
||||
if (requestId === ttsOptionsRequestSequence) {
|
||||
console.error('Failed to load TTS options:', error)
|
||||
ttsOptionsModelId.value = ''
|
||||
}
|
||||
return requestId === ttsOptionsRequestSequence ? 'failed' : 'stale'
|
||||
}
|
||||
finally {
|
||||
if (requestId === ttsOptionsRequestSequence) {
|
||||
ttsOptionsLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +708,10 @@ async function fetchAllLanguag(ttsModelId: string) {
|
||||
// }
|
||||
|
||||
// 选择角色模板
|
||||
function selectRoleTemplate(templateId: string) {
|
||||
async function selectRoleTemplate(templateId: string) {
|
||||
if (ttsOptionsLoading.value) {
|
||||
return
|
||||
}
|
||||
if (selectedTemplateId.value === templateId) {
|
||||
selectedTemplateId.value = ''
|
||||
return
|
||||
@@ -457,6 +720,9 @@ 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 = {
|
||||
...formData.value,
|
||||
systemPrompt: template.systemPrompt || formData.value.systemPrompt,
|
||||
@@ -469,18 +735,40 @@ function selectRoleTemplate(templateId: string) {
|
||||
memModelId: template.memModelId || formData.value.memModelId,
|
||||
ttsModelId: template.ttsModelId || formData.value.ttsModelId,
|
||||
ttsVoiceId: template.ttsVoiceId || formData.value.ttsVoiceId,
|
||||
ttsLanguage: hasTemplateTtsLanguage ? templateTtsLanguage : formData.value.ttsLanguage,
|
||||
agentName: template.agentName || formData.value.agentName,
|
||||
chatHistoryConf: template.chatHistoryConf || formData.value.chatHistoryConf,
|
||||
summaryMemory: template.summaryMemory || formData.value.summaryMemory,
|
||||
langCode: template.langCode || formData.value.langCode,
|
||||
}
|
||||
fetchAllLanguag(template.ttsModelId || formData.value.ttsModelId)
|
||||
if (hasTemplateTtsLanguage) {
|
||||
selectedTtsLanguage.value = templateTtsLanguage
|
||||
displayNames.value.language = templateTtsLanguage
|
||||
}
|
||||
if (template.ttsModelId || template.ttsVoiceId || hasTemplateTtsLanguage) {
|
||||
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
|
||||
}
|
||||
}
|
||||
updateDisplayNames()
|
||||
}
|
||||
}
|
||||
|
||||
// 打开选择器
|
||||
function openPicker(type: string) {
|
||||
if (ttsOptionsLoading.value && (type === 'tts' || type === 'language' || type === 'voiceprint')) {
|
||||
return
|
||||
}
|
||||
pickerShow.value[type] = true
|
||||
}
|
||||
|
||||
@@ -488,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
|
||||
|
||||
@@ -526,16 +815,34 @@ async function onPickerConfirm(type: string, value: any, name: string) {
|
||||
tempSummaryMemory.value = ''
|
||||
}
|
||||
break
|
||||
case 'tts':
|
||||
case 'tts': {
|
||||
const preferredLanguage = selectedTtsLanguage.value
|
||||
formData.value.ttsModelId = value
|
||||
await fetchAllLanguag(value)
|
||||
formData.value.ttsVoiceId = ''
|
||||
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':
|
||||
formData.value.language = value
|
||||
filterVoicesByLanguage()
|
||||
selectedTtsLanguage.value = value
|
||||
formData.value.ttsLanguage = value
|
||||
ttsLanguageTouched.value = true
|
||||
filterVoicesByLanguage({ autoSelectVoice: true })
|
||||
break
|
||||
case 'voiceprint':
|
||||
formData.value.ttsVoiceId = value
|
||||
ttsVoiceTouched.value = true
|
||||
if (selectedTtsLanguage.value) {
|
||||
formData.value.ttsLanguage = selectedTtsLanguage.value
|
||||
ttsLanguageTouched.value = true
|
||||
}
|
||||
displayNames.value.voiceprint = name // 确保显示名称正确更新
|
||||
break
|
||||
case 'report':
|
||||
@@ -623,6 +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
|
||||
@@ -633,26 +962,46 @@ async function saveAgent() {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await handleUpdateAgentTags()
|
||||
}
|
||||
catch (err) {
|
||||
toast.error(err)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
const tagNames = dynamicTags.value.map(tag => tag.tagName)
|
||||
const tagsChanged = !isSameStringList(tagNames, originalTagNames.value)
|
||||
// 构建保存数据,包含上下文配置和语音设置
|
||||
const saveData = {
|
||||
const saveData: Record<string, any> = {
|
||||
...formData.value,
|
||||
...speedPitchStore.speedPitch,
|
||||
ttsLanguage: formData.value.language,
|
||||
contextProviders: providerStore.providers,
|
||||
functions: normalizeAgentFunctions(formData.value.functions || []),
|
||||
}
|
||||
delete saveData.ttsVolume
|
||||
delete saveData.ttsRate
|
||||
delete saveData.ttsPitch
|
||||
delete saveData.ttsLanguage
|
||||
delete saveData.ttsVoiceId
|
||||
if (ttsLanguageTouched.value) {
|
||||
saveData.ttsLanguage = selectedTtsLanguage.value
|
||||
}
|
||||
if (ttsVoiceTouched.value) {
|
||||
saveData.ttsVoiceId = formData.value.ttsVoiceId
|
||||
}
|
||||
const changedTtsFields = new Set(speedPitchStore.changedFields)
|
||||
if (changedTtsFields.has('ttsVolume')) {
|
||||
saveData.ttsVolume = speedPitchStore.speedPitch.ttsVolume
|
||||
}
|
||||
if (changedTtsFields.has('ttsRate')) {
|
||||
saveData.ttsRate = speedPitchStore.speedPitch.ttsRate
|
||||
}
|
||||
if (changedTtsFields.has('ttsPitch')) {
|
||||
saveData.ttsPitch = speedPitchStore.speedPitch.ttsPitch
|
||||
}
|
||||
if (tagsChanged) {
|
||||
saveData.tagNames = tagNames
|
||||
}
|
||||
await updateAgent(agentId.value, saveData)
|
||||
loadAgentDetail()
|
||||
if (tagsChanged) {
|
||||
originalTagNames.value = [...tagNames]
|
||||
}
|
||||
speedPitchStore.resetChangedFields()
|
||||
await loadAgentDetail()
|
||||
|
||||
toast.success(t('agent.saveSuccess'))
|
||||
}
|
||||
@@ -696,18 +1045,123 @@ 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 handleUpdateAgentTags() {
|
||||
const tagNames = dynamicTags.value.map(tag => tag.tagName)
|
||||
await updateAgentTags(agentId.value, { tagNames })
|
||||
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[]) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
// 监听store中的插件配置变化
|
||||
@@ -715,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()
|
||||
|
||||
@@ -726,7 +1198,7 @@ onMounted(async () => {
|
||||
])
|
||||
|
||||
// 然后加载智能体详情,这样可以正确映射显示名称
|
||||
if (agentId.value) {
|
||||
if (agentId.value && !snapshotReloadBlocked.value) {
|
||||
await loadAgentDetail()
|
||||
}
|
||||
})
|
||||
@@ -734,6 +1206,26 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<view class="bg-[#f5f7fb] px-[20rpx]">
|
||||
<view class="mb-[24rpx] flex items-center justify-between border border-[#eeeeee] rounded-[20rpx] bg-white p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
<view>
|
||||
<text class="block text-[32rpx] text-[#232338] font-bold">
|
||||
{{ t('agent.editTitle') }}
|
||||
</text>
|
||||
<text v-if="currentVersionNo" class="mt-[8rpx] block text-[24rpx] text-[#65686f]">
|
||||
{{ t('agentSnapshot.currentVersion') }} #{{ currentVersionNo }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="saving || snapshotReloadBlocked"
|
||||
custom-class="!bg-[#336cff] !h-[64rpx] !rounded-[32rpx]"
|
||||
@click="openSnapshotPanel"
|
||||
>
|
||||
{{ t('agentSnapshot.title') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 基础信息标题
|
||||
<view class="pb-[20rpx] first:pt-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
@@ -1005,10 +1497,27 @@ onMounted(async () => {
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<view class="mt-[40rpx] p-0">
|
||||
<view
|
||||
v-if="snapshotReloadBlocked"
|
||||
class="mb-[18rpx] rounded-[16rpx] bg-[rgba(245,108,108,0.1)] p-[20rpx] text-[24rpx] text-[#a34848] leading-[1.6]"
|
||||
>
|
||||
<text class="block">
|
||||
{{ t(snapshotReloadFailed ? 'agentSnapshot.reloadAfterRestoreFailed' : 'agentSnapshot.reloadAfterRestorePending') }}
|
||||
</text>
|
||||
<wd-button
|
||||
v-if="snapshotReloadFailed"
|
||||
size="small"
|
||||
type="info"
|
||||
custom-class="mt-[14rpx] !h-[60rpx]"
|
||||
@click="retrySnapshotReload"
|
||||
>
|
||||
{{ t('agentSnapshot.retryReload') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="saving"
|
||||
:disabled="saving || ttsOptionsLoading || snapshotReloadBlocked"
|
||||
custom-class="w-full h-[80rpx] rounded-[16rpx] text-[30rpx] font-semibold bg-[#336cff] active:bg-[#2d5bd1]"
|
||||
@click="saveAgent"
|
||||
>
|
||||
@@ -1109,6 +1618,14 @@ onMounted(async () => {
|
||||
@close="onPickerCancel('report')"
|
||||
@select="({ item }) => onPickerConfirm('report', item.value, item.name)"
|
||||
/>
|
||||
<AgentSnapshotPanel
|
||||
v-model:visible="showSnapshotPanel"
|
||||
:agent-id="agentId"
|
||||
:current-version-no="currentVersionNo"
|
||||
:has-unsaved-changes="hasUnsavedChanges"
|
||||
:mutation-busy="saving || snapshotReloadBlocked"
|
||||
@restored="handleSnapshotRestored"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import CustomTabs from '@/components/custom-tabs/index.vue'
|
||||
import { t } from '@/i18n'
|
||||
import ChatHistory from '@/pages/chat-history/index.vue'
|
||||
|
||||
@@ -17,6 +17,7 @@ defineOptions({
|
||||
})
|
||||
|
||||
const speedPitchStore = useSpeedPitch()
|
||||
const SPEED_PITCH_FIELDS = ['ttsVolume', 'ttsRate', 'ttsPitch'] as const
|
||||
|
||||
const localSettings = ref({
|
||||
ttsVolume: 0,
|
||||
@@ -25,7 +26,10 @@ const localSettings = ref({
|
||||
})
|
||||
|
||||
function handleConfirm() {
|
||||
speedPitchStore.updateSpeedPitch(localSettings.value)
|
||||
const changedFields = SPEED_PITCH_FIELDS.filter((field) => {
|
||||
return localSettings.value[field] !== speedPitchStore.speedPitch[field]
|
||||
})
|
||||
speedPitchStore.updateSpeedPitch(localSettings.value, { changedFields })
|
||||
goBack()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { useMessage } from 'wot-design-uni/components/wd-message-box'
|
||||
import { getMcpAddress, getMcpTools } from '@/api/agent/agent'
|
||||
import { t } from '@/i18n'
|
||||
import { usePluginStore } from '@/store'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { useToast } from 'wot-design-uni/components/wd-toast'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
// 类型定义
|
||||
@@ -228,15 +228,15 @@ async function generateAndPlay() {
|
||||
generating.value = true
|
||||
|
||||
try {
|
||||
console.log(t('deviceConfig.generatingUltrasonicConfigAudio') + '...')
|
||||
console.log(`${t('deviceConfig.generatingUltrasonicConfigAudio')}...`)
|
||||
|
||||
// 准备配网数据 - 参考HTML文件格式
|
||||
const dataStr = `${props.selectedNetwork.ssid}\n${props.password}`
|
||||
const textBytes = stringToBytes(dataStr)
|
||||
const fullBytes = [...START_BYTES, ...textBytes, checksum(textBytes), ...END_BYTES]
|
||||
|
||||
console.log(t('deviceConfig.configData') + ':', { ssid: props.selectedNetwork.ssid, password: props.password })
|
||||
console.log(t('deviceConfig.dataBytesLength') + ':', textBytes.length)
|
||||
console.log(`${t('deviceConfig.configData')}:`, { ssid: props.selectedNetwork.ssid, password: props.password })
|
||||
console.log(`${t('deviceConfig.dataBytesLength')}:`, textBytes.length)
|
||||
|
||||
// 转换为比特流
|
||||
let bits: number[] = []
|
||||
@@ -244,7 +244,7 @@ async function generateAndPlay() {
|
||||
bits = bits.concat(toBits(b))
|
||||
})
|
||||
|
||||
console.log(t('deviceConfig.bitStreamLength') + ':', bits.length)
|
||||
console.log(`${t('deviceConfig.bitStreamLength')}:`, bits.length)
|
||||
|
||||
// AFSK调制 - 减少采样率降低文件大小
|
||||
const reducedSampleRate = 22050 // 降低采样率
|
||||
@@ -267,7 +267,7 @@ async function generateAndPlay() {
|
||||
const base64 = arrayBufferToBase64(wavBuffer)
|
||||
const dataUri = `data:audio/wav;base64,${base64}`
|
||||
|
||||
console.log(t('deviceConfig.base64Length') + ':', base64.length, t('deviceConfig.about'), Math.round(base64.length / 1024), 'KB')
|
||||
console.log(`${t('deviceConfig.base64Length')}:`, base64.length, t('deviceConfig.about'), Math.round(base64.length / 1024), 'KB')
|
||||
|
||||
// 检查数据大小
|
||||
if (base64.length > 1024 * 1024) { // 超过1MB
|
||||
@@ -277,7 +277,7 @@ async function generateAndPlay() {
|
||||
audioFilePath.value = dataUri
|
||||
audioGenerated.value = true
|
||||
|
||||
console.log(t('deviceConfig.audioGenerationSuccess') + ',比特流长度:', bits.length, t('deviceConfig.samplePoints') + ':', floatBuf.length)
|
||||
console.log(`${t('deviceConfig.audioGenerationSuccess')},比特流长度:`, bits.length, `${t('deviceConfig.samplePoints')}:`, floatBuf.length)
|
||||
|
||||
toast.success(t('deviceConfig.soundWaveGenerationSuccess'))
|
||||
|
||||
@@ -287,8 +287,8 @@ async function generateAndPlay() {
|
||||
}, 800) // 增加延迟时间
|
||||
}
|
||||
catch (error) {
|
||||
console.error(t('deviceConfig.audioGenerationFailed') + ':', error)
|
||||
toast.error(`${t('deviceConfig.soundWaveGenerationFailed')}: ${error.message || error}`)
|
||||
console.error(`${t('deviceConfig.audioGenerationFailed')}:`, error)
|
||||
toast.error(`${t('deviceConfig.soundWaveGenerationFailed')}: ${error.message || error}`)
|
||||
}
|
||||
finally {
|
||||
generating.value = false
|
||||
@@ -384,7 +384,7 @@ async function playAudio() {
|
||||
})
|
||||
|
||||
innerAudioContext.onError((error) => {
|
||||
console.error(t('deviceConfig.audioPlaybackFailed') + ':', error)
|
||||
console.error(`${t('deviceConfig.audioPlaybackFailed')}:`, error)
|
||||
playing.value = false
|
||||
|
||||
let errorMsg = t('deviceConfig.audioPlaybackFailed')
|
||||
@@ -417,10 +417,10 @@ async function playAudio() {
|
||||
}, 300)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(t('deviceConfig.audioPlaybackError') + ':', error)
|
||||
playing.value = false
|
||||
await cleanupAudio()
|
||||
toast.error(`${t('deviceConfig.playbackFailed')}: ${error.message}`)
|
||||
console.error(`${t('deviceConfig.audioPlaybackError')}:`, error)
|
||||
playing.value = false
|
||||
await cleanupAudio()
|
||||
toast.error(`${t('deviceConfig.playbackFailed')}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +433,7 @@ async function cleanupAudio() {
|
||||
console.log(t('deviceConfig.cleaningUpAudioContext'))
|
||||
}
|
||||
catch (e) {
|
||||
console.log(t('deviceConfig.cleaningUpAudioContextFailed') + ':', e)
|
||||
console.log(`${t('deviceConfig.cleaningUpAudioContextFailed')}:`, e)
|
||||
}
|
||||
finally {
|
||||
audioContext.value = null
|
||||
@@ -483,38 +483,38 @@ async function stopAudio() {
|
||||
:disabled="!canGenerate"
|
||||
@click="generateAndPlay"
|
||||
>
|
||||
{{ generating ? t('deviceConfig.generating') : '🎵 ' + t('deviceConfig.generateAndPlaySoundWave') }}
|
||||
</wd-button>
|
||||
{{ generating ? t('deviceConfig.generating') : `🎵 ${t('deviceConfig.generateAndPlaySoundWave')}` }}
|
||||
</wd-button>
|
||||
|
||||
<wd-button
|
||||
v-if="audioGenerated"
|
||||
type="success"
|
||||
size="large"
|
||||
block
|
||||
:loading="playing"
|
||||
@click="playAudio"
|
||||
>
|
||||
{{ playing ? t('deviceConfig.playing') : '🔊 ' + t('deviceConfig.playSoundWave') }}
|
||||
</wd-button>
|
||||
<wd-button
|
||||
v-if="audioGenerated"
|
||||
type="success"
|
||||
size="large"
|
||||
block
|
||||
:loading="playing"
|
||||
@click="playAudio"
|
||||
>
|
||||
{{ playing ? t('deviceConfig.playing') : `🔊 ${t('deviceConfig.playSoundWave')}` }}
|
||||
</wd-button>
|
||||
|
||||
<wd-button
|
||||
v-if="playing"
|
||||
type="warning"
|
||||
size="large"
|
||||
block
|
||||
@click="stopAudio"
|
||||
>
|
||||
⏹️ {{ t('deviceConfig.stopPlaying') }}
|
||||
</wd-button>
|
||||
<wd-button
|
||||
v-if="playing"
|
||||
type="warning"
|
||||
size="large"
|
||||
block
|
||||
@click="stopAudio"
|
||||
>
|
||||
⏹️ {{ t('deviceConfig.stopPlaying') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 音频控制选项 -->
|
||||
<view v-if="audioGenerated" class="audio-options">
|
||||
<view class="option-item">
|
||||
<wd-checkbox v-model="autoLoop">
|
||||
{{ t('deviceConfig.autoLoopPlaySoundWave') }}
|
||||
</wd-checkbox>
|
||||
</view>
|
||||
<wd-checkbox v-model="autoLoop">
|
||||
{{ t('deviceConfig.autoLoopPlaySoundWave') }}
|
||||
</wd-checkbox>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 音频播放器 -->
|
||||
@@ -531,33 +531,33 @@ async function stopAudio() {
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<view class="help-section">
|
||||
<view class="help-title">
|
||||
{{ t('deviceConfig.ultrasonicConfigInstructions') }}
|
||||
</view>
|
||||
<view class="help-content">
|
||||
<text class="help-item">
|
||||
1. {{ t('deviceConfig.ensureWifiNetworkSelectedAndPasswordEntered') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
2. {{ t('deviceConfig.clickGenerateAndPlaySoundWave') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
3. {{ t('deviceConfig.bringPhoneCloseToXiaozhiDevice') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
4. {{ t('deviceConfig.duringAudioPlaybackXiaozhiWillReceive') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
5. {{ t('deviceConfig.afterConfigSuccessDeviceWillConnect') }}
|
||||
</text>
|
||||
<text class="help-tip">
|
||||
{{ t('deviceConfig.usesAfskModulation') }}
|
||||
</text>
|
||||
<text class="help-tip">
|
||||
{{ t('deviceConfig.ensureModeratePhoneVolume') }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="help-title">
|
||||
{{ t('deviceConfig.ultrasonicConfigInstructions') }}
|
||||
</view>
|
||||
<view class="help-content">
|
||||
<text class="help-item">
|
||||
1. {{ t('deviceConfig.ensureWifiNetworkSelectedAndPasswordEntered') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
2. {{ t('deviceConfig.clickGenerateAndPlaySoundWave') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
3. {{ t('deviceConfig.bringPhoneCloseToXiaozhiDevice') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
4. {{ t('deviceConfig.duringAudioPlaybackXiaozhiWillReceive') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
5. {{ t('deviceConfig.afterConfigSuccessDeviceWillConnect') }}
|
||||
</text>
|
||||
<text class="help-tip">
|
||||
{{ t('deviceConfig.usesAfskModulation') }}
|
||||
</text>
|
||||
<text class="help-tip">
|
||||
{{ t('deviceConfig.ensureModeratePhoneVolume') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { useToast } from 'wot-design-uni/components/wd-toast'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
// 类型定义
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import UltrasonicConfig from './components/ultrasonic-config.vue'
|
||||
import WifiConfig from './components/wifi-config.vue'
|
||||
@@ -68,12 +68,10 @@ function onNetworkSelected(network: WiFiNetwork | null, password: string) {
|
||||
function onConnectionStatusChange(connected: boolean) {
|
||||
console.log('ESP32连接状态:', connected)
|
||||
}
|
||||
|
||||
// 在组件挂载后设置导航栏标题
|
||||
import { onMounted } from 'vue'
|
||||
onMounted(() => {
|
||||
uni.setNavigationBarTitle({
|
||||
title: t('deviceConfig.pageTitle')
|
||||
title: t('deviceConfig.pageTitle'),
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -86,18 +84,18 @@ onMounted(() => {
|
||||
<!-- 配网方式选择 -->
|
||||
<view class="pb-[20rpx] first:pt-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
{{ t('deviceConfig.configMethod') }}
|
||||
</text>
|
||||
{{ t('deviceConfig.configMethod') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:border-[#336cff] active:bg-[#eef3ff]" @click="showConfigTypeSelector">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
{{ t('deviceConfig.configMethod') }}
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ configType === 'wifi' ? t('deviceConfig.wifiConfig') : t('deviceConfig.ultrasonicConfig') }}
|
||||
</text>
|
||||
{{ t('deviceConfig.configMethod') }}
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ configType === 'wifi' ? t('deviceConfig.wifiConfig') : t('deviceConfig.ultrasonicConfig') }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -105,8 +103,8 @@ onMounted(() => {
|
||||
<!-- WiFi网络选择 -->
|
||||
<view class="pb-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
{{ t('deviceConfig.networkConfig') }}
|
||||
</text>
|
||||
{{ t('deviceConfig.networkConfig') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Device, FirmwareType } from '@/api/device'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { useMessage } from 'wot-design-uni/components/wd-message-box'
|
||||
import { bindDevice, bindDeviceManual, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
|
||||
import { t } from '@/i18n'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
@@ -9,23 +9,23 @@
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { useConfigStore } from "@/store";
|
||||
import { getEnvBaseUrl, sm2Encrypt } from "@/utils";
|
||||
import { toast } from "@/utils/toast";
|
||||
// 导入国际化相关功能
|
||||
import { t, initI18n } from "@/i18n";
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
// 导入API接口
|
||||
import { retrievePassword, sendSmsCode } from "@/api/auth";
|
||||
import { retrievePassword, sendSmsCode } from '@/api/auth'
|
||||
// 导入国际化相关功能
|
||||
import { initI18n, t } from '@/i18n'
|
||||
import { useConfigStore } from '@/store'
|
||||
import { getEnvBaseUrl, sm2Encrypt } from '@/utils'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets;
|
||||
let systemInfo;
|
||||
let safeAreaInsets
|
||||
let systemInfo
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序使用新的API
|
||||
systemInfo = uni.getWindowInfo();
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
@@ -33,147 +33,148 @@ safeAreaInsets = systemInfo.safeArea
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null;
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
// 其他平台继续使用uni API
|
||||
systemInfo = uni.getSystemInfoSync();
|
||||
safeAreaInsets = systemInfo.safeAreaInsets;
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
|
||||
// 表单数据
|
||||
interface ForgotPasswordData {
|
||||
mobile: string;
|
||||
captcha: string;
|
||||
captchaId: string;
|
||||
mobileCaptcha: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
areaCode: string;
|
||||
mobile: string
|
||||
captcha: string
|
||||
captchaId: string
|
||||
mobileCaptcha: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
areaCode: string
|
||||
}
|
||||
|
||||
const formData = ref<ForgotPasswordData>({
|
||||
mobile: "",
|
||||
captcha: "",
|
||||
captchaId: "",
|
||||
mobileCaptcha: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
areaCode: "+86",
|
||||
});
|
||||
mobile: '',
|
||||
captcha: '',
|
||||
captchaId: '',
|
||||
mobileCaptcha: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
areaCode: '+86',
|
||||
})
|
||||
|
||||
// 验证码图片
|
||||
const captchaImage = ref("");
|
||||
const loading = ref(false);
|
||||
const captchaImage = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
// 获取配置store
|
||||
const configStore = useConfigStore();
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// State for area code action sheet
|
||||
const showAreaCodeSheet = ref(false);
|
||||
const selectedAreaCode = ref("+86");
|
||||
const showAreaCodeSheet = ref(false)
|
||||
const selectedAreaCode = ref('+86')
|
||||
// 短信验证码倒计时
|
||||
const smsCountdown = ref(0)
|
||||
const smsLoading = ref(false)
|
||||
const areaCodeList = computed(() =>
|
||||
(configStore.config.mobileAreaList || []).map((item) => {
|
||||
return {
|
||||
value: item.key,
|
||||
label: `${item.name} (${item.key})`,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const canSendMobileCaptcha = computed(() => {
|
||||
const mobile = formData.value.mobile;
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
return phoneRegex.test(mobile) && smsCountdown.value === 0;
|
||||
});
|
||||
const mobile = formData.value.mobile
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
return phoneRegex.test(mobile) && smsCountdown.value === 0
|
||||
})
|
||||
|
||||
// SM2公钥
|
||||
const sm2PublicKey = computed(() => {
|
||||
return configStore.config.sm2PublicKey;
|
||||
});
|
||||
|
||||
// 短信验证码倒计时
|
||||
const smsCountdown = ref(0);
|
||||
const smsLoading = ref(false);
|
||||
return configStore.config.sm2PublicKey
|
||||
})
|
||||
|
||||
// 打开区号选择弹窗
|
||||
function openAreaCodeSheet() {
|
||||
showAreaCodeSheet.value = true;
|
||||
showAreaCodeSheet.value = true
|
||||
}
|
||||
|
||||
// 选择区号
|
||||
function selectAreaCode(item: { value: string; label: string }) {
|
||||
selectedAreaCode.value = item.value;
|
||||
formData.value.areaCode = item.value;
|
||||
showAreaCodeSheet.value = false;
|
||||
function selectAreaCode(item: { value: string, label: string }) {
|
||||
selectedAreaCode.value = item.value
|
||||
formData.value.areaCode = item.value
|
||||
showAreaCodeSheet.value = false
|
||||
}
|
||||
|
||||
// 关闭区号选择弹窗
|
||||
function closeAreaCodeSheet() {
|
||||
showAreaCodeSheet.value = false;
|
||||
showAreaCodeSheet.value = false
|
||||
}
|
||||
|
||||
// 生成UUID
|
||||
function generateUUID() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取图形验证码
|
||||
async function refreshCaptcha() {
|
||||
const uuid = generateUUID();
|
||||
formData.value.captchaId = uuid;
|
||||
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`;
|
||||
const uuid = generateUUID()
|
||||
formData.value.captchaId = uuid
|
||||
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`
|
||||
}
|
||||
|
||||
// 发送短信验证码
|
||||
async function handleSendSmsCode() {
|
||||
// 手机号格式验证
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
if (!phoneRegex.test(formData.value.mobile)) {
|
||||
toast.warning(t("retrievePassword.inputCorrectMobile"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.inputCorrectMobile'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.captcha) {
|
||||
toast.warning(t("retrievePassword.captchaRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.captchaRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
smsLoading.value = true;
|
||||
smsLoading.value = true
|
||||
// 将手机号转换为国际格式
|
||||
const internationalPhone = formData.value.areaCode + formData.value.mobile;
|
||||
const internationalPhone = formData.value.areaCode + formData.value.mobile
|
||||
// 调用发送短信验证码API
|
||||
await sendSmsCode({
|
||||
phone: internationalPhone,
|
||||
captcha: formData.value.captcha,
|
||||
captchaId: formData.value.captchaId
|
||||
captchaId: formData.value.captchaId,
|
||||
})
|
||||
|
||||
toast.success(t("retrievePassword.captchaSendSuccess"));
|
||||
toast.success(t('retrievePassword.captchaSendSuccess'))
|
||||
|
||||
// 开始倒计时
|
||||
smsCountdown.value = 60;
|
||||
smsCountdown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
smsCountdown.value--;
|
||||
smsCountdown.value--
|
||||
if (smsCountdown.value <= 0) {
|
||||
clearInterval(timer);
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error: any) {
|
||||
}, 1000)
|
||||
}
|
||||
catch (error: any) {
|
||||
// 处理验证码错误
|
||||
if (error.message.includes("请求错误[10067]")) {
|
||||
toast.warning(t("login.captchaError"));
|
||||
if (error.message.includes('请求错误[10067]')) {
|
||||
toast.warning(t('login.captchaError'))
|
||||
}
|
||||
// 发送失败重新获取图形验证码
|
||||
refreshCaptcha();
|
||||
} finally {
|
||||
smsLoading.value = false;
|
||||
refreshCaptcha()
|
||||
}
|
||||
finally {
|
||||
smsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,64 +182,65 @@ async function handleSendSmsCode() {
|
||||
async function handleResetPassword() {
|
||||
// 表单验证
|
||||
if (!formData.value.mobile) {
|
||||
toast.warning(t("retrievePassword.mobileRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.mobileRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// 手机号格式验证
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
if (!phoneRegex.test(formData.value.mobile)) {
|
||||
toast.warning(t("retrievePassword.inputCorrectMobile"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.inputCorrectMobile'))
|
||||
return
|
||||
}
|
||||
|
||||
// 将手机号转换为国际格式
|
||||
const internationalPhone = formData.value.areaCode + formData.value.mobile;
|
||||
const internationalPhone = formData.value.areaCode + formData.value.mobile
|
||||
|
||||
if (!formData.value.captcha) {
|
||||
toast.warning(t("retrievePassword.captchaRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.captchaRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.mobileCaptcha) {
|
||||
toast.warning(t("retrievePassword.mobileCaptchaRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.mobileCaptchaRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.newPassword) {
|
||||
toast.warning(t("retrievePassword.newPasswordRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.newPasswordRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.confirmPassword) {
|
||||
toast.warning(t("retrievePassword.confirmNewPasswordRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.confirmNewPasswordRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.value.newPassword !== formData.value.confirmPassword) {
|
||||
toast.warning(t("retrievePassword.passwordsNotMatch"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.passwordsNotMatch'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
|
||||
// 检查SM2公钥是否配置
|
||||
if (!sm2PublicKey.value) {
|
||||
toast.warning(t("sm2.publicKeyNotConfigured"));
|
||||
return;
|
||||
toast.warning(t('sm2.publicKeyNotConfigured'))
|
||||
return
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
let encryptedPassword;
|
||||
let encryptedPassword
|
||||
try {
|
||||
// 拼接图形验证码和新密码进行加密
|
||||
const captchaAndPassword = formData.value.captcha + formData.value.newPassword;
|
||||
encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword);
|
||||
} catch (error) {
|
||||
console.error("密码加密失败:", error);
|
||||
toast.warning(t("sm2.encryptionFailed"));
|
||||
return;
|
||||
const captchaAndPassword = formData.value.captcha + formData.value.newPassword
|
||||
encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('密码加密失败:', error)
|
||||
toast.warning(t('sm2.encryptionFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
// 调用重置密码API
|
||||
@@ -246,53 +248,56 @@ async function handleResetPassword() {
|
||||
phone: internationalPhone,
|
||||
code: formData.value.mobileCaptcha,
|
||||
password: encryptedPassword,
|
||||
captchaId: formData.value.captchaId
|
||||
captchaId: formData.value.captchaId,
|
||||
})
|
||||
|
||||
toast.success(t("retrievePassword.passwordUpdateSuccess"));
|
||||
toast.success(t('retrievePassword.passwordUpdateSuccess'))
|
||||
|
||||
// 跳转到登录页
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({
|
||||
url: "/pages/login/index",
|
||||
});
|
||||
}, 1000);
|
||||
} catch (error: any) {
|
||||
url: '/pages/login/index',
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
catch (error: any) {
|
||||
// 处理验证码错误
|
||||
if (error.message.includes("请求错误[10067]")) {
|
||||
toast.warning(t("login.captchaError"));
|
||||
if (error.message.includes('请求错误[10067]')) {
|
||||
toast.warning(t('login.captchaError'))
|
||||
}
|
||||
// 重置失败重新获取验证码
|
||||
refreshCaptcha();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
refreshCaptcha()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回登录
|
||||
function goBack() {
|
||||
uni.redirectTo({
|
||||
url: "/pages/login/index",
|
||||
});
|
||||
url: '/pages/login/index',
|
||||
})
|
||||
}
|
||||
|
||||
// 页面加载时获取验证码
|
||||
onLoad(() => {
|
||||
refreshCaptcha();
|
||||
});
|
||||
refreshCaptcha()
|
||||
})
|
||||
|
||||
// 组件挂载时确保配置已加载
|
||||
onMounted(async () => {
|
||||
if (!configStore.config.name) {
|
||||
try {
|
||||
await configStore.fetchPublicConfig();
|
||||
} catch (error) {
|
||||
console.error("获取配置失败:", error);
|
||||
await configStore.fetchPublicConfig()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
}
|
||||
}
|
||||
// 初始化国际化
|
||||
initI18n();
|
||||
});
|
||||
initI18n()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -471,7 +476,7 @@ onMounted(async () => {
|
||||
min-height: 100vh;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
@@ -713,10 +718,7 @@ onMounted(async () => {
|
||||
margin-bottom: 30rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
background-color: var(
|
||||
--wot-button-primary-bg-color,
|
||||
var(--wot-color-theme, #4d80f0)
|
||||
);
|
||||
background-color: var(--wot-button-primary-bg-color, var(--wot-color-theme, #4d80f0));
|
||||
text-align: center;
|
||||
line-height: 80rpx;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import type { Agent } from '@/api/agent/types'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
// 在组件挂载后设置导航栏标题
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { useMessage } from 'wot-design-uni/components/wd-message-box'
|
||||
import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js'
|
||||
import { createAgent, deleteAgent, getAgentList } from '@/api/agent/agent'
|
||||
import { t } from '@/i18n'
|
||||
@@ -167,7 +167,7 @@ function openCreateDialog() {
|
||||
msg: '',
|
||||
inputPlaceholder: t('home.inputPlaceholder'),
|
||||
inputValue: '',
|
||||
inputPattern: /^.{1,64}$/i,
|
||||
inputPattern: /^.{1,64}$/,
|
||||
inputError: t('home.createError'),
|
||||
confirmButtonText: t('home.createNow'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
|
||||
@@ -401,7 +401,9 @@ onMounted(async () => {
|
||||
<text class="policy-link" @click="goToUserAgreement">
|
||||
{{ t('login.userAgreement') }}
|
||||
</text>
|
||||
<text class="policy-divider">|</text>
|
||||
<text class="policy-divider">
|
||||
{{ '|' }}
|
||||
</text>
|
||||
<text class="policy-link" @click="goToPrivacyPolicy">
|
||||
{{ t('login.privacyPolicy') }}
|
||||
</text>
|
||||
|
||||
@@ -9,15 +9,14 @@
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { register, sendSmsCode } from '@/api/auth';
|
||||
import { useConfigStore } from '@/store';
|
||||
import { getEnvBaseUrl } from '@/utils';
|
||||
import { toast } from '@/utils/toast';
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { register, sendSmsCode } from '@/api/auth'
|
||||
// 导入国际化相关功能
|
||||
import { t, initI18n } from '@/i18n';
|
||||
import { initI18n, t } from '@/i18n'
|
||||
import { useConfigStore } from '@/store'
|
||||
// 导入SM2加密工具
|
||||
import { sm2Encrypt } from '@/utils';
|
||||
import { getEnvBaseUrl, sm2Encrypt } from '@/utils'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets
|
||||
@@ -135,9 +134,9 @@ function generateUUID() {
|
||||
|
||||
// 获取验证码
|
||||
async function refreshCaptcha() {
|
||||
const uuid = generateUUID();
|
||||
formData.value.captchaId = uuid;
|
||||
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`;
|
||||
const uuid = generateUUID()
|
||||
formData.value.captchaId = uuid
|
||||
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`
|
||||
}
|
||||
|
||||
// 发送短信验证码
|
||||
@@ -253,7 +252,8 @@ async function handleRegister() {
|
||||
// 拼接验证码和密码
|
||||
const captchaAndPassword = formData.value.captcha + formData.value.password
|
||||
encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword)
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
console.error('密码加密失败:', error)
|
||||
toast.warning(t('sm2.encryptionFailed'))
|
||||
return
|
||||
@@ -275,7 +275,7 @@ async function handleRegister() {
|
||||
// 跳转到登录页
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({
|
||||
url: '/pages/login/index'
|
||||
url: '/pages/login/index',
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
@@ -299,7 +299,7 @@ async function handleRegister() {
|
||||
// 返回登录
|
||||
function goBack() {
|
||||
uni.redirectTo({
|
||||
url: '/pages/login/index'
|
||||
url: '/pages/login/index',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -321,8 +321,6 @@ onMounted(async () => {
|
||||
// 初始化国际化
|
||||
initI18n()
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -385,41 +383,41 @@ onMounted(async () => {
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.password"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterPassword')"
|
||||
show-password
|
||||
:maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
v-model="formData.password"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterPassword')"
|
||||
show-password
|
||||
:maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.confirmPassword"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.confirmPassword')"
|
||||
show-password
|
||||
:maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
v-model="formData.confirmPassword"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.confirmPassword')"
|
||||
show-password
|
||||
:maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper captcha-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.captcha"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterCode')"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<view class="captcha-image" @click="refreshCaptcha">
|
||||
<image :src="captchaImage" class="captcha-img" />
|
||||
</view>
|
||||
v-model="formData.captcha"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterCode')"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<view class="captcha-image" @click="refreshCaptcha">
|
||||
<image :src="captchaImage" class="captcha-img" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -427,21 +425,21 @@ onMounted(async () => {
|
||||
<view v-if="enableMobileRegister" class="input-group">
|
||||
<view class="input-wrapper sms-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.mobileCaptcha"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterCode')"
|
||||
type="number"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<wd-button
|
||||
:loading="smsLoading"
|
||||
:disabled="smsCountdown > 0"
|
||||
custom-class="sms-btn"
|
||||
@click="sendSmsVerification"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s` : t('register.getCode') }}
|
||||
</wd-button>
|
||||
v-model="formData.mobileCaptcha"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterCode')"
|
||||
type="number"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<wd-button
|
||||
:loading="smsLoading"
|
||||
:disabled="smsCountdown > 0"
|
||||
custom-class="sms-btn"
|
||||
@click="sendSmsVerification"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s` : t('register.getCode') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -462,7 +460,6 @@ onMounted(async () => {
|
||||
{{ t('register.loginNow') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -508,8 +505,6 @@ onMounted(async () => {
|
||||
</view>
|
||||
</view>
|
||||
</wd-action-sheet>
|
||||
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Language } from '@/store/lang'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { useToast } from 'wot-design-uni/components/wd-toast'
|
||||
import { changeLanguage, getCurrentLanguage, getSupportedLanguages, t } from '@/i18n'
|
||||
import { useConfigStore } from '@/store'
|
||||
import { clearServerBaseUrlOverride, getEnvBaseUrl, getServerBaseUrlOverride, setServerBaseUrlOverride } from '@/utils'
|
||||
@@ -265,7 +265,7 @@ function showAbout() {
|
||||
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
|
||||
content: t('settings.aboutContent', {
|
||||
appName: import.meta.env.VITE_APP_TITLE,
|
||||
version: '0.9.5'
|
||||
version: '0.9.5',
|
||||
}),
|
||||
showCancel: false,
|
||||
confirmText: t('common.confirm'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ChatHistory, CreateSpeakerData, VoicePrint } from '@/api/voiceprint'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { useMessage } from 'wot-design-uni/components/wd-message-box'
|
||||
import { useToast } from 'wot-design-uni/components/wd-toast'
|
||||
import { createVoicePrint, deleteVoicePrint, getAudioDownloadId, getChatHistory, getVoicePrintList, updateVoicePrint } from '@/api/voiceprint'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PublicConfig } from '@/api/auth'
|
||||
import { getPublicConfig } from '@/api/auth'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getPublicConfig } from '@/api/auth'
|
||||
|
||||
// 初始化状态
|
||||
const initialConfigState: PublicConfig = {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 支持的语言类型
|
||||
export type Language = 'zh_CN' | 'en' | 'zh_TW' | 'de' | 'vi' | 'pt_BR'
|
||||
|
||||
export interface LangStore {
|
||||
currentLang: Language
|
||||
currentLang: Ref<Language>
|
||||
changeLang: (lang: Language) => void
|
||||
}
|
||||
|
||||
@@ -37,4 +38,4 @@ export const useLangStore = defineStore(
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const SPEED_PITCH_FIELDS = ['ttsVolume', 'ttsRate', 'ttsPitch'] as const
|
||||
|
||||
type SpeedPitchField = typeof SPEED_PITCH_FIELDS[number]
|
||||
type SpeedPitchSettings = Record<SpeedPitchField, number>
|
||||
|
||||
export const useSpeedPitch = defineStore('speedPitch', () => {
|
||||
const speedPitch = ref({
|
||||
const speedPitch = ref<SpeedPitchSettings>({
|
||||
ttsVolume: 0,
|
||||
ttsRate: 0,
|
||||
ttsPitch: 0,
|
||||
})
|
||||
const changedFields = ref<SpeedPitchField[]>([])
|
||||
|
||||
const updateSpeedPitch = (val: typeof speedPitch.value) => {
|
||||
const updateSpeedPitch = (val: SpeedPitchSettings, options: { changedFields?: SpeedPitchField[] } = {}) => {
|
||||
speedPitch.value = val
|
||||
if (options.changedFields) {
|
||||
changedFields.value = Array.from(new Set([
|
||||
...changedFields.value,
|
||||
...options.changedFields,
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
const resetChangedFields = () => {
|
||||
changedFields.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
speedPitch,
|
||||
changedFields,
|
||||
updateSpeedPitch,
|
||||
resetChangedFields,
|
||||
}
|
||||
}, {
|
||||
persist: {
|
||||
|
||||
@@ -201,7 +201,7 @@ export function getEnvBaseUploadUrl() {
|
||||
|
||||
/**
|
||||
* 生成SM2密钥对(十六进制格式)
|
||||
* @returns {Object} 包含公钥和私钥的对象
|
||||
* @returns {object} 包含公钥和私钥的对象
|
||||
*/
|
||||
export function generateSm2KeyPairHex() {
|
||||
// 使用sm-crypto库生成SM2密钥对
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
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<Props> = new () => { $props: Partial<Props> }
|
||||
|
||||
// wot-design-uni 1.9.1 exposes raw Vue source through its global declarations.
|
||||
// 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: TypedGlobalComponent<ExtractPropTypes<typeof actionSheetProps>>
|
||||
WdButton: TypedGlobalComponent<ExtractPropTypes<typeof buttonProps>>
|
||||
WdCheckbox: TypedGlobalComponent<ExtractPropTypes<typeof checkboxProps>>
|
||||
WdConfigProvider: TypedGlobalComponent<ExtractPropTypes<typeof configProviderProps>>
|
||||
WdFab: TypedGlobalComponent<ExtractPropTypes<typeof fabProps>>
|
||||
WdIcon: TypedGlobalComponent<ExtractPropTypes<typeof iconProps>>
|
||||
WdImg: TypedGlobalComponent<ExtractPropTypes<typeof imgProps>>
|
||||
WdInput: TypedGlobalComponent<ExtractPropTypes<typeof inputProps>>
|
||||
WdLoading: TypedGlobalComponent<ExtractPropTypes<typeof loadingProps>>
|
||||
WdMessageBox: TypedGlobalComponent<ExtractPropTypes<typeof messageBoxProps>>
|
||||
WdNavbar: TypedGlobalComponent<ExtractPropTypes<typeof navbarProps>>
|
||||
WdPicker: TypedGlobalComponent<ExtractPropTypes<typeof pickerProps>>
|
||||
WdPopup: TypedGlobalComponent<ExtractPropTypes<typeof popupProps>>
|
||||
WdSegmented: TypedGlobalComponent<ExtractPropTypes<typeof segmentedProps>>
|
||||
WdSlider: TypedGlobalComponent<ExtractPropTypes<typeof sliderProps>>
|
||||
WdStatusTip: TypedGlobalComponent<ExtractPropTypes<typeof statusTipProps>>
|
||||
WdSwipeAction: TypedGlobalComponent<ExtractPropTypes<typeof swipeActionProps>>
|
||||
WdSwitch: TypedGlobalComponent<ExtractPropTypes<typeof switchProps>>
|
||||
WdTabbar: TypedGlobalComponent<ExtractPropTypes<typeof tabbarProps>>
|
||||
WdTabbarItem: TypedGlobalComponent<ExtractPropTypes<typeof tabbarItemProps>>
|
||||
WdTag: TypedGlobalComponent<ExtractPropTypes<typeof tagProps>>
|
||||
WdToast: TypedGlobalComponent<ExtractPropTypes<typeof toastProps>>
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
"@dcloudio/types",
|
||||
"@uni-helper/uni-types",
|
||||
"@types/wechat-miniprogram",
|
||||
"wot-design-uni/global.d.ts",
|
||||
"z-paging/types",
|
||||
"./src/typings.d.ts"
|
||||
],
|
||||
@@ -31,6 +30,7 @@
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.js",
|
||||
"src/**/*.mjs",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.jsx",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;/
|
||||
);
|
||||
});
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
// 下载替换词文件
|
||||
|
||||
@@ -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模型列表
|
||||
|
||||
@@ -3,20 +3,32 @@
|
||||
<el-dialog
|
||||
:title="$t('agentSnapshot.title')"
|
||||
:visible="visible"
|
||||
width="760px"
|
||||
width="860px"
|
||||
class="agent-snapshot-dialog"
|
||||
:before-close="guardRestoreInFlightClose"
|
||||
:close-on-click-modal="!restoring"
|
||||
:close-on-press-escape="!restoring"
|
||||
:show-close="!restoring"
|
||||
@open="open"
|
||||
@close="close"
|
||||
>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="snapshots"
|
||||
:row-key="snapshotRowKey"
|
||||
:row-class-name="snapshotRowClassName"
|
||||
size="small"
|
||||
class="snapshot-table"
|
||||
:empty-text="loading ? ' ' : $t('agentSnapshot.empty')"
|
||||
>
|
||||
<template slot="title">
|
||||
<div class="snapshot-dialog-title">
|
||||
<i class="el-icon-time snapshot-title-icon" aria-hidden="true"></i>
|
||||
<span>{{ $t('agentSnapshot.title') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="snapshot-table-wrapper">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="snapshots"
|
||||
:row-key="snapshotRowKey"
|
||||
:row-class-name="snapshotRowClassName"
|
||||
size="small"
|
||||
class="snapshot-table"
|
||||
:empty-text="loading ? ' ' : $t('agentSnapshot.empty')"
|
||||
>
|
||||
<el-table-column
|
||||
prop="versionNo"
|
||||
:label="$t('agentSnapshot.version')"
|
||||
@@ -96,7 +108,8 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="pagination-row">
|
||||
<el-pagination
|
||||
@@ -116,6 +129,13 @@
|
||||
width="860px"
|
||||
class="snapshot-detail-dialog"
|
||||
>
|
||||
<template slot="title">
|
||||
<div class="snapshot-dialog-title">
|
||||
<i class="el-icon-time snapshot-title-icon" aria-hidden="true"></i>
|
||||
<span>{{ detailDialogTitle }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="detailLoading" class="snapshot-diff">
|
||||
<template v-if="currentSnapshot && snapshotDiffs.length">
|
||||
<div class="detail-summary">
|
||||
@@ -288,7 +308,18 @@
|
||||
: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"
|
||||
>
|
||||
<template slot="title">
|
||||
<div class="snapshot-dialog-title">
|
||||
<i class="el-icon-time snapshot-title-icon" aria-hidden="true"></i>
|
||||
<span>{{ restorePreviewTitle }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="restorePreviewLoading" class="snapshot-diff">
|
||||
<template v-if="restorePreviewSnapshot && restorePreviewDiffs.length">
|
||||
<div class="detail-summary">
|
||||
@@ -410,25 +441,40 @@
|
||||
:title="$t('agentSnapshot.restoreConfirm', { version: restoreTargetVersion })"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="restoreWillClearChatHistory"
|
||||
v-if="restorePreviewSnapshot"
|
||||
class="restore-risk-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="$t('agentSnapshot.restoreMemoryWarning')"
|
||||
:title="$t('agentSnapshot.unsavedChangesWarning')"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="restoreWillClearChatHistory"
|
||||
class="restore-risk-alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="$t('agentSnapshot.restoreMemoryDestructiveWarning')"
|
||||
/>
|
||||
</div>
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button @click="restorePreviewVisible = false">
|
||||
<span slot="footer" class="snapshot-dialog-footer">
|
||||
<el-button
|
||||
class="snapshot-footer-button snapshot-footer-cancel"
|
||||
:disabled="restoring"
|
||||
@click="closeRestorePreview"
|
||||
>
|
||||
{{ $t('button.cancel') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="snapshot-footer-button snapshot-footer-confirm"
|
||||
:loading="restoring"
|
||||
:disabled="restorePreviewLoading || !restorePreviewSnapshot || restorePreviewDiffs.length === 0"
|
||||
:disabled="restoring || restorePreviewLoading || !restorePreviewSnapshot || restorePreviewDiffs.length === 0"
|
||||
@click="confirmRestoreSnapshot"
|
||||
>
|
||||
{{ $t('agentSnapshot.confirmRestore') }}
|
||||
<span class="confirm-inner">
|
||||
<i class="el-icon-refresh-left confirm-icon" aria-hidden="true"></i>
|
||||
{{ $t('agentSnapshot.confirmRestore') }}
|
||||
</span>
|
||||
</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
@@ -437,7 +483,14 @@
|
||||
|
||||
<script>
|
||||
import Api from "@/apis/api";
|
||||
import correctWord from "@/apis/module/correctWord";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import {
|
||||
hasValidCurrentStateToken,
|
||||
normalizeSnapshotOrderedValue,
|
||||
redactSnapshotDisplayValue,
|
||||
SNAPSHOT_SECRET_REDACTED
|
||||
} from "./agentSnapshotDisplayUtils.mjs";
|
||||
|
||||
const FALLBACK_PLUGIN_NAME_KEYS = {
|
||||
SYSTEM_PLUGIN_WEATHER: "agentSnapshot.plugin.SYSTEM_PLUGIN_WEATHER",
|
||||
@@ -505,8 +558,6 @@ const CHAT_HISTORY_CONF_LABEL_KEYS = {
|
||||
1: "agentSnapshot.chatHistoryConf.text",
|
||||
2: "agentSnapshot.chatHistoryConf.textVoice"
|
||||
};
|
||||
const SNAPSHOT_SECRET_REDACTED = "__SNAPSHOT_SECRET_REDACTED__";
|
||||
|
||||
export default {
|
||||
name: "AgentSnapshotDialog",
|
||||
props: {
|
||||
@@ -545,6 +596,9 @@ export default {
|
||||
voiceNameMap: {},
|
||||
voiceMetadataLoading: {},
|
||||
loadedVoiceModelIds: {},
|
||||
correctWordNameMap: {},
|
||||
correctWordMetadataLoaded: false,
|
||||
correctWordMetadataLoading: null,
|
||||
snapshotFetchSeq: 0,
|
||||
detailFetchSeq: 0,
|
||||
restorePreviewFetchSeq: 0,
|
||||
@@ -598,12 +652,13 @@ export default {
|
||||
|
||||
const beforeData = this.currentSnapshot.snapshotData || {};
|
||||
const afterData = this.currentSnapshot.afterSnapshotData || {};
|
||||
return this.buildDiffs(beforeData, afterData, this.currentSnapshot.changedFields || [], {
|
||||
return this.buildDiffs(beforeData, afterData, this.currentSnapshot.changedFields, {
|
||||
beforeLabel: this.$t("agentSnapshot.beforeChange"),
|
||||
afterLabel: this.$t("agentSnapshot.afterChange"),
|
||||
beforeVersionNo: this.currentSnapshot.beforeVersionNo,
|
||||
afterVersionNo: this.currentSnapshot.afterVersionNo || this.currentSnapshot.versionNo,
|
||||
fieldOrder: this.currentSnapshot.fieldOrder
|
||||
fieldOrder: this.currentSnapshot.fieldOrder,
|
||||
forceCompare: Boolean(this.currentSnapshot.forceCompare)
|
||||
});
|
||||
},
|
||||
restorePreviewDiffs() {
|
||||
@@ -620,7 +675,8 @@ export default {
|
||||
afterLabel: this.$t("agentSnapshot.afterRestore"),
|
||||
beforeVersionNo: this.restorePreviewSnapshot.beforeVersionNo || this.resolveCurrentVersionNo(),
|
||||
afterVersionNo: this.restorePreviewSnapshot.afterVersionNo || this.restorePreviewSnapshot.versionNo,
|
||||
fieldOrder: this.restorePreviewSnapshot.fieldOrder
|
||||
fieldOrder: this.restorePreviewSnapshot.fieldOrder,
|
||||
forceCompare: true
|
||||
}
|
||||
);
|
||||
},
|
||||
@@ -668,7 +724,13 @@ export default {
|
||||
});
|
||||
},
|
||||
buildDiffs(beforeData, afterData, changedFields, options = {}) {
|
||||
const fields = this.resolveDiffFields(beforeData, afterData, changedFields, options.fieldOrder);
|
||||
const fields = this.resolveDiffFields(
|
||||
beforeData,
|
||||
afterData,
|
||||
changedFields,
|
||||
options.fieldOrder,
|
||||
options.forceCompare
|
||||
);
|
||||
return fields.map((field) => {
|
||||
const beforeValue = this.getFieldValue(beforeData, field);
|
||||
const afterValue = this.getFieldValue(afterData, field);
|
||||
@@ -691,10 +753,25 @@ export default {
|
||||
});
|
||||
},
|
||||
close() {
|
||||
if (this.restoring) {
|
||||
return;
|
||||
}
|
||||
this.cancelPendingSnapshotRequests();
|
||||
this.historyAnchorVersionNo = null;
|
||||
this.$emit("update:visible", false);
|
||||
},
|
||||
guardRestoreInFlightClose(done) {
|
||||
if (this.restoring) {
|
||||
return;
|
||||
}
|
||||
done();
|
||||
},
|
||||
closeRestorePreview() {
|
||||
if (this.restoring) {
|
||||
return;
|
||||
}
|
||||
this.restorePreviewVisible = false;
|
||||
},
|
||||
open() {
|
||||
this.historyAnchorVersionNo = null;
|
||||
this.page = 1;
|
||||
@@ -722,7 +799,7 @@ export default {
|
||||
return !!row?.id;
|
||||
},
|
||||
canRestoreSnapshot(row) {
|
||||
return !!row && !row.isLatestSnapshot;
|
||||
return !!row?.id && !row.isLatestSnapshot;
|
||||
},
|
||||
canDeleteSnapshot(row) {
|
||||
return !!row && !row.isLatestSnapshot;
|
||||
@@ -767,6 +844,13 @@ export default {
|
||||
} else {
|
||||
this.$message.error(data.msg || this.$t("agentSnapshot.fetchFailed"));
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (requestSeq !== this.snapshotFetchSeq) {
|
||||
return;
|
||||
}
|
||||
this.loading = false;
|
||||
this.$message.error(this.$t("agentSnapshot.fetchFailed"));
|
||||
}
|
||||
);
|
||||
},
|
||||
@@ -816,16 +900,20 @@ export default {
|
||||
this.ensurePluginMetadata();
|
||||
this.ensureModelMetadata();
|
||||
|
||||
Promise.all([
|
||||
this.fetchSnapshotDetail(row.id),
|
||||
this.fetchCurrentAgentData()
|
||||
]).then(([targetSnapshot, currentData]) => {
|
||||
this.fetchSnapshotDetail(row.id).then((targetSnapshot) => {
|
||||
if (requestSeq !== this.restorePreviewFetchSeq) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (
|
||||
!this.isPlainObject(targetSnapshot.currentSnapshotData)
|
||||
|| !hasValidCurrentStateToken(targetSnapshot.currentStateToken)
|
||||
) {
|
||||
throw new Error("Snapshot detail is missing its atomic current-state preview");
|
||||
}
|
||||
const previewSnapshot = {
|
||||
...targetSnapshot,
|
||||
beforeSnapshotData: currentData,
|
||||
currentStateToken: targetSnapshot.currentStateToken,
|
||||
beforeSnapshotData: targetSnapshot.currentSnapshotData,
|
||||
afterSnapshotData: targetSnapshot.snapshotData || {},
|
||||
beforeVersionNo: this.resolveCurrentVersionNo(),
|
||||
afterVersionNo: targetSnapshot.versionNo,
|
||||
@@ -856,12 +944,56 @@ export default {
|
||||
}));
|
||||
},
|
||||
confirmRestoreSnapshot() {
|
||||
if (!this.restorePreviewRow) {
|
||||
if (this.restoring || !this.restorePreviewRow) {
|
||||
return;
|
||||
}
|
||||
const snapshotId = this.restorePreviewRow.id;
|
||||
const currentStateToken = this.restorePreviewSnapshot?.currentStateToken;
|
||||
if (!hasValidCurrentStateToken(currentStateToken)) {
|
||||
this.invalidateRestorePreview();
|
||||
this.$message.error(this.$t("agentSnapshot.restoreFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.restoreWillClearChatHistory) {
|
||||
this.submitRestoreSnapshot(snapshotId, currentStateToken);
|
||||
return;
|
||||
}
|
||||
|
||||
this.restoring = true;
|
||||
const requestSeq = ++this.restoreActionSeq;
|
||||
this.$confirm(
|
||||
this.$t("agentSnapshot.restoreMemoryDestructiveWarning"),
|
||||
this.$t("common.warning"),
|
||||
{
|
||||
confirmButtonText: this.$t("common.confirm"),
|
||||
cancelButtonText: this.$t("common.cancel"),
|
||||
type: "error"
|
||||
}
|
||||
).then(() => {
|
||||
if (requestSeq !== this.restoreActionSeq) {
|
||||
return;
|
||||
}
|
||||
this.submitRestoreSnapshot(snapshotId, currentStateToken, requestSeq);
|
||||
}).catch(() => {
|
||||
if (requestSeq === this.restoreActionSeq) {
|
||||
this.restoring = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
submitRestoreSnapshot(snapshotId, currentStateToken, confirmedRequestSeq = null) {
|
||||
if (!hasValidCurrentStateToken(currentStateToken)) {
|
||||
this.restoring = false;
|
||||
this.invalidateRestorePreview();
|
||||
this.$message.error(this.$t("agentSnapshot.restoreFailed"));
|
||||
return;
|
||||
}
|
||||
const requestSeq = confirmedRequestSeq ?? ++this.restoreActionSeq;
|
||||
if (requestSeq !== this.restoreActionSeq) {
|
||||
return;
|
||||
}
|
||||
this.restoring = true;
|
||||
const requestSeq = ++this.restoreActionSeq;
|
||||
Api.agent.restoreAgentSnapshot(this.agentId, this.restorePreviewRow.id, ({ data }) => {
|
||||
Api.agent.restoreAgentSnapshot(this.agentId, snapshotId, currentStateToken, ({ data }) => {
|
||||
if (requestSeq !== this.restoreActionSeq) {
|
||||
return;
|
||||
}
|
||||
@@ -869,15 +1001,32 @@ export default {
|
||||
if (data.code === 0) {
|
||||
this.$message.success(this.$t("agentSnapshot.restoreSuccess"));
|
||||
this.restorePreviewVisible = false;
|
||||
this.restorePreviewSnapshot = null;
|
||||
this.restorePreviewRow = null;
|
||||
this.detailVisible = false;
|
||||
this.$emit("restored");
|
||||
this.historyAnchorVersionNo = null;
|
||||
this.fetchSnapshots();
|
||||
} else {
|
||||
this.invalidateRestorePreview();
|
||||
this.$message.error(this.restoreFailedMessage(data));
|
||||
}
|
||||
}, () => {
|
||||
if (requestSeq !== this.restoreActionSeq) {
|
||||
return;
|
||||
}
|
||||
this.restoring = false;
|
||||
this.invalidateRestorePreview();
|
||||
this.$message.error(this.$t("agentSnapshot.restoreFailed"));
|
||||
});
|
||||
},
|
||||
invalidateRestorePreview() {
|
||||
this.restorePreviewFetchSeq += 1;
|
||||
this.restorePreviewLoading = false;
|
||||
this.restorePreviewVisible = false;
|
||||
this.restorePreviewSnapshot = null;
|
||||
this.restorePreviewRow = null;
|
||||
},
|
||||
deleteSnapshot(row) {
|
||||
if (!this.canDeleteSnapshot(row)) {
|
||||
return;
|
||||
@@ -907,6 +1056,12 @@ export default {
|
||||
} else {
|
||||
this.$message.error(data.msg || this.$t("agentSnapshot.deleteFailed"));
|
||||
}
|
||||
}, () => {
|
||||
if (requestSeq !== this.deleteActionSeq) {
|
||||
return;
|
||||
}
|
||||
this.deletingSnapshotId = null;
|
||||
this.$message.error(this.$t("agentSnapshot.deleteFailed"));
|
||||
});
|
||||
}).catch(() => {});
|
||||
},
|
||||
@@ -953,7 +1108,7 @@ export default {
|
||||
} else {
|
||||
reject(data);
|
||||
}
|
||||
});
|
||||
}, reject);
|
||||
});
|
||||
},
|
||||
fetchSnapshotRows(params) {
|
||||
@@ -964,7 +1119,7 @@ export default {
|
||||
} else {
|
||||
reject(data);
|
||||
}
|
||||
});
|
||||
}, reject);
|
||||
});
|
||||
},
|
||||
snapshotQueryParams(params = {}) {
|
||||
@@ -1023,6 +1178,7 @@ export default {
|
||||
...selectedSnapshot,
|
||||
versionNo: displayVersionNo,
|
||||
changedFields: adjacentVersion ? (selectedSnapshot.changedFields || row.changedFields || []) : [],
|
||||
forceCompare: !adjacentVersion,
|
||||
snapshotData: previousSnapshot.snapshotData || {},
|
||||
afterSnapshotData: selectedSnapshot.snapshotData || {},
|
||||
beforeVersionNo: previousSnapshot.versionNo,
|
||||
@@ -1152,6 +1308,9 @@ export default {
|
||||
this.pluginMetadataLoaded = true;
|
||||
}
|
||||
resolve();
|
||||
}, () => {
|
||||
this.pluginMetadataLoaded = true;
|
||||
resolve();
|
||||
});
|
||||
}).finally(() => {
|
||||
this.pluginMetadataLoading = null;
|
||||
@@ -1181,9 +1340,9 @@ export default {
|
||||
};
|
||||
|
||||
if (type === "LLM") {
|
||||
Api.model.getLlmModelCodeList("", callback);
|
||||
Api.model.getLlmModelCodeList("", callback, () => resolve([]));
|
||||
} else {
|
||||
Api.model.getModelNames(type, "", callback);
|
||||
Api.model.getModelNames(type, "", callback, () => resolve([]));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1210,9 +1369,48 @@ export default {
|
||||
const afterData = snapshot.afterSnapshotData || {};
|
||||
return Promise.all([
|
||||
this.ensureModelMetadata(),
|
||||
this.ensureVoiceMetadataForData(beforeData, afterData)
|
||||
this.ensureVoiceMetadataForData(beforeData, afterData),
|
||||
this.ensureCorrectWordMetadataForData(beforeData, afterData)
|
||||
]);
|
||||
},
|
||||
ensureCorrectWordMetadataForData(...dataList) {
|
||||
const hasCorrectWordIds = dataList.some((data) => {
|
||||
return Array.isArray(data?.correctWordFileIds) && data.correctWordFileIds.length > 0;
|
||||
});
|
||||
return hasCorrectWordIds ? this.ensureCorrectWordMetadata() : Promise.resolve();
|
||||
},
|
||||
ensureCorrectWordMetadata() {
|
||||
if (this.correctWordMetadataLoaded) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this.correctWordMetadataLoading) {
|
||||
return this.correctWordMetadataLoading;
|
||||
}
|
||||
|
||||
this.correctWordMetadataLoading = new Promise((resolve) => {
|
||||
correctWord.selectAll(({ data }) => {
|
||||
if (data.code === 0) {
|
||||
const metadata = {};
|
||||
(data.data || []).forEach((item) => {
|
||||
if (item.id) {
|
||||
metadata[item.id] = item.fileName || item.id;
|
||||
}
|
||||
});
|
||||
this.correctWordNameMap = metadata;
|
||||
this.correctWordMetadataLoaded = true;
|
||||
}
|
||||
resolve();
|
||||
}, () => {
|
||||
// 权限不足或元数据服务不可用时保留原始 ID,不扩大文件列表的访问边界。
|
||||
this.correctWordMetadataLoaded = true;
|
||||
resolve();
|
||||
});
|
||||
}).finally(() => {
|
||||
this.correctWordMetadataLoading = null;
|
||||
});
|
||||
|
||||
return this.correctWordMetadataLoading;
|
||||
},
|
||||
ensureVoiceMetadataForData(...dataList) {
|
||||
const modelIds = Array.from(new Set(
|
||||
dataList
|
||||
@@ -1249,6 +1447,12 @@ export default {
|
||||
};
|
||||
}
|
||||
resolve();
|
||||
}, () => {
|
||||
this.loadedVoiceModelIds = {
|
||||
...this.loadedVoiceModelIds,
|
||||
[modelId]: true
|
||||
};
|
||||
resolve();
|
||||
});
|
||||
}).finally(() => {
|
||||
const { [modelId]: dropped, ...rest } = this.voiceMetadataLoading;
|
||||
@@ -1283,7 +1487,7 @@ export default {
|
||||
} else {
|
||||
reject(data);
|
||||
}
|
||||
});
|
||||
}, reject);
|
||||
});
|
||||
const tagsPromise = this.fetchCurrentAgentTags();
|
||||
|
||||
@@ -1303,7 +1507,7 @@ export default {
|
||||
} else {
|
||||
reject(data);
|
||||
}
|
||||
});
|
||||
}, reject);
|
||||
});
|
||||
},
|
||||
normalizeAgentData(data) {
|
||||
@@ -1501,7 +1705,7 @@ export default {
|
||||
return paramKeys.map((key) => ({
|
||||
key,
|
||||
label: this.functionParamLabel(pluginId, key),
|
||||
value: this.formatFunctionParamValue(normalizedParams[key]),
|
||||
value: this.formatFunctionParamValue(normalizedParams[key], key),
|
||||
changed: changedParamKeys.includes(key)
|
||||
}));
|
||||
},
|
||||
@@ -1529,11 +1733,11 @@ export default {
|
||||
return item.id === pluginId || item.providerCode === pluginId || item.name === pluginId;
|
||||
}) || null;
|
||||
},
|
||||
formatFunctionParamValue(value) {
|
||||
formatFunctionParamValue(value, key = "") {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return this.$t("agentSnapshot.emptyValue");
|
||||
}
|
||||
const displayValue = this.localizedSnapshotDisplayValue(value);
|
||||
const displayValue = this.localizedSnapshotDisplayValue(value, key);
|
||||
if (typeof displayValue === "object") {
|
||||
return JSON.stringify(displayValue);
|
||||
}
|
||||
@@ -1661,17 +1865,21 @@ export default {
|
||||
? `${this.$t("agentSnapshot.restoreFailedRollback")}: ${data.msg}`
|
||||
: this.$t("agentSnapshot.restoreFailedRollback");
|
||||
},
|
||||
resolveDiffFields(beforeData, afterData, changedFields = [], fieldOrder = []) {
|
||||
const safeChangedFields = Array.isArray(changedFields) ? changedFields : [];
|
||||
resolveDiffFields(beforeData, afterData, changedFields = null, fieldOrder = [], forceCompare = false) {
|
||||
const hasBackendChangedFields = Array.isArray(changedFields);
|
||||
const safeChangedFields = hasBackendChangedFields ? changedFields : [];
|
||||
const directFields = safeChangedFields
|
||||
.filter((field) => field && field !== "restore")
|
||||
.map((field) => this.canonicalField(field));
|
||||
const candidates = directFields.length > 0 && !safeChangedFields.includes("restore")
|
||||
? directFields
|
||||
: this.snapshotFieldOrder(fieldOrder, beforeData, afterData);
|
||||
if (!forceCompare && hasBackendChangedFields) {
|
||||
return Array.from(new Set(directFields));
|
||||
}
|
||||
|
||||
const candidates = this.snapshotFieldOrder(fieldOrder, beforeData, afterData);
|
||||
|
||||
return Array.from(new Set(candidates)).filter((field) => {
|
||||
return !this.isSameValue(
|
||||
return !this.isSameFieldValue(
|
||||
field,
|
||||
this.getFieldValue(beforeData, field),
|
||||
this.getFieldValue(afterData, field)
|
||||
);
|
||||
@@ -1706,6 +1914,44 @@ export default {
|
||||
isSameValue(left, right) {
|
||||
return this.isEquivalentValue(this.normalizeValue(left), this.normalizeValue(right));
|
||||
},
|
||||
isSameFieldValue(field, left, right) {
|
||||
return this.isEquivalentValue(
|
||||
this.normalizeValueForField(field, left),
|
||||
this.normalizeValueForField(field, right)
|
||||
);
|
||||
},
|
||||
normalizeValueForField(field, value) {
|
||||
if (field === "functions") {
|
||||
return this.normalizeFunctionMap(value);
|
||||
}
|
||||
if (field === "contextProviders") {
|
||||
return normalizeSnapshotOrderedValue(Array.isArray(value) ? value : []);
|
||||
}
|
||||
if (["ttsVolume", "ttsRate", "ttsPitch"].includes(field)) {
|
||||
return this.normalizeDefaultTtsNumber(value);
|
||||
}
|
||||
if (field === "summaryMemory") {
|
||||
return value === null || value === undefined || String(value).trim() === "" ? "" : String(value);
|
||||
}
|
||||
if (field === "correctWordFileIds" || field === "tagNames") {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item) => item !== null && item !== undefined && String(item).trim() !== "")
|
||||
.map((item) => String(item))
|
||||
.sort()
|
||||
: [];
|
||||
}
|
||||
return this.normalizeValue(value);
|
||||
},
|
||||
normalizeDefaultTtsNumber(value) {
|
||||
if (value === null || value === undefined || 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;
|
||||
},
|
||||
isEquivalentValue(left, right) {
|
||||
if (left === SNAPSHOT_SECRET_REDACTED || right === SNAPSHOT_SECRET_REDACTED) {
|
||||
return true;
|
||||
@@ -1729,16 +1975,7 @@ export default {
|
||||
return left === right;
|
||||
},
|
||||
normalizeValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.normalizeValue(item));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.keys(value).sort().reduce((result, key) => {
|
||||
result[key] = this.normalizeValue(value[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return value === undefined ? null : value;
|
||||
return normalizeSnapshotOrderedValue(value);
|
||||
},
|
||||
isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -1754,7 +1991,10 @@ export default {
|
||||
return this.translateKey(CHAT_HISTORY_CONF_LABEL_KEYS[value] || CHAT_HISTORY_CONF_LABEL_KEYS[Number(value)])
|
||||
|| this.formatValue(value);
|
||||
}
|
||||
return this.formatValue(value);
|
||||
if (field === "correctWordFileIds") {
|
||||
return this.correctWordDisplayNames(value);
|
||||
}
|
||||
return this.formatValue(value, field);
|
||||
},
|
||||
modelDisplayName(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
@@ -1769,14 +2009,22 @@ export default {
|
||||
return this.voiceNameMap[`${modelId}:${value}`] || this.voiceNameMap[value]
|
||||
|| this.translateKey(FALLBACK_VOICE_NAME_KEYS[value], String(value)) || String(value);
|
||||
},
|
||||
formatValue(value) {
|
||||
correctWordDisplayNames(value) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return this.$t("agentSnapshot.emptyValue");
|
||||
}
|
||||
return value
|
||||
.map((id) => this.correctWordNameMap[id] || id)
|
||||
.join(", ");
|
||||
},
|
||||
formatValue(value, parentKey = "") {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return this.$t("agentSnapshot.emptyValue");
|
||||
}
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return this.$t("agentSnapshot.emptyValue");
|
||||
}
|
||||
const displayValue = this.localizedSnapshotDisplayValue(value);
|
||||
const displayValue = this.localizedSnapshotDisplayValue(value, parentKey);
|
||||
if (Array.isArray(displayValue) && displayValue.every((item) => this.isPrimitiveValue(item))) {
|
||||
return displayValue.join(", ");
|
||||
}
|
||||
@@ -1785,20 +2033,12 @@ export default {
|
||||
}
|
||||
return String(displayValue);
|
||||
},
|
||||
localizedSnapshotDisplayValue(value) {
|
||||
if (value === SNAPSHOT_SECRET_REDACTED) {
|
||||
return this.$t("agentSnapshot.secretRedacted");
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.localizedSnapshotDisplayValue(item));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.keys(value).reduce((result, key) => {
|
||||
result[key] = this.localizedSnapshotDisplayValue(value[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
localizedSnapshotDisplayValue(value, parentKey = "") {
|
||||
return redactSnapshotDisplayValue(
|
||||
value,
|
||||
this.$t("agentSnapshot.secretRedacted"),
|
||||
parentKey
|
||||
);
|
||||
},
|
||||
isPrimitiveValue(value) {
|
||||
return value === null || ["string", "number", "boolean"].includes(typeof value);
|
||||
@@ -1846,6 +2086,59 @@ export default {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.snapshot-dialog-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
|
||||
> span {
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.snapshot-title-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 8px;
|
||||
color: #4a7cfd;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__headerbtn {
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.el-dialog__close {
|
||||
position: static;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
|
||||
.el-dialog__close {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .el-dialog__body {
|
||||
padding: 20px;
|
||||
}
|
||||
@@ -1854,10 +2147,91 @@ export default {
|
||||
padding: 12px 20px 16px;
|
||||
}
|
||||
|
||||
.snapshot-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.snapshot-footer-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 96px;
|
||||
height: 36px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
& + .snapshot-footer-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.snapshot-footer-cancel {
|
||||
border: 1px solid #d7e1ff;
|
||||
background: #f7f9ff;
|
||||
color: #4a7cfd;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: #4a7cfd;
|
||||
background: #eef4ff;
|
||||
color: #4a7cfd;
|
||||
box-shadow: 0 2px 8px rgba(74, 124, 253, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.snapshot-footer-confirm {
|
||||
border: none;
|
||||
background: linear-gradient(to right, #4a7cfd, #8154fc);
|
||||
color: #fff;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: transparent;
|
||||
background: linear-gradient(to right, #4a7cfd, #8154fc);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(74, 124, 253, 0.3);
|
||||
transform: translateY(-2px);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
&.is-disabled,
|
||||
&.is-disabled:hover,
|
||||
&.is-disabled:focus {
|
||||
border-color: transparent;
|
||||
background: linear-gradient(to right, #a0b4fd, #b8a4fd);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
margin-right: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.snapshot-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.snapshot-table-wrapper {
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
@include scrollbar-style;
|
||||
}
|
||||
|
||||
.version-cell {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
:step="1"
|
||||
:format-tooltip="formatTooltip"
|
||||
class="tts-slider"
|
||||
@change="markTtsSettingChanged('volume')"
|
||||
/>
|
||||
<span class="slider-hint">{{ $t('roleConfig.volumeHint') }}</span>
|
||||
</div>
|
||||
@@ -40,6 +41,7 @@
|
||||
:step="1"
|
||||
:format-tooltip="formatTooltip"
|
||||
class="tts-slider"
|
||||
@change="markTtsSettingChanged('speed')"
|
||||
/>
|
||||
<span class="slider-hint">{{ $t('roleConfig.speedHint') }}</span>
|
||||
</div>
|
||||
@@ -55,6 +57,7 @@
|
||||
:step="1"
|
||||
:format-tooltip="formatTooltip"
|
||||
class="tts-slider"
|
||||
@change="markTtsSettingChanged('pitch')"
|
||||
/>
|
||||
<span class="slider-hint">{{ $t('roleConfig.pitchHint') }}</span>
|
||||
</div>
|
||||
@@ -122,6 +125,11 @@ export default {
|
||||
speed: 0,
|
||||
pitch: 0,
|
||||
},
|
||||
changedTtsFields: {
|
||||
volume: false,
|
||||
speed: false,
|
||||
pitch: false,
|
||||
},
|
||||
replacementWordIds: [],
|
||||
replacementWordList: []
|
||||
};
|
||||
@@ -141,6 +149,11 @@ export default {
|
||||
if (newVal) {
|
||||
// 当抽屉打开时,复制当前设置到本地
|
||||
this.localSettings = { ...this.settings };
|
||||
this.changedTtsFields = {
|
||||
volume: false,
|
||||
speed: false,
|
||||
pitch: false,
|
||||
};
|
||||
this.replacementWordIds = [...this.checkedReplacementWordIds];
|
||||
this.fetchReplacementWordList();
|
||||
}
|
||||
@@ -156,9 +169,18 @@ export default {
|
||||
},
|
||||
handleSave() {
|
||||
// 保存设置并关闭
|
||||
this.$emit('save', { ...this.localSettings, replacementWordIds: this.replacementWordIds });
|
||||
const changedTtsFields = Object.keys(this.changedTtsFields)
|
||||
.filter((field) => this.changedTtsFields[field]);
|
||||
this.$emit('save', {
|
||||
...this.localSettings,
|
||||
changedTtsFields,
|
||||
replacementWordIds: this.replacementWordIds
|
||||
});
|
||||
this.handleClose();
|
||||
},
|
||||
markTtsSettingChanged(field) {
|
||||
this.$set(this.changedTtsFields, field, true);
|
||||
},
|
||||
formatTooltip(val) {
|
||||
return `${val}%`;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
export const SNAPSHOT_SECRET_REDACTED = "__SNAPSHOT_SECRET_REDACTED__";
|
||||
|
||||
export function hasValidCurrentStateToken(value) {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export function redactSnapshotDisplayValue(value, redactedLabel, parentKey = "") {
|
||||
if (value === SNAPSHOT_SECRET_REDACTED || isSensitiveSnapshotKey(parentKey)) {
|
||||
return redactedLabel;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactSnapshotDisplayValue(item, redactedLabel, parentKey));
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const sensitiveEntry = Object.keys(value).some((key) => {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
return (normalizedKey === "key" || normalizedKey === "name")
|
||||
&& typeof value[key] === "string"
|
||||
&& isSensitiveSnapshotKey(value[key]);
|
||||
});
|
||||
return Object.keys(value).reduce((result, key) => {
|
||||
const semanticKey = resolveUrlSemanticKey(parentKey, key);
|
||||
if (sensitiveEntry && key.toLowerCase() === "value") {
|
||||
result[key] = redactedLabel;
|
||||
} else {
|
||||
result[key] = redactSnapshotDisplayValue(value[key], redactedLabel, semanticKey);
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
if (typeof value === "string" && isSnapshotUrlValue(parentKey, value)) {
|
||||
return redactSnapshotUrl(value, redactedLabel, parentKey);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeSnapshotOrderedValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeSnapshotOrderedValue(item));
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return Object.keys(value).sort().reduce((result, key) => {
|
||||
result[key] = normalizeSnapshotOrderedValue(value[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return value === undefined ? null : value;
|
||||
}
|
||||
|
||||
export function isSensitiveSnapshotKey(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");
|
||||
}
|
||||
|
||||
function isSnapshotUrlValue(parentKey, value) {
|
||||
const normalizedKey = String(parentKey || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
return normalizedKey.includes("url")
|
||||
|| normalizedKey.endsWith("uri")
|
||||
|| normalizedKey.includes("endpoint")
|
||||
|| normalizedKey.includes("webhook")
|
||||
|| /^(?:[a-z][a-z0-9+.-]*:)*\/\//i.test(value);
|
||||
}
|
||||
|
||||
function resolveUrlSemanticKey(parentKey, childKey) {
|
||||
if (isWebhookSemanticKey(childKey)) {
|
||||
return childKey;
|
||||
}
|
||||
if (isWebhookSemanticKey(parentKey)) {
|
||||
return childKey ? `${parentKey}.${childKey}` : parentKey;
|
||||
}
|
||||
return childKey;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
function redactSnapshotUrl(value, redactedLabel, parentKey) {
|
||||
const withoutCredentials = value.replace(
|
||||
/^((?:[a-z][a-z0-9+.-]*:)*\/\/)([^/?#\s]*@)/i,
|
||||
(match, prefix) => `${prefix}${redactedLabel}@`
|
||||
);
|
||||
const suffixIndex = withoutCredentials.search(/[?#]/);
|
||||
const base = suffixIndex < 0 ? withoutCredentials : withoutCredentials.slice(0, suffixIndex);
|
||||
const redactedBase = redactSnapshotCapabilityPath(base, redactedLabel, parentKey);
|
||||
return suffixIndex < 0
|
||||
? redactedBase
|
||||
: `${redactedBase}${withoutCredentials[suffixIndex]}${redactedLabel}`;
|
||||
}
|
||||
|
||||
function redactSnapshotCapabilityPath(value, redactedLabel, parentKey) {
|
||||
const absoluteUrl = value.match(/^((?:[a-z][a-z0-9+.-]*:)*\/\/)([^/?#]*)([^?#]*)$/i);
|
||||
if (!absoluteUrl) {
|
||||
return redactGenericCapabilityPath(value, redactedLabel, parentKey);
|
||||
}
|
||||
|
||||
const [, scheme, authority, path = ""] = absoluteUrl;
|
||||
const hostWithPort = authority.slice(authority.lastIndexOf("@") + 1);
|
||||
const host = hostWithPort.replace(/:\d+$/, "").toLowerCase();
|
||||
let redactedPath = path;
|
||||
let providerMatched = false;
|
||||
|
||||
if (host === "hooks.slack.com" || host === "hooks.slack-gov.com") {
|
||||
redactedPath = redactedPath.replace(
|
||||
/^(\/services\/[^/]+\/[^/]+\/)([^/]+)(.*)$/i,
|
||||
(match, prefix, secret, suffix) => `${prefix}${redactedLabel}${suffix}`
|
||||
);
|
||||
providerMatched = redactedPath !== path;
|
||||
} else if (
|
||||
host === "discord.com"
|
||||
|| host.endsWith(".discord.com")
|
||||
|| host === "discordapp.com"
|
||||
|| host.endsWith(".discordapp.com")
|
||||
) {
|
||||
redactedPath = redactedPath.replace(
|
||||
/^(\/api(?:\/v\d+)?\/webhooks\/[^/]+\/)([^/]+)(.*)$/i,
|
||||
(match, prefix, secret, suffix) => `${prefix}${redactedLabel}${suffix}`
|
||||
);
|
||||
providerMatched = redactedPath !== path;
|
||||
} else if (host === "api.telegram.org") {
|
||||
redactedPath = redactedPath.replace(
|
||||
/^((?:\/file)?\/bot[^/:]+:)([^/]+)(.*)$/i,
|
||||
(match, prefix, secret, suffix) => `${prefix}${redactedLabel}${suffix}`
|
||||
);
|
||||
providerMatched = redactedPath !== path;
|
||||
}
|
||||
|
||||
if (!providerMatched) {
|
||||
redactedPath = redactGenericCapabilityPath(redactedPath, redactedLabel, parentKey);
|
||||
}
|
||||
|
||||
return `${scheme}${authority}${redactedPath}`;
|
||||
}
|
||||
|
||||
function redactGenericCapabilityPath(path, redactedLabel, parentKey) {
|
||||
const markerRedacted = path.replace(
|
||||
/(^|\/)(webhooks?|hooks?)(\/)(.+)$/i,
|
||||
(match, boundary, marker, separator) => `${boundary}${marker}${separator}${redactedLabel}`
|
||||
);
|
||||
if (markerRedacted !== path) {
|
||||
return markerRedacted;
|
||||
}
|
||||
if (!isWebhookSemanticKey(parentKey)) {
|
||||
return path;
|
||||
}
|
||||
return path.replace(
|
||||
/^(.*\/)([^/]+)(\/?)$/,
|
||||
(match, prefix, secret, suffix) => `${prefix}${redactedLabel}${suffix}`
|
||||
);
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* eslint-disable test/no-import-node-test -- zero-dependency security regression gate */
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
hasValidCurrentStateToken,
|
||||
normalizeSnapshotOrderedValue,
|
||||
redactSnapshotDisplayValue,
|
||||
SNAPSHOT_SECRET_REDACTED
|
||||
} from "./agentSnapshotDisplayUtils.mjs";
|
||||
|
||||
test("accepts only non-empty opaque current-state tokens", () => {
|
||||
assert.equal(hasValidCurrentStateToken("state-token"), true);
|
||||
assert.equal(hasValidCurrentStateToken(" state-token "), true);
|
||||
assert.equal(hasValidCurrentStateToken(""), false);
|
||||
assert.equal(hasValidCurrentStateToken(" "), false);
|
||||
assert.equal(hasValidCurrentStateToken(null), false);
|
||||
assert.equal(hasValidCurrentStateToken(123), false);
|
||||
});
|
||||
|
||||
test("redacts nested credentials without hiding non-sensitive display values", () => {
|
||||
const redacted = redactSnapshotDisplayValue({
|
||||
apiKey: "api-secret",
|
||||
headers: [
|
||||
{ key: "Authorization", value: "Bearer secret" },
|
||||
{ key: "Proxy-Authorization", value: "Basic 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",
|
||||
visible: "kept"
|
||||
}, "[hidden]");
|
||||
|
||||
assert.deepEqual(redacted, {
|
||||
apiKey: "[hidden]",
|
||||
headers: [
|
||||
{ key: "Authorization", value: "[hidden]" },
|
||||
{ key: "Proxy-Authorization", 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]",
|
||||
visible: "kept"
|
||||
});
|
||||
});
|
||||
|
||||
test("uses a function parameter key when redacting scalar plugin values", () => {
|
||||
assert.equal(redactSnapshotDisplayValue("secret", "[hidden]", "clientSecret"), "[hidden]");
|
||||
assert.equal(redactSnapshotDisplayValue("secret", "[hidden]", "Ocp-Apim-Subscription-Key"), "[hidden]");
|
||||
assert.equal(redactSnapshotDisplayValue("public", "[hidden]", "clientId"), "public");
|
||||
});
|
||||
|
||||
test("redacts mixed-case name/value credential entries", () => {
|
||||
assert.deepEqual(redactSnapshotDisplayValue({
|
||||
headers: [
|
||||
{ NAME: "Authorization", VALUE: "Bearer live-secret" },
|
||||
{ NaMe: "Content-Type", VaLuE: "application/json" }
|
||||
]
|
||||
}, "[hidden]"), {
|
||||
headers: [
|
||||
{ NAME: "Authorization", VALUE: "[hidden]" },
|
||||
{ NaMe: "Content-Type", VaLuE: "application/json" }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
test("redacts capability URL path secrets while preserving public route identity", () => {
|
||||
const redacted = redactSnapshotDisplayValue({
|
||||
slackUrl: "https://hooks.slack.com/services/T00000000/B00000000/slack-secret",
|
||||
slackGovUrl: "https://hooks.slack-gov.com/services/T00000000/B00000000/slack-gov-secret",
|
||||
discordEndpoint: "https://discord.com/api/webhooks/123456789/discord-secret",
|
||||
telegramUri: "https://api.telegram.org/bot123456789:telegram-secret/sendMessage",
|
||||
telegramFileUri: "https://api.telegram.org/file/bot123456789:telegram-file-secret/documents/file.txt",
|
||||
callbackUrl: "https://events.example.com/api/webhook/generic-secret/delivery",
|
||||
hooksEndpoint: "https://events.example.com/v1/hooks/hook-secret/status",
|
||||
hookEndpoint: "https://events.example.com/v1/hook/singular-secret/status",
|
||||
webhooksEndpoint: "https://events.example.com/v1/webhooks/plural-secret/status",
|
||||
relativeUrl: "/api/webhooks/relative-secret/continuation",
|
||||
nested: {
|
||||
deliveryWebhook: {
|
||||
options: {
|
||||
target: "https://events.example.com/incoming/nested-secret",
|
||||
relativeTarget: "/incoming/nested-relative-secret"
|
||||
}
|
||||
}
|
||||
},
|
||||
protocolRelativeUrl: "//protocol-user:protocol-pass@example.com/context",
|
||||
jdbcUrl: "jdbc:mysql://jdbc-user:jdbc-pass@example.com/database",
|
||||
webhook: "https://capability.example.com/public-prefix/key-secret",
|
||||
restUrl: "https://api.example.com/v1/users/customer-123",
|
||||
similarMarkerUrl: "https://api.example.com/v1/webhook-settings/public-value"
|
||||
}, "[hidden]");
|
||||
|
||||
assert.deepEqual(redacted, {
|
||||
slackUrl: "https://hooks.slack.com/services/T00000000/B00000000/[hidden]",
|
||||
slackGovUrl: "https://hooks.slack-gov.com/services/T00000000/B00000000/[hidden]",
|
||||
discordEndpoint: "https://discord.com/api/webhooks/123456789/[hidden]",
|
||||
telegramUri: "https://api.telegram.org/bot123456789:[hidden]/sendMessage",
|
||||
telegramFileUri: "https://api.telegram.org/file/bot123456789:[hidden]/documents/file.txt",
|
||||
callbackUrl: "https://events.example.com/api/webhook/[hidden]",
|
||||
hooksEndpoint: "https://events.example.com/v1/hooks/[hidden]",
|
||||
hookEndpoint: "https://events.example.com/v1/hook/[hidden]",
|
||||
webhooksEndpoint: "https://events.example.com/v1/webhooks/[hidden]",
|
||||
relativeUrl: "/api/webhooks/[hidden]",
|
||||
nested: {
|
||||
deliveryWebhook: {
|
||||
options: {
|
||||
target: "https://events.example.com/incoming/[hidden]",
|
||||
relativeTarget: "/incoming/[hidden]"
|
||||
}
|
||||
}
|
||||
},
|
||||
protocolRelativeUrl: "//[hidden]@example.com/context",
|
||||
jdbcUrl: "jdbc:mysql://[hidden]@example.com/database",
|
||||
webhook: "https://capability.example.com/public-prefix/[hidden]",
|
||||
restUrl: "https://api.example.com/v1/users/customer-123",
|
||||
similarMarkerUrl: "https://api.example.com/v1/webhook-settings/public-value"
|
||||
});
|
||||
assert.equal(JSON.stringify(redacted).includes("slack-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("slack-gov-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("discord-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("telegram-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("telegram-file-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("generic-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("hook-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("singular-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("plural-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("relative-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("nested-secret"), false);
|
||||
assert.equal(JSON.stringify(redacted).includes("key-secret"), false);
|
||||
});
|
||||
|
||||
test("normalizes object keys without erasing ordered-list changes", () => {
|
||||
const before = [
|
||||
{ url: "https://first.example", headers: { Zeta: "2", Accept: "json" } },
|
||||
{ url: "https://second.example" }
|
||||
];
|
||||
const sameOrder = [
|
||||
{ headers: { Accept: "json", Zeta: "2" }, url: "https://first.example" },
|
||||
{ url: "https://second.example" }
|
||||
];
|
||||
const reversed = [...sameOrder].reverse();
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeSnapshotOrderedValue(before),
|
||||
normalizeSnapshotOrderedValue(sameOrder)
|
||||
);
|
||||
assert.notDeepEqual(
|
||||
normalizeSnapshotOrderedValue(before),
|
||||
normalizeSnapshotOrderedValue(reversed)
|
||||
);
|
||||
});
|
||||
@@ -845,7 +845,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': 'Vor der Wiederherstellung',
|
||||
'agentSnapshot.afterRestore': 'Nach der Wiederherstellung',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Beim Wiederherstellen einer Version ohne Speicher wird der Chatverlauf dieses Agenten gelöscht. Fahren Sie nur fort, wenn Sie dieses Risiko bestätigt haben.',
|
||||
'agentSnapshot.restoreConfirm': 'Version {version} wiederherstellen? Die aktuelle Konfiguration wird zuerst als neue Version gespeichert.',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': 'Diese Wiederherstellung löscht den bestehenden Chatverlauf dieses Agenten dauerhaft. Chatverläufe sind nicht in Konfigurations-Snapshots enthalten und können nicht über den Versionsverlauf wiederhergestellt werden.',
|
||||
'agentSnapshot.unsavedChangesWarning': 'Nicht gespeicherte Änderungen auf dieser Seite werden nicht in den Verlauf übernommen und nach der Wiederherstellung verworfen.',
|
||||
'agentSnapshot.restoreConfirm': 'Version {version} wiederherstellen? Die aktuelle Konfiguration bleibt im Verlauf erhalten.',
|
||||
'agentSnapshot.restoreSuccess': 'Version wurde wiederhergestellt',
|
||||
'agentSnapshot.restoreFailed': 'Version konnte nicht wiederhergestellt werden',
|
||||
'agentSnapshot.restoreFailedRollback': 'Version konnte nicht wiederhergestellt werden. Die Transaktion wurde zurueckgerollt und die aktuelle Konfiguration wurde nicht geaendert',
|
||||
@@ -854,10 +856,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': 'Verlaufsversion konnte nicht gelöscht werden',
|
||||
'agentSnapshot.fetchFailed': 'Versionsverlauf konnte nicht abgerufen werden',
|
||||
'agentSnapshot.detailFailed': 'Snapshot-Details konnten nicht abgerufen werden',
|
||||
'agentSnapshot.currentVersion': 'Aktuelle Version',
|
||||
'agentSnapshot.currentVersion': 'Neuester Snapshot',
|
||||
'agentSnapshot.source.config': 'Konfiguration gespeichert',
|
||||
'agentSnapshot.source.current': 'Aktuelle Konfiguration',
|
||||
'agentSnapshot.source.restore': 'Vor der Wiederherstellung',
|
||||
'agentSnapshot.source.restore': 'Wiederherstellungsergebnis',
|
||||
'agentSnapshot.source.initial': 'Initialversion',
|
||||
'agentSnapshot.field.restore': 'Wiederherstellung',
|
||||
'agentSnapshot.field.initial': 'Initialer Snapshot',
|
||||
|
||||
@@ -895,7 +895,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': 'Before Restore',
|
||||
'agentSnapshot.afterRestore': 'After Restore',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Restoring to a no-memory version will clear this agent\'s chat history. Continue only after confirming this risk.',
|
||||
'agentSnapshot.restoreConfirm': 'Restore to version {version}? The current configuration will be saved as a new version first.',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': '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.unsavedChangesWarning': 'Unsaved changes on this page are not included in history and will be discarded after restore.',
|
||||
'agentSnapshot.restoreConfirm': 'Restore to version {version}? The current configuration will be preserved in history.',
|
||||
'agentSnapshot.restoreSuccess': 'Version restored',
|
||||
'agentSnapshot.restoreFailed': 'Failed to restore version',
|
||||
'agentSnapshot.restoreFailedRollback': 'Failed to restore version. The transaction was rolled back and the current configuration was not changed',
|
||||
@@ -904,10 +906,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': 'Failed to delete history version',
|
||||
'agentSnapshot.fetchFailed': 'Failed to fetch versions',
|
||||
'agentSnapshot.detailFailed': 'Failed to fetch snapshot details',
|
||||
'agentSnapshot.currentVersion': 'Current Version',
|
||||
'agentSnapshot.currentVersion': 'Latest Snapshot',
|
||||
'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.restore': 'Restore',
|
||||
'agentSnapshot.field.initial': 'Initial Snapshot',
|
||||
|
||||
@@ -845,7 +845,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': 'Antes da Restauração',
|
||||
'agentSnapshot.afterRestore': 'Depois da Restauração',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Restaurar para uma versão sem memória apagará o histórico de conversa deste agente. Continue somente após confirmar esse risco.',
|
||||
'agentSnapshot.restoreConfirm': 'Restaurar para a versão {version}? A configuração atual será salva primeiro como uma nova versão.',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': 'Esta restauração excluirá permanentemente o histórico de conversa existente deste agente. O histórico de conversa não faz parte dos snapshots de configuração e não pode ser recuperado pelo histórico de versões.',
|
||||
'agentSnapshot.unsavedChangesWarning': 'As alterações não salvas nesta página não serão incluídas no histórico e serão descartadas após a restauração.',
|
||||
'agentSnapshot.restoreConfirm': 'Restaurar para a versão {version}? A configuração atual será preservada no histórico.',
|
||||
'agentSnapshot.restoreSuccess': 'Versão restaurada',
|
||||
'agentSnapshot.restoreFailed': 'Falha ao restaurar versão',
|
||||
'agentSnapshot.restoreFailedRollback': 'Falha ao restaurar versão. A transação foi revertida e a configuração atual não foi alterada',
|
||||
@@ -854,10 +856,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': 'Falha ao excluir a versão do histórico',
|
||||
'agentSnapshot.fetchFailed': 'Falha ao buscar versões',
|
||||
'agentSnapshot.detailFailed': 'Falha ao buscar detalhes do snapshot',
|
||||
'agentSnapshot.currentVersion': 'Versão Atual',
|
||||
'agentSnapshot.currentVersion': 'Snapshot mais recente',
|
||||
'agentSnapshot.source.config': 'Configuração Salva',
|
||||
'agentSnapshot.source.current': 'Configuração Atual',
|
||||
'agentSnapshot.source.restore': 'Antes da Restauração',
|
||||
'agentSnapshot.source.restore': 'Resultado da restauração',
|
||||
'agentSnapshot.source.initial': 'Versão inicial',
|
||||
'agentSnapshot.field.restore': 'Restauração',
|
||||
'agentSnapshot.field.initial': 'Snapshot inicial',
|
||||
|
||||
@@ -845,7 +845,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': 'Trước khi khôi phục',
|
||||
'agentSnapshot.afterRestore': 'Sau khi khôi phục',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Khôi phục về phiên bản không có bộ nhớ sẽ xóa lịch sử trò chuyện của tác nhân này. Chỉ tiếp tục sau khi xác nhận rủi ro này.',
|
||||
'agentSnapshot.restoreConfirm': 'Khôi phục về phiên bản {version}? Cấu hình hiện tại sẽ được lưu thành phiên bản mới trước.',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': 'Lần 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 ảnh chụp cấu hình và không thể khôi phục từ lịch sử phiên bản.',
|
||||
'agentSnapshot.unsavedChangesWarning': 'Các thay đổi chưa lưu trên trang này không được ghi vào lịch sử và sẽ bị loại bỏ sau khi 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ẽ được giữ lại trong lịch sử.',
|
||||
'agentSnapshot.restoreSuccess': 'Đã khôi phục phiên bản',
|
||||
'agentSnapshot.restoreFailed': 'Khôi phục phiên bản thất bại',
|
||||
'agentSnapshot.restoreFailedRollback': 'Khôi phục phiên bản thất bại. Giao dịch đã được hoàn tác và cấu hình hiện tại chưa thay đổi',
|
||||
@@ -854,10 +856,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': 'Xóa phiên bản lịch sử thất bại',
|
||||
'agentSnapshot.fetchFailed': 'Không thể tải lịch sử phiên bản',
|
||||
'agentSnapshot.detailFailed': 'Không thể tải chi tiết snapshot',
|
||||
'agentSnapshot.currentVersion': 'Phiên bản hiện tại',
|
||||
'agentSnapshot.currentVersion': 'Ảnh chụp mới nhất',
|
||||
'agentSnapshot.source.config': 'Lưu cấu hình',
|
||||
'agentSnapshot.source.current': 'Cấu hình hiện tại',
|
||||
'agentSnapshot.source.restore': 'Trước khi khôi phục',
|
||||
'agentSnapshot.source.restore': 'Kết quả khôi phục',
|
||||
'agentSnapshot.source.initial': 'Phiên bản ban đầu',
|
||||
'agentSnapshot.field.restore': 'Khôi phục',
|
||||
'agentSnapshot.field.initial': 'Snapshot ban đầu',
|
||||
|
||||
@@ -895,7 +895,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': '恢复前',
|
||||
'agentSnapshot.afterRestore': '恢复后',
|
||||
'agentSnapshot.restoreMemoryWarning': '恢复到无记忆版本会清空该智能体的聊天记录,请确认后再继续。',
|
||||
'agentSnapshot.restoreConfirm': '确定恢复到版本 {version} 吗?当前配置会先自动保存为新的历史版本。',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': '此次恢复会永久删除该智能体现有的聊天记录。聊天记录不包含在配置快照中,删除后无法通过历史版本恢复。',
|
||||
'agentSnapshot.unsavedChangesWarning': '页面中尚未保存的修改不会进入历史,并会在恢复后丢失。',
|
||||
'agentSnapshot.restoreConfirm': '确定恢复到版本 {version} 吗?当前配置会保留在历史中。',
|
||||
'agentSnapshot.restoreSuccess': '已恢复历史版本',
|
||||
'agentSnapshot.restoreFailed': '恢复历史版本失败',
|
||||
'agentSnapshot.restoreFailedRollback': '恢复历史版本失败,事务已回滚,当前配置未改变',
|
||||
@@ -904,10 +906,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': '删除历史版本失败',
|
||||
'agentSnapshot.fetchFailed': '获取历史版本失败',
|
||||
'agentSnapshot.detailFailed': '获取快照详情失败',
|
||||
'agentSnapshot.currentVersion': '当前版本',
|
||||
'agentSnapshot.currentVersion': '最新快照',
|
||||
'agentSnapshot.source.config': '配置保存',
|
||||
'agentSnapshot.source.current': '当前配置',
|
||||
'agentSnapshot.source.restore': '恢复前',
|
||||
'agentSnapshot.source.restore': '恢复结果',
|
||||
'agentSnapshot.source.initial': '初始版本',
|
||||
'agentSnapshot.field.restore': '恢复操作',
|
||||
'agentSnapshot.field.initial': '初始快照',
|
||||
|
||||
@@ -845,7 +845,9 @@ export default {
|
||||
'agentSnapshot.beforeRestore': '恢復前',
|
||||
'agentSnapshot.afterRestore': '恢復後',
|
||||
'agentSnapshot.restoreMemoryWarning': '恢復到無記憶版本會清空該智能體的聊天記錄,請確認後再繼續。',
|
||||
'agentSnapshot.restoreConfirm': '確定恢復到版本 {version} 嗎?目前配置會先自動保存為新的歷史版本。',
|
||||
'agentSnapshot.restoreMemoryDestructiveWarning': '此次恢復會永久刪除該智能體現有的聊天記錄。聊天記錄不包含在配置快照中,刪除後無法透過歷史版本復原。',
|
||||
'agentSnapshot.unsavedChangesWarning': '頁面中尚未儲存的修改不會寫入歷史,並會在恢復後遺失。',
|
||||
'agentSnapshot.restoreConfirm': '確定恢復到版本 {version} 嗎?目前配置會保留在歷史記錄中。',
|
||||
'agentSnapshot.restoreSuccess': '已恢復歷史版本',
|
||||
'agentSnapshot.restoreFailed': '恢復歷史版本失敗',
|
||||
'agentSnapshot.restoreFailedRollback': '恢復歷史版本失敗,交易已回滾,目前配置未改變',
|
||||
@@ -854,10 +856,10 @@ export default {
|
||||
'agentSnapshot.deleteFailed': '刪除歷史版本失敗',
|
||||
'agentSnapshot.fetchFailed': '獲取歷史版本失敗',
|
||||
'agentSnapshot.detailFailed': '獲取快照詳情失敗',
|
||||
'agentSnapshot.currentVersion': '目前版本',
|
||||
'agentSnapshot.currentVersion': '最新快照',
|
||||
'agentSnapshot.source.config': '配置保存',
|
||||
'agentSnapshot.source.current': '目前配置',
|
||||
'agentSnapshot.source.restore': '恢復前',
|
||||
'agentSnapshot.source.restore': '恢復結果',
|
||||
'agentSnapshot.source.initial': '初始版本',
|
||||
'agentSnapshot.field.restore': '恢復操作',
|
||||
'agentSnapshot.field.initial': '初始快照',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user