Compare commits

..
Author SHA1 Message Date
Tyke Chen 942d55118b test(manager-api): clean test runtime warnings 2026-07-22 16:41:44 +08:00
CGDandGitHub f47f3b050b Merge pull request #3297 from xinnan-tech/fix/manager-api-compiler-warnings
fix(manager-api): 清理编译器告警
2026-07-22 16:22:46 +08:00
wengzhandGitHub e3453b1a87 Merge pull request #3298 from xinnan-tech/py-fix-intent
Py fix intent
2026-07-21 15:16:19 +08:00
Tyke Chen d760465408 fix(manager-api): make JSON collections type-safe 2026-07-21 14:43:25 +08:00
Tyke Chen bdb2538239 fix(manager-api): clean deprecated and generic warnings 2026-07-21 14:43:25 +08:00
CGDandGitHub ea6c144e43 Merge pull request #3294 from xinnan-tech/fix/manager-api-startup-warnings
fix(manager-api): 消除启动阶段告警
2026-07-21 14:37:22 +08:00
CGDandGitHub a79aa455d6 Merge pull request #3292 from xinnan-tech/fix/manager-api-test-i18n
test: 修复 manager-api 国际化测试配置
2026-07-21 14:29:30 +08:00
wengzh 6e92d169ec refactor(get_news_from_newsnow): 改用流式处理解析网页内容
将原有的直接转换响应内容改为流式读取字节流并传入参数,适配MarkItDown的convert_stream接口,优化大内容处理时的内存占用
2026-07-20 15:51:23 +08:00
Sakura-RanChen ed89c05245 fix: intent阻塞主线程 2026-07-20 14:48:26 +08:00
Tyke Chen b500d1c6bd fix(manager-api): avoid duplicate address book insert mapping 2026-07-20 14:46:14 +08:00
Tyke Chen 0598b09629 fix(manager-api): remove invalid logback startup directives 2026-07-20 14:46:14 +08:00
Tyke Chen e4907121f4 fix(manager-api): eliminate premature bean initialization warnings 2026-07-20 14:46:14 +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 2a618d2f8f test: 修复 manager-api 国际化测试配置 2026-07-20 10:34:33 +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
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
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
Tyke Chen aae2ef2152 fix: prevent agent IDOR access 2026-07-08 15:52:47 +08:00
144 changed files with 11413 additions and 2234 deletions
+5
View File
@@ -112,12 +112,17 @@ celerybeat.pid
# Environments # Environments
.env .env
.venv .venv
/.venv-*/
env/ env/
venv/ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# Repository-local runtimes and package-manager caches
/.runtime/
/main/manager-web/.npm-cache/
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
.spyproject .spyproject
+17 -5
View File
@@ -20,7 +20,7 @@
<java.version>21</java.version> <java.version>21</java.version>
<junit.version>5.10.1</junit.version> <junit.version>5.10.1</junit.version>
<druid.version>1.2.20</druid.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> <hutool.version>5.8.24</hutool.version>
<jsoup.version>1.19.1</jsoup.version> <jsoup.version>1.19.1</jsoup.version>
<knife4j.version>4.6.0</knife4j.version> <knife4j.version>4.6.0</knife4j.version>
@@ -33,6 +33,7 @@
<aliyun-sms-version>4.1.0</aliyun-sms-version> <aliyun-sms-version>4.1.0</aliyun-sms-version>
<okio-version>3.4.0</okio-version> <okio-version>3.4.0</okio-version>
<skipTests>true</skipTests> <skipTests>true</skipTests>
<argLine></argLine>
</properties> </properties>
<dependencies> <dependencies>
@@ -176,13 +177,13 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatisplus.version}</version> <version>${mybatisplus.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.mybatis</groupId> <groupId>com.baomidou</groupId>
<artifactId>mybatis-spring</artifactId> <artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
<version>3.0.3</version> <version>${mybatisplus.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>cn.hutool</groupId> <groupId>cn.hutool</groupId>
@@ -272,11 +273,22 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<proc>full</proc>
<compilerArgs>
<arg>-Xlint:deprecation,unchecked</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<configuration> <configuration>
<skipTests>${skipTests}</skipTests> <skipTests>${skipTests}</skipTests>
<argLine>@{argLine} -Xshare:off -javaagent:"${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar"</argLine>
</configuration> </configuration>
</plugin> </plugin>
</plugins> </plugins>
@@ -67,7 +67,7 @@ public class DataFilterInterceptor implements InnerInterceptor {
private String getSelect(String buildSql, DataScope scope) { private String getSelect(String buildSql, DataScope scope) {
try { try {
Select select = (Select) CCJSqlParserUtil.parse(buildSql); Select select = (Select) CCJSqlParserUtil.parse(buildSql);
PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); PlainSelect plainSelect = select.getPlainSelect();
Expression expression = plainSelect.getWhere(); Expression expression = plainSelect.getWhere();
if (expression == null) { if (expression == null) {
@@ -82,4 +82,4 @@ public class DataFilterInterceptor implements InnerInterceptor {
return buildSql; return buildSql;
} }
} }
} }
@@ -5,7 +5,7 @@ import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.BiConsumer; import java.util.function.BiFunction;
import org.apache.ibatis.binding.MapperMethod; import org.apache.ibatis.binding.MapperMethod;
import org.apache.ibatis.logging.Log; import org.apache.ibatis.logging.Log;
@@ -81,8 +81,8 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
// 处理排序字段 // 处理排序字段
if (orderField instanceof String) { if (orderField instanceof String) {
orderFields.add((String) orderField); orderFields.add((String) orderField);
} else if (orderField instanceof List) { } else if (orderField instanceof List<?> fields) {
orderFields.addAll((List<String>) orderField); fields.forEach(field -> orderFields.add(String.class.cast(field)));
} }
// 有排序字段则排序 // 有排序字段则排序
@@ -142,11 +142,12 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
return SqlHelper.retBool(result); return SqlHelper.retBool(result);
} }
protected Class<M> currentMapperClass() { protected Class<?> currentMapperClass() {
return (Class<M>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0); return ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0);
} }
@Override @Override
@SuppressWarnings("unchecked")
public Class<T> currentModelClass() { public Class<T> currentModelClass() {
return (Class<T>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 1); return (Class<T>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 1);
} }
@@ -181,8 +182,9 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
* 执行批量操作 * 执行批量操作
*/ */
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiConsumer<SqlSession, E> consumer) { protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiFunction<SqlSession, E, Integer> operation) {
return SqlHelper.executeBatch(this.currentModelClass(), this.log, list, batchSize, consumer); return SqlHelper.executeBatch(SqlHelper.sqlSessionFactory(this.currentModelClass()), this.log, list, batchSize,
operation);
} }
@Override @Override
@@ -209,7 +211,7 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
return executeBatch(entityList, batchSize, (sqlSession, entity) -> { return executeBatch(entityList, batchSize, (sqlSession, entity) -> {
MapperMethod.ParamMap<T> param = new MapperMethod.ParamMap<>(); MapperMethod.ParamMap<T> param = new MapperMethod.ParamMap<>();
param.put(Constants.ENTITY, entity); param.put(Constants.ENTITY, entity);
sqlSession.update(sqlStatement, param); return sqlSession.update(sqlStatement, param);
}); });
} }
@@ -225,6 +227,6 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
@Override @Override
public boolean deleteBatchIds(Collection<? extends Serializable> idList) { public boolean deleteBatchIds(Collection<? extends Serializable> idList) {
return SqlHelper.retBool(baseDao.deleteBatchIds(idList)); return SqlHelper.retBool(baseDao.deleteByIds(idList));
} }
} }
@@ -24,6 +24,7 @@ import xiaozhi.common.utils.ConvertUtils;
public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends BaseServiceImpl<M, T> public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends BaseServiceImpl<M, T>
implements CrudService<T, D> { implements CrudService<T, D> {
@SuppressWarnings("unchecked")
protected Class<D> currentDtoClass() { protected Class<D> currentDtoClass() {
return (Class<D>) ReflectionKit.getSuperClassGenericType(getClass(), CrudServiceImpl.class, 2); return (Class<D>) ReflectionKit.getSuperClassGenericType(getClass(), CrudServiceImpl.class, 2);
} }
@@ -70,6 +71,6 @@ public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends Bas
@Override @Override
public void delete(Serializable[] ids) { public void delete(Serializable[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids)); baseDao.deleteByIds(Arrays.asList(ids));
} }
} }
@@ -1,7 +1,9 @@
package xiaozhi.common.utils; package xiaozhi.common.utils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -16,6 +18,10 @@ import cn.hutool.core.util.StrUtil;
*/ */
public class JsonUtils { public class JsonUtils {
private static final ObjectMapper objectMapper = new ObjectMapper(); private static final ObjectMapper objectMapper = new ObjectMapper();
private static final TypeReference<Map<String, Object>> STRING_OBJECT_MAP = new TypeReference<>() {
};
private static final TypeReference<List<Map<String, Object>>> STRING_OBJECT_MAP_LIST = new TypeReference<>() {
};
public static String toJsonString(Object object) { public static String toJsonString(Object object) {
try { try {
@@ -67,4 +73,59 @@ public class JsonUtils {
} }
} }
public static Map<String, Object> parseMap(String text) {
if (StrUtil.isEmpty(text)) {
return null;
}
return parseObject(text, STRING_OBJECT_MAP);
}
public static List<Map<String, Object>> parseMapList(String text) {
if (StrUtil.isEmpty(text)) {
return null;
}
return parseObject(text, STRING_OBJECT_MAP_LIST);
}
public static Map<String, Object> toStringObjectMap(Object value) {
if (value == null) {
return null;
}
if (!(value instanceof Map<?, ?> map)) {
throw new ClassCastException("Expected Map but got " + value.getClass().getName());
}
Map<String, Object> result = new LinkedHashMap<>(map.size());
for (Map.Entry<?, ?> entry : map.entrySet()) {
result.put(String.class.cast(entry.getKey()), entry.getValue());
}
return result;
}
public static List<Map<String, Object>> toStringObjectMapList(Object value) {
if (value == null) {
return null;
}
List<?> list = List.class.cast(value);
List<Map<String, Object>> result = new ArrayList<>(list.size());
for (Object item : list) {
result.add(toStringObjectMap(item));
}
return result;
}
public static <T> List<T> toList(Object value, Class<T> elementType) {
if (value == null) {
return null;
}
List<?> list = List.class.cast(value);
List<T> result = new ArrayList<>(list.size());
for (Object item : list) {
result.add(elementType.cast(item));
}
return result;
}
} }
@@ -75,11 +75,11 @@ public class SensitiveDataUtils {
Object value = jsonObject.get(key); Object value = jsonObject.get(key);
if (SENSITIVE_FIELDS.contains(key.toLowerCase()) && value instanceof String) { if (SENSITIVE_FIELDS.contains(key.toLowerCase()) && value instanceof String) {
result.put(key, maskMiddle((String) value)); result.set(key, maskMiddle((String) value));
} else if (value instanceof JSONObject) { } else if (value instanceof JSONObject) {
result.put(key, maskSensitiveFields((JSONObject) value)); result.set(key, maskSensitiveFields((JSONObject) value));
} else { } else {
result.put(key, value); result.set(key, value);
} }
} }
@@ -162,4 +162,4 @@ public class SensitiveDataUtils {
} }
} }
} }
} }
@@ -22,10 +22,10 @@ public class SqlFilter {
return null; return null;
} }
// 去掉'|"|;|\字符 // 去掉'|"|;|\字符
str = StringUtils.replace(str, "'", ""); str = str.replace("'", "");
str = StringUtils.replace(str, "\"", ""); str = str.replace("\"", "");
str = StringUtils.replace(str, ";", ""); str = str.replace(";", "");
str = StringUtils.replace(str, "\\", ""); str = str.replace("\\", "");
// 转换成小写 // 转换成小写
str = str.toLowerCase(); str = str.toLowerCase();
@@ -28,10 +28,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant; import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail; import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
import xiaozhi.common.utils.ResultUtils; import xiaozhi.common.utils.ResultUtils;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO; import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
@@ -52,8 +55,6 @@ import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO; import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
import xiaozhi.modules.agent.vo.AgentInfoVO; import xiaozhi.modules.agent.vo.AgentInfoVO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.security.user.SecurityUser; import xiaozhi.modules.security.user.SecurityUser;
@Tag(name = "智能体管理") @Tag(name = "智能体管理")
@@ -61,15 +62,40 @@ import xiaozhi.modules.security.user.SecurityUser;
@RestController @RestController
@RequestMapping("/agent") @RequestMapping("/agent")
public class AgentController { public class AgentController {
private static final long AUDIO_PLAY_TOKEN_EXPIRE_SECONDS = 300L;
private final AgentService agentService; private final AgentService agentService;
private final AgentTemplateService agentTemplateService; private final AgentTemplateService agentTemplateService;
private final DeviceService deviceService;
private final AgentChatHistoryService agentChatHistoryService; private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService; private final AgentChatAudioService agentChatAudioService;
private final AgentChatSummaryService agentChatSummaryService; private final AgentChatSummaryService agentChatSummaryService;
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
private final AgentTagService agentTagService; private final AgentTagService agentTagService;
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") @GetMapping("/list")
@Operation(summary = "获取用户智能体列表") @Operation(summary = "获取用户智能体列表")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
@@ -100,7 +126,7 @@ public class AgentController {
@Operation(summary = "获取智能体详情") @Operation(summary = "获取智能体详情")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
public Result<AgentInfoVO> getAgentById(@PathVariable("id") String id) { public Result<AgentInfoVO> getAgentById(@PathVariable("id") String id) {
AgentInfoVO agent = agentService.getAgentById(id); AgentInfoVO agent = agentService.getAgentById(id, SecurityUser.getUserId());
return ResultUtils.success(agent); return ResultUtils.success(agent);
} }
@@ -114,20 +140,16 @@ public class AgentController {
@PutMapping("/saveMemory/{macAddress}") @PutMapping("/saveMemory/{macAddress}")
@Operation(summary = "根据设备id更新智能体") @Operation(summary = "根据设备id更新智能体")
@RequiresPermissions("sys:role:normal")
public Result<Void> updateByDeviceId(@PathVariable String macAddress, @RequestBody @Valid AgentMemoryDTO dto) { public Result<Void> updateByDeviceId(@PathVariable String macAddress, @RequestBody @Valid AgentMemoryDTO dto) {
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress); agentService.updateAgentMemoryByDeviceMacAddress(macAddress, dto, SecurityUser.getUserId());
if (device == null) { return new Result<Void>().ok(null);
return new Result<>();
}
AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO();
agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory());
agentService.updateAgentById(device.getAgentId(), agentUpdateDTO, false);
return new Result<>();
} }
@PostMapping("/chat-summary/{sessionId}/save") @PostMapping("/chat-summary/{sessionId}/save")
@Operation(summary = "根据会话ID生成聊天记录总结并保存(异步执行)") @Operation(summary = "根据会话ID生成聊天记录总结并保存(异步执行)")
public Result<Void> generateAndSaveChatSummary(@PathVariable String sessionId) { public Result<Void> generateAndSaveChatSummary(@PathVariable String sessionId) {
requireSessionAgent(sessionId);
try { try {
// 异步执行总结生成任务,立即返回成功响应 // 异步执行总结生成任务,立即返回成功响应
new Thread(() -> { new Thread(() -> {
@@ -149,6 +171,7 @@ public class AgentController {
@PostMapping("/chat-title/{sessionId}/generate") @PostMapping("/chat-title/{sessionId}/generate")
@Operation(summary = "根据会话ID生成聊天标题") @Operation(summary = "根据会话ID生成聊天标题")
public Result<Void> generateAndSaveChatTitle(@PathVariable String sessionId) { public Result<Void> generateAndSaveChatTitle(@PathVariable String sessionId) {
requireSessionAgent(sessionId);
agentChatSummaryService.generateAndSaveChatTitle(sessionId); agentChatSummaryService.generateAndSaveChatTitle(sessionId);
return new Result<Void>().ok(null); return new Result<Void>().ok(null);
} }
@@ -157,7 +180,7 @@ public class AgentController {
@Operation(summary = "更新智能体") @Operation(summary = "更新智能体")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) { public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) {
agentService.updateAgentById(id, dto); agentService.updateAgentById(id, dto, SecurityUser.getUserId());
return new Result<>(); return new Result<>();
} }
@@ -165,7 +188,7 @@ public class AgentController {
@Operation(summary = "删除智能体") @Operation(summary = "删除智能体")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
public Result<Void> delete(@PathVariable String id) { public Result<Void> delete(@PathVariable String id) {
agentService.deleteAgent(id); agentService.deleteAgentById(id, SecurityUser.getUserId());
return new Result<>(); return new Result<>();
} }
@@ -188,6 +211,7 @@ public class AgentController {
public Result<PageData<AgentChatSessionDTO>> getAgentSessions( public Result<PageData<AgentChatSessionDTO>> getAgentSessions(
@PathVariable("id") String id, @PathVariable("id") String id,
@Parameter(hidden = true) @RequestParam Map<String, Object> params) { @Parameter(hidden = true) @RequestParam Map<String, Object> params) {
requireAgentPermission(id);
params.put("agentId", id); params.put("agentId", id);
PageData<AgentChatSessionDTO> page = agentChatHistoryService.getSessionListByAgentId(params); PageData<AgentChatSessionDTO> page = agentChatHistoryService.getSessionListByAgentId(params);
return new Result<PageData<AgentChatSessionDTO>>().ok(page); return new Result<PageData<AgentChatSessionDTO>>().ok(page);
@@ -235,6 +259,7 @@ public class AgentController {
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
public Result<String> getContentByAudioId( public Result<String> getContentByAudioId(
@PathVariable("id") String id) { @PathVariable("id") String id) {
requireAudioPermission(id);
// 查询聊天记录 // 查询聊天记录
String data = agentChatHistoryService.getContentByAudioId(id); String data = agentChatHistoryService.getContentByAudioId(id);
return new Result<String>().ok(data); return new Result<String>().ok(data);
@@ -244,12 +269,13 @@ public class AgentController {
@Operation(summary = "获取音频下载ID") @Operation(summary = "获取音频下载ID")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
public Result<String> getAudioId(@PathVariable("audioId") String audioId) { public Result<String> getAudioId(@PathVariable("audioId") String audioId) {
requireAudioPermission(audioId);
byte[] audioData = agentChatAudioService.getAudio(audioId); byte[] audioData = agentChatAudioService.getAudio(audioId);
if (audioData == null) { if (audioData == null) {
return new Result<String>().error("音频不存在"); return new Result<String>().error("音频不存在");
} }
String uuid = UUID.randomUUID().toString(); 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); return new Result<String>().ok(uuid);
} }
@@ -305,6 +331,7 @@ public class AgentController {
@Operation(summary = "获取智能体的标签") @Operation(summary = "获取智能体的标签")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
public Result<List<AgentTagDTO>> getAgentTags(@PathVariable String id) { public Result<List<AgentTagDTO>> getAgentTags(@PathVariable String id) {
requireAgentPermission(id);
List<AgentTagDTO> tags = agentTagService.getTagsByAgentId(id); List<AgentTagDTO> tags = agentTagService.getTagsByAgentId(id);
return new Result<List<AgentTagDTO>>().ok(tags); return new Result<List<AgentTagDTO>>().ok(tags);
} }
@@ -313,8 +340,9 @@ public class AgentController {
@Operation(summary = "保存智能体的标签") @Operation(summary = "保存智能体的标签")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
public Result<Void> saveAgentTags(@PathVariable String id, @RequestBody Map<String, Object> params) { public Result<Void> saveAgentTags(@PathVariable String id, @RequestBody Map<String, Object> params) {
List<String> tagIds = (List<String>) params.get("tagIds"); requireAgentPermission(id);
List<String> tagNames = (List<String>) params.get("tagNames"); List<String> tagIds = JsonUtils.toList(params.get("tagIds"), String.class);
List<String> tagNames = JsonUtils.toList(params.get("tagNames"), String.class);
AgentUpdateDTO dto = new AgentUpdateDTO(); AgentUpdateDTO dto = new AgentUpdateDTO();
dto.setTagIds(tagIds); dto.setTagIds(tagIds);
dto.setTagNames(tagNames); dto.setTagNames(tagNames);
@@ -6,17 +6,20 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.exception.RenException; import xiaozhi.common.exception.RenException;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.common.user.UserDetail; import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.Result; import xiaozhi.common.utils.Result;
import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO; import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO;
import xiaozhi.modules.agent.dto.AgentSnapshotRestoreDTO;
import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentSnapshotService; import xiaozhi.modules.agent.service.AgentSnapshotService;
import xiaozhi.modules.agent.vo.AgentSnapshotVO; import xiaozhi.modules.agent.vo.AgentSnapshotVO;
@@ -51,9 +54,10 @@ public class AgentSnapshotController {
@PostMapping("/{snapshotId}/restore") @PostMapping("/{snapshotId}/restore")
@Operation(summary = "恢复智能体快照") @Operation(summary = "恢复智能体快照")
@RequiresPermissions("sys:role:normal") @RequiresPermissions("sys:role:normal")
public Result<Void> restore(@PathVariable String agentId, @PathVariable String snapshotId) { public Result<Void> restore(@PathVariable String agentId, @PathVariable String snapshotId,
@RequestBody @Valid AgentSnapshotRestoreDTO request) {
checkPermission(agentId); checkPermission(agentId);
agentSnapshotService.restoreSnapshot(agentId, snapshotId); agentSnapshotService.restoreSnapshot(agentId, snapshotId, request.getCurrentStateToken());
return new Result<>(); return new Result<>();
} }
@@ -43,4 +43,13 @@ public interface AgentDao extends BaseDao<AgentEntity> {
* @param agentId 智能体ID * @param agentId 智能体ID
*/ */
AgentEntity selectByIdForUpdate(@Param("agentId") String agentId); AgentEntity selectByIdForUpdate(@Param("agentId") String agentId);
/**
* 精确写入快照覆盖的智能体字段,包括目标快照中的 null 值。
* 不更新所属用户、创建信息等不属于快照的字段。
*
* @param agent 已应用目标快照的智能体
* @return 受影响行数
*/
int updateSnapshotFields(@Param("agent") AgentEntity agent);
} }
@@ -1,5 +1,7 @@
package xiaozhi.modules.agent.dao; package xiaozhi.modules.agent.dao;
import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@@ -17,4 +19,11 @@ public interface AgentSnapshotDao extends BaseDao<AgentSnapshotEntity> {
int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot); int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot);
int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit); 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);
} }
@@ -39,7 +39,7 @@ public class AgentDTO {
private String systemPrompt; private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" + @Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false) "根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String summaryMemory; private String summaryMemory;
@Schema(description = "最后连接时间", example = "2024-03-20 10:00:00") @Schema(description = "最后连接时间", example = "2024-03-20 10:00:00")
@@ -50,4 +50,4 @@ public class AgentDTO {
@Schema(description = "标签列表") @Schema(description = "标签列表")
private List<AgentTagDTO> tags; private List<AgentTagDTO> tags;
} }
@@ -14,6 +14,6 @@ public class AgentMemoryDTO implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" + @Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false) "根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String summaryMemory; private String summaryMemory;
} }
@@ -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;
}
@@ -39,10 +39,10 @@ public class AgentUpdateDTO implements Serializable {
@Schema(description = "小模型标识", example = "slm_model_02", nullable = true) @Schema(description = "小模型标识", example = "slm_model_02", nullable = true)
private String slmModelId; private String slmModelId;
@Schema(description = "VLLM模型标识", example = "vllm_model_02", required = false) @Schema(description = "VLLM模型标识", example = "vllm_model_02", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String vllmModelId; private String vllmModelId;
@Schema(description = "语音合成模型标识", example = "tts_model_02", required = false) @Schema(description = "语音合成模型标识", example = "tts_model_02", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String ttsModelId; private String ttsModelId;
@Schema(description = "音色标识", example = "voice_02", nullable = true) @Schema(description = "音色标识", example = "voice_02", nullable = true)
@@ -74,7 +74,7 @@ public class AgentEntity {
private String systemPrompt; private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" + @Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false) "根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String summaryMemory; private String summaryMemory;
@Schema(description = "语言编码") @Schema(description = "语言编码")
@@ -97,4 +97,4 @@ public class AgentEntity {
@Schema(description = "更新时间") @Schema(description = "更新时间")
private Date updatedAt; private Date updatedAt;
} }
@@ -47,4 +47,7 @@ public class AgentSnapshotEntity {
@Schema(description = "创建时间") @Schema(description = "创建时间")
private Date createdAt; private Date createdAt;
@Schema(description = "快照数据脱敏规则版本")
private Integer redactionVersion;
} }
@@ -1,6 +1,6 @@
package xiaozhi.modules.agent.service; 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; import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
@@ -11,7 +11,7 @@ import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
* @version 1.0, 2025/5/8 * @version 1.0, 2025/5/8
* @since 1.0.0 * @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.List;
import java.util.Map; import java.util.Map;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.repository.IRepository;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO; import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
import xiaozhi.modules.agent.dto.AgentChatSessionDTO; import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity; import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
@@ -18,7 +18,7 @@ import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
* @version 1.0, 2025/4/30 * @version 1.0, 2025/4/30
* @since 1.0.0 * @since 1.0.0
*/ */
public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity> { public interface AgentChatHistoryService extends IRepository<AgentChatHistoryEntity> {
/** /**
* 根据智能体ID获取会话列表 * 根据智能体ID获取会话列表
@@ -37,6 +37,14 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
*/ */
List<AgentChatHistoryDTO> getChatHistoryBySessionId(String agentId, String sessionId); List<AgentChatHistoryDTO> getChatHistoryBySessionId(String agentId, String sessionId);
/**
* 根据会话ID获取智能体ID
*
* @param sessionId 会话ID
* @return 智能体ID
*/
String getAgentIdBySessionId(String sessionId);
/** /**
* 根据智能体ID删除聊天记录 * 根据智能体ID删除聊天记录
* *
@@ -62,6 +70,14 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
*/ */
String getContentByAudioId(String audioId); String getContentByAudioId(String audioId);
/**
* 根据音频ID获取智能体ID
*
* @param audioId 音频ID
* @return 智能体ID
*/
String getAgentIdByAudioId(String audioId);
/** /**
* 查询此音频id是否属于此智能体 * 查询此音频id是否属于此智能体
@@ -2,7 +2,7 @@ package xiaozhi.modules.agent.service;
import java.util.List; import java.util.List;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.repository.IRepository;
import xiaozhi.modules.agent.entity.AgentPluginMapping; import xiaozhi.modules.agent.entity.AgentPluginMapping;
@@ -10,7 +10,7 @@ import xiaozhi.modules.agent.entity.AgentPluginMapping;
* @description 针对表【ai_agent_plugin_mapping(Agent与插件的唯一映射表)】的数据库操作Service * @description 针对表【ai_agent_plugin_mapping(Agent与插件的唯一映射表)】的数据库操作Service
* @createDate 2025-05-25 22:33:17 * @createDate 2025-05-25 22:33:17
*/ */
public interface AgentPluginMappingService extends IService<AgentPluginMapping> { public interface AgentPluginMappingService extends IRepository<AgentPluginMapping> {
/** /**
* 根据智能体id获取插件参数 * 根据智能体id获取插件参数
@@ -7,6 +7,7 @@ import xiaozhi.common.page.PageData;
import xiaozhi.common.service.BaseService; import xiaozhi.common.service.BaseService;
import xiaozhi.modules.agent.dto.AgentCreateDTO; import xiaozhi.modules.agent.dto.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO; import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.vo.AgentInfoVO; import xiaozhi.modules.agent.vo.AgentInfoVO;
@@ -35,6 +36,15 @@ public interface AgentService extends BaseService<AgentEntity> {
*/ */
AgentInfoVO getAgentById(String id); AgentInfoVO getAgentById(String id);
/**
* 根据ID获取当前用户有权访问的智能体
*
* @param id 智能体ID
* @param userId 当前用户ID
* @return 智能体实体
*/
AgentInfoVO getAgentById(String id, Long userId);
/** /**
* 插入智能体 * 插入智能体
* *
@@ -100,6 +110,32 @@ public interface AgentService extends BaseService<AgentEntity> {
*/ */
void updateAgentById(String agentId, AgentUpdateDTO dto); 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);
/** /**
* 更新智能体 * 更新智能体
* *
@@ -13,11 +13,13 @@ public interface AgentSnapshotService extends BaseService<AgentSnapshotEntity> {
AgentSnapshotVO getSnapshot(String agentId, String snapshotId); AgentSnapshotVO getSnapshot(String agentId, String snapshotId);
void restoreSnapshot(String agentId, String snapshotId); void restoreSnapshot(String agentId, String snapshotId, String currentStateToken);
void deleteSnapshot(String agentId, String snapshotId); void deleteSnapshot(String agentId, String snapshotId);
Integer getCurrentVersionNo(String agentId); Integer getCurrentVersionNo(String agentId);
void deleteByAgentId(String agentId); void deleteByAgentId(String agentId);
long redactLegacySnapshots();
} }
@@ -1,7 +1,7 @@
package xiaozhi.modules.agent.service; 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; import xiaozhi.modules.agent.entity.AgentTemplateEntity;
/** /**
@@ -9,7 +9,7 @@ import xiaozhi.modules.agent.entity.AgentTemplateEntity;
* @description 针对表【ai_agent_template(智能体配置模板表)】的数据库操作Service * @description 针对表【ai_agent_template(智能体配置模板表)】的数据库操作Service
* @createDate 2025-03-22 11:48:18 * @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 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.dao.AiAgentChatAudioDao;
import xiaozhi.modules.agent.entity.AgentChatAudioEntity; import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
@@ -16,7 +16,7 @@ import xiaozhi.modules.agent.service.AgentChatAudioService;
* @since 1.0.0 * @since 1.0.0
*/ */
@Service @Service
public class AgentChatAudioServiceImpl extends ServiceImpl<AiAgentChatAudioDao, AgentChatAudioEntity> public class AgentChatAudioServiceImpl extends CrudRepository<AiAgentChatAudioDao, AgentChatAudioEntity>
implements AgentChatAudioService { implements AgentChatAudioService {
@Override @Override
public String saveAudio(byte[] audioData) { 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.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 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.constant.Constant;
import xiaozhi.common.page.PageData; import xiaozhi.common.page.PageData;
@@ -39,7 +39,7 @@ import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryDao, AgentChatHistoryEntity> public class AgentChatHistoryServiceImpl extends CrudRepository<AiAgentChatHistoryDao, AgentChatHistoryEntity>
implements AgentChatHistoryService { implements AgentChatHistoryService {
private final AgentChatTitleService agentChatTitleService; private final AgentChatTitleService agentChatTitleService;
@@ -88,6 +88,19 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
return ConvertUtils.sourceToTarget(historyList, AgentChatHistoryDTO.class); 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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) { public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
@@ -154,7 +167,7 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
// 尝试解析为 JSON // 尝试解析为 JSON
try { try {
Map<String, Object> jsonMap = JsonUtils.parseObject(content, Map.class); Map<String, Object> jsonMap = JsonUtils.parseMap(content);
if (jsonMap != null && jsonMap.containsKey("content")) { if (jsonMap != null && jsonMap.containsKey("content")) {
Object contentObj = jsonMap.get("content"); Object contentObj = jsonMap.get("content");
return contentObj != null ? contentObj.toString() : content; return contentObj != null ? contentObj.toString() : content;
@@ -176,6 +189,19 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
return agentChatHistoryEntity == null ? null : agentChatHistoryEntity.getContent(); 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 @Override
public boolean isAudioOwnedByAgent(String audioId, String agentId) { public boolean isAudioOwnedByAgent(String audioId, String agentId) {
// 查询是否有指定音频id和智能体id的数据,如果有且只有一条说明此数据属性此智能体 // 查询是否有指定音频id和智能体id的数据,如果有且只有一条说明此数据属性此智能体
@@ -76,7 +76,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 等待初始化响应 (id=1) - 移除固定延迟,改为响应驱动 // 等待初始化响应 (id=1) - 移除固定延迟,改为响应驱动
List<String> initResponses = client.listenerWithoutClose(response -> { List<String> initResponses = client.listenerWithoutClose(response -> {
try { try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class); Map<String, Object> jsonMap = JsonUtils.parseMap(response);
if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) { if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
// 检查是否有result字段,表示初始化成功 // 检查是否有result字段,表示初始化成功
return jsonMap.containsKey("result") && !jsonMap.containsKey("error"); return jsonMap.containsKey("result") && !jsonMap.containsKey("error");
@@ -92,7 +92,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
boolean initSucceeded = false; boolean initSucceeded = false;
for (String response : initResponses) { for (String response : initResponses) {
try { try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class); Map<String, Object> jsonMap = JsonUtils.parseMap(response);
if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) { if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
if (jsonMap.containsKey("result")) { if (jsonMap.containsKey("result")) {
log.info("MCP初始化成功,智能体ID: {}", id); log.info("MCP初始化成功,智能体ID: {}", id);
@@ -123,7 +123,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 等待工具列表响应 (id=2) // 等待工具列表响应 (id=2)
List<String> toolsResponses = client.listener(response -> { List<String> toolsResponses = client.listener(response -> {
try { try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class); Map<String, Object> jsonMap = JsonUtils.parseMap(response);
return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id")); return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"));
} catch (Exception e) { } catch (Exception e) {
log.warn("解析工具列表响应失败: {}", response, e); log.warn("解析工具列表响应失败: {}", response, e);
@@ -134,18 +134,18 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 处理工具列表响应 // 处理工具列表响应
for (String response : toolsResponses) { for (String response : toolsResponses) {
try { try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class); Map<String, Object> jsonMap = JsonUtils.parseMap(response);
if (jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"))) { if (jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"))) {
// 检查是否有result字段 // 检查是否有result字段
Object resultObj = jsonMap.get("result"); Object resultObj = jsonMap.get("result");
if (resultObj instanceof Map) { if (resultObj instanceof Map<?, ?>) {
Map<String, Object> resultMap = (Map<String, Object>) resultObj; Map<String, Object> resultMap = JsonUtils.toStringObjectMap(resultObj);
Object toolsObj = resultMap.get("tools"); Object toolsObj = resultMap.get("tools");
if (toolsObj instanceof List) { if (toolsObj instanceof List<?>) {
List<Map<String, Object>> toolsList = (List<Map<String, Object>>) toolsObj; List<Map<String, Object>> toolsList = JsonUtils.toStringObjectMapList(toolsObj);
// 提取工具名称列表 // 提取工具名称列表
List<String> result = toolsList.stream() List<String> result = toolsList.stream()
.map(tool -> (String) tool.get("name")) .map(tool -> String.class.cast(tool.get("name")))
.filter(name -> name != null) .filter(name -> name != null)
.sorted() .sorted()
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -232,4 +232,4 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 加密后成token值 // 加密后成token值
return AESUtils.encrypt(key, json); return AESUtils.encrypt(key, json);
} }
} }
@@ -9,7 +9,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -29,7 +29,7 @@ import xiaozhi.modules.model.service.ModelConfigService;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class AgentPluginMappingServiceImpl extends ServiceImpl<AgentPluginMappingMapper, AgentPluginMapping> public class AgentPluginMappingServiceImpl extends CrudRepository<AgentPluginMappingMapper, AgentPluginMapping>
implements AgentPluginMappingService { implements AgentPluginMappingService {
private final AgentPluginMappingMapper agentPluginMappingMapper; private final AgentPluginMappingMapper agentPluginMappingMapper;
private final KnowledgeBaseService knowledgeBaseService; private final KnowledgeBaseService knowledgeBaseService;
@@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import xiaozhi.common.constant.Constant; import xiaozhi.common.constant.Constant;
@@ -33,6 +34,7 @@ import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.agent.dao.AgentTagDao; import xiaozhi.modules.agent.dao.AgentTagDao;
import xiaozhi.modules.agent.dto.AgentCreateDTO; import xiaozhi.modules.agent.dto.AgentCreateDTO;
import xiaozhi.modules.agent.dto.AgentDTO; import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentTagDTO; import xiaozhi.modules.agent.dto.AgentTagDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO; import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentContextProviderEntity; import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
@@ -56,9 +58,9 @@ import xiaozhi.modules.model.dto.VoiceDTO;
import xiaozhi.modules.model.entity.ModelConfigEntity; import xiaozhi.modules.model.entity.ModelConfigEntity;
import xiaozhi.modules.model.service.ModelConfigService; import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.modules.model.service.ModelProviderService; import xiaozhi.modules.model.service.ModelProviderService;
import xiaozhi.modules.security.user.SecurityUser; import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.enums.SuperAdminEnum; import xiaozhi.modules.sys.enums.SuperAdminEnum;
import xiaozhi.modules.timbre.service.TimbreService; import xiaozhi.modules.timbre.service.TimbreService;
@Service @Service
@AllArgsConstructor @AllArgsConstructor
@@ -93,6 +95,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
if (agent == null) { if (agent == null) {
throw new RenException(ErrorCode.AGENT_NOT_FOUND); throw new RenException(ErrorCode.AGENT_NOT_FOUND);
} }
requireCurrentUserPermissionIfPresent(agent);
if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) { if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode()); agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode());
@@ -116,6 +119,65 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
return agent; 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 @Override
public boolean insert(AgentEntity entity) { public boolean insert(AgentEntity entity) {
// 如果ID为空,自动生成一个UUID作为ID // 如果ID为空,自动生成一个UUID作为ID
@@ -275,25 +337,11 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
} }
@Override @Override
public boolean checkAgentPermission(String agentId, Long userId) { public boolean checkAgentPermission(String agentId, Long userId) {
if (SecurityUser.getUser() == null || SecurityUser.getUser().getId() == null) { AgentEntity agent = agentDao.selectById(agentId);
return false; return hasAgentPermission(agent, userId);
} }
// 获取智能体信息
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更新智能体信息 // 根据id更新智能体信息
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -301,27 +349,44 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
updateAgentById(agentId, dto, true); updateAgentById(agentId, dto, true);
} }
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId) {
updateAgentById(agentId, dto, userId, true);
}
// 根据id更新智能体信息 // 根据id更新智能体信息
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void updateAgentById(String agentId, AgentUpdateDTO dto, boolean createSnapshot) { public void updateAgentById(String agentId, AgentUpdateDTO dto, boolean createSnapshot) {
if (agentDao.selectByIdForUpdate(agentId) == null) { 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); throw new RenException(ErrorCode.AGENT_NOT_FOUND);
} }
if (userId == null) {
requireCurrentUserPermissionIfPresent(lockedAgent);
} else {
requireAgentPermission(lockedAgent, userId);
}
// 锁定后查询现有实体和关联配置 // 锁定后查询现有实体和关联配置
AgentEntity existingEntity = this.getAgentById(agentId); AgentEntity existingEntity = this.getAgentById(agentId);
if (createSnapshot && agentSnapshotService.getCurrentVersionNo(agentId) == 0) { if (createSnapshot) {
agentSnapshotService.createSnapshot(agentId, "initial"); int currentVersionNo = agentSnapshotService.getCurrentVersionNo(agentId);
agentSnapshotService.createSnapshot(agentId, currentVersionNo == 0 ? "initial" : "current");
} }
// 只更新提供的非空字段 // 只更新提供的非空字段
if (dto.getAgentName() != null) { if (dto.getAgentName() != null) {
existingEntity.setAgentName(dto.getAgentName()); existingEntity.setAgentName(dto.getAgentName());
} }
if (dto.getAgentCode() != null) { if (dto.getAgentCode() != null) {
existingEntity.setAgentCode(dto.getAgentCode()); existingEntity.setAgentCode(dto.getAgentCode());
} }
if (dto.getAsrModelId() != null) { if (dto.getAsrModelId() != null) {
existingEntity.setAsrModelId(dto.getAsrModelId()); existingEntity.setAsrModelId(dto.getAsrModelId());
} }
@@ -418,10 +483,10 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
.toList(); .toList();
if (!toUpdate.isEmpty()) { if (!toUpdate.isEmpty()) {
agentPluginMappingService.updateBatchById(toUpdate); agentPluginMappingService.updateBatchById(toUpdate, IRepository.DEFAULT_BATCH_SIZE);
} }
if (!toInsert.isEmpty()) { if (!toInsert.isEmpty()) {
agentPluginMappingService.saveBatch(toInsert); agentPluginMappingService.saveBatch(toInsert, IRepository.DEFAULT_BATCH_SIZE);
} }
// 5. 删除本次不在提交列表里的插件映射 // 5. 删除本次不在提交列表里的插件映射
@@ -430,7 +495,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
.map(AgentPluginMapping::getId) .map(AgentPluginMapping::getId)
.toList(); .toList();
if (!toDelete.isEmpty()) { if (!toDelete.isEmpty()) {
agentPluginMappingService.removeBatchByIds(toDelete); agentPluginMappingService.removeByIds(toDelete);
} }
} }
@@ -478,6 +543,29 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
} }
} }
@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);
}
/** /**
* 验证大语言模型和意图识别的参数是否符合匹配 * 验证大语言模型和意图识别的参数是否符合匹配
* *
@@ -528,16 +616,22 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
template.setTtsVoiceId(timbre.getId()); template.setTtsVoiceId(timbre.getId());
} }
} }
} }
entity.setTtsVoiceId(template.getTtsVoiceId()); entity.setTtsVoiceId(template.getTtsVoiceId());
entity.setMemModelId(template.getMemModelId()); entity.setTtsLanguage(defaultIfBlank(template.getTtsLanguage(),
entity.setIntentModelId(template.getIntentModelId()); timbreModelService.getDefaultLanguageById(entity.getTtsVoiceId())));
entity.setSystemPrompt(template.getSystemPrompt()); entity.setMemModelId(template.getMemModelId());
entity.setSummaryMemory(template.getSummaryMemory()); entity.setIntentModelId(template.getIntentModelId());
entity.setSystemPrompt(template.getSystemPrompt());
// 根据记忆模型类型设置默认的chatHistoryConf值 entity.setSummaryMemory(template.getSummaryMemory());
if (template.getMemModelId() != null) { 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")) { if (template.getMemModelId().equals("Memory_nomem")) {
// 无记忆功能的模型,默认不记录聊天记录 // 无记忆功能的模型,默认不记录聊天记录
entity.setChatHistoryConf(0); entity.setChatHistoryConf(0);
@@ -583,7 +677,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
mapping.setPluginId(pluginId); mapping.setPluginId(pluginId);
Map<String, Object> paramInfo = new HashMap<>(); Map<String, Object> paramInfo = new HashMap<>();
List<Map<String, Object>> fields = JsonUtils.parseObject(provider.getFields(), List.class); List<Map<String, Object>> fields = JsonUtils.parseMapList(provider.getFields());
if (fields != null) { if (fields != null) {
for (Map<String, Object> field : fields) { for (Map<String, Object> field : fields) {
paramInfo.put((String) field.get("key"), field.get("default")); paramInfo.put((String) field.get("key"), field.get("default"));
@@ -592,13 +686,18 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
mapping.setParamInfo(JsonUtils.toJsonString(paramInfo)); mapping.setParamInfo(JsonUtils.toJsonString(paramInfo));
mapping.setAgentId(entity.getId()); mapping.setAgentId(entity.getId());
toInsert.add(mapping); toInsert.add(mapping);
} }
// 保存默认插件 // 保存默认插件
agentPluginMappingService.saveBatch(toInsert); agentPluginMappingService.saveBatch(toInsert, IRepository.DEFAULT_BATCH_SIZE);
return entity.getId(); agentSnapshotService.createSnapshot(entity.getId(), "initial");
} return entity.getId();
}
private String getDefaultLLMModelId() {
private String defaultIfBlank(String value, String defaultValue) {
return StringUtils.isBlank(value) ? defaultValue : value;
}
private String getDefaultLLMModelId() {
try { try {
List<ModelConfigEntity> llmConfigs = modelConfigService.getEnabledModelsByType("LLM"); List<ModelConfigEntity> llmConfigs = modelConfigService.getEnabledModelsByType("LLM");
if (llmConfigs == null || llmConfigs.isEmpty()) { if (llmConfigs == null || llmConfigs.isEmpty()) {
@@ -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;
}
}
}
@@ -133,7 +133,7 @@ public class AgentTagServiceImpl extends BaseServiceImpl<AgentTagDao, AgentTagEn
} }
if (tagIds != null && !tagIds.isEmpty()) { if (tagIds != null && !tagIds.isEmpty()) {
List<AgentTagEntity> tagIdEntities = baseDao.selectBatchIds(tagIds); List<AgentTagEntity> tagIdEntities = baseDao.selectByIds(tagIds);
for (AgentTagEntity tag : tagIdEntities) { for (AgentTagEntity tag : tagIdEntities) {
if (tag != null && (currentTagNames.contains(tag.getTagName()) || if (tag != null && (currentTagNames.contains(tag.getTagName()) ||
newTagNames.contains(tag.getTagName()))) { newTagNames.contains(tag.getTagName()))) {
@@ -4,8 +4,8 @@ import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; 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.dao.AgentTemplateDao;
import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.service.AgentTemplateService; 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 * @createDate 2025-03-22 11:48:18
*/ */
@Service @Service
public class AgentTemplateServiceImpl extends ServiceImpl<AgentTemplateDao, AgentTemplateEntity> public class AgentTemplateServiceImpl extends CrudRepository<AgentTemplateDao, AgentTemplateEntity>
implements AgentTemplateService { implements AgentTemplateService {
/** /**
@@ -22,7 +22,7 @@ import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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 lombok.extern.slf4j.Slf4j;
import xiaozhi.common.constant.Constant; import xiaozhi.common.constant.Constant;
@@ -46,7 +46,7 @@ import xiaozhi.modules.sys.service.SysParamsService;
*/ */
@Service @Service
@Slf4j @Slf4j
public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao, AgentVoicePrintEntity> public class AgentVoicePrintServiceImpl extends CrudRepository<AgentVoicePrintDao, AgentVoicePrintEntity>
implements AgentVoicePrintService { implements AgentVoicePrintService {
private final AgentChatAudioService agentChatAudioService; private final AgentChatAudioService agentChatAudioService;
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
@@ -1,31 +1,59 @@
package xiaozhi.modules.agent.typehandler; 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.Collections;
import java.util.List; import java.util.List;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import xiaozhi.common.utils.JsonUtils; import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.agent.dto.ContextProviderDTO; import xiaozhi.modules.agent.dto.ContextProviderDTO;
public class ContextProviderListTypeHandler extends AbstractJsonTypeHandler<List<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<>() { private static final TypeReference<List<ContextProviderDTO>> CONTEXT_PROVIDER_LIST_TYPE = new TypeReference<>() {
}; };
@Override @Override
protected List<ContextProviderDTO> parse(String json) { 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)) { if (StringUtils.isBlank(json)) {
return Collections.emptyList(); return null;
} }
List<ContextProviderDTO> providers = JsonUtils.parseObject(json, CONTEXT_PROVIDER_LIST_TYPE); List<ContextProviderDTO> providers = JsonUtils.parseObject(json, CONTEXT_PROVIDER_LIST_TYPE);
return providers == null ? Collections.emptyList() : providers; return providers == null ? Collections.emptyList() : providers;
} }
@Override
protected String toJson(List<ContextProviderDTO> obj) {
return JsonUtils.toJsonString(obj == null ? Collections.emptyList() : obj);
}
} }
@@ -18,13 +18,17 @@ public class AgentSnapshotVO {
private List<String> changedFields; private List<String> changedFields;
private List<String> fieldOrder; private List<String> fieldOrder;
private String source; private String source;
@Schema(description = "恢复来源快照ID,仅恢复前自动备份版本有值") @Schema(description = "恢复来源快照ID,仅恢复结果版本有值")
private String restoreFromSnapshotId; private String restoreFromSnapshotId;
@Schema(description = "恢复来源版本号,仅恢复前自动备份版本有值") @Schema(description = "恢复来源版本号,仅恢复结果版本有值")
private Integer restoreFromVersionNo; private Integer restoreFromVersionNo;
@Schema(description = "创建者,表示触发本次快照写入的操作人") @Schema(description = "创建者,表示触发本次快照写入的操作人")
private Long creator; private Long creator;
private Date createdAt; private Date createdAt;
private AgentSnapshotDataDTO snapshotData; private AgentSnapshotDataDTO snapshotData;
private AgentSnapshotDataDTO afterSnapshotData; private AgentSnapshotDataDTO afterSnapshotData;
@Schema(description = "恢复预览对应的脱敏当前配置,仅详情接口有值")
private AgentSnapshotDataDTO currentSnapshotData;
@Schema(description = "恢复预览对应的当前配置状态指纹,仅详情接口有值")
private String currentStateToken;
} }
@@ -10,7 +10,7 @@ public interface ConfigService {
* @param isCache 是否缓存 * @param isCache 是否缓存
* @return 配置信息 * @return 配置信息
*/ */
Object getConfig(Boolean isCache); Map<String, Object> getConfig(Boolean isCache);
/** /**
* 获取智能体模型配置 * 获取智能体模型配置
@@ -28,4 +28,4 @@ public interface ConfigService {
* @return 替换词列表,格式如 ["模板1|模板01", "模板2|模板02"] * @return 替换词列表,格式如 ["模板1|模板01", "模板2|模板02"]
*/ */
List<String> getCorrectWords(String macAddress); List<String> getCorrectWords(String macAddress);
} }
@@ -65,12 +65,12 @@ public class ConfigServiceImpl implements ConfigService {
private final CorrectWordFileService correctWordFileService; private final CorrectWordFileService correctWordFileService;
@Override @Override
public Object getConfig(Boolean isCache) { public Map<String, Object> getConfig(Boolean isCache) {
if (isCache) { if (isCache) {
// 先从Redis获取配置 // 先从Redis获取配置
Object cachedConfig = redisUtils.get(RedisKeys.getServerConfigKey()); Object cachedConfig = redisUtils.get(RedisKeys.getServerConfigKey());
if (cachedConfig != null) { if (cachedConfig != null) {
return cachedConfig; return JsonUtils.toStringObjectMap(cachedConfig);
} }
} }
@@ -123,7 +123,7 @@ public class ConfigServiceImpl implements ConfigService {
if (isAdminRequest != null && "true".equals(isAdminRequest)) { if (isAdminRequest != null && "true".equals(isAdminRequest)) {
// 管理控制台请求,返回getConfig的结果 // 管理控制台请求,返回getConfig的结果
redisUtils.delete(redisKey); // 使用后清理 redisUtils.delete(redisKey); // 使用后清理
return (Map<String, Object>) getConfig(true); return getConfig(true);
} }
// 根据MAC地址查找设备 // 根据MAC地址查找设备
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress); DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
@@ -277,10 +277,10 @@ public class ConfigServiceImpl implements ConfigService {
// 遍历除最后一个key之外的所有key // 遍历除最后一个key之外的所有key
for (int i = 0; i < keys.length - 1; i++) { for (int i = 0; i < keys.length - 1; i++) {
String key = keys[i]; String key = keys[i];
if (!current.containsKey(key)) { Object nestedConfig = current.computeIfAbsent(key, ignored -> new HashMap<String, Object>());
current.put(key, new HashMap<String, Object>()); Map<String, Object> nestedMap = JsonUtils.toStringObjectMap(nestedConfig);
} current.put(key, nestedMap);
current = (Map<String, Object>) current.get(key); current = nestedMap;
} }
// 处理最后一个key // 处理最后一个key
@@ -12,6 +12,11 @@ import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
@Mapper @Mapper
public interface DeviceAddressBookDao extends BaseMapper<DeviceAddressBookEntity> { public interface DeviceAddressBookDao extends BaseMapper<DeviceAddressBookEntity> {
/**
* 新增设备通讯录记录
*/
int insertAddressBook(DeviceAddressBookEntity entity);
/** /**
* 获取设备通讯录列表 * 获取设备通讯录列表
*/ */
@@ -31,4 +36,4 @@ public interface DeviceAddressBookDao extends BaseMapper<DeviceAddressBookEntity
* 批量删除设备相关的通讯录记录 * 批量删除设备相关的通讯录记录
*/ */
void deleteByMacAddresses(@Param("macAddresses") List<String> macAddresses); void deleteByMacAddresses(@Param("macAddresses") List<String> macAddresses);
} }
@@ -1,11 +1,10 @@
package xiaozhi.modules.device.service.impl; package xiaozhi.modules.device.service.impl;
import java.util.Date; import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.nio.charset.StandardCharsets;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
@@ -13,8 +12,8 @@ import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import cn.hutool.http.HttpRequest;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.redis.RedisKeys; import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.device.dao.DeviceAddressBookDao; import xiaozhi.modules.device.dao.DeviceAddressBookDao;
@@ -168,7 +167,7 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
alias = generateUniqueAlias(macAddress, targetMac, alias); alias = generateUniqueAlias(macAddress, targetMac, alias);
entity.setAlias(alias); entity.setAlias(alias);
entity.setHasPermission(hasPermission); entity.setHasPermission(hasPermission);
deviceAddressBookDao.insert(entity); deviceAddressBookDao.insertAddressBook(entity);
} else { } else {
if (alias != null) { if (alias != null) {
updateAlias(macAddress, targetMac, alias); updateAlias(macAddress, targetMac, alias);
@@ -201,36 +200,22 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
result.put("status", "error"); result.put("status", "error");
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true); 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) if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)
|| StringUtils.isBlank(mqttSignatureKey) || "null".equals(mqttSignatureKey)) { || MqttGatewayAuthorization.isMissingSignatureKey(mqttSignatureKey)) {
result.put("message", action + "失败,网关配置缺失"); result.put("message", action + "失败,网关配置缺失");
return result; return result;
} }
String dateStr = new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date());
try { 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 url = "http://" + mqttGatewayUrl + path;
String response = HttpRequest.post(url) String response = MqttGatewayAuthorization.postJson(
.header("Authorization", "Bearer " + token) url,
.header("Content-Type", "application/json") JSONUtil.toJsonStr(body),
.body(JSONUtil.toJsonStr(body)) mqttSignatureKey,
.timeout(5000) Instant.now(),
.execute() 5000);
.body();
if (StringUtils.isNotBlank(response)) { if (StringUtils.isNotBlank(response)) {
Map<String, Object> gwResult = JSONUtil.parseObj(response); Map<String, Object> gwResult = JSONUtil.parseObj(response);
@@ -270,4 +255,4 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
} }
return newAlias; 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.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; 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.map.MapUtil;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil; 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.JSONArray;
import cn.hutool.json.JSONObject; import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
@@ -56,6 +50,7 @@ import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.user.UserDetail; import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.DateUtils; import xiaozhi.common.utils.DateUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil; import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.device.dao.DeviceDao; import xiaozhi.modules.device.dao.DeviceDao;
import xiaozhi.modules.device.dto.DeviceManualAddDTO; import xiaozhi.modules.device.dto.DeviceManualAddDTO;
@@ -115,7 +110,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
String deviceId = (String) cacheDeviceId; String deviceId = (String) cacheDeviceId;
String safeDeviceId = deviceId.replace(":", "_").toLowerCase(); String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String cacheDeviceKey = RedisKeys.getOtaDeviceActivationInfo(safeDeviceId); String cacheDeviceKey = RedisKeys.getOtaDeviceActivationInfo(safeDeviceId);
Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(cacheDeviceKey); Map<String, Object> cacheMap = JsonUtils.toStringObjectMap(redisUtils.get(cacheDeviceKey));
if (ToolUtil.isEmpty(cacheMap)) { if (ToolUtil.isEmpty(cacheMap)) {
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR); throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
} }
@@ -187,14 +182,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.put("clientIds", deviceIds).build(); .put("clientIds", deviceIds).build();
if (ToolUtil.isNotEmpty(deviceIds)) { if (ToolUtil.isNotEmpty(deviceIds)) {
// 发送请求 return postToMqttGateway(url, params);
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 ""; return "";
@@ -304,15 +292,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
@Override @Override
public List<UserShowDeviceListVO> getUserDeviceList(Long userId, String agentId) { public List<UserShowDeviceListVO> getUserDeviceList(Long userId, String agentId) {
List<DeviceEntity> devices = getUserDevices(userId, agentId); List<DeviceEntity> devices = getUserDevices(userId, agentId);
return devices.stream().map(device -> { return devices.stream().map(this::toUserShowDeviceListVO).toList();
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class); }
vo.setDeviceType(device.getBoard());
// 设置UTC时间戳供前端使用时区转换 private UserShowDeviceListVO toUserShowDeviceListVO(DeviceEntity device) {
if (device.getLastConnectedAt() != null) { UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime()); vo.setDeviceType(device.getBoard());
} vo.setBoard(device.getBoard());
return vo; vo.setCreateDateTimestamp(toTimestamp(device.getCreateDate()));
}).toList(); vo.setLastConnectedAtTimestamp(toTimestamp(device.getLastConnectedAt()));
return vo;
}
private Long toTimestamp(Date date) {
return date == null ? null : date.getTime();
} }
@Override @Override
@@ -385,17 +378,11 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.like(StringUtils.isNotBlank(dto.getKeywords()), "alias", dto.getKeywords())); .like(StringUtils.isNotBlank(dto.getKeywords()), "alias", dto.getKeywords()));
// 循环处理page获取回来的数据,返回需要的字段 // 循环处理page获取回来的数据,返回需要的字段
List<UserShowDeviceListVO> list = page.getRecords().stream().map(device -> { 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())); vo.setRecentChatTime(DateUtils.getShortTime(device.getUpdateDate()));
sysUserUtilService.assignUsername(device.getUserId(), sysUserUtilService.assignUsername(device.getUserId(),
vo::setBindUserName); vo::setBindUserName);
vo.setDeviceType(device.getBoard());
vo.setBoard(device.getBoard());
// 设置UTC时间戳供前端使用时区转换
if (device.getLastConnectedAt() != null) {
vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime());
}
return vo; return vo;
}).toList(); }).toList();
// 计算页数 // 计算页数
@@ -425,7 +412,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
public String geCodeByDeviceId(String deviceId) { public String geCodeByDeviceId(String deviceId) {
String dataKey = getDeviceCacheKey(deviceId); String dataKey = getDeviceCacheKey(deviceId);
Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(dataKey); Map<String, Object> cacheMap = JsonUtils.toStringObjectMap(redisUtils.get(dataKey));
if (cacheMap != null && cacheMap.containsKey("activation_code")) { if (cacheMap != null && cacheMap.containsKey("activation_code")) {
String cachedCode = (String) cacheMap.get("activation_code"); String cachedCode = (String) cacheMap.get("activation_code");
return cachedCode; return cachedCode;
@@ -699,20 +686,13 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
return mqtt; return mqtt;
} }
/** private String postToMqttGateway(String url, Object requestBody) {
* 生成BearerToken String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false);
*/ return MqttGatewayAuthorization.postJson(
private String generateBearerToken() { url,
try { JSONUtil.toJsonStr(requestBody),
String dateStr = DateUtil.format(new Date(), DatePattern.NORM_DATE_PATTERN); signatureKey,
String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false); Instant.now());
if (ToolUtil.isEmpty(signatureKey)) {
return null;
}
return DigestUtil.sha256Hex(dateStr + signatureKey);
} catch (Exception e) {
return null;
}
} }
@Override @Override
@@ -773,13 +753,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.put("payload", payload) .put("payload", payload)
.build(); .build();
// 发送请求 String resultMessage = postToMqttGateway(url, requestBody);
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();
// 解析响应 // 解析响应
if (StringUtils.isBlank(resultMessage)) { if (StringUtils.isBlank(resultMessage)) {
@@ -870,13 +844,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.put("payload", payload) .put("payload", payload)
.build(); .build();
// 发送请求 String resultMessage = postToMqttGateway(url, requestBody);
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();
// 解析响应 // 解析响应
if (StringUtils.isNotBlank(resultMessage)) { 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;
}
}
}
@@ -56,7 +56,7 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
@Override @Override
public void delete(String[] ids) { public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids)); baseDao.deleteByIds(Arrays.asList(ids));
} }
@Override @Override
@@ -82,4 +82,4 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
.last("LIMIT 1"); .last("LIMIT 1");
return baseDao.selectOne(wrapper); return baseDao.selectOne(wrapper);
} }
} }
@@ -38,11 +38,14 @@ public class UserShowDeviceListVO {
@Schema(description = "最近对话时间") @Schema(description = "最近对话时间")
private String recentChatTime; private String recentChatTime;
@Schema(description = "最后连接时间(UTC毫秒)") @Schema(description = "最后连接时间戳(毫秒)", type = "string", example = "1783689702000")
private Long lastConnectedAtTimestamp; 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") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
private Date createDate; private Date createDate;
} }
@@ -149,8 +149,8 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
log.info("=== [RAGFlow] 获取文档列表: datasetId={} ===", datasetId); log.info("=== [RAGFlow] 获取文档列表: datasetId={} ===", datasetId);
// 使用 Jackson 将 DTO 转为 Map 作为查询参数 // 使用 Jackson 将 DTO 转为 Map 作为查询参数
@SuppressWarnings("unchecked") Map<String, Object> params = objectMapper.convertValue(req, new TypeReference<Map<String, Object>>() {
Map<String, Object> params = objectMapper.convertValue(req, Map.class); });
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params); Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
@@ -174,13 +174,12 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
.pageSize(1) .pageSize(1)
.build(); .build();
@SuppressWarnings("unchecked") Map<String, Object> params = objectMapper.convertValue(req, new TypeReference<Map<String, Object>>() {
Map<String, Object> params = objectMapper.convertValue(req, Map.class); });
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params); Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
Object dataObj = response.get("data"); Object dataObj = response.get("data");
if (dataObj instanceof Map) { if (dataObj instanceof Map<?, ?> dataMap) {
Map<String, Object> dataMap = (Map<String, Object>) dataObj;
List<?> documents = (List<?>) dataMap.get("docs"); List<?> documents = (List<?>) dataMap.get("docs");
if (documents != null && !documents.isEmpty()) { if (documents != null && !documents.isEmpty()) {
return objectMapper.convertValue(documents.get(0), DocumentDTO.InfoVO.class); return objectMapper.convertValue(documents.get(0), DocumentDTO.InfoVO.class);
@@ -567,8 +566,8 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
return new PageData<>(new ArrayList<>(), 0); return new PageData<>(new ArrayList<>(), 0);
} }
Map<String, Object> dataMap = (Map<String, Object>) dataObj; Map<?, ?> dataMap = Map.class.cast(dataObj);
List<Map<String, Object>> documents = (List<Map<String, Object>>) dataMap.get("docs"); List<?> documents = List.class.cast(dataMap.get("docs"));
if (documents == null || documents.isEmpty()) { if (documents == null || documents.isEmpty()) {
// RAGFlow 明确返回了空文档列表,这是合法的"真空" // RAGFlow 明确返回了空文档列表,这是合法的"真空"
return new PageData<>(new ArrayList<>(), 0); return new PageData<>(new ArrayList<>(), 0);
@@ -679,7 +678,9 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
dto.setChunkMethod(info.getChunkMethod().name().toLowerCase()); dto.setChunkMethod(info.getChunkMethod().name().toLowerCase());
} }
if (info.getParserConfig() != null) { if (info.getParserConfig() != null) {
dto.setParserConfig(objectMapper.convertValue(info.getParserConfig(), Map.class)); dto.setParserConfig(objectMapper.convertValue(info.getParserConfig(),
new TypeReference<Map<String, Object>>() {
}));
} }
return dto; return dto;
@@ -717,4 +718,4 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
return !multipartFile.isEmpty(); return !multipartFile.isEmpty();
} }
} }
} }
@@ -116,13 +116,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model != null ? model : "gpt-3.5-turbo"); requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
Map<String, Object>[] messages = new Map[1];
Map<String, Object> message = new HashMap<>(); Map<String, Object> message = new HashMap<>();
message.put("role", "user"); message.put("role", "user");
message.put("content", prompt); message.put("content", prompt);
messages[0] = message;
requestBody.put("messages", messages); requestBody.put("messages", List.of(message));
requestBody.put("temperature", temperature != null ? temperature : 0.7); requestBody.put("temperature", temperature != null ? temperature : 0.7);
requestBody.put("max_tokens", maxTokens != null ? maxTokens : 2000); requestBody.put("max_tokens", maxTokens != null ? maxTokens : 2000);
@@ -212,13 +210,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model != null ? model : "gpt-3.5-turbo"); requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
Map<String, Object>[] messages = new Map[1];
Map<String, Object> message = new HashMap<>(); Map<String, Object> message = new HashMap<>();
message.put("role", "user"); message.put("role", "user");
message.put("content", prompt); message.put("content", prompt);
messages[0] = message;
requestBody.put("messages", messages); requestBody.put("messages", List.of(message));
requestBody.put("temperature", 0.2); requestBody.put("temperature", 0.2);
requestBody.put("max_tokens", 2000); requestBody.put("max_tokens", 2000);
@@ -368,13 +364,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model != null ? model : "gpt-3.5-turbo"); requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
Map<String, Object>[] messages = new Map[1];
Map<String, Object> message = new HashMap<>(); Map<String, Object> message = new HashMap<>();
message.put("role", "user"); message.put("role", "user");
message.put("content", prompt); message.put("content", prompt);
messages[0] = message;
requestBody.put("messages", messages); requestBody.put("messages", List.of(message));
requestBody.put("temperature", 0.3); requestBody.put("temperature", 0.3);
requestBody.put("max_tokens", 50); requestBody.put("max_tokens", 50);
@@ -422,4 +416,4 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
return null; return null;
} }
} }
@@ -362,14 +362,14 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
if (SensitiveDataUtils.isSensitiveField(key)) { if (SensitiveDataUtils.isSensitiveField(key)) {
if (value instanceof String && !SensitiveDataUtils.isMaskedValue((String) value)) { if (value instanceof String && !SensitiveDataUtils.isMaskedValue((String) value)) {
updatedJson.put(key, value); updatedJson.set(key, value);
} }
} else if (value instanceof JSONObject) { } else if (value instanceof JSONObject) {
// 递归处理嵌套JSON // 递归处理嵌套JSON
mergeJson(updatedJson, key, (JSONObject) value); mergeJson(updatedJson, key, (JSONObject) value);
} else { } else {
// 非敏感字段直接更新 // 非敏感字段直接更新
updatedJson.put(key, value); updatedJson.set(key, value);
} }
} }
@@ -405,7 +405,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
// 如果 original 中不存在 key,创建一个新的 JSON 对象 // 如果 original 中不存在 key,创建一个新的 JSON 对象
if (!original.containsKey(key)) { if (!original.containsKey(key)) {
original.put(key, new JSONObject()); original.set(key, new JSONObject());
} }
// 获取 original 中的子对象 // 获取 original 中的子对象
@@ -420,7 +420,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
log.warn("mergeJson: key '{}' 的值不是 JSONObject 类型 (实际类型:{}),将创建新对象", log.warn("mergeJson: key '{}' 的值不是 JSONObject 类型 (实际类型:{}),将创建新对象",
key, originalValue != null ? originalValue.getClass().getSimpleName() : "null"); key, originalValue != null ? originalValue.getClass().getSimpleName() : "null");
originalChild = new JSONObject(); originalChild = new JSONObject();
original.put(key, originalChild); original.set(key, originalChild);
} }
for (String childKey : updated.keySet()) { for (String childKey : updated.keySet()) {
@@ -430,7 +430,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
} else { } else {
if (!SensitiveDataUtils.isSensitiveField(childKey) || if (!SensitiveDataUtils.isSensitiveField(childKey) ||
(childValue instanceof String && !isMaskedValue((String) childValue))) { (childValue instanceof String && !isMaskedValue((String) childValue))) {
originalChild.put(childKey, childValue); originalChild.set(childKey, childValue);
} }
} }
} }
@@ -164,7 +164,7 @@ public class ModelProviderServiceImpl extends BaseServiceImpl<ModelProviderDao,
@Override @Override
public void delete(List<String> ids) { public void delete(List<String> ids) {
if (modelProviderDao.deleteBatchIds(ids) == 0) { if (modelProviderDao.deleteByIds(ids) == 0) {
throw new RenException(ErrorCode.DELETE_DATA_FAILED); throw new RenException(ErrorCode.DELETE_DATA_FAILED);
} }
} }
@@ -4,16 +4,19 @@ import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager; import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor; import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor; import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean; import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.config.ShiroFilterConfiguration; import org.apache.shiro.web.config.ShiroFilterConfiguration;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.mgt.WebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager; import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Role;
import jakarta.servlet.Filter; import jakarta.servlet.Filter;
import xiaozhi.modules.security.oauth2.Oauth2Filter; import xiaozhi.modules.security.oauth2.Oauth2Filter;
@@ -39,7 +42,7 @@ public class ShiroConfig {
} }
@Bean("securityManager") @Bean("securityManager")
public SecurityManager securityManager(Oauth2Realm oAuth2Realm, SessionManager sessionManager) { public WebSecurityManager securityManager(Oauth2Realm oAuth2Realm, SessionManager sessionManager) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(oAuth2Realm); securityManager.setRealm(oAuth2Realm);
securityManager.setSessionManager(sessionManager); securityManager.setSessionManager(sessionManager);
@@ -48,7 +51,8 @@ public class ShiroConfig {
} }
@Bean("shiroFilter") @Bean("shiroFilter")
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager, SysParamsService sysParamsService) { public static ShiroFilterFactoryBean shirFilter(@Lazy WebSecurityManager securityManager,
@Lazy SysParamsService sysParamsService) {
ShiroFilterConfiguration config = new ShiroFilterConfiguration(); ShiroFilterConfiguration config = new ShiroFilterConfiguration();
config.setFilterOncePerRequest(true); config.setFilterOncePerRequest(true);
@@ -101,12 +105,14 @@ public class ShiroConfig {
} }
@Bean("lifecycleBeanPostProcessor") @Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor(); return new LifecycleBeanPostProcessor();
} }
@Bean @Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public static AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(
@Lazy WebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager); advisor.setSecurityManager(securityManager);
return advisor; return advisor;
@@ -31,7 +31,7 @@ public class SysUserDTO implements Serializable {
@NotNull(message = "{id.require}", groups = UpdateGroup.class) @NotNull(message = "{id.require}", groups = UpdateGroup.class)
private Long id; private Long id;
@Schema(description = "用户名", required = true) @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "{sysuser.username.require}", groups = DefaultGroup.class) @NotBlank(message = "{sysuser.username.require}", groups = DefaultGroup.class)
private String username; private String username;
@@ -40,14 +40,14 @@ public class SysUserDTO implements Serializable {
@NotBlank(message = "{sysuser.password.require}", groups = AddGroup.class) @NotBlank(message = "{sysuser.password.require}", groups = AddGroup.class)
private String password; private String password;
@Schema(description = "姓名", required = true) @Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "{sysuser.realname.require}", groups = DefaultGroup.class) @NotBlank(message = "{sysuser.realname.require}", groups = DefaultGroup.class)
private String realName; private String realName;
@Schema(description = "头像") @Schema(description = "头像")
private String headUrl; private String headUrl;
@Schema(description = "性别 0:男 1:女 2:保密", required = true) @Schema(description = "性别 0:男 1:女 2:保密", requiredMode = Schema.RequiredMode.REQUIRED)
@Range(min = 0, max = 2, message = "{sysuser.gender.range}", groups = DefaultGroup.class) @Range(min = 0, max = 2, message = "{sysuser.gender.range}", groups = DefaultGroup.class)
private Integer gender; private Integer gender;
@@ -58,11 +58,11 @@ public class SysUserDTO implements Serializable {
@Schema(description = "手机号") @Schema(description = "手机号")
private String mobile; private String mobile;
@Schema(description = "部门ID", required = true) @Schema(description = "部门ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "{sysuser.deptId.require}", groups = DefaultGroup.class) @NotNull(message = "{sysuser.deptId.require}", groups = DefaultGroup.class)
private Long deptId; private Long deptId;
@Schema(description = "状态 0:停用 1:正常", required = true) @Schema(description = "状态 0:停用 1:正常", requiredMode = Schema.RequiredMode.REQUIRED)
@Range(min = 0, max = 1, message = "{sysuser.status.range}", groups = DefaultGroup.class) @Range(min = 0, max = 1, message = "{sysuser.status.range}", groups = DefaultGroup.class)
private Integer status; private Integer status;
@@ -84,4 +84,4 @@ public class SysUserDTO implements Serializable {
@Schema(description = "部门名称") @Schema(description = "部门名称")
private String deptName; private String deptName;
} }
@@ -20,6 +20,7 @@ import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl; import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils; import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil; import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.sys.dao.SysDictDataDao; import xiaozhi.modules.sys.dao.SysDictDataDao;
import xiaozhi.modules.sys.dao.SysUserDao; import xiaozhi.modules.sys.dao.SysUserDao;
@@ -138,7 +139,7 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
// 设置更新者和创建者名称 // 设置更新者和创建者名称
if (!userIds.isEmpty()) { if (!userIds.isEmpty()) {
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds); List<SysUserEntity> sysUserEntities = sysUserDao.selectByIds(userIds);
// 把List转成MapMap<Long, String> // 把List转成MapMap<Long, String>
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId, Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
SysUserEntity::getUsername, (existing, replacement) -> existing)); SysUserEntity::getUsername, (existing, replacement) -> existing));
@@ -170,7 +171,7 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
// 先从Redis获取缓存 // 先从Redis获取缓存
String key = RedisKeys.getDictDataByTypeKey(dictType); String key = RedisKeys.getDictDataByTypeKey(dictType);
List<SysDictDataItem> cachedData = (List<SysDictDataItem>) redisUtils.get(key); List<SysDictDataItem> cachedData = JsonUtils.toList(redisUtils.get(key), SysDictDataItem.class);
if (cachedData != null) { if (cachedData != null) {
return cachedData; return cachedData;
} }
@@ -185,4 +186,4 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
return data; return data;
} }
} }
@@ -128,7 +128,7 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
// 设置更新者和创建者名称 // 设置更新者和创建者名称
if (!userIds.isEmpty()) { if (!userIds.isEmpty()) {
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds); List<SysUserEntity> sysUserEntities = sysUserDao.selectByIds(userIds);
// 把List转成MapMap<Long, String> // 把List转成MapMap<Long, String>
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId, Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
SysUserEntity::getUsername, (existing, replacement) -> existing)); SysUserEntity::getUsername, (existing, replacement) -> existing));
@@ -151,4 +151,4 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
throw new RenException(ErrorCode.DICT_TYPE_DUPLICATE); throw new RenException(ErrorCode.DICT_TYPE_DUPLICATE);
} }
} }
} }
@@ -287,10 +287,10 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
try { try {
if (StringUtils.isNotBlank(currentConfig)) { if (StringUtils.isNotBlank(currentConfig)) {
currentMap = JsonUtils.parseObject(currentConfig, Map.class); currentMap = JsonUtils.parseMap(currentConfig);
} }
if (StringUtils.isNotBlank(configJson)) { if (StringUtils.isNotBlank(configJson)) {
newMap = JsonUtils.parseObject(configJson, Map.class); newMap = JsonUtils.parseMap(configJson);
} }
} catch (Exception e) { } catch (Exception e) {
throw new RenException(ErrorCode.PARAM_JSON_INVALID); throw new RenException(ErrorCode.PARAM_JSON_INVALID);
@@ -298,8 +298,8 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
// 检查addressBook功能是否被关闭 // 检查addressBook功能是否被关闭
if (currentMap != null && newMap != null) { if (currentMap != null && newMap != null) {
Map<String, Object> currentFeatures = (Map<String, Object>) currentMap.get("features"); Map<?, ?> currentFeatures = Map.class.cast(currentMap.get("features"));
Map<String, Object> newFeatures = (Map<String, Object>) newMap.get("features"); Map<?, ?> newFeatures = Map.class.cast(newMap.get("features"));
if (currentFeatures != null && newFeatures != null) { if (currentFeatures != null && newFeatures != null) {
Object currentAddressBookObj = currentFeatures.get("addressBook"); Object currentAddressBookObj = currentFeatures.get("addressBook");
@@ -308,16 +308,14 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
Boolean currentEnabled = false; Boolean currentEnabled = false;
Boolean newEnabled = false; Boolean newEnabled = false;
if (currentAddressBookObj instanceof Map) { if (currentAddressBookObj instanceof Map<?, ?> currentAddressBook) {
Map<String, Object> currentAddressBook = (Map<String, Object>) currentAddressBookObj; Object enabled = currentAddressBook.get("enabled");
currentEnabled = currentAddressBook.get("enabled") != null currentEnabled = enabled != null ? Boolean.class.cast(enabled) : false;
? (Boolean) currentAddressBook.get("enabled") : false;
} }
if (newAddressBookObj instanceof Map) { if (newAddressBookObj instanceof Map<?, ?> newAddressBook) {
Map<String, Object> newAddressBook = (Map<String, Object>) newAddressBookObj; Object enabled = newAddressBook.get("enabled");
newEnabled = newAddressBook.get("enabled") != null newEnabled = enabled != null ? Boolean.class.cast(enabled) : false;
? (Boolean) newAddressBook.get("enabled") : false;
} }
// 如果之前是启用状态,现在被禁用,删除所有call_device插件 // 如果之前是启用状态,现在被禁用,删除所有call_device插件
@@ -57,6 +57,14 @@ public interface TimbreService extends BaseService<TimbreEntity> {
List<VoiceDTO> getVoiceNames(String ttsModelId, String voiceName); List<VoiceDTO> getVoiceNames(String ttsModelId, String voiceName);
/**
* 获取普通音色或克隆音色配置的首个有效语言。
*
* @param id 音色ID
* @return 默认语言;音色不存在或未配置有效语言时返回null
*/
String getDefaultLanguageById(String id);
/** /**
* 根据ID获取音色名称 * 根据ID获取音色名称
* *
@@ -73,4 +81,4 @@ public interface TimbreService extends BaseService<TimbreEntity> {
* @return 音色信息 * @return 音色信息
*/ */
VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode); VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode);
} }
@@ -1,6 +1,7 @@
package xiaozhi.modules.timbre.service.impl; package xiaozhi.modules.timbre.service.impl;
import java.util.*; import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -41,6 +42,8 @@ import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
@Service @Service
public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity> implements TimbreService { public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity> implements TimbreService {
private static final Pattern LANGUAGE_SEPARATOR = Pattern.compile("[、;;,]");
private final TimbreDao timbreDao; private final TimbreDao timbreDao;
private final VoiceCloneDao voiceCloneDao; private final VoiceCloneDao voiceCloneDao;
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
@@ -114,7 +117,7 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void delete(String[] ids) { public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids)); baseDao.deleteByIds(Arrays.asList(ids));
} }
@Override @Override
@@ -158,6 +161,32 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
return CollectionUtil.isEmpty(voiceDTOs) ? null : voiceDTOs; 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 * 处理是不是tts模型的id
*/ */
@@ -214,4 +243,4 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
dto.setIsClone(false); // 设置为普通音色 dto.setIsClone(false); // 设置为普通音色
return dto; return dto;
} }
} }
@@ -127,7 +127,7 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
@Override @Override
public void delete(String[] ids) { public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids)); baseDao.deleteByIds(Arrays.asList(ids));
} }
@Override @Override
@@ -0,0 +1,8 @@
-- liquibase formatted sql
-- changeset tykechen:202607101200
ALTER TABLE `ai_agent_snapshot`
ADD COLUMN `redaction_version` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '快照脱敏规则版本' AFTER `created_at`,
ADD INDEX `idx_snapshot_redaction_version_id` (`redaction_version`, `id`);
-- rollback ALTER TABLE `ai_agent_snapshot` DROP INDEX `idx_snapshot_redaction_version_id`, DROP COLUMN `redaction_version`;
@@ -704,3 +704,10 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202607071530.sql path: classpath:db/changelog/202607071530.sql
- changeSet:
id: 202607101200
author: tykechen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607101200.sql
@@ -1,20 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<configuration> <configuration>
<!-- 启用JansiConsoleAppender以确保控制台输出有颜色 -->
<conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
<!-- 确保日志目录存在 -->
<timestamp key="bySecond" datePattern="yyyyMMdd'T'HHmmss"/>
<!-- 定义日志文件存储位置 --> <!-- 定义日志文件存储位置 -->
<property name="LOG_HOME" value="./logs" /> <property name="LOG_HOME" value="./logs" />
<!-- 使用自定义的初始化监听器确保日志目录存在 -->
<define name="LOGBACK_DIR_CHECK" class="ch.qos.logback.core.property.FileExistsPropertyDefiner">
<path>${LOG_HOME}</path>
<createIfMissing>true</createIfMissing>
</define>
<!-- 引入Spring Boot默认配置 --> <!-- 引入Spring Boot默认配置 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml" /> <include resource="org/springframework/boot/logging/logback/defaults.xml" />
@@ -89,4 +89,32 @@
FOR UPDATE FOR UPDATE
</select> </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> </mapper>
@@ -19,7 +19,8 @@
restore_from_snapshot_id AS restoreFromSnapshotId, restore_from_snapshot_id AS restoreFromSnapshotId,
restore_from_version_no AS restoreFromVersionNo, restore_from_version_no AS restoreFromVersionNo,
creator, creator,
created_at AS createdAt created_at AS createdAt,
redaction_version AS redactionVersion
FROM ai_agent_snapshot FROM ai_agent_snapshot
WHERE agent_id = #{agentId} WHERE agent_id = #{agentId}
ORDER BY version_no DESC ORDER BY version_no DESC
@@ -37,7 +38,8 @@
restore_from_snapshot_id AS restoreFromSnapshotId, restore_from_snapshot_id AS restoreFromSnapshotId,
restore_from_version_no AS restoreFromVersionNo, restore_from_version_no AS restoreFromVersionNo,
creator, creator,
created_at AS createdAt created_at AS createdAt,
redaction_version AS redactionVersion
FROM ai_agent_snapshot FROM ai_agent_snapshot
WHERE agent_id = #{agentId} WHERE agent_id = #{agentId}
AND version_no &gt; #{versionNo} AND version_no &gt; #{versionNo}
@@ -57,7 +59,8 @@
restore_from_snapshot_id, restore_from_snapshot_id,
restore_from_version_no, restore_from_version_no,
creator, creator,
created_at created_at,
redaction_version
) )
SELECT #{snapshot.id}, SELECT #{snapshot.id},
#{snapshot.agentId}, #{snapshot.agentId},
@@ -69,7 +72,8 @@
#{snapshot.restoreFromSnapshotId}, #{snapshot.restoreFromSnapshotId},
#{snapshot.restoreFromVersionNo}, #{snapshot.restoreFromVersionNo},
#{snapshot.creator}, #{snapshot.creator},
#{snapshot.createdAt} #{snapshot.createdAt},
#{snapshot.redactionVersion}
FROM ai_agent_snapshot FROM ai_agent_snapshot
WHERE agent_id = #{snapshot.agentId} WHERE agent_id = #{snapshot.agentId}
</insert> </insert>
@@ -89,4 +93,31 @@
) )
</delete> </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> </mapper>
@@ -21,7 +21,7 @@
WHERE mac_address = #{macAddress} AND target_mac = #{targetMac} WHERE mac_address = #{macAddress} AND target_mac = #{targetMac}
</update> </update>
<insert id="insert"> <insert id="insertAddressBook">
INSERT INTO ai_device_address_book (mac_address, target_mac, alias, has_permission, creator, create_date, updater, update_date) INSERT INTO ai_device_address_book (mac_address, target_mac, alias, has_permission, creator, create_date, updater, update_date)
VALUES (#{macAddress}, #{targetMac}, #{alias}, #{hasPermission}, #{creator}, NOW(), #{updater}, NOW()) VALUES (#{macAddress}, #{targetMac}, #{alias}, #{hasPermission}, #{creator}, NOW(), #{updater}, NOW())
</insert> </insert>
@@ -36,4 +36,4 @@
#{mac} #{mac}
</foreach> </foreach>
</delete> </delete>
</mapper> </mapper>
@@ -0,0 +1,57 @@
package xiaozhi.common.redis;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.serializer.RedisSerializer;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.sys.vo.SysDictDataItem;
class RedisSerializationTest {
private final RedisSerializer<Object> serializer = RedisSerializer.json();
@Test
void serverConfigRoundTripRestoresStringKeyedMaps() {
Map<String, Object> features = new HashMap<>();
features.put("addressBook", Map.of("enabled", true));
Map<String, Object> config = new HashMap<>();
config.put("features", features);
Object restored = roundTrip(config);
Map<String, Object> restoredConfig = JsonUtils.toStringObjectMap(restored);
Map<String, Object> restoredFeatures = JsonUtils.toStringObjectMap(restoredConfig.get("features"));
Map<String, Object> addressBook = JsonUtils.toStringObjectMap(restoredFeatures.get("addressBook"));
assertEquals(true, addressBook.get("enabled"));
}
@Test
void dictionaryListRoundTripRestoresDtoElements() {
SysDictDataItem item = new SysDictDataItem();
item.setName("enabled");
item.setKey("1");
Object restored = roundTrip(new ArrayList<>(List.of(item)));
List<SysDictDataItem> restoredItems = JsonUtils.toList(restored, SysDictDataItem.class);
assertInstanceOf(SysDictDataItem.class, restoredItems.get(0));
assertEquals("enabled", restoredItems.get(0).getName());
assertEquals("1", restoredItems.get(0).getKey());
}
private Object roundTrip(Object value) {
byte[] bytes = serializer.serialize(value);
assertNotNull(bytes);
Object restored = serializer.deserialize(bytes);
assertNotNull(restored);
return restored;
}
}
@@ -0,0 +1,219 @@
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.logging.nologging.NoLoggingImpl;
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)))
.thenReturn(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 deleteBatchIdsDelegatesToCompatibleApiAndPreservesAffectedRowResult() {
TestMapper mapper = mock(TestMapper.class);
List<Long> ids = List.of(1L, 2L);
service.baseDao = mapper;
when(mapper.deleteByIds(ids)).thenReturn(2).thenReturn(0);
assertTrue(service.deleteBatchIds(ids));
assertFalse(service.deleteBatchIds(ids));
verify(mapper, times(2)).deleteByIds(ids);
}
@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> {
private TestService() {
log = new NoLoggingImpl(TestService.class.getName());
}
@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,77 @@
package xiaozhi.common.utils;
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.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class JsonUtilsTest {
@Test
void parsesTypedMapsAndPreservesNestedCollectionShapes() {
Map<String, Object> map = JsonUtils.parseMap(
"{\"enabled\":true,\"nested\":{\"items\":[{\"name\":\"tool\"}]}}");
Map<?, ?> nested = assertInstanceOf(Map.class, map.get("nested"));
List<?> items = assertInstanceOf(List.class, nested.get("items"));
Map<?, ?> item = assertInstanceOf(Map.class, items.get(0));
assertEquals(true, map.get("enabled"));
assertEquals("tool", item.get("name"));
List<Map<String, Object>> maps = JsonUtils.parseMapList(
"[{\"name\":\"first\",\"values\":[1,2]},{\"name\":\"second\"}]");
assertEquals("first", maps.get(0).get("name"));
assertEquals(List.of(1, 2), maps.get(0).get("values"));
assertEquals("second", maps.get(1).get("name"));
}
@Test
void checkedConvertersAreShallowMutableCopies() {
List<Object> nested = new ArrayList<>(List.of(Map.of("name", "tool")));
Map<String, Object> source = new LinkedHashMap<>();
source.put("nested", nested);
Map<String, Object> map = JsonUtils.toStringObjectMap(source);
List<Map<String, Object>> maps = JsonUtils.toStringObjectMapList(List.of(source));
List<String> strings = JsonUtils.toList(Arrays.asList("a", null), String.class);
assertSame(nested, map.get("nested"));
assertSame(nested, maps.get(0).get("nested"));
map.put("enabled", true);
maps.add(Map.of("name", "second"));
strings.add("b");
assertEquals(true, map.get("enabled"));
assertEquals(2, maps.size());
assertEquals(Arrays.asList("a", null, "b"), strings);
}
@Test
void preservesNullInputs() {
assertNull(JsonUtils.parseMap(""));
assertNull(JsonUtils.parseMapList(null));
assertNull(JsonUtils.toStringObjectMap(null));
assertNull(JsonUtils.toStringObjectMapList(null));
assertNull(JsonUtils.toList(null, String.class));
}
@Test
void rejectsUnexpectedJsonShapesAndRuntimeTypes() {
assertThrows(RuntimeException.class, () -> JsonUtils.parseMap("[]"));
assertThrows(RuntimeException.class, () -> JsonUtils.parseMapList("{}"));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMap(List.of()));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMap(Map.of(1, "value")));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMapList(List.of("value")));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMapList(Map.of()));
assertThrows(ClassCastException.class, () -> JsonUtils.toList(List.of(1), String.class));
assertThrows(ClassCastException.class, () -> JsonUtils.toList(Map.of(), String.class));
}
}
@@ -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);
}
}
@@ -2,36 +2,30 @@ package xiaozhi.modules.agent.typehandler;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue; 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 java.util.List;
import org.apache.ibatis.type.TypeHandler; import org.apache.ibatis.type.TypeHandler;
import org.apache.ibatis.type.TypeHandlerRegistry; import org.apache.ibatis.type.TypeHandlerRegistry;
import org.junit.jupiter.api.Test; 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; import xiaozhi.modules.agent.dto.ContextProviderDTO;
class ContextProviderListTypeHandlerTest { class ContextProviderListTypeHandlerTest {
private final ContextProviderListTypeHandler handler = new ContextProviderListTypeHandler(); private final ContextProviderListTypeHandler handler = new ContextProviderListTypeHandler();
@Test
void parseKeepsContextProviderDtoElementType() {
List<ContextProviderDTO> providers = handler
.parse("[{\"url\":\"https://example.com/context\",\"headers\":{\"Authorization\":\"Bearer token\"}}]");
assertEquals(1, providers.size());
assertInstanceOf(ContextProviderDTO.class, providers.get(0));
assertEquals("https://example.com/context", providers.get(0).getUrl());
assertEquals("Bearer token", providers.get(0).getHeaders().get("Authorization"));
}
@Test
void parseBlankJsonAsEmptyList() {
assertTrue(handler.parse(" ").isEmpty());
}
@Test @Test
void myBatisCanInstantiateHandlerForListField() { void myBatisCanInstantiateHandlerForListField() {
TypeHandler<?> typeHandler = new TypeHandlerRegistry().getInstance(List.class, TypeHandler<?> typeHandler = new TypeHandlerRegistry().getInstance(List.class,
@@ -39,4 +33,41 @@ class ContextProviderListTypeHandlerTest {
assertInstanceOf(ContextProviderListTypeHandler.class, typeHandler); 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,96 @@
package xiaozhi.modules.config.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.agent.dao.AgentVoicePrintDao;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
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.device.service.DeviceService;
import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.modules.sys.dto.SysParamsDTO;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.timbre.service.TimbreService;
import xiaozhi.modules.voiceclone.service.VoiceCloneService;
class ConfigServiceImplTest {
@Test
void cachedServerConfigIsCheckedAsAStringKeyedMapWithoutChangingNestedValues() {
RedisUtils redisUtils = mock(RedisUtils.class);
Map<String, Object> nested = new HashMap<>();
nested.put("enabled", true);
Map<String, Object> cached = new HashMap<>();
cached.put("features", nested);
when(redisUtils.get(RedisKeys.getServerConfigKey())).thenReturn(cached);
ConfigServiceImpl service = newService(mock(SysParamsService.class), redisUtils);
Map<String, Object> result = service.getConfig(true);
assertNotSame(cached, result);
assertSame(nested, result.get("features"));
assertEquals(true, ((Map<?, ?>) result.get("features")).get("enabled"));
}
@Test
void nestedSystemParametersStillShareAndPopulateTheSameConfigBranch() {
SysParamsService sysParamsService = mock(SysParamsService.class);
SysParamsDTO enabled = parameter("server.features.enabled", "true", "boolean");
SysParamsDTO labels = parameter("server.features.labels", "first;second", "array");
when(sysParamsService.list(anyMap())).thenReturn(List.of(enabled, labels));
ConfigServiceImpl service = newService(sysParamsService, mock(RedisUtils.class));
Map<String, Object> config = new HashMap<>();
Object returned = ReflectionTestUtils.invokeMethod(service, "buildConfig", config);
assertSame(config, returned);
Map<?, ?> server = assertInstanceOf(Map.class, config.get("server"));
Map<?, ?> features = assertInstanceOf(Map.class, server.get("features"));
assertEquals(true, features.get("enabled"));
assertEquals(List.of("first", "second"), features.get("labels"));
}
private static SysParamsDTO parameter(String code, String value, String type) {
SysParamsDTO parameter = new SysParamsDTO();
parameter.setParamCode(code);
parameter.setParamValue(value);
parameter.setValueType(type);
return parameter;
}
private static ConfigServiceImpl newService(SysParamsService sysParamsService, RedisUtils redisUtils) {
return new ConfigServiceImpl(
sysParamsService,
mock(DeviceService.class),
mock(ModelConfigService.class),
mock(AgentService.class),
mock(AgentTemplateService.class),
redisUtils,
mock(TimbreService.class),
mock(AgentPluginMappingService.class),
mock(AgentMcpAccessPointService.class),
mock(AgentContextProviderService.class),
mock(VoiceCloneService.class),
mock(AgentVoicePrintDao.class),
mock(CorrectWordFileService.class));
}
}
@@ -1,7 +1,6 @@
package xiaozhi.modules.device; package xiaozhi.modules.device;
import java.util.HashMap; import java.util.HashMap;
import java.util.UUID;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
@@ -11,6 +10,8 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.dto.SysUserDTO; import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.service.SysUserService; import xiaozhi.modules.sys.service.SysUserService;
@@ -27,11 +28,13 @@ public class DeviceTest {
private SysUserService sysUserService; private SysUserService sysUserService;
@Test @Test
public void testSaveUser() { public void testRejectWeakPassword() {
SysUserDTO userDTO = new SysUserDTO(); SysUserDTO userDTO = new SysUserDTO();
userDTO.setUsername("test"); userDTO.setUsername("test");
userDTO.setPassword(UUID.randomUUID().toString()); userDTO.setPassword("weak-password-123");
sysUserService.save(userDTO);
RenException exception = Assertions.assertThrows(RenException.class, () -> sysUserService.save(userDTO));
Assertions.assertEquals(ErrorCode.PASSWORD_WEAK_ERROR, exception.getCode());
} }
@Test @Test
@@ -70,4 +73,4 @@ public class DeviceTest {
log.info("测试完成"); log.info("测试完成");
} }
} }
@@ -0,0 +1,46 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
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 java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.sys.service.SysParamsService;
class DeviceAddressBookServiceImplTest {
@Test
void newEntryUsesTheDedicatedAddressBookInsertMapping() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceService deviceService = mock(DeviceService.class);
SysParamsService sysParamsService = mock(SysParamsService.class);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, deviceService, sysParamsService);
when(addressBookDao.selectOne(any())).thenReturn(null);
when(addressBookDao.selectList(null)).thenReturn(List.of());
service.saveOrUpdate("00:11:22:33:44:55", "00:11:22:33:44:66", "living-room", true);
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
DeviceAddressBookEntity entity = entityCaptor.getValue();
assertEquals("00:11:22:33:44:55", entity.getMacAddress());
assertEquals("00:11:22:33:44:66", entity.getTargetMac());
assertEquals("living-room", entity.getAlias());
assertTrue(entity.getHasPermission());
verify(addressBookDao, never()).insert(any(DeviceAddressBookEntity.class));
}
}
@@ -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,60 @@
package xiaozhi.modules.security.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.WebSecurityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Role;
import xiaozhi.modules.security.oauth2.Oauth2Realm;
import xiaozhi.modules.sys.service.SysParamsService;
class ShiroConfigTest {
@Test
void beanPostProcessorFactoriesDoNotInstantiateShiroConfigOrBusinessDependenciesEarly() throws Exception {
Method lifecycleFactory = ShiroConfig.class.getDeclaredMethod("lifecycleBeanPostProcessor");
Method filterFactory = ShiroConfig.class.getDeclaredMethod(
"shirFilter", WebSecurityManager.class, SysParamsService.class);
assertTrue(Modifier.isStatic(lifecycleFactory.getModifiers()));
assertTrue(BeanPostProcessor.class.isAssignableFrom(ShiroFilterFactoryBean.class));
assertTrue(Modifier.isStatic(filterFactory.getModifiers()));
Lazy securityManagerLazy = filterFactory.getParameters()[0].getAnnotation(Lazy.class);
Lazy sysParamsServiceLazy = filterFactory.getParameters()[1].getAnnotation(Lazy.class);
assertNotNull(securityManagerLazy);
assertNotNull(sysParamsServiceLazy);
assertTrue(securityManagerLazy.value());
assertTrue(sysParamsServiceLazy.value());
}
@Test
void authorizationAdvisorIsInfrastructureAndDefersItsSecurityManager() throws Exception {
Method advisorFactory = ShiroConfig.class.getDeclaredMethod(
"authorizationAttributeSourceAdvisor", WebSecurityManager.class);
assertTrue(Modifier.isStatic(advisorFactory.getModifiers()));
Lazy securityManagerLazy = advisorFactory.getParameters()[0].getAnnotation(Lazy.class);
assertNotNull(securityManagerLazy);
assertTrue(securityManagerLazy.value());
assertEquals(BeanDefinition.ROLE_INFRASTRUCTURE, advisorFactory.getAnnotation(Role.class).value());
}
@Test
void securityManagerRetainsTheWebSecurityContractRequiredByShiroFilter() throws Exception {
Method securityManagerFactory = ShiroConfig.class.getDeclaredMethod(
"securityManager", Oauth2Realm.class, SessionManager.class);
assertEquals(WebSecurityManager.class, securityManagerFactory.getReturnType());
}
}
@@ -1,15 +1,23 @@
package xiaozhi.modules.sys; package xiaozhi.modules.sys;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.modules.security.controller.LoginController; import xiaozhi.modules.security.controller.LoginController;
import xiaozhi.modules.security.dto.LoginDTO; import xiaozhi.modules.security.dto.LoginDTO;
import xiaozhi.modules.security.dto.SmsVerificationDTO; import xiaozhi.modules.security.dto.SmsVerificationDTO;
import xiaozhi.modules.sys.dto.RetrievePasswordDTO; import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
import xiaozhi.modules.sys.service.SysUserService;
@Slf4j @Slf4j
@SpringBootTest @SpringBootTest
@@ -19,12 +27,19 @@ class loginControllerTest {
@Autowired @Autowired
LoginController loginController; LoginController loginController;
@MockitoBean
SysUserService sysUserService;
@Test @Test
public void testRegister() { public void testRegister() {
when(sysUserService.getAllowUserRegister()).thenReturn(false);
LoginDTO loginDTO = new LoginDTO(); LoginDTO loginDTO = new LoginDTO();
loginDTO.setUsername("手机号码"); loginDTO.setUsername("手机号码");
loginDTO.setPassword("密码"); loginDTO.setPassword("密码");
loginController.register(loginDTO);
RenException exception = assertThrows(RenException.class, () -> loginController.register(loginDTO));
assertEquals(ErrorCode.USER_REGISTER_DISABLED, exception.getCode());
} }
@Test @Test
@@ -54,4 +69,4 @@ class loginControllerTest {
} }
} }
@@ -0,0 +1,35 @@
package xiaozhi.modules.sys.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.vo.SysDictDataItem;
class SysDictDataServiceImplTest {
@Test
void cachedDictionaryItemsAreCheckedAndReturnedAsDtos() {
RedisUtils redisUtils = mock(RedisUtils.class);
SysDictDataItem item = new SysDictDataItem();
item.setName("enabled");
item.setKey("1");
List<SysDictDataItem> cached = new ArrayList<>(List.of(item));
when(redisUtils.get(RedisKeys.getDictDataByTypeKey("status"))).thenReturn(cached);
SysDictDataServiceImpl service = new SysDictDataServiceImpl(mock(SysUserDao.class), redisUtils);
List<SysDictDataItem> result = service.getDictDataByType("status");
assertNotSame(cached, result);
assertEquals(List.of(item), result);
}
}
@@ -0,0 +1,36 @@
package xiaozhi.modules.sys.service.impl;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.common.constant.Constant;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.sys.dao.SysParamsDao;
import xiaozhi.modules.sys.redis.SysParamsRedis;
class SysParamsServiceImplTest {
@Test
void disablingAddressBookStillDeletesItsSystemPlugin() {
SysParamsRedis sysParamsRedis = mock(SysParamsRedis.class);
AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class);
SysParamsDao sysParamsDao = mock(SysParamsDao.class);
SysParamsServiceImpl service = new SysParamsServiceImpl(sysParamsRedis, pluginMappingService);
ReflectionTestUtils.setField(service, "baseDao", sysParamsDao);
String currentConfig = "{\"features\":{\"addressBook\":{\"enabled\":true}}}";
String newConfig = "{\"features\":{\"addressBook\":{\"enabled\":false}}}";
when(sysParamsDao.getValueByCode(Constant.SYSTEM_WEB_MENU)).thenReturn(currentConfig);
when(sysParamsDao.updateValueByCode(Constant.SYSTEM_WEB_MENU, newConfig)).thenReturn(1);
service.updateSystemWebMenu(newConfig);
verify(pluginMappingService).deleteByPluginId("SYSTEM_PLUGIN_CALL_DEVICE");
verify(sysParamsDao).updateValueByCode(Constant.SYSTEM_WEB_MENU, newConfig);
verify(sysParamsRedis).set(Constant.SYSTEM_WEB_MENU, newConfig);
}
}
@@ -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,4 +1,7 @@
spring: spring:
messages:
encoding: UTF-8
basename: i18n/messages
data: data:
redis: redis:
host: localhost host: localhost
@@ -19,4 +22,4 @@ renren:
logging: logging:
level: level:
xiaozhi.modules.device: DEBUG xiaozhi.modules.device: DEBUG
org.springframework.data.redis: DEBUG org.springframework.data.redis: DEBUG
+1
View File
@@ -69,6 +69,7 @@
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei", "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union", "build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"type-check": "vue-tsc --noEmit", "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", "openapi-ts-request": "openapi-ts",
"prepare": "git init && husky", "prepare": "git init && husky",
"lint": "eslint", "lint": "eslint",
+9 -8
View File
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app' import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
import { watch, onMounted } from 'vue' import { onMounted, watch } from 'vue'
import { usePageAuth } from '@/hooks/usePageAuth' import { usePageAuth } from '@/hooks/usePageAuth'
import { useConfigStore } from '@/store'
import { t } from '@/i18n' import { t } from '@/i18n'
import { useConfigStore } from '@/store'
import { useLangStore } from '@/store/lang' import { useLangStore } from '@/store/lang'
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only' import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
@@ -37,9 +37,9 @@ function updateTabBarText() {
success: () => {}, success: () => {},
fail: (err) => { fail: (err) => {
console.log('设置首页tabBar文本失败:', err) console.log('设置首页tabBar文本失败:', err)
} },
}) })
// 设置配网tabBar文本 // 设置配网tabBar文本
uni.setTabBarItem({ uni.setTabBarItem({
index: 1, index: 1,
@@ -47,9 +47,9 @@ function updateTabBarText() {
success: () => {}, success: () => {},
fail: (err) => { fail: (err) => {
console.log('设置配网tabBar文本失败:', err) console.log('设置配网tabBar文本失败:', err)
} },
}) })
// 设置系统tabBar文本 // 设置系统tabBar文本
uni.setTabBarItem({ uni.setTabBarItem({
index: 2, index: 2,
@@ -57,9 +57,10 @@ function updateTabBarText() {
success: () => {}, success: () => {},
fail: (err) => { fail: (err) => {
console.log('设置系统tabBar文本失败:', err) console.log('设置系统tabBar文本失败:', err)
} },
}) })
} catch (error) { }
catch (error) {
console.log('更新tabBar文本时出错:', error) console.log('更新tabBar文本时出错:', error)
} }
} }
+65 -1
View File
@@ -2,7 +2,11 @@ import type {
Agent, Agent,
AgentCreateData, AgentCreateData,
AgentDetail, AgentDetail,
AgentSnapshot,
AgentSnapshotPageParams,
CorrectWordFile,
ModelOption, ModelOption,
PageData,
RoleTemplate, RoleTemplate,
} from './types' } from './types'
import { http } from '@/http/request/alova' 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, { return http.Put(`/agent/${id}`, data, {
meta: { meta: {
ignoreAuth: false, 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 createdAt: string
updater: string updater: string
updatedAt: string updatedAt: string
ttsLanguage: string ttsLanguage: string | null
ttsVolume: number ttsVolume: number | null
ttsRate: number ttsRate: number | null
ttsPitch: number ttsPitch: number | null
currentVersionNo?: number | null
tagNames?: string[]
functions: AgentFunction[] functions: AgentFunction[]
contextProviders: Providers[] contextProviders: Providers[]
} }
@@ -67,6 +69,51 @@ export interface AgentFunction {
paramInfo: Record<string, string | number | boolean> | null 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 { export interface RoleTemplate {
id: string id: string
@@ -78,6 +125,7 @@ export interface RoleTemplate {
vllmModelId: string vllmModelId: string
ttsModelId: string ttsModelId: string
ttsVoiceId: string ttsVoiceId: string
ttsLanguage?: string | null
memModelId: string memModelId: string
intentModelId: string intentModelId: string
chatHistoryConf: number chatHistoryConf: number
File diff suppressed because it is too large Load Diff
+87
View File
@@ -130,6 +130,7 @@ export default {
'agent.saving': 'Saving...', 'agent.saving': 'Saving...',
'agent.saveSuccess': 'Save successful', 'agent.saveSuccess': 'Save successful',
'agent.saveFail': 'Save failed', 'agent.saveFail': 'Save failed',
'agent.ttsOptionsLoadFailed': 'Voice options could not be loaded. The previous voice settings were kept.',
'agent.loadFail': 'Load failed', 'agent.loadFail': 'Load failed',
'agent.pleaseInputAgentName': 'Please input agent name', 'agent.pleaseInputAgentName': 'Please input agent name',
'agent.pleaseInputRoleDescription': 'Please input role description', 'agent.pleaseInputRoleDescription': 'Please input role description',
@@ -146,6 +147,92 @@ export default {
'agent.speedHint': '-100=Slower, 0=Standard, 100=Faster', 'agent.speedHint': '-100=Slower, 0=Standard, 100=Faster',
'agent.pitchHint': '-100=Lowest, 0=Standard, 100=Highest', '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 // Context provider dialog related
'contextProviderDialog.title': 'Edit Source', 'contextProviderDialog.title': 'Edit Source',
'contextProviderDialog.noContextApi': 'No Context API', 'contextProviderDialog.noContextApi': 'No Context API',
+10 -10
View File
@@ -1,23 +1,23 @@
import type { Language } from '@/store/lang'
import { ref } from 'vue' import { ref } from 'vue'
import { useLangStore } from '@/store/lang' 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 zh_CN from './zh_CN'
import en from './en'
import zh_TW from './zh_TW' import zh_TW from './zh_TW'
import de from './de'
import vi from './vi'
import pt_BR from './pt_BR'
// 语言包映射 // 语言包映射
const messages = { const messages = {
zh_CN: zh_CN, zh_CN,
en, en,
zh_TW: zh_TW, zh_TW,
de, de,
vi, 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) { if (langMessages && typeof langMessages === 'object' && key in langMessages) {
let value = langMessages[key] const value = langMessages[key]
if (typeof value === 'string') { if (typeof value === 'string') {
// 处理参数替换 // 处理参数替换
if (params) { if (params) {
@@ -76,4 +76,4 @@ export function getSupportedLanguages(): { code: Language, name: string }[] {
{ code: 'vi', name: 'Tiếng Việt' }, { code: 'vi', name: 'Tiếng Việt' },
{ code: 'pt_BR', name: 'Português (Brasil)' }, { code: 'pt_BR', name: 'Português (Brasil)' },
] ]
} }
+90 -3
View File
@@ -29,9 +29,9 @@ export default {
'login.requiredCaptcha': 'O código de verificação não pode estar vazio', '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.requiredMobile': 'Por favor, insira um número de telefone válido',
'login.captchaError': 'Erro no código de verificação gráfico', 'login.captchaError': 'Erro no código de verificação gráfico',
'login.forgotPassword': 'Esqueceu a Senha', 'login.forgotPassword': 'Esqueceu a Senha',
'login.userAgreement': 'Termos de Uso', 'login.userAgreement': 'Termos de Uso',
'login.privacyPolicy': 'Política de Privacidade', 'login.privacyPolicy': 'Política de Privacidade',
// Register page // Register page
'register.pageTitle': 'Cadastro', 'register.pageTitle': 'Cadastro',
@@ -130,6 +130,7 @@ export default {
'agent.saving': 'Salvando...', 'agent.saving': 'Salvando...',
'agent.saveSuccess': 'Salvo com sucesso', 'agent.saveSuccess': 'Salvo com sucesso',
'agent.saveFail': 'Falha ao salvar', '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.loadFail': 'Falha ao carregar',
'agent.pleaseInputAgentName': 'Por favor, insira o nome do agente', 'agent.pleaseInputAgentName': 'Por favor, insira o nome do agente',
'agent.pleaseInputRoleDescription': 'Por favor, insira a descrição do papel', 'agent.pleaseInputRoleDescription': 'Por favor, insira a descrição do papel',
@@ -146,6 +147,92 @@ export default {
'agent.speedHint': '-100=Slower, 0=Standard, 100=Faster', 'agent.speedHint': '-100=Slower, 0=Standard, 100=Faster',
'agent.pitchHint': '-100=Lowest, 0=Standard, 100=Highest', '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 // Diálogo de provedor de contexto
'contextProviderDialog.title': 'Editar Fonte', 'contextProviderDialog.title': 'Editar Fonte',
'contextProviderDialog.noContextApi': 'Sem API de Contexto', 'contextProviderDialog.noContextApi': 'Sem API de Contexto',
File diff suppressed because it is too large Load Diff
+87
View File
@@ -130,6 +130,7 @@ export default {
'agent.saving': '保存中...', 'agent.saving': '保存中...',
'agent.saveSuccess': '保存成功', 'agent.saveSuccess': '保存成功',
'agent.saveFail': '保存失败', 'agent.saveFail': '保存失败',
'agent.ttsOptionsLoadFailed': '语音选项加载失败,已保留原来的语音配置',
'agent.loadFail': '加载失败', 'agent.loadFail': '加载失败',
'agent.pleaseInputAgentName': '请输入智能体名称', 'agent.pleaseInputAgentName': '请输入智能体名称',
'agent.pleaseInputRoleDescription': '请输入角色介绍', 'agent.pleaseInputRoleDescription': '请输入角色介绍',
@@ -146,6 +147,92 @@ export default {
'agent.speedHint': '-100=最慢, 0=标准, 100=最快', 'agent.speedHint': '-100=最慢, 0=标准, 100=最快',
'agent.pitchHint': '-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.title': '编辑源',
'contextProviderDialog.noContextApi': '暂无上下文API', 'contextProviderDialog.noContextApi': '暂无上下文API',
+90 -3
View File
@@ -29,9 +29,9 @@ export default {
'login.requiredCaptcha': '驗證碼不能為空', 'login.requiredCaptcha': '驗證碼不能為空',
'login.requiredMobile': '請輸入正確的手機號碼', 'login.requiredMobile': '請輸入正確的手機號碼',
'login.captchaError': '圖形驗證碼錯誤', 'login.captchaError': '圖形驗證碼錯誤',
'login.forgotPassword': '忘記密碼', 'login.forgotPassword': '忘記密碼',
'login.userAgreement': '用戶協議', 'login.userAgreement': '用戶協議',
'login.privacyPolicy': '隱私政策', 'login.privacyPolicy': '隱私政策',
// 忘記密碼頁面 // 忘記密碼頁面
'retrievePassword.title': '重置密碼', 'retrievePassword.title': '重置密碼',
@@ -151,6 +151,7 @@ export default {
'agent.saving': '儲存中...', 'agent.saving': '儲存中...',
'agent.saveSuccess': '儲存成功', 'agent.saveSuccess': '儲存成功',
'agent.saveFail': '儲存失敗', 'agent.saveFail': '儲存失敗',
'agent.ttsOptionsLoadFailed': '語音選項載入失敗,已保留原來的語音設定',
'agent.loadFail': '加載失敗', 'agent.loadFail': '加載失敗',
'agent.pleaseInputAgentName': '請輸入助手暱稱', 'agent.pleaseInputAgentName': '請輸入助手暱稱',
'agent.pleaseInputRoleDescription': '請輸入角色介紹', 'agent.pleaseInputRoleDescription': '請輸入角色介紹',
@@ -167,6 +168,92 @@ export default {
'agent.speedHint': '-100=最慢, 0=標準, 100=最快', 'agent.speedHint': '-100=最慢, 0=標準, 100=最快',
'agent.pitchHint': '-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.title': '編輯源',
'contextProviderDialog.noContextApi': '暫無上下文API', 'contextProviderDialog.noContextApi': '暫無上下文API',
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup> <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 = { const themeVars: ConfigProviderThemeVars = {
// colorTheme: 'red', // colorTheme: 'red',
@@ -38,10 +38,8 @@ onLoad(() => {
<wd-tabbar <wd-tabbar
v-if="customTabbarEnable" v-if="customTabbarEnable"
v-model="tabbarStore.curIdx" v-model="tabbarStore.curIdx"
bordered
safe-area-inset-bottom safe-area-inset-bottom bordered placeholder fixed
placeholder
fixed
@change="selectTabBar" @change="selectTabBar"
> >
<block v-for="(item, idx) in tabbarList" :key="item.path"> <block v-for="(item, idx) in tabbarList" :key="item.path">
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup> <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' import FgTabbar from './fg-tabbar/fg-tabbar.vue'
const themeVars: ConfigProviderThemeVars = { const themeVars: ConfigProviderThemeVars = {
+6 -7
View File
@@ -1,15 +1,14 @@
import { VueQueryPlugin } from '@tanstack/vue-query' import { VueQueryPlugin } from '@tanstack/vue-query'
import { createSSRApp } from 'vue' import { createSSRApp } from 'vue'
import App from './App.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 { 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() { export function createApp() {
const app = createSSRApp(App) const app = createSSRApp(App)
+55 -1
View File
@@ -1,7 +1,7 @@
{ {
"globalStyle": { "globalStyle": {
"navigationStyle": "default", "navigationStyle": "default",
"navigationBarTitleText": "智控台", "navigationBarTitleText": "智",
"navigationBarBackgroundColor": "#f8f8f8", "navigationBarBackgroundColor": "#f8f8f8",
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"backgroundColor": "#FFFFFF" "backgroundColor": "#FFFFFF"
@@ -74,6 +74,24 @@
"navigationStyle": "custom" "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", "path": "pages/agent/tools",
"type": "page", "type": "page",
@@ -126,6 +144,42 @@
"navigationBarTitleText": "Login" "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", "path": "pages/register/index",
"type": "page", "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')
})
+603 -86
View File
@@ -1,10 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types' import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
import { computed, nextTick, onMounted, ref, watch } from 'vue' 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 { t } from '@/i18n'
import { usePluginStore, useProvider, useSpeedPitch } from '@/store' import { usePluginStore, useProvider, useSpeedPitch } from '@/store'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue'
import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs'
defineOptions({ defineOptions({
name: 'AgentEdit', name: 'AgentEdit',
@@ -64,6 +66,10 @@ const selectedTemplateId = ref('')
// 加载状态 // 加载状态
const loading = ref(false) const loading = ref(false)
const saving = 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<{ 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 = [ const reportOptions = [
@@ -110,9 +116,25 @@ const allFunctions = ref<PluginDefinition[]>([])
const dynamicTags = ref([]) const dynamicTags = ref([])
const inputValue = ref('') const inputValue = ref('')
const inputVisible = ref(false) const inputVisible = ref(false)
const languageOptions = ref([]) const languageOptions = ref<any[]>([])
const isVisibleReport = ref(false) const isVisibleReport = ref(false)
const tempSummaryMemory = ref('') 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) const audioRef = ref<UniApp.InnerAudioContext | null>(null)
@@ -123,6 +145,91 @@ const pluginStore = usePluginStore()
const speedPitchStore = useSpeedPitch() const speedPitchStore = useSpeedPitch()
const providerStore = useProvider() 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 // tabs
const tabList = [ const tabList = [
{ {
@@ -205,49 +312,97 @@ function handleRegulate() {
} }
// 加载智能体详情 // 加载智能体详情
async function loadAgentDetail() { async function loadAgentDetail(targetAgentId = agentId.value) {
if (!agentId.value) if (!targetAgentId)
return return false
const requestId = ++agentDetailRequestSequence
invalidateTtsMetadataRequest()
try { try {
loading.value = true loading.value = true
tempSummaryMemory.value = '' const detail = await getAgentDetail(targetAgentId)
const detail = await getAgentDetail(agentId.value) if (!isActiveAgentDetailRequest(targetAgentId, requestId)) {
const normalizedFunctions = normalizeAgentFunctions(detail.functions || []) return false
formData.value = { ...detail, functions: normalizedFunctions }
// 更新插件store
pluginStore.setCurrentAgentId(agentId.value)
pluginStore.setCurrentFunctions(normalizedFunctions)
// 更新语速音调
speedPitchStore.updateSpeedPitch({
ttsVolume: detail.ttsVolume || 0,
ttsRate: detail.ttsRate || 0,
ttsPitch: detail.ttsPitch || 0,
})
// 加载上下文配置
providerStore.updateProviders(detail.contextProviders || [])
// 如果有TTS模型,加载对应的音色选项
if (detail.ttsModelId) {
await fetchAllLanguag(detail.ttsModelId)
} }
applyPersistedAgentDetail(detail, targetAgentId)
// 等待模型选项加载完成后再更新显示名称 await enhanceAgentDetailMetadata(detail, targetAgentId, requestId)
await nextTick() return isActiveAgentDetailRequest(targetAgentId, requestId)
updateDisplayNames()
} }
catch (error) { catch (error) {
console.error('加载智能体详情失败:', error) if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
toast.error(t('agent.loadFail')) console.error('加载智能体详情失败:', error)
toast.error(t('agent.loadFail'))
}
return false
} }
finally { 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) { function getVoiceDisplayName(ttsVoiceId: string) {
if (!ttsVoiceId) if (!ttsVoiceId)
@@ -344,7 +499,67 @@ async function loadModelOptions() {
} }
// 根据语言筛选音色 // 根据语言筛选音色
function filterVoicesByLanguage() { interface VoiceSelectionOptions {
autoSelectVoice?: boolean
preferredLanguage?: string | null
preferredVoiceId?: string | null
}
interface TtsSelectionState {
modelId: string
voiceId: string
language: string
selectedLanguage: string
languageTouched: boolean
voiceTouched: boolean
optionsModelId: string
voiceOptions: any[]
voiceDetails: Record<string, any>
languageOptions: any[]
displayNames: {
tts: string
voiceprint: string
language: string
}
}
function captureTtsSelectionState(): TtsSelectionState {
return {
modelId: formData.value.ttsModelId || '',
voiceId: formData.value.ttsVoiceId || '',
language: formData.value.ttsLanguage || '',
selectedLanguage: selectedTtsLanguage.value,
languageTouched: ttsLanguageTouched.value,
voiceTouched: ttsVoiceTouched.value,
optionsModelId: ttsOptionsModelId.value,
voiceOptions: voiceOptions.value,
voiceDetails: voiceDetails.value,
languageOptions: languageOptions.value,
displayNames: {
tts: displayNames.value.tts,
voiceprint: displayNames.value.voiceprint,
language: displayNames.value.language,
},
}
}
function restoreTtsSelectionState(state: TtsSelectionState) {
formData.value.ttsModelId = state.modelId
formData.value.ttsVoiceId = state.voiceId
formData.value.ttsLanguage = state.language
selectedTtsLanguage.value = state.selectedLanguage
ttsLanguageTouched.value = state.languageTouched
ttsVoiceTouched.value = state.voiceTouched
ttsOptionsModelId.value = state.optionsModelId
voiceOptions.value = state.voiceOptions
voiceDetails.value = state.voiceDetails
languageOptions.value = state.languageOptions
displayNames.value.tts = state.displayNames.tts
displayNames.value.voiceprint = state.displayNames.voiceprint
displayNames.value.language = state.displayNames.language
}
function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) {
if (!voiceDetails.value || Object.keys(voiceDetails.value).length === 0) { if (!voiceDetails.value || Object.keys(voiceDetails.value).length === 0) {
voiceOptions.value = [] voiceOptions.value = []
return return
@@ -353,13 +568,7 @@ function filterVoicesByLanguage() {
const allVoices = Object.values(voiceDetails.value) as any[] const allVoices = Object.values(voiceDetails.value) as any[]
// 根据选中的语言筛选音色 // 根据选中的语言筛选音色
const filteredVoices = allVoices.filter((voice) => { const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
if (!voice.languages) {
return false
}
const languagesArray = voice.languages.split(/[、;;,]/).map(lang => lang.trim()).filter(lang => lang)
return languagesArray.includes(formData.value.language)
})
voiceOptions.value = filteredVoices.map(voice => ({ voiceOptions.value = filteredVoices.map(voice => ({
value: voice.id, value: voice.id,
@@ -374,26 +583,46 @@ function filterVoicesByLanguage() {
const currentVoiceSupportsLanguage = formData.value.ttsVoiceId const currentVoiceSupportsLanguage = formData.value.ttsVoiceId
&& filteredVoices.some(voice => voice.id === 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 : '' formData.value.ttsVoiceId = filteredVoices.length > 0 ? filteredVoices[0].id : ''
displayNames.value.voiceprint = filteredVoices.length > 0 ? filteredVoices[0].name : '' displayNames.value.voiceprint = filteredVoices.length > 0 ? filteredVoices[0].name : ''
ttsVoiceTouched.value = true
} }
else { else {
displayNames.value.voiceprint = filteredVoices.find(item => item.id === formData.value.ttsVoiceId)?.name displayNames.value.voiceprint = filteredVoices.find(item => item.id === formData.value.ttsVoiceId)?.name
|| getVoiceDisplayName(formData.value.ttsVoiceId)
} }
}
// 同步到ttsSettings(如果值为null,使用0作为显示默认值,但不修改form中的值) function getVoiceDefaultLanguage(ttsVoiceId: string) {
speedPitchStore.updateSpeedPitch({ if (!ttsVoiceId || !voiceDetails.value?.[ttsVoiceId]?.languages) {
ttsVolume: formData.value.ttsVolume !== null && formData.value.ttsVolume !== undefined ? formData.value.ttsVolume : 0, return ''
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, 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 { try {
const res = await getAllLanguage(ttsModelId) 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) => { voiceDetails.value = res.reduce((acc, voice) => {
acc[voice.id] = voice acc[voice.id] = voice
@@ -412,21 +641,52 @@ async function fetchAllLanguag(ttsModelId: string) {
name: lang, name: lang,
})) }))
// 使用后端返回的用户选择的语言,如果没有则使用第一个语言选项 const requestedLanguage = options.preferredLanguage
if (formData.value.ttsLanguage && languageOptions.value.some(option => option.value === formData.value.ttsLanguage)) { const preferredVoiceLanguage = options.preferredVoiceId
formData.value.language = formData.value.ttsLanguage ? 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 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) { 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 displayNames.value.language = languageOptions.value[0].value
} }
// 根据选中的语言筛选音色 // 根据选中的语言筛选音色
filterVoicesByLanguage() filterVoicesByLanguage(options)
ttsOptionsModelId.value = ttsModelId
return 'loaded'
} }
catch { catch (error) {
languageOptions.value = [] if (requestId === ttsOptionsRequestSequence) {
console.error('Failed to load TTS options:', error)
ttsOptionsModelId.value = ''
}
return requestId === ttsOptionsRequestSequence ? 'failed' : 'stale'
}
finally {
if (requestId === ttsOptionsRequestSequence) {
ttsOptionsLoading.value = false
}
} }
} }
@@ -448,7 +708,10 @@ async function fetchAllLanguag(ttsModelId: string) {
// } // }
// 选择角色模板 // 选择角色模板
function selectRoleTemplate(templateId: string) { async function selectRoleTemplate(templateId: string) {
if (ttsOptionsLoading.value) {
return
}
if (selectedTemplateId.value === templateId) { if (selectedTemplateId.value === templateId) {
selectedTemplateId.value = '' selectedTemplateId.value = ''
return return
@@ -457,6 +720,9 @@ function selectRoleTemplate(templateId: string) {
selectedTemplateId.value = templateId selectedTemplateId.value = templateId
const template = roleTemplates.value.find(t => t.id === templateId) const template = roleTemplates.value.find(t => t.id === templateId)
if (template) { if (template) {
const previousTtsState = captureTtsSelectionState()
const templateTtsLanguage = template.ttsLanguage?.trim() || ''
const hasTemplateTtsLanguage = Boolean(templateTtsLanguage)
formData.value = { formData.value = {
...formData.value, ...formData.value,
systemPrompt: template.systemPrompt || formData.value.systemPrompt, systemPrompt: template.systemPrompt || formData.value.systemPrompt,
@@ -469,18 +735,40 @@ function selectRoleTemplate(templateId: string) {
memModelId: template.memModelId || formData.value.memModelId, memModelId: template.memModelId || formData.value.memModelId,
ttsModelId: template.ttsModelId || formData.value.ttsModelId, ttsModelId: template.ttsModelId || formData.value.ttsModelId,
ttsVoiceId: template.ttsVoiceId || formData.value.ttsVoiceId, ttsVoiceId: template.ttsVoiceId || formData.value.ttsVoiceId,
ttsLanguage: hasTemplateTtsLanguage ? templateTtsLanguage : formData.value.ttsLanguage,
agentName: template.agentName || formData.value.agentName, agentName: template.agentName || formData.value.agentName,
chatHistoryConf: template.chatHistoryConf || formData.value.chatHistoryConf, chatHistoryConf: template.chatHistoryConf || formData.value.chatHistoryConf,
summaryMemory: template.summaryMemory || formData.value.summaryMemory, summaryMemory: template.summaryMemory || formData.value.summaryMemory,
langCode: template.langCode || formData.value.langCode, 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() updateDisplayNames()
} }
} }
// 打开选择器 // 打开选择器
function openPicker(type: string) { function openPicker(type: string) {
if (ttsOptionsLoading.value && (type === 'tts' || type === 'language' || type === 'voiceprint')) {
return
}
pickerShow.value[type] = true pickerShow.value[type] = true
} }
@@ -488,6 +776,7 @@ function openPicker(type: string) {
async function onPickerConfirm(type: string, value: any, name: string) { async function onPickerConfirm(type: string, value: any, name: string) {
console.log('选择器确认:', type, value, name) console.log('选择器确认:', type, value, name)
const previousTtsState = type === 'tts' ? captureTtsSelectionState() : null
// 保存显示名称 // 保存显示名称
displayNames.value[type] = name displayNames.value[type] = name
@@ -526,16 +815,34 @@ async function onPickerConfirm(type: string, value: any, name: string) {
tempSummaryMemory.value = '' tempSummaryMemory.value = ''
} }
break break
case 'tts': case 'tts': {
const preferredLanguage = selectedTtsLanguage.value
formData.value.ttsModelId = 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 break
}
case 'language': case 'language':
formData.value.language = value selectedTtsLanguage.value = value
filterVoicesByLanguage() formData.value.ttsLanguage = value
ttsLanguageTouched.value = true
filterVoicesByLanguage({ autoSelectVoice: true })
break break
case 'voiceprint': case 'voiceprint':
formData.value.ttsVoiceId = value formData.value.ttsVoiceId = value
ttsVoiceTouched.value = true
if (selectedTtsLanguage.value) {
formData.value.ttsLanguage = selectedTtsLanguage.value
ttsLanguageTouched.value = true
}
displayNames.value.voiceprint = name // 确保显示名称正确更新 displayNames.value.voiceprint = name // 确保显示名称正确更新
break break
case 'report': case 'report':
@@ -623,6 +930,28 @@ function getModelDisplayName(modelType: string, modelId: string) {
// 保存智能体 // 保存智能体
async function saveAgent() { 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()) { if (!formData.value.agentName?.trim()) {
toast.warning(t('agent.pleaseInputAgentName')) toast.warning(t('agent.pleaseInputAgentName'))
return return
@@ -633,26 +962,46 @@ async function saveAgent() {
return return
} }
try {
await handleUpdateAgentTags()
}
catch (err) {
toast.error(err)
return
}
try { try {
saving.value = true 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, ...formData.value,
...speedPitchStore.speedPitch,
ttsLanguage: formData.value.language,
contextProviders: providerStore.providers, contextProviders: providerStore.providers,
functions: normalizeAgentFunctions(formData.value.functions || []), 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) await updateAgent(agentId.value, saveData)
loadAgentDetail() if (tagsChanged) {
originalTagNames.value = [...tagNames]
}
speedPitchStore.resetChangedFields()
await loadAgentDetail()
toast.success(t('agent.saveSuccess')) toast.success(t('agent.saveSuccess'))
} }
@@ -696,18 +1045,123 @@ function handleTools() {
} }
// 获取智能体标签 // 获取智能体标签
async function loadAgentTags() { async function loadAgentTags(targetAgentId = agentId.value) {
if (!targetAgentId) {
return false
}
const requestId = ++agentTagRequestSequence
try { try {
const res = await getAgentTags(agentId.value) const res = await getAgentTags(targetAgentId)
if (!isActiveAgentTagRequest(targetAgentId, requestId)) {
return false
}
dynamicTags.value = res || [] 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) {}
} }
// 更新智能体标签 function isActiveAgentTagRequest(targetAgentId: string, requestId: number) {
async function handleUpdateAgentTags() { return targetAgentId === agentId.value && requestId === agentTagRequestSequence
const tagNames = dynamicTags.value.map(tag => tag.tagName) }
await updateAgentTags(agentId.value, { tagNames })
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中的插件配置变化 // 监听store中的插件配置变化
@@ -715,6 +1169,24 @@ watch(() => pluginStore.currentFunctions, (newFunctions) => {
formData.value.functions = normalizeAgentFunctions(newFunctions || []) formData.value.functions = normalizeAgentFunctions(newFunctions || [])
}, { deep: true }) }, { 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 () => { onMounted(async () => {
loadAgentTags() loadAgentTags()
@@ -726,7 +1198,7 @@ onMounted(async () => {
]) ])
// 然后加载智能体详情,这样可以正确映射显示名称 // 然后加载智能体详情,这样可以正确映射显示名称
if (agentId.value) { if (agentId.value && !snapshotReloadBlocked.value) {
await loadAgentDetail() await loadAgentDetail()
} }
}) })
@@ -734,6 +1206,26 @@ onMounted(async () => {
<template> <template>
<view class="bg-[#f5f7fb] px-[20rpx]"> <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]"> <view class="pb-[20rpx] first:pt-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold"> <text class="text-[32rpx] text-[#232338] font-bold">
@@ -1005,10 +1497,27 @@ onMounted(async () => {
<!-- 保存按钮 --> <!-- 保存按钮 -->
<view class="mt-[40rpx] p-0"> <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 <wd-button
type="primary" type="primary"
:loading="saving" :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]" custom-class="w-full h-[80rpx] rounded-[16rpx] text-[30rpx] font-semibold bg-[#336cff] active:bg-[#2d5bd1]"
@click="saveAgent" @click="saveAgent"
> >
@@ -1109,6 +1618,14 @@ onMounted(async () => {
@close="onPickerCancel('report')" @close="onPickerCancel('report')"
@select="({ item }) => onPickerConfirm('report', item.value, item.name)" @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> </view>
</template> </template>

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