Compare commits

..
Author SHA1 Message Date
wengzh 2a9f809700 refactor(reportHandle): 重构上报处理逻辑,适配多格式音频转换
1.  重命名参数与变量,明确上报类型与音频格式
2.  拆分PCM和Opus转WAV逻辑,分别处理不同输入格式
3.  移动校验逻辑到上报队列函数开头,提前拦截无效上报
4.  完善Opus转WAV的解码与资源释放流程
2026-07-27 09:59:32 +08:00
CGDandGitHub 7c58fa37b2 Merge pull request #3293 from xinnan-tech/refactor/mybatis-plus-single-source
refactor(manager-api): consolidate MyBatis-Plus 3.5.17 integration
2026-07-20 14:42:31 +08:00
Tyke Chen 10a67c772f refactor(manager-api): consolidate mybatis-plus 3.5.17 integration 2026-07-20 10:31:08 +08:00
CGDandGitHub 7e8e5d34b8 Merge pull request #3279 from xinnan-tech/fix/issue-3276-context-provider
fix: 修复 ContextProviderListTypeHandler 多版本兼容问题
2026-07-17 09:28:14 +08:00
CGDandGitHub c6bc20c05e Merge pull request #3283 from xinnan-tech/fix/device-timezone-consistency
fix: 修复跨时区场景下设备时间与在线状态异常
2026-07-16 16:58:49 +08:00
wengzhandGitHub 1998807b69 Merge pull request #3285 from xinnan-tech/py-aec
perf: 优化相关逻辑减少重复计算
2026-07-14 16:36:16 +08:00
Sakura-RanChen 55c0495661 perf: 优化相关逻辑减少重复计算 2026-07-14 15:52:30 +08:00
Tyke Chen dbeda01697 fix: 修复设备跨时区时间与在线状态 2026-07-14 10:08:23 +08:00
Tyke Chen 023dea2441 fix: 修复上下文源类型处理器兼容问题 2026-07-13 16:03:39 +08:00
CGDandGitHub ad3fb4d8c8 Merge pull request #3278 from xinnan-tech/agent-snapshot-history-fixes
fix: 完善智能体配置历史与恢复逻辑
2026-07-13 15:47:08 +08:00
3030332422 176785830a update:修改部分样式 2026-07-13 15:44:06 +08:00
Tyke Chen d78ecfce6f Merge remote-tracking branch 'origin/main' into agent-snapshot-history-fixes 2026-07-10 20:26:30 +08:00
Tyke Chen 179281e49c fix: harden agent snapshot restore flow 2026-07-10 20:21:44 +08:00
hrzandGitHub bc1aa1a089 Merge pull request #3267 from xinnan-tech/fix-agent-idor
fix: prevent agent IDOR access
2026-07-10 17:36:00 +08:00
wengzhandGitHub c5b64c3ef0 Merge pull request #3270 from xinnan-tech/fix-voiceprint-dialogue-bug
fix: 修改提示词与说话人注入逻辑,减少模型回答带着说话人信息频率
2026-07-10 15:55:58 +08:00
DaGou12138 2aa6c5ee49 fix:
1.优化context注入方式,合并为一个system,解决qwen本地部署模型多system异常400问题
2026-07-10 15:05:02 +08:00
Tyke Chen 687b6db96b fix: resolve mobile type and lint errors 2026-07-10 11:45:16 +08:00
Tyke Chen a5aee109fe feat: improve agent snapshot history 2026-07-10 11:44:35 +08:00
hrzandGitHub 8ec5851027 Merge pull request #3273 from xinnan-tech/agent-delete-name-confirm
feat: 删除智能体前需要用户进行确认
2026-07-10 10:39:47 +08:00
Tyke Chen 48d629c79c fix: localize agent deletion success message 2026-07-10 10:18:48 +08:00
wengzh 14e57a1add fix(powermem): 修复用户配置启用状态的类型判断问题
将配置的启用值统一转为小写字符串后判断是否为true,避免非布尔类型配置导致的判断失效
2026-07-10 10:06:07 +08:00
hrzandGitHub 634967b2fe Merge pull request #3274 from xinnan-tech/i18n-duplicate-check
test: 增加 i18n 重复 key 检测
2026-07-10 09:59:33 +08:00
Tyke Chen e648453c67 fix: rename i18n check command 2026-07-10 09:55:53 +08:00
Tyke Chen d11cfc6923 Merge remote-tracking branch 'origin/main' into fix-agent-idor
# Conflicts:
#	main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentController.java
#	main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentService.java
#	main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java
2026-07-09 20:21:37 +08:00
DaGou12138 32fbbf92fe Merge branch 'refs/heads/main' into fix-voiceprint-dialogue-bug 2026-07-09 16:56:22 +08:00
wengzhandGitHub bd981aac79 Merge pull request #3275 from xinnan-tech/py-audio-aec
feat: 服务端AEC功能
2026-07-09 16:24:03 +08:00
Sakura-RanChenandGitHub adee070bb0 Merge branch 'main' into py-audio-aec 2026-07-09 15:51:01 +08:00
wengzhandGitHub 03ec3bac3f Merge pull request #3271 from xinnan-tech/py-fix-bug
Py fix bug
2026-07-09 15:36:59 +08:00
Tyke Chen af5fbb5282 test: add i18n duplicate key check 2026-07-09 15:25:36 +08:00
Tyke Chen 9301c1889f feat: require agent name confirmation before delete 2026-07-09 15:03:12 +08:00
hrzandGitHub ef2ed07c1c Merge pull request #3266 from xinnan-tech/agent-snapshot-history
Add agent configuration history
2026-07-09 14:54:27 +08:00
Tyke Chen 1e2c4c6a28 fix: 修复智能体快照基线与查看逻辑 2026-07-09 11:16:07 +08:00
Sakura-RanChen a32500a6a4 fix: 消除死锁隐患 2026-07-09 10:29:41 +08:00
hrz 772af6c918 Merge branch 'agent-snapshot-history' of https://github.com/xinnan-tech/xiaozhi-esp32-server into agent-snapshot-history 2026-07-08 17:46:50 +08:00
hrz 2a917c1e62 update:对齐i18n文件行数 2026-07-08 17:44:10 +08:00
Tyke Chen c32fbe69c5 Remove wakeup word audio asset 2026-07-08 16:33:36 +08:00
DaGou12138 3b6e8f0e4b fix:
1.优化声纹称呼人提示词和注入逻辑,减少次次都称呼的频率
2026-07-08 16:14:10 +08:00
Tyke Chen aae2ef2152 fix: prevent agent IDOR access 2026-07-08 15:52:47 +08:00
Tyke Chen c82b351457 Fix agent snapshot history issues 2026-07-08 15:15:26 +08:00
DaGou12138 04c10ef225 fix:
1.修改提示词与说话人注入,减少模型每次回答都带着说话人信息
2026-07-08 14:12:16 +08:00
Sakura-RanChen 0b8bb728e2 同步方法进行包装避免阻塞 2026-07-08 11:47:04 +08:00
mie 35ec3ba803 Add agent configuration history 2026-07-07 17:49:34 +08:00
wengzhandGitHub 8808b69303 Merge pull request #3265 from xinnan-tech/ha-muisc-timeout
Ha muisc timeout
2026-07-07 16:25:12 +08:00
Sakura-RanChen fc573a72ea fix: 修复设备时间戳上报逻辑 2026-07-07 15:50:27 +08:00
Sakura-RanChen 59f6566cfb 同步工具异步化处理 2026-07-07 14:45:50 +08:00
Sakura-RanChen d8c97a9f81 fix: 修复携程死锁 2026-07-06 16:44:41 +08:00
Sakura-RanChen 3d204fb181 feat: 服务端AEC功能
ref: 入口解码为pcm音频数据,vad和asr可直接使用
2026-07-06 16:21:24 +08:00
hrzandGitHub 209fd3fbc8 Merge pull request #3260 from xinnan-tech/test-funasr
FunASR识别指定语言
2026-07-06 13:56:48 +08:00
3030332422 bc0ffd1bd3 update:修改sql文件,删去测试文件 2026-07-01 15:02:26 +08:00
hrzandGitHub f0d61442b8 Merge pull request #3259 from xinnan-tech/update-theme
顶部下拉菜单图标间距调整
2026-06-30 18:45:14 +08:00
CGDandGitHub e6c2fac7ec Merge pull request #3255 from LauraGPT/funasr-language-option
fix: allow configuring FunASR recognition language
2026-06-30 14:57:31 +08:00
zhifu gao 5a9b5af441 fix: add FunASR language config migration 2026-06-30 14:07:16 +08:00
zhuoqinglian af3ca4f609 update: 顶部下拉菜单图标间距调整 2026-06-30 09:47:28 +08:00
LauraGPT 2fae96d60c fix: allow configuring FunASR recognition language 2026-06-29 20:58:51 +00:00
166 changed files with 17042 additions and 2483 deletions
+5
View File
@@ -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
+7 -6
View File
@@ -20,7 +20,7 @@
<java.version>21</java.version>
<junit.version>5.10.1</junit.version>
<druid.version>1.2.20</druid.version>
<mybatisplus.version>3.5.5</mybatisplus.version>
<mybatisplus.version>3.5.17</mybatisplus.version>
<hutool.version>5.8.24</hutool.version>
<jsoup.version>1.19.1</jsoup.version>
<knife4j.version>4.6.0</knife4j.version>
@@ -32,6 +32,7 @@
<liquibase-core.version>4.20.0</liquibase-core.version>
<aliyun-sms-version>4.1.0</aliyun-sms-version>
<okio-version>3.4.0</okio-version>
<skipTests>true</skipTests>
</properties>
<dependencies>
@@ -175,13 +176,13 @@
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>3.0.3</version>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
@@ -275,7 +276,7 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>true</skipTests>
<skipTests>${skipTests}</skipTests>
</configuration>
</plugin>
</plugins>
@@ -5,7 +5,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import org.apache.ibatis.binding.MapperMethod;
import org.apache.ibatis.logging.Log;
@@ -181,8 +181,9 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
* 执行批量操作
*/
@SuppressWarnings("deprecation")
protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiConsumer<SqlSession, E> consumer) {
return SqlHelper.executeBatch(this.currentModelClass(), this.log, list, batchSize, consumer);
protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiFunction<SqlSession, E, Integer> operation) {
return SqlHelper.executeBatch(SqlHelper.sqlSessionFactory(this.currentModelClass()), this.log, list, batchSize,
operation);
}
@Override
@@ -209,7 +210,7 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
return executeBatch(entityList, batchSize, (sqlSession, entity) -> {
MapperMethod.ParamMap<T> param = new MapperMethod.ParamMap<>();
param.put(Constants.ENTITY, entity);
sqlSession.update(sqlStatement, param);
return sqlSession.update(sqlStatement, param);
});
}
@@ -227,4 +228,4 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
public boolean deleteBatchIds(Collection<? extends Serializable> idList) {
return SqlHelper.retBool(baseDao.deleteBatchIds(idList));
}
}
}
@@ -0,0 +1,103 @@
package xiaozhi.modules.agent.Enums;
import java.util.Arrays;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function;
import lombok.Getter;
import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentEntity;
@Getter
public enum AgentSnapshotField {
AGENT_CODE("agentCode", AgentSnapshotDataDTO::getAgentCode, AgentUpdateDTO::getAgentCode,
(agent, data) -> agent.setAgentCode(data.getAgentCode())),
AGENT_NAME("agentName", AgentSnapshotDataDTO::getAgentName, AgentUpdateDTO::getAgentName,
(agent, data) -> agent.setAgentName(data.getAgentName())),
ASR_MODEL_ID("asrModelId", AgentSnapshotDataDTO::getAsrModelId, AgentUpdateDTO::getAsrModelId,
(agent, data) -> agent.setAsrModelId(data.getAsrModelId())),
VAD_MODEL_ID("vadModelId", AgentSnapshotDataDTO::getVadModelId, AgentUpdateDTO::getVadModelId,
(agent, data) -> agent.setVadModelId(data.getVadModelId())),
LLM_MODEL_ID("llmModelId", AgentSnapshotDataDTO::getLlmModelId, AgentUpdateDTO::getLlmModelId,
(agent, data) -> agent.setLlmModelId(data.getLlmModelId())),
SLM_MODEL_ID("slmModelId", AgentSnapshotDataDTO::getSlmModelId, AgentUpdateDTO::getSlmModelId,
(agent, data) -> agent.setSlmModelId(data.getSlmModelId())),
VLLM_MODEL_ID("vllmModelId", AgentSnapshotDataDTO::getVllmModelId, AgentUpdateDTO::getVllmModelId,
(agent, data) -> agent.setVllmModelId(data.getVllmModelId())),
TTS_MODEL_ID("ttsModelId", AgentSnapshotDataDTO::getTtsModelId, AgentUpdateDTO::getTtsModelId,
(agent, data) -> agent.setTtsModelId(data.getTtsModelId())),
TTS_VOICE_ID("ttsVoiceId", AgentSnapshotDataDTO::getTtsVoiceId, AgentUpdateDTO::getTtsVoiceId,
(agent, data) -> agent.setTtsVoiceId(data.getTtsVoiceId())),
TTS_LANGUAGE("ttsLanguage", AgentSnapshotDataDTO::getTtsLanguage, AgentUpdateDTO::getTtsLanguage,
(agent, data) -> agent.setTtsLanguage(data.getTtsLanguage())),
TTS_VOLUME("ttsVolume", AgentSnapshotDataDTO::getTtsVolume, AgentUpdateDTO::getTtsVolume,
(agent, data) -> agent.setTtsVolume(data.getTtsVolume())),
TTS_RATE("ttsRate", AgentSnapshotDataDTO::getTtsRate, AgentUpdateDTO::getTtsRate,
(agent, data) -> agent.setTtsRate(data.getTtsRate())),
TTS_PITCH("ttsPitch", AgentSnapshotDataDTO::getTtsPitch, AgentUpdateDTO::getTtsPitch,
(agent, data) -> agent.setTtsPitch(data.getTtsPitch())),
MEM_MODEL_ID("memModelId", AgentSnapshotDataDTO::getMemModelId, AgentUpdateDTO::getMemModelId,
(agent, data) -> agent.setMemModelId(data.getMemModelId())),
INTENT_MODEL_ID("intentModelId", AgentSnapshotDataDTO::getIntentModelId, AgentUpdateDTO::getIntentModelId,
(agent, data) -> agent.setIntentModelId(data.getIntentModelId())),
CHAT_HISTORY_CONF("chatHistoryConf", AgentSnapshotDataDTO::getChatHistoryConf, AgentUpdateDTO::getChatHistoryConf,
(agent, data) -> agent.setChatHistoryConf(data.getChatHistoryConf())),
SYSTEM_PROMPT("systemPrompt", AgentSnapshotDataDTO::getSystemPrompt, AgentUpdateDTO::getSystemPrompt,
(agent, data) -> agent.setSystemPrompt(data.getSystemPrompt())),
SUMMARY_MEMORY("summaryMemory", AgentSnapshotDataDTO::getSummaryMemory, AgentUpdateDTO::getSummaryMemory,
(agent, data) -> agent.setSummaryMemory(data.getSummaryMemory())),
LANG_CODE("langCode", AgentSnapshotDataDTO::getLangCode, AgentUpdateDTO::getLangCode,
(agent, data) -> agent.setLangCode(data.getLangCode())),
LANGUAGE("language", AgentSnapshotDataDTO::getLanguage, AgentUpdateDTO::getLanguage,
(agent, data) -> agent.setLanguage(data.getLanguage())),
SORT("sort", AgentSnapshotDataDTO::getSort, AgentUpdateDTO::getSort,
(agent, data) -> agent.setSort(data.getSort())),
FUNCTIONS("functions", AgentSnapshotDataDTO::getFunctions, AgentUpdateDTO::getFunctions, null),
CONTEXT_PROVIDERS("contextProviders", AgentSnapshotDataDTO::getContextProviders,
AgentUpdateDTO::getContextProviders, null),
CORRECT_WORD_FILE_IDS("correctWordFileIds", AgentSnapshotDataDTO::getCorrectWordFileIds,
AgentUpdateDTO::getCorrectWordFileIds, null),
TAG_NAMES("tagNames", AgentSnapshotDataDTO::getTagNames, AgentUpdateDTO::getTagNames, null);
private final String fieldName;
private final Function<AgentSnapshotDataDTO, Object> snapshotGetter;
private final Function<AgentUpdateDTO, Object> updateGetter;
private final BiConsumer<AgentEntity, AgentSnapshotDataDTO> restoreApplier;
AgentSnapshotField(String fieldName, Function<AgentSnapshotDataDTO, Object> snapshotGetter,
Function<AgentUpdateDTO, Object> updateGetter,
BiConsumer<AgentEntity, AgentSnapshotDataDTO> restoreApplier) {
this.fieldName = fieldName;
this.snapshotGetter = snapshotGetter;
this.updateGetter = updateGetter;
this.restoreApplier = restoreApplier;
}
public static List<String> names() {
return Arrays.stream(values()).map(AgentSnapshotField::getFieldName).toList();
}
public static String canonicalName(String fieldName) {
return "tags".equals(fieldName) ? TAG_NAMES.getFieldName() : fieldName;
}
public Object snapshotValue(AgentSnapshotDataDTO data) {
return data == null ? null : snapshotGetter.apply(data);
}
public Object updateValue(AgentUpdateDTO data) {
return data == null ? null : updateGetter.apply(data);
}
public boolean isRestorableAgentField() {
return restoreApplier != null;
}
public void applyTo(AgentEntity agent, AgentSnapshotDataDTO data) {
if (restoreApplier != null) {
restoreApplier.accept(agent, data);
}
}
}
@@ -28,6 +28,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
@@ -48,15 +50,10 @@ import xiaozhi.modules.agent.service.AgentTagService;
import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentChatSummaryService;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.correctword.service.CorrectWordFileService;
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
import xiaozhi.modules.agent.vo.AgentInfoVO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.security.user.SecurityUser;
@Tag(name = "智能体管理")
@@ -64,17 +61,39 @@ import xiaozhi.modules.security.user.SecurityUser;
@RestController
@RequestMapping("/agent")
public class AgentController {
private static final long AUDIO_PLAY_TOKEN_EXPIRE_SECONDS = 300L;
private final AgentService agentService;
private final AgentTemplateService agentTemplateService;
private final DeviceService deviceService;
private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService;
private final AgentPluginMappingService agentPluginMappingService;
private final AgentContextProviderService agentContextProviderService;
private final AgentChatSummaryService agentChatSummaryService;
private final RedisUtils redisUtils;
private final AgentTagService agentTagService;
private final CorrectWordFileService correctWordFileService;
private void requireAgentPermission(String agentId) {
if (!agentService.checkAgentPermission(agentId, SecurityUser.getUserId())) {
throw new RenException(ErrorCode.NO_PERMISSION);
}
}
private String requireSessionAgent(String sessionId) {
String agentId = agentChatHistoryService.getAgentIdBySessionId(sessionId);
if (StringUtils.isBlank(agentId)) {
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
}
agentService.getAgentById(agentId);
return agentId;
}
private String requireAudioPermission(String audioId) {
String agentId = agentChatHistoryService.getAgentIdByAudioId(audioId);
if (StringUtils.isBlank(agentId)) {
throw new RenException(ErrorCode.NO_PERMISSION);
}
requireAgentPermission(agentId);
return agentId;
}
@GetMapping("/list")
@Operation(summary = "获取用户智能体列表")
@@ -106,7 +125,7 @@ public class AgentController {
@Operation(summary = "获取智能体详情")
@RequiresPermissions("sys:role:normal")
public Result<AgentInfoVO> getAgentById(@PathVariable("id") String id) {
AgentInfoVO agent = agentService.getAgentById(id);
AgentInfoVO agent = agentService.getAgentById(id, SecurityUser.getUserId());
return ResultUtils.success(agent);
}
@@ -120,20 +139,16 @@ public class AgentController {
@PutMapping("/saveMemory/{macAddress}")
@Operation(summary = "根据设备id更新智能体")
@RequiresPermissions("sys:role:normal")
public Result<Void> updateByDeviceId(@PathVariable String macAddress, @RequestBody @Valid AgentMemoryDTO dto) {
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device == null) {
return new Result<>();
}
AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO();
agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory());
agentService.updateAgentById(device.getAgentId(), agentUpdateDTO);
return new Result<>();
agentService.updateAgentMemoryByDeviceMacAddress(macAddress, dto, SecurityUser.getUserId());
return new Result<Void>().ok(null);
}
@PostMapping("/chat-summary/{sessionId}/save")
@Operation(summary = "根据会话ID生成聊天记录总结并保存(异步执行)")
public Result<Void> generateAndSaveChatSummary(@PathVariable String sessionId) {
requireSessionAgent(sessionId);
try {
// 异步执行总结生成任务,立即返回成功响应
new Thread(() -> {
@@ -155,6 +170,7 @@ public class AgentController {
@PostMapping("/chat-title/{sessionId}/generate")
@Operation(summary = "根据会话ID生成聊天标题")
public Result<Void> generateAndSaveChatTitle(@PathVariable String sessionId) {
requireSessionAgent(sessionId);
agentChatSummaryService.generateAndSaveChatTitle(sessionId);
return new Result<Void>().ok(null);
}
@@ -163,7 +179,7 @@ public class AgentController {
@Operation(summary = "更新智能体")
@RequiresPermissions("sys:role:normal")
public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) {
agentService.updateAgentById(id, dto);
agentService.updateAgentById(id, dto, SecurityUser.getUserId());
return new Result<>();
}
@@ -171,18 +187,7 @@ public class AgentController {
@Operation(summary = "删除智能体")
@RequiresPermissions("sys:role:normal")
public Result<Void> delete(@PathVariable String id) {
// 先删除关联的设备
deviceService.deleteByAgentId(id);
// 删除关联的聊天记录
agentChatHistoryService.deleteByAgentId(id, true, true);
// 删除关联的插件
agentPluginMappingService.deleteByAgentId(id);
// 删除关联的上下文源配置
agentContextProviderService.deleteByAgentId(id);
// 删除关联的替换词文件关联记录
correctWordFileService.deleteMappingsByAgentId(id);
// 再删除智能体
agentService.deleteById(id);
agentService.deleteAgentById(id, SecurityUser.getUserId());
return new Result<>();
}
@@ -205,6 +210,7 @@ public class AgentController {
public Result<PageData<AgentChatSessionDTO>> getAgentSessions(
@PathVariable("id") String id,
@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
requireAgentPermission(id);
params.put("agentId", id);
PageData<AgentChatSessionDTO> page = agentChatHistoryService.getSessionListByAgentId(params);
return new Result<PageData<AgentChatSessionDTO>>().ok(page);
@@ -252,6 +258,7 @@ public class AgentController {
@RequiresPermissions("sys:role:normal")
public Result<String> getContentByAudioId(
@PathVariable("id") String id) {
requireAudioPermission(id);
// 查询聊天记录
String data = agentChatHistoryService.getContentByAudioId(id);
return new Result<String>().ok(data);
@@ -261,12 +268,13 @@ public class AgentController {
@Operation(summary = "获取音频下载ID")
@RequiresPermissions("sys:role:normal")
public Result<String> getAudioId(@PathVariable("audioId") String audioId) {
requireAudioPermission(audioId);
byte[] audioData = agentChatAudioService.getAudio(audioId);
if (audioData == null) {
return new Result<String>().error("音频不存在");
}
String uuid = UUID.randomUUID().toString();
redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId);
redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId, AUDIO_PLAY_TOKEN_EXPIRE_SECONDS);
return new Result<String>().ok(uuid);
}
@@ -322,6 +330,7 @@ public class AgentController {
@Operation(summary = "获取智能体的标签")
@RequiresPermissions("sys:role:normal")
public Result<List<AgentTagDTO>> getAgentTags(@PathVariable String id) {
requireAgentPermission(id);
List<AgentTagDTO> tags = agentTagService.getTagsByAgentId(id);
return new Result<List<AgentTagDTO>>().ok(tags);
}
@@ -330,10 +339,14 @@ public class AgentController {
@Operation(summary = "保存智能体的标签")
@RequiresPermissions("sys:role:normal")
public Result<Void> saveAgentTags(@PathVariable String id, @RequestBody Map<String, Object> params) {
requireAgentPermission(id);
List<String> tagIds = (List<String>) params.get("tagIds");
List<String> tagNames = (List<String>) params.get("tagNames");
agentTagService.saveAgentTags(id, tagIds, tagNames);
AgentUpdateDTO dto = new AgentUpdateDTO();
dto.setTagIds(tagIds);
dto.setTagNames(tagNames);
agentService.updateAgentById(id, dto);
return new Result<Void>().ok(null);
}
}
}
@@ -0,0 +1,79 @@
package xiaozhi.modules.agent.controller;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.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;
import xiaozhi.modules.security.user.SecurityUser;
@Tag(name = "智能体快照")
@AllArgsConstructor
@RestController
@RequestMapping("/agent/{agentId}/snapshots")
public class AgentSnapshotController {
private final AgentSnapshotService agentSnapshotService;
private final AgentService agentService;
@GetMapping
@Operation(summary = "获取智能体快照列表")
@RequiresPermissions("sys:role:normal")
public Result<PageData<AgentSnapshotVO>> page(
@PathVariable String agentId,
@ParameterObject AgentSnapshotPageDTO params) {
checkPermission(agentId);
return new Result<PageData<AgentSnapshotVO>>().ok(agentSnapshotService.page(agentId, params));
}
@GetMapping("/{snapshotId}")
@Operation(summary = "获取智能体快照详情")
@RequiresPermissions("sys:role:normal")
public Result<AgentSnapshotVO> getSnapshot(@PathVariable String agentId, @PathVariable String snapshotId) {
checkPermission(agentId);
return new Result<AgentSnapshotVO>().ok(agentSnapshotService.getSnapshot(agentId, snapshotId));
}
@PostMapping("/{snapshotId}/restore")
@Operation(summary = "恢复智能体快照")
@RequiresPermissions("sys:role:normal")
public Result<Void> restore(@PathVariable String agentId, @PathVariable String snapshotId,
@RequestBody @Valid AgentSnapshotRestoreDTO request) {
checkPermission(agentId);
agentSnapshotService.restoreSnapshot(agentId, snapshotId, request.getCurrentStateToken());
return new Result<>();
}
@DeleteMapping("/{snapshotId}")
@Operation(summary = "删除智能体历史快照")
@RequiresPermissions("sys:role:normal")
public Result<Void> deleteSnapshot(@PathVariable String agentId, @PathVariable String snapshotId) {
checkPermission(agentId);
agentSnapshotService.deleteSnapshot(agentId, snapshotId);
return new Result<>();
}
private void checkPermission(String agentId) {
UserDetail user = SecurityUser.getUser();
if (user == null || !agentService.checkAgentPermission(agentId, user.getId())) {
throw new RenException("没有权限访问该智能体快照");
}
}
}
@@ -36,4 +36,20 @@ public interface AgentDao extends BaseDao<AgentEntity> {
* @param agentId 智能体ID
*/
AgentInfoVO selectAgentInfoById(@Param("agentId") String agentId);
/**
* 锁定智能体主记录,用于串行化同一智能体的配置写入
*
* @param agentId 智能体ID
*/
AgentEntity selectByIdForUpdate(@Param("agentId") String agentId);
/**
* 精确写入快照覆盖的智能体字段,包括目标快照中的 null 值。
* 不更新所属用户、创建信息等不属于快照的字段。
*
* @param agent 已应用目标快照的智能体
* @return 受影响行数
*/
int updateSnapshotFields(@Param("agent") AgentEntity agent);
}
@@ -0,0 +1,29 @@
package xiaozhi.modules.agent.dao;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import xiaozhi.common.dao.BaseDao;
import xiaozhi.modules.agent.entity.AgentSnapshotEntity;
@Mapper
public interface AgentSnapshotDao extends BaseDao<AgentSnapshotEntity> {
Integer selectMaxVersionNo(@Param("agentId") String agentId);
AgentSnapshotEntity selectLatestSnapshot(@Param("agentId") String agentId);
AgentSnapshotEntity selectNextSnapshot(@Param("agentId") String agentId, @Param("versionNo") Integer versionNo);
int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot);
int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit);
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,40 @@
package xiaozhi.modules.agent.dto;
import java.io.Serializable;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "智能体快照数据")
public class AgentSnapshotDataDTO implements Serializable {
private static final long serialVersionUID = 1L;
private String agentCode;
private String agentName;
private String asrModelId;
private String vadModelId;
private String llmModelId;
private String slmModelId;
private String vllmModelId;
private String ttsModelId;
private String ttsVoiceId;
private String ttsLanguage;
private Integer ttsVolume;
private Integer ttsRate;
private Integer ttsPitch;
private String memModelId;
private String intentModelId;
private Integer chatHistoryConf;
private String systemPrompt;
private String summaryMemory;
private String langCode;
private String language;
private Integer sort;
private List<AgentUpdateDTO.FunctionInfo> functions;
private List<ContextProviderDTO> contextProviders;
private List<String> correctWordFileIds;
private List<String> tagNames;
private List<AgentSnapshotTagDTO> tags;
}
@@ -0,0 +1,28 @@
package xiaozhi.modules.agent.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "智能体快照分页查询参数")
public class AgentSnapshotPageDTO {
@Schema(description = "当前页码,从1开始", example = "1")
private Integer page = 1;
@Schema(description = "每页数量", example = "10")
private Integer limit = 10;
@Schema(description = "版本锚点,只查询小于等于该版本号的历史快照", example = "20")
private Integer maxVersionNo;
public int pageOrDefault() {
return page == null || page < 1 ? 1 : page;
}
public int limitOrDefault() {
if (limit == null || limit < 1) {
return 10;
}
return limit;
}
}
@@ -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;
}
@@ -0,0 +1,16 @@
package xiaozhi.modules.agent.dto;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "智能体快照标签")
public class AgentSnapshotTagDTO implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String tagName;
private Integer sort;
}
@@ -4,9 +4,12 @@ import java.io.Serializable;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import xiaozhi.common.utils.JsonUtils;
/**
* 智能体更新DTO
@@ -91,15 +94,50 @@ public class AgentUpdateDTO implements Serializable {
@Schema(description = "替换词文件ID列表", nullable = true)
private List<String> correctWordFileIds;
@Schema(description = "标签名称列表", nullable = true)
private List<String> tagNames;
@Schema(description = "标签ID列表", nullable = true)
private List<String> tagIds;
@Data
@Schema(description = "插件函数信息")
public static class FunctionInfo implements Serializable {
private static final TypeReference<HashMap<String, Object>> PARAM_INFO_TYPE = new TypeReference<>() {
};
@Schema(description = "插件ID", example = "plugin_01")
private String pluginId;
@Schema(description = "函数参数信息", nullable = true)
private HashMap<String, Object> paramInfo;
private HashMap<String, Object> paramInfo = new HashMap<>();
public void setParamInfo(Object paramInfo) {
this.paramInfo = normalizeParamInfo(paramInfo);
}
private static HashMap<String, Object> normalizeParamInfo(Object paramInfo) {
if (paramInfo == null) {
return new HashMap<>();
}
if (paramInfo instanceof String value) {
if (value.trim().isEmpty()) {
return new HashMap<>();
}
return JsonUtils.parseObject(value, PARAM_INFO_TYPE);
}
if (paramInfo instanceof Map<?, ?> value) {
HashMap<String, Object> normalized = new HashMap<>();
value.forEach((key, val) -> {
if (key != null) {
normalized.put(String.valueOf(key), val);
}
});
return normalized;
}
return JsonUtils.parseObject(JsonUtils.toJsonString(paramInfo), PARAM_INFO_TYPE);
}
private static final long serialVersionUID = 1L;
}
}
}
@@ -7,11 +7,11 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import xiaozhi.modules.agent.dto.ContextProviderDTO;
import xiaozhi.modules.agent.typehandler.ContextProviderListTypeHandler;
@Data
@TableName(value = "ai_agent_context_provider", autoResultMap = true)
@@ -26,7 +26,7 @@ public class AgentContextProviderEntity {
private String agentId;
@Schema(description = "上下文源配置")
@TableField(typeHandler = JacksonTypeHandler.class)
@TableField(typeHandler = ContextProviderListTypeHandler.class)
private List<ContextProviderDTO> contextProviders;
@Schema(description = "创建者")
@@ -0,0 +1,53 @@
package xiaozhi.modules.agent.entity;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@TableName("ai_agent_snapshot")
@Schema(description = "智能体配置快照")
public class AgentSnapshotEntity {
@TableId(type = IdType.ASSIGN_UUID)
@Schema(description = "快照ID")
private String id;
@Schema(description = "智能体ID")
private String agentId;
@Schema(description = "所属用户ID")
private Long userId;
@Schema(description = "版本号")
private Integer versionNo;
@Schema(description = "快照数据JSON")
private String snapshotData;
@Schema(description = "变更字段JSON")
private String changedFields;
@Schema(description = "快照来源")
private String source;
@Schema(description = "恢复来源快照ID")
private String restoreFromSnapshotId;
@Schema(description = "恢复来源版本号")
private Integer restoreFromVersionNo;
@Schema(description = "创建者")
private Long creator;
@Schema(description = "创建时间")
private Date createdAt;
@Schema(description = "快照数据脱敏规则版本")
private Integer redactionVersion;
}
@@ -1,6 +1,6 @@
package xiaozhi.modules.agent.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
@@ -11,7 +11,7 @@ import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
* @version 1.0, 2025/5/8
* @since 1.0.0
*/
public interface AgentChatAudioService extends IService<AgentChatAudioEntity> {
public interface AgentChatAudioService extends IRepository<AgentChatAudioEntity> {
/**
* 保存音频数据
*
@@ -3,9 +3,9 @@ package xiaozhi.modules.agent.service;
import java.util.List;
import java.util.Map;
import com.baomidou.mybatisplus.extension.service.IService;
import xiaozhi.common.page.PageData;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import xiaozhi.common.page.PageData;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
@@ -18,7 +18,7 @@ import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
* @version 1.0, 2025/4/30
* @since 1.0.0
*/
public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity> {
public interface AgentChatHistoryService extends IRepository<AgentChatHistoryEntity> {
/**
* 根据智能体ID获取会话列表
@@ -37,6 +37,14 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
*/
List<AgentChatHistoryDTO> getChatHistoryBySessionId(String agentId, String sessionId);
/**
* 根据会话ID获取智能体ID
*
* @param sessionId 会话ID
* @return 智能体ID
*/
String getAgentIdBySessionId(String sessionId);
/**
* 根据智能体ID删除聊天记录
*
@@ -62,6 +70,14 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
*/
String getContentByAudioId(String audioId);
/**
* 根据音频ID获取智能体ID
*
* @param audioId 音频ID
* @return 智能体ID
*/
String getAgentIdByAudioId(String audioId);
/**
* 查询此音频id是否属于此智能体
@@ -2,7 +2,7 @@ package xiaozhi.modules.agent.service;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import xiaozhi.modules.agent.entity.AgentPluginMapping;
@@ -10,7 +10,7 @@ import xiaozhi.modules.agent.entity.AgentPluginMapping;
* @description 针对表【ai_agent_plugin_mapping(Agent与插件的唯一映射表)】的数据库操作Service
* @createDate 2025-05-25 22:33:17
*/
public interface AgentPluginMappingService extends IService<AgentPluginMapping> {
public interface AgentPluginMappingService extends IRepository<AgentPluginMapping> {
/**
* 根据智能体id获取插件参数
@@ -7,6 +7,7 @@ import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.agent.dto.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.vo.AgentInfoVO;
@@ -35,6 +36,15 @@ public interface AgentService extends BaseService<AgentEntity> {
*/
AgentInfoVO getAgentById(String id);
/**
* 根据ID获取当前用户有权访问的智能体
*
* @param id 智能体ID
* @param userId 当前用户ID
* @return 智能体实体
*/
AgentInfoVO getAgentById(String id, Long userId);
/**
* 插入智能体
*
@@ -50,6 +60,13 @@ public interface AgentService extends BaseService<AgentEntity> {
*/
void deleteAgentByUserId(Long userId);
/**
* 删除智能体及其关联数据
*
* @param agentId 智能体ID
*/
void deleteAgent(String agentId);
/**
* 获取用户智能体列表
*
@@ -93,6 +110,41 @@ public interface AgentService extends BaseService<AgentEntity> {
*/
void updateAgentById(String agentId, AgentUpdateDTO dto);
/**
* 更新当前用户有权访问的智能体
*
* @param agentId 智能体ID
* @param dto 更新智能体所需的信息
* @param userId 当前用户ID
*/
void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId);
/**
* 根据设备MAC地址更新当前用户有权访问的智能体记忆
*
* @param macAddress 设备MAC地址
* @param dto 智能体记忆
* @param userId 当前用户ID
*/
void updateAgentMemoryByDeviceMacAddress(String macAddress, AgentMemoryDTO dto, Long userId);
/**
* 删除当前用户有权访问的智能体
*
* @param agentId 智能体ID
* @param userId 当前用户ID
*/
void deleteAgentById(String agentId, Long userId);
/**
* 更新智能体
*
* @param agentId 智能体ID
* @param dto 更新智能体所需的信息
* @param createSnapshot 是否创建配置快照
*/
void updateAgentById(String agentId, AgentUpdateDTO dto, boolean createSnapshot);
/**
* 创建智能体
*
@@ -0,0 +1,25 @@
package xiaozhi.modules.agent.service;
import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService;
import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO;
import xiaozhi.modules.agent.entity.AgentSnapshotEntity;
import xiaozhi.modules.agent.vo.AgentSnapshotVO;
public interface AgentSnapshotService extends BaseService<AgentSnapshotEntity> {
void createSnapshot(String agentId, String source);
PageData<AgentSnapshotVO> page(String agentId, AgentSnapshotPageDTO params);
AgentSnapshotVO getSnapshot(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();
}
@@ -1,7 +1,7 @@
package xiaozhi.modules.agent.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
/**
@@ -9,7 +9,7 @@ import xiaozhi.modules.agent.entity.AgentTemplateEntity;
* @description 针对表【ai_agent_template(智能体配置模板表)】的数据库操作Service
* @createDate 2025-03-22 11:48:18
*/
public interface AgentTemplateService extends IService<AgentTemplateEntity> {
public interface AgentTemplateService extends IRepository<AgentTemplateEntity> {
/**
* 获取默认模板
@@ -2,7 +2,7 @@ package xiaozhi.modules.agent.service.impl;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
import xiaozhi.modules.agent.dao.AiAgentChatAudioDao;
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
@@ -16,7 +16,7 @@ import xiaozhi.modules.agent.service.AgentChatAudioService;
* @since 1.0.0
*/
@Service
public class AgentChatAudioServiceImpl extends ServiceImpl<AiAgentChatAudioDao, AgentChatAudioEntity>
public class AgentChatAudioServiceImpl extends CrudRepository<AiAgentChatAudioDao, AgentChatAudioEntity>
implements AgentChatAudioService {
@Override
public String saveAudio(byte[] audioData) {
@@ -14,7 +14,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.page.PageData;
@@ -39,7 +39,7 @@ import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
*/
@Service
@RequiredArgsConstructor
public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryDao, AgentChatHistoryEntity>
public class AgentChatHistoryServiceImpl extends CrudRepository<AiAgentChatHistoryDao, AgentChatHistoryEntity>
implements AgentChatHistoryService {
private final AgentChatTitleService agentChatTitleService;
@@ -88,6 +88,19 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
return ConvertUtils.sourceToTarget(historyList, AgentChatHistoryDTO.class);
}
@Override
public String getAgentIdBySessionId(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
return null;
}
AgentChatHistoryEntity entity = baseMapper.selectOne(
new LambdaQueryWrapper<AgentChatHistoryEntity>()
.select(AgentChatHistoryEntity::getAgentId)
.eq(AgentChatHistoryEntity::getSessionId, sessionId)
.last("LIMIT 1"));
return entity == null ? null : entity.getAgentId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
@@ -176,6 +189,19 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
return agentChatHistoryEntity == null ? null : agentChatHistoryEntity.getContent();
}
@Override
public String getAgentIdByAudioId(String audioId) {
if (audioId == null || audioId.isBlank()) {
return null;
}
AgentChatHistoryEntity entity = baseMapper.selectOne(
new LambdaQueryWrapper<AgentChatHistoryEntity>()
.select(AgentChatHistoryEntity::getAgentId)
.eq(AgentChatHistoryEntity::getAudioId, audioId)
.last("LIMIT 1"));
return entity == null ? null : entity.getAgentId();
}
@Override
public boolean isAudioOwnedByAgent(String audioId, String agentId) {
// 查询是否有指定音频id和智能体id的数据,如果有且只有一条说明此数据属性此智能体
@@ -118,7 +118,7 @@ public class AgentChatSummaryServiceImpl implements AgentChatSummaryService {
{
setSummaryMemory(summaryDTO.getSummary());
}
});
}, false);
log.info("成功保存会话 {} 的聊天记录总结到智能体 {}", sessionId, agentId);
} else {
log.info("生成总结失败: {}", summaryDTO.getErrorMessage());
@@ -518,4 +518,4 @@ public class AgentChatSummaryServiceImpl implements AgentChatSummaryService {
return null;
}
}
}
}
@@ -9,7 +9,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -29,7 +29,7 @@ import xiaozhi.modules.model.service.ModelConfigService;
@Service
@RequiredArgsConstructor
@Slf4j
public class AgentPluginMappingServiceImpl extends ServiceImpl<AgentPluginMappingMapper, AgentPluginMapping>
public class AgentPluginMappingServiceImpl extends CrudRepository<AgentPluginMappingMapper, AgentPluginMapping>
implements AgentPluginMappingService {
private final AgentPluginMappingMapper agentPluginMappingMapper;
private final KnowledgeBaseService knowledgeBaseService;
@@ -15,8 +15,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant;
@@ -34,6 +34,7 @@ import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.dao.AgentTagDao;
import xiaozhi.modules.agent.dto.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentTagDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
@@ -43,9 +44,10 @@ import xiaozhi.modules.agent.entity.AgentTagEntity;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTagService;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentSnapshotService;
import xiaozhi.modules.agent.service.AgentTagService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.agent.vo.AgentInfoVO;
import xiaozhi.modules.correctword.service.CorrectWordFileService;
@@ -56,9 +58,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
@@ -73,9 +75,10 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
private final AgentChatHistoryService agentChatHistoryService;
private final AgentTemplateService agentTemplateService;
private final ModelProviderService modelProviderService;
private final AgentContextProviderService agentContextProviderService;
private final AgentTagService agentTagService;
private final CorrectWordFileService correctWordFileService;
private final AgentContextProviderService agentContextProviderService;
private final AgentTagService agentTagService;
private final CorrectWordFileService correctWordFileService;
private final AgentSnapshotService agentSnapshotService;
@Override
public PageData<AgentEntity> adminAgentList(Map<String, Object> params) {
@@ -92,6 +95,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
if (agent == null) {
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
}
requireCurrentUserPermissionIfPresent(agent);
if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode());
@@ -106,14 +110,74 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
agent.setContextProviders(contextProviderEntity.getContextProviders());
}
// 查询替换词文件ID列表
List<String> correctWordFileIds = correctWordFileService.getAgentCorrectWordFileIds(id);
agent.setCorrectWordFileIds(correctWordFileIds);
// 无需额外查询插件列表,已通过SQL查询出来
return agent;
// 查询替换词文件ID列表
List<String> correctWordFileIds = correctWordFileService.getAgentCorrectWordFileIds(id);
agent.setCorrectWordFileIds(correctWordFileIds);
agent.setCurrentVersionNo(agentSnapshotService.getCurrentVersionNo(id));
// 无需额外查询插件列表,已通过SQL查询出来
return agent;
}
@Override
public AgentInfoVO getAgentById(String id, Long userId) {
AgentInfoVO agent = getAgentById(id);
requireAgentPermission(agent, userId);
return agent;
}
private AgentEntity getAgentEntityOrThrow(String agentId) {
AgentEntity agent = agentDao.selectById(agentId);
if (agent == null) {
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
}
return agent;
}
private boolean isCurrentUserSuperAdmin() {
UserDetail user = SecurityUser.getUser();
return user != null && Integer.valueOf(SuperAdminEnum.YES.value()).equals(user.getSuperAdmin());
}
private void requireCurrentUserPermissionIfPresent(AgentEntity agent) {
Long userId = SecurityUser.getUserId();
if (userId != null) {
requireAgentPermission(agent, userId);
}
}
private boolean hasAgentPermission(AgentEntity agent, Long userId) {
if (agent == null) {
return false;
}
if (isCurrentUserSuperAdmin()) {
return true;
}
return userId != null && userId.equals(agent.getUserId());
}
private void requireAgentPermission(AgentEntity agent, Long userId) {
if (!hasAgentPermission(agent, userId)) {
throw new RenException(ErrorCode.NO_PERMISSION);
}
}
private boolean hasDevicePermission(DeviceEntity device, Long userId) {
if (device == null) {
return false;
}
if (isCurrentUserSuperAdmin()) {
return true;
}
return userId != null && userId.equals(device.getUserId());
}
private void requireDevicePermission(DeviceEntity device, Long userId) {
if (!hasDevicePermission(device, userId)) {
throw new RenException(ErrorCode.NO_PERMISSION);
}
}
@Override
public boolean insert(AgentEntity entity) {
// 如果ID为空,自动生成一个UUID作为ID
@@ -134,15 +198,35 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
return super.insert(entity);
}
@Override
public void deleteAgentByUserId(Long userId) {
UpdateWrapper<AgentEntity> wrapper = new UpdateWrapper<>();
wrapper.eq("user_id", userId);
baseDao.delete(wrapper);
}
@Override
public List<AgentDTO> getUserAgents(Long userId, String keyword, String searchType) {
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteAgentByUserId(Long userId) {
List<AgentEntity> agents = baseDao.selectList(new QueryWrapper<AgentEntity>()
.select("id")
.eq("user_id", userId));
for (AgentEntity agent : agents) {
deleteAgent(agent.getId());
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteAgent(String agentId) {
if (agentDao.selectByIdForUpdate(agentId) == null) {
return;
}
deviceService.deleteByAgentId(agentId);
agentChatHistoryService.deleteByAgentId(agentId, true, true);
agentPluginMappingService.deleteByAgentId(agentId);
agentContextProviderService.deleteByAgentId(agentId);
correctWordFileService.deleteMappingsByAgentId(agentId);
agentTagService.deleteAgentTags(agentId);
agentSnapshotService.deleteByAgentId(agentId);
deleteById(agentId);
}
@Override
public List<AgentDTO> getUserAgents(Long userId, String keyword, String searchType) {
QueryWrapper<AgentEntity> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", userId).orderByDesc("created_at");
@@ -253,42 +337,56 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
}
@Override
public boolean checkAgentPermission(String agentId, Long userId) {
if (SecurityUser.getUser() == null || SecurityUser.getUser().getId() == null) {
return false;
}
// 获取智能体信息
AgentEntity agent = getAgentById(agentId);
if (agent == null) {
return false;
}
// 如果是超级管理员,直接返回true
if (SecurityUser.getUser().getSuperAdmin() == SuperAdminEnum.YES.value()) {
return true;
}
// 检查是否是智能体的所有者
return userId.equals(agent.getUserId());
}
// 根据id更新智能体信息
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAgentById(String agentId, AgentUpdateDTO dto) {
// 先查询现有实体
AgentEntity existingEntity = this.getAgentById(agentId);
if (existingEntity == null) {
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
}
// 只更新提供的非空字段
if (dto.getAgentName() != null) {
existingEntity.setAgentName(dto.getAgentName());
}
if (dto.getAgentCode() != null) {
existingEntity.setAgentCode(dto.getAgentCode());
}
public boolean checkAgentPermission(String agentId, Long userId) {
AgentEntity agent = agentDao.selectById(agentId);
return hasAgentPermission(agent, userId);
}
// 根据id更新智能体信息
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAgentById(String agentId, AgentUpdateDTO dto) {
updateAgentById(agentId, dto, true);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId) {
updateAgentById(agentId, dto, userId, true);
}
// 根据id更新智能体信息
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAgentById(String agentId, AgentUpdateDTO dto, boolean createSnapshot) {
updateAgentById(agentId, dto, null, createSnapshot);
}
private void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId, boolean createSnapshot) {
AgentEntity lockedAgent = agentDao.selectByIdForUpdate(agentId);
if (lockedAgent == null) {
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
}
if (userId == null) {
requireCurrentUserPermissionIfPresent(lockedAgent);
} else {
requireAgentPermission(lockedAgent, userId);
}
// 锁定后查询现有实体和关联配置
AgentEntity existingEntity = this.getAgentById(agentId);
if (createSnapshot) {
int currentVersionNo = agentSnapshotService.getCurrentVersionNo(agentId);
agentSnapshotService.createSnapshot(agentId, currentVersionNo == 0 ? "initial" : "current");
}
// 只更新提供的非空字段
if (dto.getAgentName() != null) {
existingEntity.setAgentName(dto.getAgentName());
}
if (dto.getAgentCode() != null) {
existingEntity.setAgentCode(dto.getAgentCode());
}
if (dto.getAsrModelId() != null) {
existingEntity.setAsrModelId(dto.getAsrModelId());
}
@@ -385,10 +483,10 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
.toList();
if (!toUpdate.isEmpty()) {
agentPluginMappingService.updateBatchById(toUpdate);
agentPluginMappingService.updateBatchById(toUpdate, IRepository.DEFAULT_BATCH_SIZE);
}
if (!toInsert.isEmpty()) {
agentPluginMappingService.saveBatch(toInsert);
agentPluginMappingService.saveBatch(toInsert, IRepository.DEFAULT_BATCH_SIZE);
}
// 5. 删除本次不在提交列表里的插件映射
@@ -397,7 +495,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
.map(AgentPluginMapping::getId)
.toList();
if (!toDelete.isEmpty()) {
agentPluginMappingService.removeBatchByIds(toDelete);
agentPluginMappingService.removeByIds(toDelete);
}
}
@@ -426,17 +524,48 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
}
// 更新替换词文件关联
if (dto.getCorrectWordFileIds() != null) {
correctWordFileService.saveAgentCorrectWords(agentId, dto.getCorrectWordFileIds());
}
boolean b = validateLLMIntentParams(dto.getLlmModelId(), dto.getIntentModelId());
if (!b) {
throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH);
}
this.updateById(existingEntity);
}
if (dto.getCorrectWordFileIds() != null) {
correctWordFileService.saveAgentCorrectWords(agentId, dto.getCorrectWordFileIds());
}
// 更新智能体标签
if (dto.getTagNames() != null || dto.getTagIds() != null) {
agentTagService.saveAgentTags(agentId, dto.getTagIds(), dto.getTagNames());
}
boolean b = validateLLMIntentParams(existingEntity.getLlmModelId(), existingEntity.getIntentModelId());
if (!b) {
throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH);
}
this.updateById(existingEntity);
if (createSnapshot) {
agentSnapshotService.createSnapshot(agentId, "config");
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAgentMemoryByDeviceMacAddress(String macAddress, AgentMemoryDTO dto, Long userId) {
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
if (device == null || StringUtils.isBlank(device.getAgentId()) || dto == null) {
return;
}
requireDevicePermission(device, userId);
AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO();
agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory());
updateAgentById(device.getAgentId(), agentUpdateDTO, userId, false);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteAgentById(String agentId, Long userId) {
AgentEntity agent = getAgentEntityOrThrow(agentId);
requireAgentPermission(agent, userId);
deleteAgent(agentId);
}
/**
* 验证大语言模型和意图识别的参数是否符合匹配
*
@@ -487,16 +616,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);
@@ -551,13 +686,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, IRepository.DEFAULT_BATCH_SIZE);
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()) {
@@ -576,4 +716,4 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
}
}
}
}
@@ -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;
}
}
}
@@ -4,8 +4,8 @@ import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
import xiaozhi.modules.agent.dao.AgentTemplateDao;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentTemplateService;
@@ -21,7 +21,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
* @createDate 2025-03-22 11:48:18
*/
@Service
public class AgentTemplateServiceImpl extends ServiceImpl<AgentTemplateDao, AgentTemplateEntity>
public class AgentTemplateServiceImpl extends CrudRepository<AgentTemplateDao, AgentTemplateEntity>
implements AgentTemplateService {
/**
@@ -22,7 +22,7 @@ import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant;
@@ -46,7 +46,7 @@ import xiaozhi.modules.sys.service.SysParamsService;
*/
@Service
@Slf4j
public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao, AgentVoicePrintEntity>
public class AgentVoicePrintServiceImpl extends CrudRepository<AgentVoicePrintDao, AgentVoicePrintEntity>
implements AgentVoicePrintService {
private final AgentChatAudioService agentChatAudioService;
private final RestTemplate restTemplate;
@@ -0,0 +1,59 @@
package xiaozhi.modules.agent.typehandler;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collections;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import com.fasterxml.jackson.core.type.TypeReference;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.dto.ContextProviderDTO;
/**
* JSON type handler for context providers.
*
* <p>Do not extend MyBatis-Plus {@code AbstractJsonTypeHandler}: its constructor and JSON handler
* contract changed in MyBatis-Plus 3.5.6. {@link BaseTypeHandler} is part of MyBatis itself and
* keeps this handler compatible with both 3.5.5 and newer MyBatis-Plus releases.</p>
*/
public class ContextProviderListTypeHandler extends BaseTypeHandler<List<ContextProviderDTO>> {
private static final TypeReference<List<ContextProviderDTO>> CONTEXT_PROVIDER_LIST_TYPE = new TypeReference<>() {
};
@Override
public void setNonNullParameter(PreparedStatement ps, int i, List<ContextProviderDTO> parameter,
JdbcType jdbcType) throws SQLException {
ps.setString(i, JsonUtils.toJsonString(parameter));
}
@Override
public List<ContextProviderDTO> getNullableResult(ResultSet rs, String columnName) throws SQLException {
return parseNullable(rs.getString(columnName));
}
@Override
public List<ContextProviderDTO> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return parseNullable(rs.getString(columnIndex));
}
@Override
public List<ContextProviderDTO> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return parseNullable(cs.getString(columnIndex));
}
private List<ContextProviderDTO> parseNullable(String json) {
if (StringUtils.isBlank(json)) {
return null;
}
List<ContextProviderDTO> providers = JsonUtils.parseObject(json, CONTEXT_PROVIDER_LIST_TYPE);
return providers == null ? Collections.emptyList() : providers;
}
}
@@ -1,7 +1,5 @@
package xiaozhi.modules.agent.vo;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -20,7 +18,6 @@ import java.util.List;
public class AgentInfoVO extends AgentEntity
{
@Schema(description = "插件列表Id")
@TableField(typeHandler = JacksonTypeHandler.class)
private List<AgentPluginMapping> functions;
@Schema(description = "上下文源配置")
@@ -28,4 +25,7 @@ public class AgentInfoVO extends AgentEntity
@Schema(description = "替换词文件ID列表")
private List<String> correctWordFileIds;
@Schema(description = "当前配置版本号")
private Integer currentVersionNo;
}
@@ -0,0 +1,34 @@
package xiaozhi.modules.agent.vo;
import java.util.Date;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO;
@Data
@Schema(description = "智能体配置快照")
public class AgentSnapshotVO {
private String id;
private String agentId;
@Schema(description = "所属用户ID,表示该快照归属的智能体所有者")
private Long userId;
private Integer versionNo;
private List<String> changedFields;
private List<String> fieldOrder;
private String source;
@Schema(description = "恢复来源快照ID,仅恢复结果版本有值")
private String restoreFromSnapshotId;
@Schema(description = "恢复来源版本号,仅恢复结果版本有值")
private Integer restoreFromVersionNo;
@Schema(description = "创建者,表示触发本次快照写入的操作人")
private Long creator;
private Date createdAt;
private AgentSnapshotDataDTO snapshotData;
private AgentSnapshotDataDTO afterSnapshotData;
@Schema(description = "恢复预览对应的脱敏当前配置,仅详情接口有值")
private AgentSnapshotDataDTO currentSnapshotData;
@Schema(description = "恢复预览对应的当前配置状态指纹,仅详情接口有值")
private String currentStateToken;
}
@@ -1,11 +1,10 @@
package xiaozhi.modules.device.service.impl;
import java.util.Date;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.nio.charset.StandardCharsets;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Lazy;
@@ -13,8 +12,8 @@ import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import cn.hutool.http.HttpRequest;
import cn.hutool.json.JSONUtil;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
@@ -201,36 +200,22 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
result.put("status", "error");
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
String mqttSignatureKey = sysParamsService.getValue("server.mqtt_signature_key", true);
String mqttSignatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true);
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)
|| StringUtils.isBlank(mqttSignatureKey) || "null".equals(mqttSignatureKey)) {
|| MqttGatewayAuthorization.isMissingSignatureKey(mqttSignatureKey)) {
result.put("message", action + "失败,网关配置缺失");
return result;
}
String dateStr = new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date());
try {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest((dateStr + mqttSignatureKey).getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
String token = hexString.toString();
String url = "http://" + mqttGatewayUrl + path;
String response = HttpRequest.post(url)
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.body(JSONUtil.toJsonStr(body))
.timeout(5000)
.execute()
.body();
String response = MqttGatewayAuthorization.postJson(
url,
JSONUtil.toJsonStr(body),
mqttSignatureKey,
Instant.now(),
5000);
if (StringUtils.isNotBlank(response)) {
Map<String, Object> gwResult = JSONUtil.parseObj(response);
@@ -270,4 +255,4 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
}
return newAlias;
}
}
}
@@ -31,15 +31,9 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.http.ContentType;
import cn.hutool.http.Header;
import cn.hutool.http.HttpRequest;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
@@ -187,14 +181,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.put("clientIds", deviceIds).build();
if (ToolUtil.isNotEmpty(deviceIds)) {
// 发送请求
String resultMessage = HttpRequest.post(url)
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue())
.header(Header.AUTHORIZATION, "Bearer " + generateBearerToken())
.body(JSONUtil.toJsonStr(params))
.timeout(10000) // 超时,毫秒
.execute().body();
return resultMessage;
return postToMqttGateway(url, params);
}
// 返回响应
return "";
@@ -304,15 +291,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
@Override
public List<UserShowDeviceListVO> getUserDeviceList(Long userId, String agentId) {
List<DeviceEntity> devices = getUserDevices(userId, agentId);
return devices.stream().map(device -> {
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
vo.setDeviceType(device.getBoard());
// 设置UTC时间戳供前端使用时区转换
if (device.getLastConnectedAt() != null) {
vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime());
}
return vo;
}).toList();
return devices.stream().map(this::toUserShowDeviceListVO).toList();
}
private UserShowDeviceListVO toUserShowDeviceListVO(DeviceEntity device) {
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
vo.setDeviceType(device.getBoard());
vo.setBoard(device.getBoard());
vo.setCreateDateTimestamp(toTimestamp(device.getCreateDate()));
vo.setLastConnectedAtTimestamp(toTimestamp(device.getLastConnectedAt()));
return vo;
}
private Long toTimestamp(Date date) {
return date == null ? null : date.getTime();
}
@Override
@@ -385,17 +377,11 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.like(StringUtils.isNotBlank(dto.getKeywords()), "alias", dto.getKeywords()));
// 循环处理page获取回来的数据,返回需要的字段
List<UserShowDeviceListVO> list = page.getRecords().stream().map(device -> {
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
UserShowDeviceListVO vo = toUserShowDeviceListVO(device);
// 把最后修改的时间,改为简短描述的时间
vo.setRecentChatTime(DateUtils.getShortTime(device.getUpdateDate()));
sysUserUtilService.assignUsername(device.getUserId(),
vo::setBindUserName);
vo.setDeviceType(device.getBoard());
vo.setBoard(device.getBoard());
// 设置UTC时间戳供前端使用时区转换
if (device.getLastConnectedAt() != null) {
vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime());
}
return vo;
}).toList();
// 计算页数
@@ -699,20 +685,13 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
return mqtt;
}
/**
* 生成BearerToken
*/
private String generateBearerToken() {
try {
String dateStr = DateUtil.format(new Date(), DatePattern.NORM_DATE_PATTERN);
String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false);
if (ToolUtil.isEmpty(signatureKey)) {
return null;
}
return DigestUtil.sha256Hex(dateStr + signatureKey);
} catch (Exception e) {
return null;
}
private String postToMqttGateway(String url, Object requestBody) {
String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false);
return MqttGatewayAuthorization.postJson(
url,
JSONUtil.toJsonStr(requestBody),
signatureKey,
Instant.now());
}
@Override
@@ -773,13 +752,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.put("payload", payload)
.build();
// 发送请求
String resultMessage = HttpRequest.post(url)
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue())
.header(Header.AUTHORIZATION, "Bearer " + generateBearerToken())
.body(JSONUtil.toJsonStr(requestBody))
.timeout(10000) // 超时,毫秒
.execute().body();
String resultMessage = postToMqttGateway(url, requestBody);
// 解析响应
if (StringUtils.isBlank(resultMessage)) {
@@ -870,13 +843,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.put("payload", payload)
.build();
// 发送请求
String resultMessage = HttpRequest.post(url)
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue())
.header(Header.AUTHORIZATION, "Bearer " + generateBearerToken())
.body(JSONUtil.toJsonStr(requestBody))
.timeout(10000) // 超时,毫秒
.execute().body();
String resultMessage = postToMqttGateway(url, requestBody);
// 解析响应
if (StringUtils.isNotBlank(resultMessage)) {
@@ -0,0 +1,111 @@
package xiaozhi.modules.device.service.impl;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import java.util.function.Function;
import org.apache.commons.lang3.StringUtils;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.http.ContentType;
import cn.hutool.http.Header;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
final class MqttGatewayAuthorization {
private static final int HTTP_UNAUTHORIZED = 401;
private static final int DEFAULT_TIMEOUT_MILLIS = 10000;
private MqttGatewayAuthorization() {
}
static String postJson(String url, String jsonBody, String signatureKey, Instant now) {
return postJson(url, jsonBody, signatureKey, now, DEFAULT_TIMEOUT_MILLIS);
}
static String postJson(String url, String jsonBody, String signatureKey, Instant now, int timeoutMillis) {
GatewayResponse response = executeWithDateFallback(
signatureKey,
now,
token -> executeRequest(url, jsonBody, token, timeoutMillis));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new GatewayRequestException(
"MQTT Gateway request failed with HTTP status " + response.statusCode(),
response.statusCode());
}
return response.body();
}
static List<String> generateDailyTokens(String signatureKey, Instant now) {
if (isMissingSignatureKey(signatureKey)) {
throw new GatewayRequestException("MQTT Gateway signature key is empty", null);
}
LocalDate utcDate = now.atZone(ZoneOffset.UTC).toLocalDate();
return List.of(
tokenFor(utcDate, signatureKey),
tokenFor(utcDate.minusDays(1), signatureKey),
tokenFor(utcDate.plusDays(1), signatureKey));
}
static GatewayResponse executeWithDateFallback(String signatureKey, Instant now,
Function<String, GatewayResponse> requestExecutor) {
GatewayResponse lastAuthenticationFailure = null;
for (String token : generateDailyTokens(signatureKey, now)) {
GatewayResponse response = requestExecutor.apply(token);
if (!isAuthenticationFailure(response.statusCode())) {
return response;
}
lastAuthenticationFailure = response;
}
Integer statusCode = lastAuthenticationFailure == null ? null : lastAuthenticationFailure.statusCode();
throw new GatewayRequestException(
"MQTT Gateway rejected all daily authorization tokens"
+ (statusCode == null ? "" : " (HTTP " + statusCode + ")"),
statusCode);
}
private static String tokenFor(LocalDate date, String signatureKey) {
return DigestUtil.sha256Hex(date + signatureKey);
}
private static boolean isAuthenticationFailure(int statusCode) {
return statusCode == HTTP_UNAUTHORIZED;
}
static boolean isMissingSignatureKey(String signatureKey) {
return StringUtils.isBlank(signatureKey) || "null".equalsIgnoreCase(signatureKey.trim());
}
private static GatewayResponse executeRequest(String url, String jsonBody, String token, int timeoutMillis) {
try (HttpResponse response = HttpRequest.post(url)
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue())
.header(Header.AUTHORIZATION, "Bearer " + token)
.body(jsonBody)
.timeout(timeoutMillis)
.execute()) {
return new GatewayResponse(response.getStatus(), response.body());
}
}
record GatewayResponse(int statusCode, String body) {
}
static final class GatewayRequestException extends RuntimeException {
private final Integer statusCode;
GatewayRequestException(String message, Integer statusCode) {
super(message);
this.statusCode = statusCode;
}
Integer statusCode() {
return statusCode;
}
}
}
@@ -38,11 +38,14 @@ public class UserShowDeviceListVO {
@Schema(description = "最近对话时间")
private String recentChatTime;
@Schema(description = "最后连接时间(UTC毫秒)")
@Schema(description = "最后连接时间戳(毫秒)", type = "string", example = "1783689702000")
private Long lastConnectedAtTimestamp;
@Schema(description = "绑定时间")
@Schema(description = "绑定时间戳(毫秒)", type = "string", example = "1783689702000")
private Long createDateTimestamp;
@Schema(description = "绑定时间(兼容字段,请使用 createDateTimestamp", deprecated = true)
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
private Date createDate;
}
}
@@ -57,6 +57,14 @@ public interface TimbreService extends BaseService<TimbreEntity> {
List<VoiceDTO> getVoiceNames(String ttsModelId, String voiceName);
/**
* 获取普通音色或克隆音色配置的首个有效语言。
*
* @param id 音色ID
* @return 默认语言;音色不存在或未配置有效语言时返回null
*/
String getDefaultLanguageById(String id);
/**
* 根据ID获取音色名称
*
@@ -73,4 +81,4 @@ public interface TimbreService extends BaseService<TimbreEntity> {
* @return 音色信息
*/
VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode);
}
}
@@ -1,6 +1,7 @@
package xiaozhi.modules.timbre.service.impl;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
@@ -41,6 +42,8 @@ import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
@Service
public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity> implements TimbreService {
private static final Pattern LANGUAGE_SEPARATOR = Pattern.compile("[、;;,]");
private final TimbreDao timbreDao;
private final VoiceCloneDao voiceCloneDao;
private final RedisUtils redisUtils;
@@ -158,6 +161,32 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
return CollectionUtil.isEmpty(voiceDTOs) ? null : voiceDTOs;
}
@Override
public String getDefaultLanguageById(String id) {
if (StringUtils.isBlank(id)) {
return null;
}
TimbreEntity timbre = timbreDao.selectById(id);
if (timbre != null) {
return firstNonBlankLanguage(timbre.getLanguages());
}
VoiceCloneEntity voiceClone = voiceCloneDao.selectById(id);
return voiceClone == null ? null : firstNonBlankLanguage(voiceClone.getLanguages());
}
private String firstNonBlankLanguage(String languages) {
if (StringUtils.isBlank(languages)) {
return null;
}
return LANGUAGE_SEPARATOR.splitAsStream(languages)
.map(StringUtils::trimToNull)
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
/**
* 处理是不是tts模型的id
*/
@@ -214,4 +243,4 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
dto.setIsClone(false); // 设置为普通音色
return dto;
}
}
}
@@ -0,0 +1,19 @@
-- Add language configuration for local FunASR ASR.
UPDATE `ai_model_provider`
SET `fields` = '[{"key":"model_dir","label":"模型目录","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"language","label":"识别语言","type":"string","default":"auto"}]'
WHERE `id` = 'SYSTEM_ASR_FunASR';
UPDATE `ai_model_config`
SET `config_json` = JSON_SET(`config_json`, '$.language', 'auto')
WHERE `id` = 'ASR_FunASR'
AND JSON_EXTRACT(`config_json`, '$.language') IS NULL;
-- Update the FunASR local model configuration description to mention the language option.
UPDATE `ai_model_config`
SET `remark` = 'FunASR本地模型配置说明:
1. 需要下载模型文件到xiaozhi-server/models/SenseVoiceSmall目录
2. 支持中日韩粤语音识别
3. 本地推理,无需网络连接
4. 待识别文件保存在tmp/目录
5. “识别语言”字段控制识别语种:auto = 自动检测;如需限定只识别中文可设为 zh(en=英语、ja=日语、ko=韩语、yue=粤语)。'
WHERE `id` = 'ASR_FunASR';
@@ -0,0 +1,20 @@
-- liquibase formatted sql
-- changeset tykechen:202607071530
CREATE TABLE IF NOT EXISTS `ai_agent_snapshot` (
`id` VARCHAR(32) NOT NULL COMMENT '快照ID',
`agent_id` VARCHAR(32) NOT NULL COMMENT '智能体ID',
`user_id` BIGINT DEFAULT NULL COMMENT '所属用户ID',
`version_no` INT UNSIGNED NOT NULL COMMENT '版本号',
`snapshot_data` JSON NOT NULL DEFAULT (JSON_OBJECT()) COMMENT '快照数据',
`changed_fields` JSON DEFAULT NULL COMMENT '变更字段',
`source` VARCHAR(32) DEFAULT 'config' COMMENT '快照来源',
`restore_from_snapshot_id` VARCHAR(32) DEFAULT NULL COMMENT '恢复来源快照ID',
`restore_from_version_no` INT UNSIGNED DEFAULT NULL COMMENT '恢复来源版本号',
`creator` BIGINT DEFAULT NULL COMMENT '创建者',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_agent_version` (`agent_id`, `version_no`),
INDEX `idx_agent_created_at` (`agent_id`, `created_at`),
INDEX `idx_snapshot_user_created_at` (`user_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体配置快照表';
@@ -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`;
+21
View File
@@ -690,3 +690,24 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202606261131.sql
- changeSet:
id: 202607011405
author: LauraGPT
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607011405.sql
- changeSet:
id: 202607071530
author: tykechen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607071530.sql
- changeSet:
id: 202607101200
author: tykechen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607101200.sql
@@ -25,9 +25,6 @@
<result column="memModelId" property="memModelId"/>
<result column="intentModelId" property="intentModelId"/>
<result column="functions" property="functions"
typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
<result column="chatHistoryConf" property="chatHistoryConf"/>
<result column="systemPrompt" property="systemPrompt"/>
<result column="summaryMemory" property="summaryMemory"/>
@@ -38,6 +35,13 @@
<result column="createdAt" property="createdAt"/>
<result column="updater" property="updater"/>
<result column="updatedAt" property="updatedAt"/>
<collection property="functions"
ofType="xiaozhi.modules.agent.entity.AgentPluginMapping">
<id column="functionId" property="id"/>
<result column="functionAgentId" property="agentId"/>
<result column="pluginId" property="pluginId"/>
<result column="paramInfo" property="paramInfo"/>
</collection>
</resultMap>
<select id="selectAgentInfoById" resultMap="AgentInfoMap">
@@ -58,19 +62,6 @@
a.tts_pitch AS ttsPitch,
a.mem_model_id AS memModelId,
a.intent_model_id AS intentModelId,
COALESCE(
(SELECT JSON_ARRAYAGG(
JSON_OBJECT(
'id', m.id,
'agentId', m.agent_id,
'pluginId', m.plugin_id,
'paramInfo', m.param_info
)
)
FROM ai_agent_plugin_mapping m
WHERE m.agent_id = a.id),
JSON_ARRAY()
) AS functions,
a.chat_history_conf AS chatHistoryConf,
a.system_prompt AS systemPrompt,
a.summary_memory AS summaryMemory,
@@ -80,8 +71,50 @@
a.creator,
a.created_at AS createdAt,
a.updater,
a.updated_at AS updatedAt
a.updated_at AS updatedAt,
f.id AS functionId,
f.agent_id AS functionAgentId,
f.plugin_id AS pluginId,
f.param_info AS paramInfo
FROM ai_agent a
LEFT JOIN ai_agent_plugin_mapping f ON f.agent_id = a.id
WHERE a.id = #{agentId}
ORDER BY f.id ASC
</select>
</mapper>
<select id="selectByIdForUpdate" resultType="xiaozhi.modules.agent.entity.AgentEntity">
SELECT *
FROM ai_agent
WHERE id = #{agentId}
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>
@@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="xiaozhi.modules.agent.dao.AgentSnapshotDao">
<select id="selectMaxVersionNo" resultType="java.lang.Integer">
SELECT COALESCE(MAX(version_no), 0)
FROM ai_agent_snapshot
WHERE agent_id = #{agentId}
</select>
<select id="selectLatestSnapshot" resultType="xiaozhi.modules.agent.entity.AgentSnapshotEntity">
SELECT id,
agent_id AS agentId,
user_id AS userId,
version_no AS versionNo,
snapshot_data AS snapshotData,
changed_fields AS changedFields,
source,
restore_from_snapshot_id AS restoreFromSnapshotId,
restore_from_version_no AS restoreFromVersionNo,
creator,
created_at AS createdAt,
redaction_version AS redactionVersion
FROM ai_agent_snapshot
WHERE agent_id = #{agentId}
ORDER BY version_no DESC
LIMIT 1
</select>
<select id="selectNextSnapshot" resultType="xiaozhi.modules.agent.entity.AgentSnapshotEntity">
SELECT id,
agent_id AS agentId,
user_id AS userId,
version_no AS versionNo,
snapshot_data AS snapshotData,
changed_fields AS changedFields,
source,
restore_from_snapshot_id AS restoreFromSnapshotId,
restore_from_version_no AS restoreFromVersionNo,
creator,
created_at AS createdAt,
redaction_version AS redactionVersion
FROM ai_agent_snapshot
WHERE agent_id = #{agentId}
AND version_no &gt; #{versionNo}
ORDER BY version_no ASC
LIMIT 1
</select>
<insert id="insertWithNextVersion">
INSERT INTO ai_agent_snapshot (
id,
agent_id,
user_id,
version_no,
snapshot_data,
changed_fields,
source,
restore_from_snapshot_id,
restore_from_version_no,
creator,
created_at,
redaction_version
)
SELECT #{snapshot.id},
#{snapshot.agentId},
#{snapshot.userId},
COALESCE(MAX(version_no), 0) + 1,
#{snapshot.snapshotData},
#{snapshot.changedFields},
#{snapshot.source},
#{snapshot.restoreFromSnapshotId},
#{snapshot.restoreFromVersionNo},
#{snapshot.creator},
#{snapshot.createdAt},
#{snapshot.redactionVersion}
FROM ai_agent_snapshot
WHERE agent_id = #{snapshot.agentId}
</insert>
<delete id="deleteOlderThanKeepLimit">
DELETE FROM ai_agent_snapshot
WHERE agent_id = #{agentId}
AND id NOT IN (
SELECT id
FROM (
SELECT id
FROM ai_agent_snapshot
WHERE agent_id = #{agentId}
ORDER BY version_no DESC
LIMIT #{keepLimit}
) retained_snapshots
)
</delete>
<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 &lt; #{targetRedactionVersion}
AND (#{afterId} IS NULL OR id &gt; #{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 &lt; #{redactionVersion}
AND id IN
<foreach collection="snapshots" item="snapshot" open="(" separator="," close=")">
#{snapshot.id}
</foreach>
</update>
</mapper>
@@ -0,0 +1,200 @@
package xiaozhi.common.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import org.apache.ibatis.binding.MapperMethod;
import org.apache.ibatis.executor.BatchResult;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.ExecutorType;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import com.baomidou.mybatisplus.core.enums.SqlMethod;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
class BaseServiceImplTest {
private static final String INSERT_STATEMENT = TestMapper.class.getName() + ".insert";
private static final String UPDATE_STATEMENT = TestMapper.class.getName() + ".updateById";
private final TestService service = new TestService();
@AfterEach
void clearTransactionSynchronization() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void insertBatchFlushesAtTheRequestedBatchSizeAndCommitsWithoutATransaction() {
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
SqlSession sqlSession = mock(SqlSession.class);
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(1);
when(sqlSession.flushStatements())
.thenReturn(List.of(batchResult(1, 1)), List.of(batchResult(1)));
TestEntity first = new TestEntity(1L);
TestEntity second = new TestEntity(2L);
TestEntity third = new TestEntity(3L);
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
assertTrue(service.insertBatch(List.of(first, second, third), 2));
}
verify(sqlSession).insert(INSERT_STATEMENT, first);
verify(sqlSession).insert(INSERT_STATEMENT, second);
verify(sqlSession).insert(INSERT_STATEMENT, third);
verify(sqlSession, times(2)).flushStatements();
verify(sqlSession).commit(true);
verify(sqlSession).close();
}
@Test
void updateBatchByIdPassesEachEntityAndReturnsSuccessfulFlushResult() {
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
SqlSession sqlSession = mock(SqlSession.class);
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
when(sqlSession.update(eq(UPDATE_STATEMENT), any())).thenReturn(1);
when(sqlSession.flushStatements()).thenReturn(List.of(batchResult(1, 1)));
TestEntity first = new TestEntity(1L);
TestEntity second = new TestEntity(2L);
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
assertTrue(service.updateBatchById(List.of(first, second), 10));
}
ArgumentCaptor<Object> params = ArgumentCaptor.forClass(Object.class);
verify(sqlSession, times(2)).update(eq(UPDATE_STATEMENT), params.capture());
assertSame(first, entityFrom(params.getAllValues().get(0)));
assertSame(second, entityFrom(params.getAllValues().get(1)));
verify(sqlSession).flushStatements();
}
@Test
void batchCallbacksReturnActualAffectedRowCounts() {
SqlSession sqlSession = mock(SqlSession.class);
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(3);
when(sqlSession.update(eq(UPDATE_STATEMENT), any())).thenReturn(2);
CallbackCapturingTestService capturingService = new CallbackCapturingTestService(sqlSession);
TestEntity entity = new TestEntity(1L);
assertTrue(capturingService.insertBatch(List.of(entity), 7));
assertEquals(7, capturingService.batchSize);
assertEquals(List.of(3), capturingService.affectedRows);
assertTrue(capturingService.updateBatchById(List.of(entity), 9));
assertEquals(9, capturingService.batchSize);
assertEquals(List.of(2), capturingService.affectedRows);
}
@Test
void emptyBatchesReturnFalseWithoutOpeningABatchSession() {
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
assertFalse(service.insertBatch(List.of(), 10));
assertFalse(service.updateBatchById(List.of(), 10));
}
verifyNoInteractions(sqlSessionFactory);
}
@Test
void activeTransactionSynchronizationUsesTransactionAwareCommitAndLifecycle() {
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
SqlSession sqlSession = mock(SqlSession.class);
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(1);
when(sqlSession.flushStatements()).thenReturn(List.of(batchResult(1)));
TransactionSynchronizationManager.initSynchronization();
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
assertTrue(service.insertBatch(List.of(new TestEntity(1L)), 1));
}
verify(sqlSession).flushStatements();
verify(sqlSession).commit(false);
verify(sqlSession, never()).commit(true);
verify(sqlSession, never()).close();
}
@SuppressWarnings("deprecation")
private static MockedStatic<SqlHelper> sqlHelperUsing(SqlSessionFactory sqlSessionFactory) {
MockedStatic<SqlHelper> sqlHelper = mockStatic(SqlHelper.class, CALLS_REAL_METHODS);
sqlHelper.when(() -> SqlHelper.sqlSessionFactory(TestEntity.class)).thenReturn(sqlSessionFactory);
return sqlHelper;
}
private static BatchResult batchResult(int... updateCounts) {
BatchResult result = new BatchResult(mock(MappedStatement.class), "batch");
result.setUpdateCounts(updateCounts);
return result;
}
private static Object entityFrom(Object parameter) {
assertTrue(parameter instanceof MapperMethod.ParamMap<?>);
return ((Map<?, ?>) parameter).get(Constants.ENTITY);
}
private interface TestMapper extends BaseMapper<TestEntity> {
}
private record TestEntity(Long id) {
}
private static class TestService extends BaseServiceImpl<TestMapper, TestEntity> {
@Override
protected String getSqlStatement(SqlMethod sqlMethod) {
return switch (sqlMethod) {
case INSERT_ONE -> INSERT_STATEMENT;
case UPDATE_BY_ID -> UPDATE_STATEMENT;
default -> throw new IllegalArgumentException("Unexpected SQL method: " + sqlMethod);
};
}
}
private static class CallbackCapturingTestService extends TestService {
private final SqlSession sqlSession;
private int batchSize;
private List<Integer> affectedRows;
private CallbackCapturingTestService(SqlSession sqlSession) {
this.sqlSession = sqlSession;
}
@Override
protected <E> boolean executeBatch(Collection<E> list, int batchSize,
BiFunction<SqlSession, E, Integer> operation) {
this.batchSize = batchSize;
this.affectedRows = list.stream().map(entity -> operation.apply(sqlSession, entity)).toList();
return true;
}
}
}
@@ -0,0 +1,44 @@
package xiaozhi.modules.agent.dto;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
class AgentUpdateDTOTest {
@Test
void functionInfoAcceptsJsonStringParamInfo() {
AgentUpdateDTO.FunctionInfo info = new AgentUpdateDTO.FunctionInfo();
info.setParamInfo("{\"api_key\":\"abc\",\"max_results\":5}");
assertEquals("abc", info.getParamInfo().get("api_key"));
assertEquals(5, info.getParamInfo().get("max_results"));
}
@Test
void functionInfoNormalizesMapKeysToStrings() {
AgentUpdateDTO.FunctionInfo info = new AgentUpdateDTO.FunctionInfo();
Map<Object, Object> params = new LinkedHashMap<>();
params.put("api_key", "abc");
params.put(42, true);
info.setParamInfo(params);
assertEquals("abc", info.getParamInfo().get("api_key"));
assertEquals(true, info.getParamInfo().get("42"));
}
@Test
void functionInfoFallsBackToEmptyMapForBlankParamInfo() {
AgentUpdateDTO.FunctionInfo info = new AgentUpdateDTO.FunctionInfo();
info.setParamInfo(" ");
assertTrue(info.getParamInfo().isEmpty());
}
}
@@ -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);
}
}
@@ -0,0 +1,73 @@
package xiaozhi.modules.agent.typehandler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.List;
import org.apache.ibatis.type.TypeHandler;
import org.apache.ibatis.type.TypeHandlerRegistry;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import com.fasterxml.jackson.core.type.TypeReference;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.dto.ContextProviderDTO;
class ContextProviderListTypeHandlerTest {
private final ContextProviderListTypeHandler handler = new ContextProviderListTypeHandler();
@Test
void myBatisCanInstantiateHandlerForListField() {
TypeHandler<?> typeHandler = new TypeHandlerRegistry().getInstance(List.class,
ContextProviderListTypeHandler.class);
assertInstanceOf(ContextProviderListTypeHandler.class, typeHandler);
}
@Test
void resultSetDeserializationKeepsContextProviderDtoElementType() throws Exception {
ResultSet resultSet = mock(ResultSet.class);
when(resultSet.getString("context_providers"))
.thenReturn("[{\"url\":\"https://example.com/context\",\"headers\":{\"Authorization\":\"Bearer token\"}}]");
List<ContextProviderDTO> providers = handler.getNullableResult(resultSet, "context_providers");
assertEquals(1, providers.size());
assertInstanceOf(ContextProviderDTO.class, providers.get(0));
assertEquals("https://example.com/context", providers.get(0).getUrl());
assertEquals("Bearer token", providers.get(0).getHeaders().get("Authorization"));
}
@Test
void sqlNullRemainsNull() throws Exception {
ResultSet resultSet = mock(ResultSet.class);
assertNull(handler.getNullableResult(resultSet, 1));
}
@Test
void serializesProviderListAsJson() throws Exception {
ContextProviderDTO provider = new ContextProviderDTO();
provider.setUrl("https://example.com/context");
PreparedStatement statement = mock(PreparedStatement.class);
handler.setNonNullParameter(statement, 1, List.of(provider), null);
ArgumentCaptor<String> json = ArgumentCaptor.forClass(String.class);
verify(statement).setString(eq(1), json.capture());
List<ContextProviderDTO> serialized = JsonUtils.parseObject(json.getValue(), new TypeReference<>() {
});
assertEquals(1, serialized.size());
assertEquals("https://example.com/context", serialized.get(0).getUrl());
}
}
@@ -0,0 +1,87 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.vo.UserShowDeviceListVO;
import xiaozhi.modules.security.config.WebMvcConfig;
@DisplayName("设备时间序列化回归测试")
class DeviceTimeSerializationTest {
@ParameterizedTest(name = "浏览器时区 {0}")
@ValueSource(strings = { "Asia/Shanghai", "America/Sao_Paulo" })
@DisplayName("#3280 绑定时间和最后连接时间在任意浏览器时区都表示同一时刻")
void serializedDeviceTimesDescribeTheSameInstantAcrossBrowserTimeZones(String browserTimeZone) {
Instant connectedAt = Instant.parse("2026-07-10T13:21:42Z");
DeviceEntity entity = new DeviceEntity();
entity.setCreateDate(Date.from(connectedAt));
entity.setLastConnectedAt(Date.from(connectedAt));
DeviceServiceImpl deviceService = serviceReturning(entity);
UserShowDeviceListVO device = deviceService.getUserDeviceList(1L, "agent-id").getFirst();
ObjectMapper objectMapper = new WebMvcConfig().jackson2HttpMessageConverter().getObjectMapper();
JsonNode payload = objectMapper.valueToTree(device);
Instant createDate = Instant.ofEpochMilli(
Long.parseLong(payload.path("createDateTimestamp").asText()));
Instant lastConnectedAt = Instant.ofEpochMilli(
Long.parseLong(payload.path("lastConnectedAtTimestamp").asText()));
ZoneId browserZone = ZoneId.of(browserTimeZone);
assertAll(
() -> assertTrue(payload.path("createDateTimestamp").isTextual(),
"Long 时间戳必须遵循现有 JSON 契约序列化为字符串"),
() -> assertTrue(payload.path("lastConnectedAtTimestamp").isTextual(),
"Long 时间戳必须遵循现有 JSON 契约序列化为字符串"),
() -> assertEquals(connectedAt, createDate,
"createDateTimestamp 必须保留源时间点"),
() -> assertEquals(connectedAt, lastConnectedAt,
"lastConnectedAtTimestamp 必须保留源时间点"),
() -> assertEquals(lastConnectedAt.atZone(browserZone).toLocalDateTime(),
createDate.atZone(browserZone).toLocalDateTime(),
"绑定时间和最后连接时间在同一浏览器中必须显示为相同的本地时间"),
() -> assertTrue(payload.path("createDate").isTextual(),
"兼容字段 createDate 必须继续保留"));
}
@Test
@DisplayName("时间为空时新旧字段均保持 null")
void nullDeviceTimesRemainNull() {
DeviceEntity entity = new DeviceEntity();
DeviceServiceImpl deviceService = serviceReturning(entity);
UserShowDeviceListVO device = deviceService.getUserDeviceList(1L, "agent-id").getFirst();
ObjectMapper objectMapper = new WebMvcConfig().jackson2HttpMessageConverter().getObjectMapper();
JsonNode payload = objectMapper.valueToTree(device);
assertAll(
() -> assertTrue(payload.path("createDateTimestamp").isNull()),
() -> assertTrue(payload.path("lastConnectedAtTimestamp").isNull()),
() -> assertTrue(payload.path("createDate").isNull()));
}
private DeviceServiceImpl serviceReturning(DeviceEntity entity) {
return new DeviceServiceImpl(null, null, null, null, null, null) {
@Override
public List<DeviceEntity> getUserDevices(Long userId, String agentId) {
return List.of(entity);
}
};
}
}
@@ -0,0 +1,180 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import cn.hutool.crypto.digest.DigestUtil;
@DisplayName("MQTT Gateway 日期鉴权回归测试")
class MqttGatewayAuthorizationTest {
private static final String SIGNATURE_KEY = "test-signature-key";
private static final Instant FIXED_INSTANT = Instant.parse("2026-07-14T00:30:00Z");
private HttpServer server;
@AfterEach
void stopServer() {
if (server != null) {
server.stop(0);
}
}
@Test
@DisplayName("以 UTC 日期生成当天、前一天和后一天三个候选 token")
void generatesUtcDateCandidates() {
List<String> tokens = MqttGatewayAuthorization.generateDailyTokens(SIGNATURE_KEY, FIXED_INSTANT);
assertEquals(List.of(
tokenFor("2026-07-14"),
tokenFor("2026-07-13"),
tokenFor("2026-07-15")), tokens);
}
@ParameterizedTest(name = "Gateway 时区 {0}")
@MethodSource("gatewayTimeZones")
@DisplayName("候选 token 覆盖上海和圣保罗 Gateway 的本地日期")
void coversGatewayLocalDate(String gatewayTimeZone, Instant now, int expectedTokenIndex) {
String gatewayDate = now.atZone(ZoneId.of(gatewayTimeZone)).toLocalDate().toString();
List<String> tokens = MqttGatewayAuthorization.generateDailyTokens(SIGNATURE_KEY, now);
assertEquals(tokenFor(gatewayDate), tokens.get(expectedTokenIndex));
}
@Test
@DisplayName("仅在 401 时按日期候选重试并保留请求体")
void retriesOnlyAuthenticationFailures() throws IOException {
AtomicInteger requestCount = new AtomicInteger();
List<String> authorizationHeaders = new ArrayList<>();
List<String> requestBodies = new ArrayList<>();
List<String> expectedTokens = MqttGatewayAuthorization.generateDailyTokens(SIGNATURE_KEY, FIXED_INSTANT);
startServer(exchange -> {
int attempt = requestCount.getAndIncrement();
authorizationHeaders.add(exchange.getRequestHeaders().getFirst("Authorization"));
requestBodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
if (attempt == 0) {
respond(exchange, 401, "{\"error\":\"unauthorized\"}");
} else if (attempt == 1) {
respond(exchange, 401, "{\"error\":\"unauthorized\"}");
} else {
respond(exchange, 200, "{\"online\":true}");
}
});
String requestBody = "{\"clientIds\":[\"device-id\"]}";
String response = MqttGatewayAuthorization.postJson(
serverUrl(), requestBody, SIGNATURE_KEY, FIXED_INSTANT);
assertEquals("{\"online\":true}", response);
assertEquals(3, requestCount.get());
assertEquals(expectedTokens.stream().map(token -> "Bearer " + token).toList(), authorizationHeaders);
assertEquals(List.of(requestBody, requestBody, requestBody), requestBodies);
}
@ParameterizedTest(name = "HTTP {0}")
@ValueSource(ints = { 403, 500 })
@DisplayName("非 401 错误不重试且向上抛出")
void doesNotRetryNonAuthenticationFailure(int statusCode) throws IOException {
AtomicInteger requestCount = new AtomicInteger();
startServer(exchange -> {
requestCount.incrementAndGet();
exchange.getRequestBody().readAllBytes();
respond(exchange, statusCode, "{\"error\":\"request rejected\"}");
});
MqttGatewayAuthorization.GatewayRequestException exception = assertThrows(
MqttGatewayAuthorization.GatewayRequestException.class,
() -> MqttGatewayAuthorization.postJson(
serverUrl(), "{}", SIGNATURE_KEY, FIXED_INSTANT));
assertEquals(statusCode, exception.statusCode());
assertEquals(1, requestCount.get());
}
@Test
@DisplayName("所有日期候选被拒绝时不把错误响应当作设备离线数据")
void propagatesAuthenticationFailureAfterAllCandidatesAreRejected() throws IOException {
AtomicInteger requestCount = new AtomicInteger();
startServer(exchange -> {
requestCount.incrementAndGet();
exchange.getRequestBody().readAllBytes();
respond(exchange, 401, "{\"error\":\"unauthorized\"}");
});
MqttGatewayAuthorization.GatewayRequestException exception = assertThrows(
MqttGatewayAuthorization.GatewayRequestException.class,
() -> MqttGatewayAuthorization.postJson(
serverUrl(), "{}", SIGNATURE_KEY, FIXED_INSTANT));
assertEquals(401, exception.statusCode());
assertEquals(3, requestCount.get());
}
@ParameterizedTest(name = "密钥值 [{0}]")
@NullAndEmptySource
@ValueSource(strings = { " ", "null", " NULL " })
@DisplayName("缺少或占位签名密钥时在发起 HTTP 请求前失败")
void rejectsMissingSignatureKeyBeforeSendingRequest(String signatureKey) {
MqttGatewayAuthorization.GatewayRequestException exception = assertThrows(
MqttGatewayAuthorization.GatewayRequestException.class,
() -> MqttGatewayAuthorization.postJson(
"http://127.0.0.1:1", "{}", signatureKey, FIXED_INSTANT));
assertNull(exception.statusCode());
}
private static Stream<Arguments> gatewayTimeZones() {
return Stream.of(
Arguments.of("Asia/Shanghai", Instant.parse("2026-07-13T17:30:00Z"), 2),
Arguments.of("America/Sao_Paulo", Instant.parse("2026-07-14T00:30:00Z"), 1));
}
private void startServer(HttpHandler handler) throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/mqtt", handler);
server.start();
}
private String serverUrl() {
assertTrue(server != null, "test server must be started");
return "http://127.0.0.1:" + server.getAddress().getPort() + "/mqtt";
}
private static void respond(HttpExchange exchange, int statusCode, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (var output = exchange.getResponseBody()) {
output.write(bytes);
}
}
private static String tokenFor(String date) {
return DigestUtil.sha256Hex(date + SIGNATURE_KEY);
}
}
@@ -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"));
}
}
+1
View File
@@ -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",
+15
View File
@@ -91,6 +91,9 @@ importers:
pinia-plugin-persistedstate:
specifier: 3.2.1
version: 3.2.1(pinia@2.0.36(typescript@5.7.2)(vue@3.5.15(typescript@5.7.2)))
sm-crypto:
specifier: ^0.3.13
version: 0.3.14
vue:
specifier: ^3.4.21
version: 3.5.15(typescript@5.7.2)
@@ -4719,6 +4722,9 @@ packages:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
jsbn@1.1.0:
resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==}
jsdoc-type-pratt-parser@4.1.0:
resolution: {integrity: sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==}
engines: {node: '>=12.0.0'}
@@ -6020,6 +6026,9 @@ packages:
resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==}
engines: {node: '>=18'}
sm-crypto@0.3.14:
resolution: {integrity: sha512-sR7NuGAJH93Om8keAF2/rj1EqFzEiUey4aKGyDo3nQ2BiVqhgq8UkiIoIp5qhV9w4pG7qArhfqyewcG8AbhXbg==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -12652,6 +12661,8 @@ snapshots:
dependencies:
argparse: 2.0.1
jsbn@1.1.0: {}
jsdoc-type-pratt-parser@4.1.0: {}
jsdom@16.7.0:
@@ -14145,6 +14156,10 @@ snapshots:
ansi-styles: 6.2.1
is-fullwidth-code-point: 5.0.0
sm-crypto@0.3.14:
dependencies:
jsbn: 1.1.0
source-map-js@1.2.1: {}
source-map-support@0.5.21:
+9 -8
View File
@@ -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)
}
}
+65 -1
View File
@@ -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,
},
})
}
+52 -4
View File
@@ -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
File diff suppressed because it is too large Load Diff
+94
View File
@@ -71,6 +71,13 @@ export default {
'home.inputPlaceholder': 'e.g. Customer Service Assistant, Voice Assistant, Knowledge Q&A',
'home.createError': 'The name length must be between 1 and 64 characters',
'home.createNow': 'Create Now',
'home.deleteConfirmTitle': 'Delete Agent',
'home.confirmDeleteAgent': 'Deletion cannot be undone. Type the agent name "{agentName}" to confirm.',
'home.deleteAgentNamePlaceholder': 'Enter agent name',
'home.deleteAgentNameMismatch': 'The agent name does not match',
'home.deleteAgentPasteForbidden': 'Please type the agent name manually. Pasting is not allowed.',
'home.deleteAgentMissingInfo': 'Unable to confirm agent information. Please refresh and try again.',
'home.deleteAgentSuccess': 'Agent deleted successfully',
'home.justNow': 'Just now',
'home.minutesAgo': 'minutes ago',
'home.hoursAgo': 'hours ago',
@@ -123,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',
@@ -139,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',
+10 -10
View File
@@ -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)' },
]
}
}
+97 -3
View File
@@ -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',
@@ -71,6 +71,13 @@ export default {
'home.inputPlaceholder': 'ex: Assistente de Atendimento, Assistente de Voz, Perguntas e Respostas',
'home.createError': 'O comprimento do nome deve estar entre 1 e 64 caracteres',
'home.createNow': 'Criar Agora',
'home.deleteConfirmTitle': 'Excluir Agente',
'home.confirmDeleteAgent': 'A exclusão não pode ser desfeita. Digite o nome do agente "{agentName}" para confirmar.',
'home.deleteAgentNamePlaceholder': 'Digite o nome do agente',
'home.deleteAgentNameMismatch': 'O nome do agente não corresponde',
'home.deleteAgentPasteForbidden': 'Digite o nome do agente manualmente. Colar não é permitido.',
'home.deleteAgentMissingInfo': 'Não foi possível confirmar as informações do agente. Atualize a página e tente novamente.',
'home.deleteAgentSuccess': 'Agente excluído com sucesso',
'home.justNow': 'Agora mesmo',
'home.minutesAgo': 'minutos atrás',
'home.hoursAgo': 'horas atrás',
@@ -123,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',
@@ -139,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',
File diff suppressed because it is too large Load Diff
+94
View File
@@ -71,6 +71,13 @@ export default {
'home.inputPlaceholder': '例如:客服助手、语音助理、知识问答',
'home.createError': '名称长度必须在 1 到 64 个字符之间',
'home.createNow': '立即创建',
'home.deleteConfirmTitle': '删除智能体',
'home.confirmDeleteAgent': '删除后无法恢复。请输入智能体名称“{agentName}”确认删除。',
'home.deleteAgentNamePlaceholder': '请输入智能体名称',
'home.deleteAgentNameMismatch': '输入的智能体名称不匹配',
'home.deleteAgentPasteForbidden': '请手动输入智能体名称,不允许粘贴',
'home.deleteAgentMissingInfo': '无法确认智能体信息,请刷新后重试',
'home.deleteAgentSuccess': '智能体删除成功',
'home.justNow': '刚刚',
'home.minutesAgo': '分钟前',
'home.hoursAgo': '小时前',
@@ -123,6 +130,7 @@ export default {
'agent.saving': '保存中...',
'agent.saveSuccess': '保存成功',
'agent.saveFail': '保存失败',
'agent.ttsOptionsLoadFailed': '语音选项加载失败,已保留原来的语音配置',
'agent.loadFail': '加载失败',
'agent.pleaseInputAgentName': '请输入智能体名称',
'agent.pleaseInputRoleDescription': '请输入角色介绍',
@@ -139,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',
+97 -3
View File
@@ -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': '重置密碼',
@@ -92,6 +92,13 @@ export default {
'home.inputPlaceholder': '例如:客服助手、語音助理、知識問答',
'home.createError': '暱稱長度必須在 1 到 64 個字元之間。',
'home.createNow': '立即創建',
'home.deleteConfirmTitle': '刪除智能體',
'home.confirmDeleteAgent': '刪除後無法復原。請輸入智能體名稱「{agentName}」確認刪除。',
'home.deleteAgentNamePlaceholder': '請輸入智能體名稱',
'home.deleteAgentNameMismatch': '輸入的智能體名稱不匹配',
'home.deleteAgentPasteForbidden': '請手動輸入智能體名稱,不允許貼上',
'home.deleteAgentMissingInfo': '無法確認智能體資訊,請重新整理後再試',
'home.deleteAgentSuccess': '智能體刪除成功',
'home.justNow': '剛剛',
'home.minutesAgo': '分鐘前',
'home.hoursAgo': '小時前',
@@ -144,6 +151,7 @@ export default {
'agent.saving': '儲存中...',
'agent.saveSuccess': '儲存成功',
'agent.saveFail': '儲存失敗',
'agent.ttsOptionsLoadFailed': '語音選項載入失敗,已保留原來的語音設定',
'agent.loadFail': '加載失敗',
'agent.pleaseInputAgentName': '請輸入助手暱稱',
'agent.pleaseInputRoleDescription': '請輸入角色介紹',
@@ -160,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 -1
View File
@@ -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 -1
View File
@@ -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 = {
+6 -7
View File
@@ -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)
+55 -1
View File
@@ -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')
})
+628 -87
View File
@@ -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 = [
{
@@ -169,6 +276,28 @@ function handleInputConfirm() {
// 是否禁用历史记忆输入框
const isMemoryDisabled = computed(() => formData.value.memModelId !== 'Memory_mem_local_short')
function normalizeFunctionParams(paramInfo: any) {
if (!paramInfo)
return {}
if (typeof paramInfo === 'string') {
try {
const parsed = JSON.parse(paramInfo)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
}
catch {
return {}
}
}
return typeof paramInfo === 'object' && !Array.isArray(paramInfo) ? { ...paramInfo } : {}
}
function normalizeAgentFunctions(functions: any[] = []) {
return functions.map(item => ({
...item,
paramInfo: normalizeFunctionParams(item.paramInfo),
}))
}
// 打开上下文源编辑弹窗
function openContextProviderDialog() {
uni.navigateTo({
@@ -183,48 +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)
formData.value = { ...detail }
// 更新插件store
pluginStore.setCurrentAgentId(agentId.value)
pluginStore.setCurrentFunctions(detail.functions || [])
// 更新语速音调
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)
@@ -321,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
@@ -330,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,
@@ -351,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
@@ -389,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
}
}
}
@@ -425,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
@@ -434,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,
@@ -446,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
}
@@ -465,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
@@ -503,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':
@@ -600,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
@@ -610,25 +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'))
}
@@ -663,7 +1036,7 @@ function handleTools() {
// 确保store中有最新数据
pluginStore.setCurrentAgentId(agentId.value)
pluginStore.setCurrentFunctions(formData.value.functions || [])
pluginStore.setCurrentFunctions(normalizeAgentFunctions(formData.value.functions || []))
pluginStore.setAllFunctions(allFunctions.value)
uni.navigateTo({
@@ -672,25 +1045,148 @@ 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中的插件配置变化
watch(() => pluginStore.currentFunctions, (newFunctions) => {
formData.value.functions = 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()
@@ -702,7 +1198,7 @@ onMounted(async () => {
])
// 然后加载智能体详情,这样可以正确映射显示名称
if (agentId.value) {
if (agentId.value && !snapshotReloadBlocked.value) {
await loadAgentDetail()
}
})
@@ -710,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">
@@ -981,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"
>
@@ -1085,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()
}
+21 -6
View File
@@ -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'
@@ -41,17 +41,32 @@ const tempParams = ref<Record<string, any>>({})
const arrayTextCache = ref<Record<string, string>>({})
const jsonTextCache = ref<Record<string, string>>({})
function normalizeFunctionParams(paramInfo: any, fallback: Record<string, any> = {}) {
if (!paramInfo)
return { ...fallback }
if (typeof paramInfo === 'string') {
try {
const parsed = JSON.parse(paramInfo)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : { ...fallback }
}
catch {
return { ...fallback }
}
}
return typeof paramInfo === 'object' && !Array.isArray(paramInfo) ? { ...fallback, ...paramInfo } : { ...fallback }
}
async function mergeFunctions() {
selectedList.value = functions.value.map((mapping) => {
const meta = allFunctions.value.find(f => f.id === mapping.pluginId)
if (!meta) {
return { id: mapping.pluginId, name: mapping.pluginId, params: {} }
return { id: mapping.pluginId, name: mapping.pluginId, params: normalizeFunctionParams(mapping.paramInfo) }
}
return {
id: mapping.pluginId,
name: meta.name,
params: mapping.paramInfo || { ...meta.params },
params: normalizeFunctionParams(mapping.paramInfo, meta.params),
fieldsMeta: meta.fieldsMeta,
}
})
@@ -91,7 +106,7 @@ function selectFunction(func: any) {
selectedList.value = [...selectedList.value, {
id: func.id,
name: func.name,
params: { ...func.params },
params: normalizeFunctionParams(func.params),
fieldsMeta: func.fieldsMeta,
}]
@@ -118,7 +133,7 @@ function editFunction(func: any) {
currentFunction.value = func
// 直接使用当前函数的参数
tempParams.value = { ...func.params }
tempParams.value = normalizeFunctionParams(func.params)
// 初始化文本缓存
if (func.fieldsMeta) {
@@ -252,7 +267,7 @@ function getFieldRemark(field: any) {
watch(() => selectedList.value, (newSelectedList) => {
const finalFunctions = newSelectedList.map(f => ({
pluginId: f.id,
paramInfo: f.params,
paramInfo: normalizeFunctionParams(f.params),
}))
pluginStore.updateFunctions(finalFunctions)
})
@@ -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;
+221 -7
View File
@@ -12,9 +12,9 @@
<script lang="ts" setup>
import type { Agent } from '@/api/agent/types'
import { onMounted, ref } from 'vue'
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'
@@ -53,6 +53,14 @@ const pagingRef = ref()
useZPaging(pagingRef)
// 消息组件
const message = useMessage()
const deleteDialogVisible = ref(false)
const deleteTargetAgent = ref<Agent | null>(null)
const deleteConfirmText = ref('')
const deleteAgentLoading = ref(false)
const isDeleteAgentNameMatched = computed(() => {
return !!deleteTargetAgent.value?.agentName && deleteConfirmText.value === deleteTargetAgent.value.agentName
})
// z-paging查询列表数据
async function queryList(pageNo: number, pageSize: number) {
@@ -89,19 +97,53 @@ async function handleCreateAgent(agentName: string) {
}
}
function openDeleteAgentDialog(agent: Agent) {
if (!agent.id || !agent.agentName) {
toast.error(t('home.deleteAgentMissingInfo'))
return
}
deleteTargetAgent.value = agent
deleteConfirmText.value = ''
deleteDialogVisible.value = true
}
function closeDeleteAgentDialog() {
if (deleteAgentLoading.value)
return
deleteDialogVisible.value = false
deleteTargetAgent.value = null
deleteConfirmText.value = ''
}
function handleDeleteAgentPaste() {
toast.warning(t('home.deleteAgentPasteForbidden'))
}
// 删除智能体
async function handleDeleteAgent(agent: Agent) {
async function handleDeleteAgent() {
if (!deleteTargetAgent.value || !isDeleteAgentNameMatched.value || deleteAgentLoading.value)
return
try {
await deleteAgent(agent.id)
deleteAgentLoading.value = true
await deleteAgent(deleteTargetAgent.value.id)
// 删除成功后刷新列表
pagingRef.value.reload()
toast.success(`${t('home.agentName')}${t('message.deleteSuccess')}`)
toast.success(t('home.deleteAgentSuccess'))
deleteDialogVisible.value = false
deleteTargetAgent.value = null
deleteConfirmText.value = ''
}
catch (error: any) {
console.error('删除智能体失败:', error)
const errorMessage = error?.message || t('message.deleteFail')
toast.error(errorMessage)
}
finally {
deleteAgentLoading.value = false
}
}
// 进入编辑页面
@@ -125,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'),
@@ -254,7 +296,7 @@ onMounted(() => {
<template #right>
<view class="swipe-actions">
<view class="action-btn delete-btn" @click.stop="handleDeleteAgent(agent)">
<view class="action-btn delete-btn" @click.stop="openDeleteAgentDialog(agent)">
<wd-icon name="delete" />
<text>{{ t('home.delete') }}</text>
</view>
@@ -283,6 +325,79 @@ onMounted(() => {
<!-- MessageBox 组件 -->
<wd-message-box />
</z-paging>
<wd-popup
v-model="deleteDialogVisible"
position="center"
custom-style="width: 88%; max-width: 420px; border-radius: 16px;"
:close-on-click-modal="!deleteAgentLoading"
safe-area-inset-bottom
@close="closeDeleteAgentDialog"
>
<view class="delete-agent-dialog">
<view class="delete-agent-header">
<text class="delete-agent-title">
{{ t('home.deleteConfirmTitle') }}
</text>
</view>
<view class="delete-agent-body">
<view class="delete-agent-content">
<wd-icon name="warning" custom-class="delete-agent-warning" />
<view class="delete-agent-message">
<text
class="delete-agent-copy-guard delete-agent-tip"
@copy.stop.prevent
@cut.stop.prevent
@contextmenu.stop.prevent
@longpress.stop.prevent
>
{{ t('home.confirmDeleteAgent', { agentName: deleteTargetAgent?.agentName || '' }) }}
</text>
<view
class="delete-agent-copy-guard delete-agent-target"
@copy.stop.prevent
@cut.stop.prevent
@contextmenu.stop.prevent
@longpress.stop.prevent
>
<text>{{ deleteTargetAgent?.agentName || '' }}</text>
</view>
<input
v-model="deleteConfirmText"
class="delete-agent-input"
type="text"
:maxlength="deleteTargetAgent?.agentName?.length || 64"
:placeholder="t('home.deleteAgentNamePlaceholder')"
:disabled="deleteAgentLoading"
@paste.stop.prevent="handleDeleteAgentPaste"
@drop.stop.prevent="handleDeleteAgentPaste"
@contextmenu.stop.prevent
>
<text v-if="deleteConfirmText && !isDeleteAgentNameMatched" class="delete-agent-helper">
{{ t('home.deleteAgentNameMismatch') }}
</text>
</view>
</view>
</view>
<view class="delete-agent-footer">
<wd-button type="info" custom-class="delete-agent-button" :disabled="deleteAgentLoading" @click="closeDeleteAgentDialog">
{{ t('common.cancel') }}
</wd-button>
<wd-button
type="primary"
custom-class="delete-agent-button"
:loading="deleteAgentLoading"
:disabled="!isDeleteAgentNameMatched"
@click="handleDeleteAgent"
>
{{ t('common.confirm') }}
</wd-button>
</view>
</view>
</wd-popup>
</template>
<style lang="scss" scoped>
@@ -587,4 +702,103 @@ onMounted(() => {
text-align: center;
border-top: 1rpx solid #eeeeee;
}
.delete-agent-dialog {
overflow: hidden;
background: #ffffff;
}
.delete-agent-header {
padding: 32rpx 32rpx 20rpx;
border-bottom: 1rpx solid #f0f0f0;
text-align: center;
}
.delete-agent-title {
font-size: 32rpx;
font-weight: 600;
color: #232338;
}
.delete-agent-body {
padding: 32rpx;
}
.delete-agent-content {
display: flex;
align-items: flex-start;
gap: 16rpx;
}
:deep(.delete-agent-warning) {
margin-top: 4rpx;
color: #e6a23c;
font-size: 40rpx;
}
.delete-agent-message {
flex: 1;
min-width: 0;
}
.delete-agent-tip {
display: block;
color: #606266;
font-size: 28rpx;
line-height: 1.6;
}
.delete-agent-target {
box-sizing: border-box;
width: 100%;
margin-top: 20rpx;
padding: 18rpx 20rpx;
border: 1rpx solid #ebeef5;
border-radius: 12rpx;
background: #f5f7fa;
color: #303133;
font-size: 30rpx;
font-weight: 600;
line-height: 1.5;
word-break: break-all;
}
.delete-agent-copy-guard {
user-select: none;
-webkit-user-select: none;
}
.delete-agent-input {
box-sizing: border-box;
width: 100%;
height: 80rpx;
margin-top: 28rpx;
padding: 0 20rpx;
border: 1rpx solid #eeeeee;
border-radius: 12rpx;
background: #f5f7fb;
color: #232338;
font-size: 28rpx;
outline: none;
}
.delete-agent-helper {
display: block;
min-height: 32rpx;
margin-top: 10rpx;
color: #f56c6c;
font-size: 24rpx;
line-height: 1.4;
}
.delete-agent-footer {
display: flex;
gap: 16rpx;
padding: 24rpx 32rpx 32rpx;
border-top: 1rpx solid #eeeeee;
}
:deep(.delete-agent-button) {
flex: 1;
}
</style>
@@ -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 -1
View File
@@ -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 = {
+4 -3
View File
@@ -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(
},
},
},
)
)
+20 -2
View File
@@ -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: {
+1 -1
View File
@@ -201,7 +201,7 @@ export function getEnvBaseUploadUrl() {
/**
* 生成SM2密钥对(十六进制格式)
* @returns {Object} 包含公钥和私钥的对象
* @returns {object} 包含公钥和私钥的对象
*/
export function generateSm2KeyPairHex() {
// 使用sm-crypto库生成SM2密钥对
+56
View File
@@ -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>>
}
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -116,7 +116,7 @@ export default async ({ command, mode }) => {
&& mode === 'production'
&& visualizer({
filename: './node_modules/.cache/visualizer/stats.html',
open: true,
open: false,
gzipSize: true,
brotliSize: true,
}),
+10
View File
@@ -11096,6 +11096,16 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"license": "MIT",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+3
View File
@@ -4,7 +4,10 @@
"private": true,
"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",
"test:unit": "node --test tests/*.test.mjs",
"analyze": "cross-env ANALYZE=true vue-cli-service build"
},
"dependencies": {
+96
View File
@@ -0,0 +1,96 @@
const fs = require('fs');
const path = require('path');
const rootDir = path.resolve(__dirname, '..');
const locales = [
{ name: 'zh_CN', file: 'src/i18n/zh_CN.js' },
{ name: 'zh_TW', file: 'src/i18n/zh_TW.js' },
{ name: 'en', file: 'src/i18n/en.js' },
{ name: 'de', file: 'src/i18n/de.js' },
{ name: 'vi', file: 'src/i18n/vi.js' },
{ name: 'pt_BR', file: 'src/i18n/pt_BR.js' },
];
function readLocale(locale) {
const filePath = path.join(rootDir, locale.file);
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
const keys = [];
const seen = new Map();
const duplicates = [];
lines.forEach((line, index) => {
const match = line.match(/^\s*['"]([^'"]+)['"]\s*:/);
if (!match) {
return;
}
const key = match[1];
keys.push(key);
if (!seen.has(key)) {
seen.set(key, []);
}
seen.get(key).push(index + 1);
});
seen.forEach((lineNumbers, key) => {
if (lineNumbers.length > 1) {
duplicates.push({ key, lineNumbers });
}
});
return {
...locale,
filePath,
keys,
keySet: new Set(keys),
duplicates,
};
}
function diffKeys(left, right) {
return [...left].filter((key) => !right.has(key)).sort();
}
function formatKeyList(keys) {
return keys.length ? keys.map((key) => ` - ${key}`).join('\n') : '';
}
function main() {
const results = locales.map(readLocale);
const base = results.find((locale) => locale.name === 'zh_CN');
const errors = [];
results.forEach((locale) => {
locale.duplicates.forEach(({ key, lineNumbers }) => {
errors.push(
`${locale.file} has duplicate key "${key}" on lines ${lineNumbers.join(', ')}`
);
});
});
results
.filter((locale) => locale !== base)
.forEach((locale) => {
const missing = diffKeys(base.keySet, locale.keySet);
const extra = diffKeys(locale.keySet, base.keySet);
if (missing.length) {
errors.push(`${locale.file} is missing keys from ${base.file}:\n${formatKeyList(missing)}`);
}
if (extra.length) {
errors.push(`${locale.file} has keys that do not exist in ${base.file}:\n${formatKeyList(extra)}`);
}
});
if (errors.length) {
console.error(`i18n check failed with ${errors.length} issue(s):`);
console.error(errors.join('\n\n'));
process.exit(1);
}
const keyCount = base.keySet.size;
console.log(`i18n check passed: ${results.length} locale files, ${keyCount} keys each.`);
}
main();
+158 -13
View File
@@ -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) {
@@ -81,6 +128,92 @@ export default {
});
}).send();
},
// 获取智能体配置快照列表
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)
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.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, 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((error) => {
retryCallbackRequest(
(nextRetryCount, nextRetryStartedAt) => this.getAgentSnapshot(
agentId,
snapshotId,
callback,
onTerminalFailure,
nextRetryCount,
nextRetryStartedAt
),
retryCount,
onTerminalFailure,
error,
retryWindowStartedAt
)
})
attachTerminalFailure(request, onTerminalFailure).send();
},
// 恢复智能体配置快照
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((error) => {
terminateCallbackRequest(onTerminalFailure, error)
})
attachTerminalFailure(request, onTerminalFailure).send();
},
// 删除智能体配置快照
deleteAgentSnapshot(agentId, snapshotId, callback, onTerminalFailure) {
const request = RequestService.sendRequest()
.url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}`)
.method('DELETE')
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.networkFail((error) => {
terminateCallbackRequest(onTerminalFailure, error)
})
attachTerminalFailure(request, onTerminalFailure).send();
},
// 新增方法:获取智能体模板
getAgentTemplate(callback) { // 移除templateName参数
RequestService.sendRequest()
@@ -402,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;/
);
});

Some files were not shown because too many files have changed in this diff Show More