mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-23 07:33:53 +08:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d760465408 | ||
|
|
bdb2538239 | ||
|
|
ea6c144e43 | ||
|
|
a79aa455d6 | ||
|
|
b500d1c6bd | ||
|
|
0598b09629 | ||
|
|
e4907121f4 | ||
|
|
7c58fa37b2 | ||
|
|
2a618d2f8f | ||
|
|
10a67c772f | ||
|
|
7e8e5d34b8 | ||
|
|
c6bc20c05e | ||
|
|
1998807b69 | ||
|
|
55c0495661 | ||
|
|
dbeda01697 | ||
|
|
023dea2441 | ||
|
|
ad3fb4d8c8 | ||
|
|
176785830a | ||
|
|
d78ecfce6f | ||
|
|
179281e49c | ||
|
|
bc1aa1a089 | ||
|
|
687b6db96b | ||
|
|
a5aee109fe | ||
|
|
d11cfc6923 | ||
|
|
aae2ef2152 |
@@ -112,12 +112,17 @@ celerybeat.pid
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
/.venv-*/
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Repository-local runtimes and package-manager caches
|
||||
/.runtime/
|
||||
/main/manager-web/.npm-cache/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<java.version>21</java.version>
|
||||
<junit.version>5.10.1</junit.version>
|
||||
<druid.version>1.2.20</druid.version>
|
||||
<mybatisplus.version>3.5.5</mybatisplus.version>
|
||||
<mybatisplus.version>3.5.17</mybatisplus.version>
|
||||
<hutool.version>5.8.24</hutool.version>
|
||||
<jsoup.version>1.19.1</jsoup.version>
|
||||
<knife4j.version>4.6.0</knife4j.version>
|
||||
@@ -176,13 +176,13 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-boot-starter</artifactId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatisplus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mybatis</groupId>
|
||||
<artifactId>mybatis-spring</artifactId>
|
||||
<version>3.0.3</version>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
|
||||
<version>${mybatisplus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
@@ -272,6 +272,16 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</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>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
|
||||
+2
-2
@@ -67,7 +67,7 @@ public class DataFilterInterceptor implements InnerInterceptor {
|
||||
private String getSelect(String buildSql, DataScope scope) {
|
||||
try {
|
||||
Select select = (Select) CCJSqlParserUtil.parse(buildSql);
|
||||
PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
|
||||
PlainSelect plainSelect = select.getPlainSelect();
|
||||
|
||||
Expression expression = plainSelect.getWhere();
|
||||
if (expression == null) {
|
||||
@@ -82,4 +82,4 @@ public class DataFilterInterceptor implements InnerInterceptor {
|
||||
return buildSql;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import org.apache.ibatis.binding.MapperMethod;
|
||||
import org.apache.ibatis.logging.Log;
|
||||
@@ -81,8 +81,8 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
|
||||
// 处理排序字段
|
||||
if (orderField instanceof String) {
|
||||
orderFields.add((String) orderField);
|
||||
} else if (orderField instanceof List) {
|
||||
orderFields.addAll((List<String>) orderField);
|
||||
} else if (orderField instanceof List<?> fields) {
|
||||
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);
|
||||
}
|
||||
|
||||
protected Class<M> currentMapperClass() {
|
||||
return (Class<M>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0);
|
||||
protected Class<?> currentMapperClass() {
|
||||
return ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Class<T> currentModelClass() {
|
||||
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")
|
||||
protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiConsumer<SqlSession, E> consumer) {
|
||||
return SqlHelper.executeBatch(this.currentModelClass(), this.log, list, batchSize, consumer);
|
||||
protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiFunction<SqlSession, E, Integer> operation) {
|
||||
return SqlHelper.executeBatch(SqlHelper.sqlSessionFactory(this.currentModelClass()), this.log, list, batchSize,
|
||||
operation);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -209,7 +211,7 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
|
||||
return executeBatch(entityList, batchSize, (sqlSession, entity) -> {
|
||||
MapperMethod.ParamMap<T> param = new MapperMethod.ParamMap<>();
|
||||
param.put(Constants.ENTITY, entity);
|
||||
sqlSession.update(sqlStatement, param);
|
||||
return sqlSession.update(sqlStatement, param);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,6 +227,6 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
|
||||
|
||||
@Override
|
||||
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>
|
||||
implements CrudService<T, D> {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
protected Class<D> currentDtoClass() {
|
||||
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
|
||||
public void delete(Serializable[] ids) {
|
||||
baseDao.deleteBatchIds(Arrays.asList(ids));
|
||||
baseDao.deleteByIds(Arrays.asList(ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package xiaozhi.common.utils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -16,6 +18,10 @@ import cn.hutool.core.util.StrUtil;
|
||||
*/
|
||||
public class JsonUtils {
|
||||
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) {
|
||||
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);
|
||||
|
||||
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) {
|
||||
result.put(key, maskSensitiveFields((JSONObject) value));
|
||||
result.set(key, maskSensitiveFields((JSONObject) value));
|
||||
} 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;
|
||||
}
|
||||
// 去掉'|"|;|\字符
|
||||
str = StringUtils.replace(str, "'", "");
|
||||
str = StringUtils.replace(str, "\"", "");
|
||||
str = StringUtils.replace(str, ";", "");
|
||||
str = StringUtils.replace(str, "\\", "");
|
||||
str = str.replace("'", "");
|
||||
str = str.replace("\"", "");
|
||||
str = str.replace(";", "");
|
||||
str = str.replace("\\", "");
|
||||
|
||||
// 转换成小写
|
||||
str = str.toLowerCase();
|
||||
|
||||
+45
-17
@@ -28,10 +28,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.common.utils.ResultUtils;
|
||||
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.vo.AgentChatHistoryUserVO;
|
||||
import xiaozhi.modules.agent.vo.AgentInfoVO;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
|
||||
@Tag(name = "智能体管理")
|
||||
@@ -61,15 +62,40 @@ import xiaozhi.modules.security.user.SecurityUser;
|
||||
@RestController
|
||||
@RequestMapping("/agent")
|
||||
public class AgentController {
|
||||
private static final long AUDIO_PLAY_TOKEN_EXPIRE_SECONDS = 300L;
|
||||
|
||||
private final AgentService agentService;
|
||||
private final AgentTemplateService agentTemplateService;
|
||||
private final DeviceService deviceService;
|
||||
private final AgentChatHistoryService agentChatHistoryService;
|
||||
private final AgentChatAudioService agentChatAudioService;
|
||||
private final AgentChatSummaryService agentChatSummaryService;
|
||||
private final RedisUtils redisUtils;
|
||||
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")
|
||||
@Operation(summary = "获取用户智能体列表")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
@@ -100,7 +126,7 @@ public class AgentController {
|
||||
@Operation(summary = "获取智能体详情")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<AgentInfoVO> getAgentById(@PathVariable("id") String id) {
|
||||
AgentInfoVO agent = agentService.getAgentById(id);
|
||||
AgentInfoVO agent = agentService.getAgentById(id, SecurityUser.getUserId());
|
||||
return ResultUtils.success(agent);
|
||||
}
|
||||
|
||||
@@ -114,20 +140,16 @@ public class AgentController {
|
||||
|
||||
@PutMapping("/saveMemory/{macAddress}")
|
||||
@Operation(summary = "根据设备id更新智能体")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> updateByDeviceId(@PathVariable String macAddress, @RequestBody @Valid AgentMemoryDTO dto) {
|
||||
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
|
||||
if (device == null) {
|
||||
return new Result<>();
|
||||
}
|
||||
AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO();
|
||||
agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory());
|
||||
agentService.updateAgentById(device.getAgentId(), agentUpdateDTO, false);
|
||||
return new Result<>();
|
||||
agentService.updateAgentMemoryByDeviceMacAddress(macAddress, dto, SecurityUser.getUserId());
|
||||
return new Result<Void>().ok(null);
|
||||
}
|
||||
|
||||
@PostMapping("/chat-summary/{sessionId}/save")
|
||||
@Operation(summary = "根据会话ID生成聊天记录总结并保存(异步执行)")
|
||||
public Result<Void> generateAndSaveChatSummary(@PathVariable String sessionId) {
|
||||
requireSessionAgent(sessionId);
|
||||
try {
|
||||
// 异步执行总结生成任务,立即返回成功响应
|
||||
new Thread(() -> {
|
||||
@@ -149,6 +171,7 @@ public class AgentController {
|
||||
@PostMapping("/chat-title/{sessionId}/generate")
|
||||
@Operation(summary = "根据会话ID生成聊天标题")
|
||||
public Result<Void> generateAndSaveChatTitle(@PathVariable String sessionId) {
|
||||
requireSessionAgent(sessionId);
|
||||
agentChatSummaryService.generateAndSaveChatTitle(sessionId);
|
||||
return new Result<Void>().ok(null);
|
||||
}
|
||||
@@ -157,7 +180,7 @@ public class AgentController {
|
||||
@Operation(summary = "更新智能体")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) {
|
||||
agentService.updateAgentById(id, dto);
|
||||
agentService.updateAgentById(id, dto, SecurityUser.getUserId());
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
@@ -165,7 +188,7 @@ public class AgentController {
|
||||
@Operation(summary = "删除智能体")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> delete(@PathVariable String id) {
|
||||
agentService.deleteAgent(id);
|
||||
agentService.deleteAgentById(id, SecurityUser.getUserId());
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
@@ -188,6 +211,7 @@ public class AgentController {
|
||||
public Result<PageData<AgentChatSessionDTO>> getAgentSessions(
|
||||
@PathVariable("id") String id,
|
||||
@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
|
||||
requireAgentPermission(id);
|
||||
params.put("agentId", id);
|
||||
PageData<AgentChatSessionDTO> page = agentChatHistoryService.getSessionListByAgentId(params);
|
||||
return new Result<PageData<AgentChatSessionDTO>>().ok(page);
|
||||
@@ -235,6 +259,7 @@ public class AgentController {
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<String> getContentByAudioId(
|
||||
@PathVariable("id") String id) {
|
||||
requireAudioPermission(id);
|
||||
// 查询聊天记录
|
||||
String data = agentChatHistoryService.getContentByAudioId(id);
|
||||
return new Result<String>().ok(data);
|
||||
@@ -244,12 +269,13 @@ public class AgentController {
|
||||
@Operation(summary = "获取音频下载ID")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<String> getAudioId(@PathVariable("audioId") String audioId) {
|
||||
requireAudioPermission(audioId);
|
||||
byte[] audioData = agentChatAudioService.getAudio(audioId);
|
||||
if (audioData == null) {
|
||||
return new Result<String>().error("音频不存在");
|
||||
}
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId);
|
||||
redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId, AUDIO_PLAY_TOKEN_EXPIRE_SECONDS);
|
||||
return new Result<String>().ok(uuid);
|
||||
}
|
||||
|
||||
@@ -305,6 +331,7 @@ public class AgentController {
|
||||
@Operation(summary = "获取智能体的标签")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<List<AgentTagDTO>> getAgentTags(@PathVariable String id) {
|
||||
requireAgentPermission(id);
|
||||
List<AgentTagDTO> tags = agentTagService.getTagsByAgentId(id);
|
||||
return new Result<List<AgentTagDTO>>().ok(tags);
|
||||
}
|
||||
@@ -313,8 +340,9 @@ public class AgentController {
|
||||
@Operation(summary = "保存智能体的标签")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> saveAgentTags(@PathVariable String id, @RequestBody Map<String, Object> params) {
|
||||
List<String> tagIds = (List<String>) params.get("tagIds");
|
||||
List<String> tagNames = (List<String>) params.get("tagNames");
|
||||
requireAgentPermission(id);
|
||||
List<String> tagIds = JsonUtils.toList(params.get("tagIds"), String.class);
|
||||
List<String> tagNames = JsonUtils.toList(params.get("tagNames"), String.class);
|
||||
AgentUpdateDTO dto = new AgentUpdateDTO();
|
||||
dto.setTagIds(tagIds);
|
||||
dto.setTagNames(tagNames);
|
||||
|
||||
+6
-2
@@ -6,17 +6,20 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentSnapshotRestoreDTO;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.AgentSnapshotService;
|
||||
import xiaozhi.modules.agent.vo.AgentSnapshotVO;
|
||||
@@ -51,9 +54,10 @@ public class AgentSnapshotController {
|
||||
@PostMapping("/{snapshotId}/restore")
|
||||
@Operation(summary = "恢复智能体快照")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> restore(@PathVariable String agentId, @PathVariable String snapshotId) {
|
||||
public Result<Void> restore(@PathVariable String agentId, @PathVariable String snapshotId,
|
||||
@RequestBody @Valid AgentSnapshotRestoreDTO request) {
|
||||
checkPermission(agentId);
|
||||
agentSnapshotService.restoreSnapshot(agentId, snapshotId);
|
||||
agentSnapshotService.restoreSnapshot(agentId, snapshotId, request.getCurrentStateToken());
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
|
||||
@@ -43,4 +43,13 @@ public interface AgentDao extends BaseDao<AgentEntity> {
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
AgentEntity selectByIdForUpdate(@Param("agentId") String agentId);
|
||||
|
||||
/**
|
||||
* 精确写入快照覆盖的智能体字段,包括目标快照中的 null 值。
|
||||
* 不更新所属用户、创建信息等不属于快照的字段。
|
||||
*
|
||||
* @param agent 已应用目标快照的智能体
|
||||
* @return 受影响行数
|
||||
*/
|
||||
int updateSnapshotFields(@Param("agent") AgentEntity agent);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package xiaozhi.modules.agent.dao;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@@ -17,4 +19,11 @@ public interface AgentSnapshotDao extends BaseDao<AgentSnapshotEntity> {
|
||||
int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot);
|
||||
|
||||
int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit);
|
||||
|
||||
List<AgentSnapshotEntity> selectLegacyRedactionBatch(@Param("afterId") String afterId,
|
||||
@Param("limit") int limit,
|
||||
@Param("targetRedactionVersion") int targetRedactionVersion);
|
||||
|
||||
int updateRedactedSnapshots(@Param("snapshots") List<AgentSnapshotEntity> snapshots,
|
||||
@Param("redactionVersion") int redactionVersion);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public class AgentDTO {
|
||||
private String systemPrompt;
|
||||
|
||||
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
||||
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
|
||||
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||
private String summaryMemory;
|
||||
|
||||
@Schema(description = "最后连接时间", example = "2024-03-20 10:00:00")
|
||||
@@ -50,4 +50,4 @@ public class AgentDTO {
|
||||
|
||||
@Schema(description = "标签列表")
|
||||
private List<AgentTagDTO> tags;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ public class AgentMemoryDTO implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
||||
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
|
||||
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||
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)
|
||||
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;
|
||||
|
||||
@Schema(description = "语音合成模型标识", example = "tts_model_02", required = false)
|
||||
@Schema(description = "语音合成模型标识", example = "tts_model_02", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||
private String ttsModelId;
|
||||
|
||||
@Schema(description = "音色标识", example = "voice_02", nullable = true)
|
||||
|
||||
@@ -74,7 +74,7 @@ public class AgentEntity {
|
||||
private String systemPrompt;
|
||||
|
||||
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
||||
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
|
||||
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||
private String summaryMemory;
|
||||
|
||||
@Schema(description = "语言编码")
|
||||
@@ -97,4 +97,4 @@ public class AgentEntity {
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private Date updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,4 +47,7 @@ public class AgentSnapshotEntity {
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private Date createdAt;
|
||||
|
||||
@Schema(description = "快照数据脱敏规则版本")
|
||||
private Integer redactionVersion;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
package xiaozhi.modules.agent.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.baomidou.mybatisplus.extension.repository.IRepository;
|
||||
|
||||
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
|
||||
|
||||
@@ -11,7 +11,7 @@ import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
|
||||
* @version 1.0, 2025/5/8
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public interface AgentChatAudioService extends IService<AgentChatAudioEntity> {
|
||||
public interface AgentChatAudioService extends IRepository<AgentChatAudioEntity> {
|
||||
/**
|
||||
* 保存音频数据
|
||||
*
|
||||
|
||||
+20
-4
@@ -3,9 +3,9 @@ package xiaozhi.modules.agent.service;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
import xiaozhi.common.page.PageData;
|
||||
import com.baomidou.mybatisplus.extension.repository.IRepository;
|
||||
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentChatSessionDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentChatHistoryEntity;
|
||||
@@ -18,7 +18,7 @@ import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
|
||||
* @version 1.0, 2025/4/30
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity> {
|
||||
public interface AgentChatHistoryService extends IRepository<AgentChatHistoryEntity> {
|
||||
|
||||
/**
|
||||
* 根据智能体ID获取会话列表
|
||||
@@ -37,6 +37,14 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
|
||||
*/
|
||||
List<AgentChatHistoryDTO> getChatHistoryBySessionId(String agentId, String sessionId);
|
||||
|
||||
/**
|
||||
* 根据会话ID获取智能体ID
|
||||
*
|
||||
* @param sessionId 会话ID
|
||||
* @return 智能体ID
|
||||
*/
|
||||
String getAgentIdBySessionId(String sessionId);
|
||||
|
||||
/**
|
||||
* 根据智能体ID删除聊天记录
|
||||
*
|
||||
@@ -62,6 +70,14 @@ public interface AgentChatHistoryService extends IService<AgentChatHistoryEntity
|
||||
*/
|
||||
String getContentByAudioId(String audioId);
|
||||
|
||||
/**
|
||||
* 根据音频ID获取智能体ID
|
||||
*
|
||||
* @param audioId 音频ID
|
||||
* @return 智能体ID
|
||||
*/
|
||||
String getAgentIdByAudioId(String audioId);
|
||||
|
||||
|
||||
/**
|
||||
* 查询此音频id是否属于此智能体
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ package xiaozhi.modules.agent.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.baomidou.mybatisplus.extension.repository.IRepository;
|
||||
|
||||
import xiaozhi.modules.agent.entity.AgentPluginMapping;
|
||||
|
||||
@@ -10,7 +10,7 @@ import xiaozhi.modules.agent.entity.AgentPluginMapping;
|
||||
* @description 针对表【ai_agent_plugin_mapping(Agent与插件的唯一映射表)】的数据库操作Service
|
||||
* @createDate 2025-05-25 22:33:17
|
||||
*/
|
||||
public interface AgentPluginMappingService extends IService<AgentPluginMapping> {
|
||||
public interface AgentPluginMappingService extends IRepository<AgentPluginMapping> {
|
||||
|
||||
/**
|
||||
* 根据智能体id获取插件参数
|
||||
|
||||
@@ -7,6 +7,7 @@ import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.BaseService;
|
||||
import xiaozhi.modules.agent.dto.AgentCreateDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||
import xiaozhi.modules.agent.vo.AgentInfoVO;
|
||||
@@ -35,6 +36,15 @@ public interface AgentService extends BaseService<AgentEntity> {
|
||||
*/
|
||||
AgentInfoVO getAgentById(String id);
|
||||
|
||||
/**
|
||||
* 根据ID获取当前用户有权访问的智能体
|
||||
*
|
||||
* @param id 智能体ID
|
||||
* @param userId 当前用户ID
|
||||
* @return 智能体实体
|
||||
*/
|
||||
AgentInfoVO getAgentById(String id, Long userId);
|
||||
|
||||
/**
|
||||
* 插入智能体
|
||||
*
|
||||
@@ -100,6 +110,32 @@ public interface AgentService extends BaseService<AgentEntity> {
|
||||
*/
|
||||
void updateAgentById(String agentId, AgentUpdateDTO dto);
|
||||
|
||||
/**
|
||||
* 更新当前用户有权访问的智能体
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
* @param dto 更新智能体所需的信息
|
||||
* @param userId 当前用户ID
|
||||
*/
|
||||
void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId);
|
||||
|
||||
/**
|
||||
* 根据设备MAC地址更新当前用户有权访问的智能体记忆
|
||||
*
|
||||
* @param macAddress 设备MAC地址
|
||||
* @param dto 智能体记忆
|
||||
* @param userId 当前用户ID
|
||||
*/
|
||||
void updateAgentMemoryByDeviceMacAddress(String macAddress, AgentMemoryDTO dto, Long userId);
|
||||
|
||||
/**
|
||||
* 删除当前用户有权访问的智能体
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
* @param userId 当前用户ID
|
||||
*/
|
||||
void deleteAgentById(String agentId, Long userId);
|
||||
|
||||
/**
|
||||
* 更新智能体
|
||||
*
|
||||
|
||||
+3
-1
@@ -13,11 +13,13 @@ public interface AgentSnapshotService extends BaseService<AgentSnapshotEntity> {
|
||||
|
||||
AgentSnapshotVO getSnapshot(String agentId, String snapshotId);
|
||||
|
||||
void restoreSnapshot(String agentId, String snapshotId);
|
||||
void restoreSnapshot(String agentId, String snapshotId, String currentStateToken);
|
||||
|
||||
void deleteSnapshot(String agentId, String snapshotId);
|
||||
|
||||
Integer getCurrentVersionNo(String agentId);
|
||||
|
||||
void deleteByAgentId(String agentId);
|
||||
|
||||
long redactLegacySnapshots();
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package xiaozhi.modules.agent.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.repository.IRepository;
|
||||
|
||||
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
||||
|
||||
/**
|
||||
@@ -9,7 +9,7 @@ import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
||||
* @description 针对表【ai_agent_template(智能体配置模板表)】的数据库操作Service
|
||||
* @createDate 2025-03-22 11:48:18
|
||||
*/
|
||||
public interface AgentTemplateService extends IService<AgentTemplateEntity> {
|
||||
public interface AgentTemplateService extends IRepository<AgentTemplateEntity> {
|
||||
|
||||
/**
|
||||
* 获取默认模板
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ package xiaozhi.modules.agent.service.impl;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
|
||||
|
||||
import xiaozhi.modules.agent.dao.AiAgentChatAudioDao;
|
||||
import xiaozhi.modules.agent.entity.AgentChatAudioEntity;
|
||||
@@ -16,7 +16,7 @@ import xiaozhi.modules.agent.service.AgentChatAudioService;
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Service
|
||||
public class AgentChatAudioServiceImpl extends ServiceImpl<AiAgentChatAudioDao, AgentChatAudioEntity>
|
||||
public class AgentChatAudioServiceImpl extends CrudRepository<AiAgentChatAudioDao, AgentChatAudioEntity>
|
||||
implements AgentChatAudioService {
|
||||
@Override
|
||||
public String saveAudio(byte[] audioData) {
|
||||
|
||||
+29
-3
@@ -14,7 +14,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
|
||||
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.page.PageData;
|
||||
@@ -39,7 +39,7 @@ import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryDao, AgentChatHistoryEntity>
|
||||
public class AgentChatHistoryServiceImpl extends CrudRepository<AiAgentChatHistoryDao, AgentChatHistoryEntity>
|
||||
implements AgentChatHistoryService {
|
||||
|
||||
private final AgentChatTitleService agentChatTitleService;
|
||||
@@ -88,6 +88,19 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
|
||||
return ConvertUtils.sourceToTarget(historyList, AgentChatHistoryDTO.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAgentIdBySessionId(String sessionId) {
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
AgentChatHistoryEntity entity = baseMapper.selectOne(
|
||||
new LambdaQueryWrapper<AgentChatHistoryEntity>()
|
||||
.select(AgentChatHistoryEntity::getAgentId)
|
||||
.eq(AgentChatHistoryEntity::getSessionId, sessionId)
|
||||
.last("LIMIT 1"));
|
||||
return entity == null ? null : entity.getAgentId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
|
||||
@@ -154,7 +167,7 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
|
||||
|
||||
// 尝试解析为 JSON
|
||||
try {
|
||||
Map<String, Object> jsonMap = JsonUtils.parseObject(content, Map.class);
|
||||
Map<String, Object> jsonMap = JsonUtils.parseMap(content);
|
||||
if (jsonMap != null && jsonMap.containsKey("content")) {
|
||||
Object contentObj = jsonMap.get("content");
|
||||
return contentObj != null ? contentObj.toString() : content;
|
||||
@@ -176,6 +189,19 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
|
||||
return agentChatHistoryEntity == null ? null : agentChatHistoryEntity.getContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAgentIdByAudioId(String audioId) {
|
||||
if (audioId == null || audioId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
AgentChatHistoryEntity entity = baseMapper.selectOne(
|
||||
new LambdaQueryWrapper<AgentChatHistoryEntity>()
|
||||
.select(AgentChatHistoryEntity::getAgentId)
|
||||
.eq(AgentChatHistoryEntity::getAudioId, audioId)
|
||||
.last("LIMIT 1"));
|
||||
return entity == null ? null : entity.getAgentId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAudioOwnedByAgent(String audioId, String agentId) {
|
||||
// 查询是否有指定音频id和智能体id的数据,如果有且只有一条说明此数据属性此智能体
|
||||
|
||||
+10
-10
@@ -76,7 +76,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
||||
// 等待初始化响应 (id=1) - 移除固定延迟,改为响应驱动
|
||||
List<String> initResponses = client.listenerWithoutClose(response -> {
|
||||
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"))) {
|
||||
// 检查是否有result字段,表示初始化成功
|
||||
return jsonMap.containsKey("result") && !jsonMap.containsKey("error");
|
||||
@@ -92,7 +92,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
||||
boolean initSucceeded = false;
|
||||
for (String response : initResponses) {
|
||||
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.containsKey("result")) {
|
||||
log.info("MCP初始化成功,智能体ID: {}", id);
|
||||
@@ -123,7 +123,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
||||
// 等待工具列表响应 (id=2)
|
||||
List<String> toolsResponses = client.listener(response -> {
|
||||
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"));
|
||||
} catch (Exception e) {
|
||||
log.warn("解析工具列表响应失败: {}", response, e);
|
||||
@@ -134,18 +134,18 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
||||
// 处理工具列表响应
|
||||
for (String response : toolsResponses) {
|
||||
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"))) {
|
||||
// 检查是否有result字段
|
||||
Object resultObj = jsonMap.get("result");
|
||||
if (resultObj instanceof Map) {
|
||||
Map<String, Object> resultMap = (Map<String, Object>) resultObj;
|
||||
if (resultObj instanceof Map<?, ?>) {
|
||||
Map<String, Object> resultMap = JsonUtils.toStringObjectMap(resultObj);
|
||||
Object toolsObj = resultMap.get("tools");
|
||||
if (toolsObj instanceof List) {
|
||||
List<Map<String, Object>> toolsList = (List<Map<String, Object>>) toolsObj;
|
||||
if (toolsObj instanceof List<?>) {
|
||||
List<Map<String, Object>> toolsList = JsonUtils.toStringObjectMapList(toolsObj);
|
||||
// 提取工具名称列表
|
||||
List<String> result = toolsList.stream()
|
||||
.map(tool -> (String) tool.get("name"))
|
||||
.map(tool -> String.class.cast(tool.get("name")))
|
||||
.filter(name -> name != null)
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
@@ -232,4 +232,4 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
||||
// 加密后成token值
|
||||
return AESUtils.encrypt(key, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -29,7 +29,7 @@ import xiaozhi.modules.model.service.ModelConfigService;
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AgentPluginMappingServiceImpl extends ServiceImpl<AgentPluginMappingMapper, AgentPluginMapping>
|
||||
public class AgentPluginMappingServiceImpl extends CrudRepository<AgentPluginMappingMapper, AgentPluginMapping>
|
||||
implements AgentPluginMappingService {
|
||||
private final AgentPluginMappingMapper agentPluginMappingMapper;
|
||||
private final KnowledgeBaseService knowledgeBaseService;
|
||||
|
||||
+148
-49
@@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.repository.IRepository;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
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.dto.AgentCreateDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentTagDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
|
||||
@@ -56,9 +58,9 @@ import xiaozhi.modules.model.dto.VoiceDTO;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
import xiaozhi.modules.model.service.ModelProviderService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.sys.enums.SuperAdminEnum;
|
||||
import xiaozhi.modules.timbre.service.TimbreService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.sys.enums.SuperAdminEnum;
|
||||
import xiaozhi.modules.timbre.service.TimbreService;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
@@ -93,6 +95,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
if (agent == null) {
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
requireCurrentUserPermissionIfPresent(agent);
|
||||
|
||||
if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
|
||||
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode());
|
||||
@@ -116,6 +119,65 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
return agent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentInfoVO getAgentById(String id, Long userId) {
|
||||
AgentInfoVO agent = getAgentById(id);
|
||||
requireAgentPermission(agent, userId);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private AgentEntity getAgentEntityOrThrow(String agentId) {
|
||||
AgentEntity agent = agentDao.selectById(agentId);
|
||||
if (agent == null) {
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
private boolean isCurrentUserSuperAdmin() {
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
return user != null && Integer.valueOf(SuperAdminEnum.YES.value()).equals(user.getSuperAdmin());
|
||||
}
|
||||
|
||||
private void requireCurrentUserPermissionIfPresent(AgentEntity agent) {
|
||||
Long userId = SecurityUser.getUserId();
|
||||
if (userId != null) {
|
||||
requireAgentPermission(agent, userId);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasAgentPermission(AgentEntity agent, Long userId) {
|
||||
if (agent == null) {
|
||||
return false;
|
||||
}
|
||||
if (isCurrentUserSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return userId != null && userId.equals(agent.getUserId());
|
||||
}
|
||||
|
||||
private void requireAgentPermission(AgentEntity agent, Long userId) {
|
||||
if (!hasAgentPermission(agent, userId)) {
|
||||
throw new RenException(ErrorCode.NO_PERMISSION);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasDevicePermission(DeviceEntity device, Long userId) {
|
||||
if (device == null) {
|
||||
return false;
|
||||
}
|
||||
if (isCurrentUserSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return userId != null && userId.equals(device.getUserId());
|
||||
}
|
||||
|
||||
private void requireDevicePermission(DeviceEntity device, Long userId) {
|
||||
if (!hasDevicePermission(device, userId)) {
|
||||
throw new RenException(ErrorCode.NO_PERMISSION);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean insert(AgentEntity entity) {
|
||||
// 如果ID为空,自动生成一个UUID作为ID
|
||||
@@ -275,25 +337,11 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean checkAgentPermission(String agentId, Long userId) {
|
||||
if (SecurityUser.getUser() == null || SecurityUser.getUser().getId() == null) {
|
||||
return false;
|
||||
}
|
||||
// 获取智能体信息
|
||||
AgentEntity agent = getAgentById(agentId);
|
||||
if (agent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果是超级管理员,直接返回true
|
||||
if (SecurityUser.getUser().getSuperAdmin() == SuperAdminEnum.YES.value()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否是智能体的所有者
|
||||
return userId.equals(agent.getUserId());
|
||||
}
|
||||
|
||||
public boolean checkAgentPermission(String agentId, Long userId) {
|
||||
AgentEntity agent = agentDao.selectById(agentId);
|
||||
return hasAgentPermission(agent, userId);
|
||||
}
|
||||
|
||||
// 根据id更新智能体信息
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -301,27 +349,44 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
updateAgentById(agentId, dto, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId) {
|
||||
updateAgentById(agentId, dto, userId, true);
|
||||
}
|
||||
|
||||
// 根据id更新智能体信息
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateAgentById(String agentId, AgentUpdateDTO dto, boolean createSnapshot) {
|
||||
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);
|
||||
}
|
||||
if (userId == null) {
|
||||
requireCurrentUserPermissionIfPresent(lockedAgent);
|
||||
} else {
|
||||
requireAgentPermission(lockedAgent, userId);
|
||||
}
|
||||
|
||||
// 锁定后查询现有实体和关联配置
|
||||
AgentEntity existingEntity = this.getAgentById(agentId);
|
||||
if (createSnapshot && agentSnapshotService.getCurrentVersionNo(agentId) == 0) {
|
||||
agentSnapshotService.createSnapshot(agentId, "initial");
|
||||
if (createSnapshot) {
|
||||
int currentVersionNo = agentSnapshotService.getCurrentVersionNo(agentId);
|
||||
agentSnapshotService.createSnapshot(agentId, currentVersionNo == 0 ? "initial" : "current");
|
||||
}
|
||||
|
||||
// 只更新提供的非空字段
|
||||
if (dto.getAgentName() != null) {
|
||||
existingEntity.setAgentName(dto.getAgentName());
|
||||
}
|
||||
if (dto.getAgentCode() != null) {
|
||||
existingEntity.setAgentCode(dto.getAgentCode());
|
||||
}
|
||||
if (dto.getAgentCode() != null) {
|
||||
existingEntity.setAgentCode(dto.getAgentCode());
|
||||
}
|
||||
if (dto.getAsrModelId() != null) {
|
||||
existingEntity.setAsrModelId(dto.getAsrModelId());
|
||||
}
|
||||
@@ -418,10 +483,10 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
.toList();
|
||||
|
||||
if (!toUpdate.isEmpty()) {
|
||||
agentPluginMappingService.updateBatchById(toUpdate);
|
||||
agentPluginMappingService.updateBatchById(toUpdate, IRepository.DEFAULT_BATCH_SIZE);
|
||||
}
|
||||
if (!toInsert.isEmpty()) {
|
||||
agentPluginMappingService.saveBatch(toInsert);
|
||||
agentPluginMappingService.saveBatch(toInsert, IRepository.DEFAULT_BATCH_SIZE);
|
||||
}
|
||||
|
||||
// 5. 删除本次不在提交列表里的插件映射
|
||||
@@ -430,7 +495,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
.map(AgentPluginMapping::getId)
|
||||
.toList();
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entity.setTtsVoiceId(template.getTtsVoiceId());
|
||||
entity.setMemModelId(template.getMemModelId());
|
||||
entity.setIntentModelId(template.getIntentModelId());
|
||||
entity.setSystemPrompt(template.getSystemPrompt());
|
||||
entity.setSummaryMemory(template.getSummaryMemory());
|
||||
|
||||
// 根据记忆模型类型设置默认的chatHistoryConf值
|
||||
if (template.getMemModelId() != null) {
|
||||
}
|
||||
|
||||
entity.setTtsVoiceId(template.getTtsVoiceId());
|
||||
entity.setTtsLanguage(defaultIfBlank(template.getTtsLanguage(),
|
||||
timbreModelService.getDefaultLanguageById(entity.getTtsVoiceId())));
|
||||
entity.setMemModelId(template.getMemModelId());
|
||||
entity.setIntentModelId(template.getIntentModelId());
|
||||
entity.setSystemPrompt(template.getSystemPrompt());
|
||||
entity.setSummaryMemory(template.getSummaryMemory());
|
||||
if (Constant.MEMORY_NO_MEM.equals(entity.getMemModelId())
|
||||
|| Constant.MEMORY_MEM_REPORT_ONLY.equals(entity.getMemModelId())) {
|
||||
entity.setSummaryMemory("");
|
||||
}
|
||||
|
||||
// 根据记忆模型类型设置默认的chatHistoryConf值
|
||||
if (template.getMemModelId() != null) {
|
||||
if (template.getMemModelId().equals("Memory_nomem")) {
|
||||
// 无记忆功能的模型,默认不记录聊天记录
|
||||
entity.setChatHistoryConf(0);
|
||||
@@ -583,7 +677,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
mapping.setPluginId(pluginId);
|
||||
|
||||
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) {
|
||||
for (Map<String, Object> field : fields) {
|
||||
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.setAgentId(entity.getId());
|
||||
toInsert.add(mapping);
|
||||
}
|
||||
// 保存默认插件
|
||||
agentPluginMappingService.saveBatch(toInsert);
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
private String getDefaultLLMModelId() {
|
||||
}
|
||||
// 保存默认插件
|
||||
agentPluginMappingService.saveBatch(toInsert, IRepository.DEFAULT_BATCH_SIZE);
|
||||
agentSnapshotService.createSnapshot(entity.getId(), "initial");
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
private String defaultIfBlank(String value, String defaultValue) {
|
||||
return StringUtils.isBlank(value) ? defaultValue : value;
|
||||
}
|
||||
|
||||
private String getDefaultLLMModelId() {
|
||||
try {
|
||||
List<ModelConfigEntity> llmConfigs = modelConfigService.getEnabledModelsByType("LLM");
|
||||
if (llmConfigs == null || llmConfigs.isEmpty()) {
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package xiaozhi.modules.agent.service.impl;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.modules.agent.service.AgentSnapshotService;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AgentSnapshotRedactionRunner implements SmartInitializingSingleton {
|
||||
static final long ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS = 5_000;
|
||||
static final long ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS = 15_000;
|
||||
|
||||
private final AgentSnapshotService agentSnapshotService;
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
redactAndReport("startup");
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS,
|
||||
fixedDelay = ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS)
|
||||
public void redactLateRollingDeploymentWrites() {
|
||||
redactAndReport("rolling-deployment");
|
||||
}
|
||||
|
||||
private void redactAndReport(String trigger) {
|
||||
long startedAt = System.nanoTime();
|
||||
try {
|
||||
long migrated = agentSnapshotService.redactLegacySnapshots();
|
||||
long durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
|
||||
if (migrated > 0) {
|
||||
log.warn("Agent snapshot legacy redaction trigger={} migrated={} durationMs={}. Rotate credentials "
|
||||
+ "that may have appeared in historical snapshot URLs, cookies, sessions, or structured "
|
||||
+ "headers.", trigger, migrated, durationMillis);
|
||||
} else if ("startup".equals(trigger)) {
|
||||
log.info("Agent snapshot legacy redaction startup pass completed: migrated=0 durationMs={}; "
|
||||
+ "rolling-deployment compensation starts after {} ms and repeats every {} ms.",
|
||||
durationMillis, ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS,
|
||||
ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
if ("startup".equals(trigger)) {
|
||||
log.error("Agent snapshot legacy redaction failed during startup; blocking application startup "
|
||||
+ "before it can accept traffic.", exception);
|
||||
} else {
|
||||
log.error("Agent snapshot legacy redaction failed during rolling-deployment compensation; the "
|
||||
+ "scheduler will retry on its next run.", exception);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1073
-60
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -133,7 +133,7 @@ public class AgentTagServiceImpl extends BaseServiceImpl<AgentTagDao, AgentTagEn
|
||||
}
|
||||
|
||||
if (tagIds != null && !tagIds.isEmpty()) {
|
||||
List<AgentTagEntity> tagIdEntities = baseDao.selectBatchIds(tagIds);
|
||||
List<AgentTagEntity> tagIdEntities = baseDao.selectByIds(tagIds);
|
||||
for (AgentTagEntity tag : tagIdEntities) {
|
||||
if (tag != null && (currentTagNames.contains(tag.getTagName()) ||
|
||||
newTagNames.contains(tag.getTagName()))) {
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
|
||||
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
|
||||
|
||||
import xiaozhi.modules.agent.dao.AgentTemplateDao;
|
||||
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
@@ -21,7 +21,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
* @createDate 2025-03-22 11:48:18
|
||||
*/
|
||||
@Service
|
||||
public class AgentTemplateServiceImpl extends ServiceImpl<AgentTemplateDao, AgentTemplateEntity>
|
||||
public class AgentTemplateServiceImpl extends CrudRepository<AgentTemplateDao, AgentTemplateEntity>
|
||||
implements AgentTemplateService {
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
@@ -46,7 +46,7 @@ import xiaozhi.modules.sys.service.SysParamsService;
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class AgentVoicePrintServiceImpl extends ServiceImpl<AgentVoicePrintDao, AgentVoicePrintEntity>
|
||||
public class AgentVoicePrintServiceImpl extends CrudRepository<AgentVoicePrintDao, AgentVoicePrintEntity>
|
||||
implements AgentVoicePrintService {
|
||||
private final AgentChatAudioService agentChatAudioService;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
+36
-8
@@ -1,31 +1,59 @@
|
||||
package xiaozhi.modules.agent.typehandler;
|
||||
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.ibatis.type.BaseTypeHandler;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
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<>() {
|
||||
};
|
||||
|
||||
@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)) {
|
||||
return Collections.emptyList();
|
||||
return null;
|
||||
}
|
||||
List<ContextProviderDTO> providers = JsonUtils.parseObject(json, CONTEXT_PROVIDER_LIST_TYPE);
|
||||
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> fieldOrder;
|
||||
private String source;
|
||||
@Schema(description = "恢复来源快照ID,仅恢复前自动备份版本有值")
|
||||
@Schema(description = "恢复来源快照ID,仅恢复结果版本有值")
|
||||
private String restoreFromSnapshotId;
|
||||
@Schema(description = "恢复来源版本号,仅恢复前自动备份版本有值")
|
||||
@Schema(description = "恢复来源版本号,仅恢复结果版本有值")
|
||||
private Integer restoreFromVersionNo;
|
||||
@Schema(description = "创建者,表示触发本次快照写入的操作人")
|
||||
private Long creator;
|
||||
private Date createdAt;
|
||||
private AgentSnapshotDataDTO snapshotData;
|
||||
private AgentSnapshotDataDTO afterSnapshotData;
|
||||
@Schema(description = "恢复预览对应的脱敏当前配置,仅详情接口有值")
|
||||
private AgentSnapshotDataDTO currentSnapshotData;
|
||||
@Schema(description = "恢复预览对应的当前配置状态指纹,仅详情接口有值")
|
||||
private String currentStateToken;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public interface ConfigService {
|
||||
* @param isCache 是否缓存
|
||||
* @return 配置信息
|
||||
*/
|
||||
Object getConfig(Boolean isCache);
|
||||
Map<String, Object> getConfig(Boolean isCache);
|
||||
|
||||
/**
|
||||
* 获取智能体模型配置
|
||||
@@ -28,4 +28,4 @@ public interface ConfigService {
|
||||
* @return 替换词列表,格式如 ["模板1|模板01", "模板2|模板02"]
|
||||
*/
|
||||
List<String> getCorrectWords(String macAddress);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -65,12 +65,12 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
private final CorrectWordFileService correctWordFileService;
|
||||
|
||||
@Override
|
||||
public Object getConfig(Boolean isCache) {
|
||||
public Map<String, Object> getConfig(Boolean isCache) {
|
||||
if (isCache) {
|
||||
// 先从Redis获取配置
|
||||
Object cachedConfig = redisUtils.get(RedisKeys.getServerConfigKey());
|
||||
if (cachedConfig != null) {
|
||||
return cachedConfig;
|
||||
return JsonUtils.toStringObjectMap(cachedConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
if (isAdminRequest != null && "true".equals(isAdminRequest)) {
|
||||
// 管理控制台请求,返回getConfig的结果
|
||||
redisUtils.delete(redisKey); // 使用后清理
|
||||
return (Map<String, Object>) getConfig(true);
|
||||
return getConfig(true);
|
||||
}
|
||||
// 根据MAC地址查找设备
|
||||
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
|
||||
@@ -277,10 +277,10 @@ public class ConfigServiceImpl implements ConfigService {
|
||||
// 遍历除最后一个key之外的所有key
|
||||
for (int i = 0; i < keys.length - 1; i++) {
|
||||
String key = keys[i];
|
||||
if (!current.containsKey(key)) {
|
||||
current.put(key, new HashMap<String, Object>());
|
||||
}
|
||||
current = (Map<String, Object>) current.get(key);
|
||||
Object nestedConfig = current.computeIfAbsent(key, ignored -> new HashMap<String, Object>());
|
||||
Map<String, Object> nestedMap = JsonUtils.toStringObjectMap(nestedConfig);
|
||||
current.put(key, nestedMap);
|
||||
current = nestedMap;
|
||||
}
|
||||
|
||||
// 处理最后一个key
|
||||
|
||||
@@ -12,6 +12,11 @@ import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
|
||||
@Mapper
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-27
@@ -1,11 +1,10 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import java.util.Date;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
@@ -13,8 +12,8 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
|
||||
@@ -168,7 +167,7 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
alias = generateUniqueAlias(macAddress, targetMac, alias);
|
||||
entity.setAlias(alias);
|
||||
entity.setHasPermission(hasPermission);
|
||||
deviceAddressBookDao.insert(entity);
|
||||
deviceAddressBookDao.insertAddressBook(entity);
|
||||
} else {
|
||||
if (alias != null) {
|
||||
updateAlias(macAddress, targetMac, alias);
|
||||
@@ -201,36 +200,22 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
result.put("status", "error");
|
||||
|
||||
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
|
||||
String mqttSignatureKey = sysParamsService.getValue("server.mqtt_signature_key", true);
|
||||
String mqttSignatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true);
|
||||
|
||||
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)
|
||||
|| StringUtils.isBlank(mqttSignatureKey) || "null".equals(mqttSignatureKey)) {
|
||||
|| MqttGatewayAuthorization.isMissingSignatureKey(mqttSignatureKey)) {
|
||||
result.put("message", action + "失败,网关配置缺失");
|
||||
return result;
|
||||
}
|
||||
|
||||
String dateStr = new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date());
|
||||
try {
|
||||
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest((dateStr + mqttSignatureKey).getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
String token = hexString.toString();
|
||||
|
||||
String url = "http://" + mqttGatewayUrl + path;
|
||||
String response = HttpRequest.post(url)
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(JSONUtil.toJsonStr(body))
|
||||
.timeout(5000)
|
||||
.execute()
|
||||
.body();
|
||||
String response = MqttGatewayAuthorization.postJson(
|
||||
url,
|
||||
JSONUtil.toJsonStr(body),
|
||||
mqttSignatureKey,
|
||||
Instant.now(),
|
||||
5000);
|
||||
|
||||
if (StringUtils.isNotBlank(response)) {
|
||||
Map<String, Object> gwResult = JSONUtil.parseObj(response);
|
||||
@@ -270,4 +255,4 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
}
|
||||
return newAlias;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-60
@@ -31,15 +31,9 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import cn.hutool.core.date.DatePattern;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.hutool.http.ContentType;
|
||||
import cn.hutool.http.Header;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
@@ -56,6 +50,7 @@ import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.DateUtils;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.common.utils.ToolUtil;
|
||||
import xiaozhi.modules.device.dao.DeviceDao;
|
||||
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
|
||||
@@ -115,7 +110,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
String deviceId = (String) cacheDeviceId;
|
||||
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
|
||||
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)) {
|
||||
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
||||
}
|
||||
@@ -187,14 +182,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.put("clientIds", deviceIds).build();
|
||||
|
||||
if (ToolUtil.isNotEmpty(deviceIds)) {
|
||||
// 发送请求
|
||||
String resultMessage = HttpRequest.post(url)
|
||||
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue())
|
||||
.header(Header.AUTHORIZATION, "Bearer " + generateBearerToken())
|
||||
.body(JSONUtil.toJsonStr(params))
|
||||
.timeout(10000) // 超时,毫秒
|
||||
.execute().body();
|
||||
return resultMessage;
|
||||
return postToMqttGateway(url, params);
|
||||
}
|
||||
// 返回响应
|
||||
return "";
|
||||
@@ -304,15 +292,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
@Override
|
||||
public List<UserShowDeviceListVO> getUserDeviceList(Long userId, String agentId) {
|
||||
List<DeviceEntity> devices = getUserDevices(userId, agentId);
|
||||
return devices.stream().map(device -> {
|
||||
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
|
||||
vo.setDeviceType(device.getBoard());
|
||||
// 设置UTC时间戳供前端使用时区转换
|
||||
if (device.getLastConnectedAt() != null) {
|
||||
vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime());
|
||||
}
|
||||
return vo;
|
||||
}).toList();
|
||||
return devices.stream().map(this::toUserShowDeviceListVO).toList();
|
||||
}
|
||||
|
||||
private UserShowDeviceListVO toUserShowDeviceListVO(DeviceEntity device) {
|
||||
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
|
||||
vo.setDeviceType(device.getBoard());
|
||||
vo.setBoard(device.getBoard());
|
||||
vo.setCreateDateTimestamp(toTimestamp(device.getCreateDate()));
|
||||
vo.setLastConnectedAtTimestamp(toTimestamp(device.getLastConnectedAt()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private Long toTimestamp(Date date) {
|
||||
return date == null ? null : date.getTime();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -385,17 +378,11 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.like(StringUtils.isNotBlank(dto.getKeywords()), "alias", dto.getKeywords()));
|
||||
// 循环处理page获取回来的数据,返回需要的字段
|
||||
List<UserShowDeviceListVO> list = page.getRecords().stream().map(device -> {
|
||||
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
|
||||
UserShowDeviceListVO vo = toUserShowDeviceListVO(device);
|
||||
// 把最后修改的时间,改为简短描述的时间
|
||||
vo.setRecentChatTime(DateUtils.getShortTime(device.getUpdateDate()));
|
||||
sysUserUtilService.assignUsername(device.getUserId(),
|
||||
vo::setBindUserName);
|
||||
vo.setDeviceType(device.getBoard());
|
||||
vo.setBoard(device.getBoard());
|
||||
// 设置UTC时间戳供前端使用时区转换
|
||||
if (device.getLastConnectedAt() != null) {
|
||||
vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime());
|
||||
}
|
||||
return vo;
|
||||
}).toList();
|
||||
// 计算页数
|
||||
@@ -425,7 +412,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
public String geCodeByDeviceId(String 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")) {
|
||||
String cachedCode = (String) cacheMap.get("activation_code");
|
||||
return cachedCode;
|
||||
@@ -699,20 +686,13 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
return mqtt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成BearerToken
|
||||
*/
|
||||
private String generateBearerToken() {
|
||||
try {
|
||||
String dateStr = DateUtil.format(new Date(), DatePattern.NORM_DATE_PATTERN);
|
||||
String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false);
|
||||
if (ToolUtil.isEmpty(signatureKey)) {
|
||||
return null;
|
||||
}
|
||||
return DigestUtil.sha256Hex(dateStr + signatureKey);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
private String postToMqttGateway(String url, Object requestBody) {
|
||||
String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false);
|
||||
return MqttGatewayAuthorization.postJson(
|
||||
url,
|
||||
JSONUtil.toJsonStr(requestBody),
|
||||
signatureKey,
|
||||
Instant.now());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -773,13 +753,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.put("payload", payload)
|
||||
.build();
|
||||
|
||||
// 发送请求
|
||||
String resultMessage = HttpRequest.post(url)
|
||||
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue())
|
||||
.header(Header.AUTHORIZATION, "Bearer " + generateBearerToken())
|
||||
.body(JSONUtil.toJsonStr(requestBody))
|
||||
.timeout(10000) // 超时,毫秒
|
||||
.execute().body();
|
||||
String resultMessage = postToMqttGateway(url, requestBody);
|
||||
|
||||
// 解析响应
|
||||
if (StringUtils.isBlank(resultMessage)) {
|
||||
@@ -870,13 +844,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.put("payload", payload)
|
||||
.build();
|
||||
|
||||
// 发送请求
|
||||
String resultMessage = HttpRequest.post(url)
|
||||
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue())
|
||||
.header(Header.AUTHORIZATION, "Bearer " + generateBearerToken())
|
||||
.body(JSONUtil.toJsonStr(requestBody))
|
||||
.timeout(10000) // 超时,毫秒
|
||||
.execute().body();
|
||||
String resultMessage = postToMqttGateway(url, requestBody);
|
||||
|
||||
// 解析响应
|
||||
if (StringUtils.isNotBlank(resultMessage)) {
|
||||
|
||||
+111
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -56,7 +56,7 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
|
||||
|
||||
@Override
|
||||
public void delete(String[] ids) {
|
||||
baseDao.deleteBatchIds(Arrays.asList(ids));
|
||||
baseDao.deleteByIds(Arrays.asList(ids));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -82,4 +82,4 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
|
||||
.last("LIMIT 1");
|
||||
return baseDao.selectOne(wrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +38,14 @@ public class UserShowDeviceListVO {
|
||||
@Schema(description = "最近对话时间")
|
||||
private String recentChatTime;
|
||||
|
||||
@Schema(description = "最后连接时间(UTC毫秒)")
|
||||
@Schema(description = "最后连接时间戳(毫秒)", type = "string", example = "1783689702000")
|
||||
private Long lastConnectedAtTimestamp;
|
||||
|
||||
@Schema(description = "绑定时间")
|
||||
@Schema(description = "绑定时间戳(毫秒)", type = "string", example = "1783689702000")
|
||||
private Long createDateTimestamp;
|
||||
|
||||
@Schema(description = "绑定时间(兼容字段,请使用 createDateTimestamp)", deprecated = true)
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
|
||||
private Date createDate;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+11
-10
@@ -149,8 +149,8 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
|
||||
log.info("=== [RAGFlow] 获取文档列表: datasetId={} ===", datasetId);
|
||||
|
||||
// 使用 Jackson 将 DTO 转为 Map 作为查询参数
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> params = objectMapper.convertValue(req, Map.class);
|
||||
Map<String, Object> params = objectMapper.convertValue(req, new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
|
||||
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
|
||||
|
||||
@@ -174,13 +174,12 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
|
||||
.pageSize(1)
|
||||
.build();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> params = objectMapper.convertValue(req, Map.class);
|
||||
Map<String, Object> params = objectMapper.convertValue(req, new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
|
||||
|
||||
Object dataObj = response.get("data");
|
||||
if (dataObj instanceof Map) {
|
||||
Map<String, Object> dataMap = (Map<String, Object>) dataObj;
|
||||
if (dataObj instanceof Map<?, ?> dataMap) {
|
||||
List<?> documents = (List<?>) dataMap.get("docs");
|
||||
if (documents != null && !documents.isEmpty()) {
|
||||
return objectMapper.convertValue(documents.get(0), DocumentDTO.InfoVO.class);
|
||||
@@ -567,8 +566,8 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
|
||||
return new PageData<>(new ArrayList<>(), 0);
|
||||
}
|
||||
|
||||
Map<String, Object> dataMap = (Map<String, Object>) dataObj;
|
||||
List<Map<String, Object>> documents = (List<Map<String, Object>>) dataMap.get("docs");
|
||||
Map<?, ?> dataMap = Map.class.cast(dataObj);
|
||||
List<?> documents = List.class.cast(dataMap.get("docs"));
|
||||
if (documents == null || documents.isEmpty()) {
|
||||
// RAGFlow 明确返回了空文档列表,这是合法的"真空"
|
||||
return new PageData<>(new ArrayList<>(), 0);
|
||||
@@ -679,7 +678,9 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
|
||||
dto.setChunkMethod(info.getChunkMethod().name().toLowerCase());
|
||||
}
|
||||
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;
|
||||
@@ -717,4 +718,4 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
|
||||
return !multipartFile.isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-10
@@ -116,13 +116,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
|
||||
|
||||
Map<String, Object>[] messages = new Map[1];
|
||||
Map<String, Object> message = new HashMap<>();
|
||||
message.put("role", "user");
|
||||
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("max_tokens", maxTokens != null ? maxTokens : 2000);
|
||||
|
||||
@@ -212,13 +210,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
|
||||
|
||||
Map<String, Object>[] messages = new Map[1];
|
||||
Map<String, Object> message = new HashMap<>();
|
||||
message.put("role", "user");
|
||||
message.put("content", prompt);
|
||||
messages[0] = message;
|
||||
|
||||
requestBody.put("messages", messages);
|
||||
requestBody.put("messages", List.of(message));
|
||||
requestBody.put("temperature", 0.2);
|
||||
requestBody.put("max_tokens", 2000);
|
||||
|
||||
@@ -368,13 +364,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
|
||||
|
||||
Map<String, Object>[] messages = new Map[1];
|
||||
Map<String, Object> message = new HashMap<>();
|
||||
message.put("role", "user");
|
||||
message.put("content", prompt);
|
||||
messages[0] = message;
|
||||
|
||||
requestBody.put("messages", messages);
|
||||
requestBody.put("messages", List.of(message));
|
||||
requestBody.put("temperature", 0.3);
|
||||
requestBody.put("max_tokens", 50);
|
||||
|
||||
@@ -422,4 +416,4 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -362,14 +362,14 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
if (SensitiveDataUtils.isSensitiveField(key)) {
|
||||
|
||||
if (value instanceof String && !SensitiveDataUtils.isMaskedValue((String) value)) {
|
||||
updatedJson.put(key, value);
|
||||
updatedJson.set(key, value);
|
||||
}
|
||||
} else if (value instanceof JSONObject) {
|
||||
// 递归处理嵌套JSON
|
||||
mergeJson(updatedJson, key, (JSONObject) value);
|
||||
} else {
|
||||
// 非敏感字段直接更新
|
||||
updatedJson.put(key, value);
|
||||
updatedJson.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
|
||||
// 如果 original 中不存在 key,创建一个新的 JSON 对象
|
||||
if (!original.containsKey(key)) {
|
||||
original.put(key, new JSONObject());
|
||||
original.set(key, new JSONObject());
|
||||
}
|
||||
|
||||
// 获取 original 中的子对象
|
||||
@@ -420,7 +420,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
log.warn("mergeJson: key '{}' 的值不是 JSONObject 类型 (实际类型:{}),将创建新对象",
|
||||
key, originalValue != null ? originalValue.getClass().getSimpleName() : "null");
|
||||
originalChild = new JSONObject();
|
||||
original.put(key, originalChild);
|
||||
original.set(key, originalChild);
|
||||
}
|
||||
|
||||
for (String childKey : updated.keySet()) {
|
||||
@@ -430,7 +430,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
} else {
|
||||
if (!SensitiveDataUtils.isSensitiveField(childKey) ||
|
||||
(childValue instanceof String && !isMaskedValue((String) childValue))) {
|
||||
originalChild.put(childKey, childValue);
|
||||
originalChild.set(childKey, childValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ public class ModelProviderServiceImpl extends BaseServiceImpl<ModelProviderDao,
|
||||
|
||||
@Override
|
||||
public void delete(List<String> ids) {
|
||||
if (modelProviderDao.deleteBatchIds(ids) == 0) {
|
||||
if (modelProviderDao.deleteByIds(ids) == 0) {
|
||||
throw new RenException(ErrorCode.DELETE_DATA_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,19 @@ import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.shiro.mgt.SecurityManager;
|
||||
import org.apache.shiro.session.mgt.SessionManager;
|
||||
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
|
||||
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
|
||||
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
|
||||
import org.apache.shiro.web.config.ShiroFilterConfiguration;
|
||||
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
|
||||
import org.apache.shiro.web.mgt.WebSecurityManager;
|
||||
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.Configuration;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.annotation.Role;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import xiaozhi.modules.security.oauth2.Oauth2Filter;
|
||||
@@ -39,7 +42,7 @@ public class ShiroConfig {
|
||||
}
|
||||
|
||||
@Bean("securityManager")
|
||||
public SecurityManager securityManager(Oauth2Realm oAuth2Realm, SessionManager sessionManager) {
|
||||
public WebSecurityManager securityManager(Oauth2Realm oAuth2Realm, SessionManager sessionManager) {
|
||||
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
|
||||
securityManager.setRealm(oAuth2Realm);
|
||||
securityManager.setSessionManager(sessionManager);
|
||||
@@ -48,7 +51,8 @@ public class ShiroConfig {
|
||||
}
|
||||
|
||||
@Bean("shiroFilter")
|
||||
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager, SysParamsService sysParamsService) {
|
||||
public static ShiroFilterFactoryBean shirFilter(@Lazy WebSecurityManager securityManager,
|
||||
@Lazy SysParamsService sysParamsService) {
|
||||
ShiroFilterConfiguration config = new ShiroFilterConfiguration();
|
||||
config.setFilterOncePerRequest(true);
|
||||
|
||||
@@ -101,12 +105,14 @@ public class ShiroConfig {
|
||||
}
|
||||
|
||||
@Bean("lifecycleBeanPostProcessor")
|
||||
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
|
||||
public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
|
||||
return new LifecycleBeanPostProcessor();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
|
||||
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
|
||||
public static AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(
|
||||
@Lazy WebSecurityManager securityManager) {
|
||||
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
|
||||
advisor.setSecurityManager(securityManager);
|
||||
return advisor;
|
||||
|
||||
@@ -31,7 +31,7 @@ public class SysUserDTO implements Serializable {
|
||||
@NotNull(message = "{id.require}", groups = UpdateGroup.class)
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户名", required = true)
|
||||
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "{sysuser.username.require}", groups = DefaultGroup.class)
|
||||
private String username;
|
||||
|
||||
@@ -40,14 +40,14 @@ public class SysUserDTO implements Serializable {
|
||||
@NotBlank(message = "{sysuser.password.require}", groups = AddGroup.class)
|
||||
private String password;
|
||||
|
||||
@Schema(description = "姓名", required = true)
|
||||
@Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "{sysuser.realname.require}", groups = DefaultGroup.class)
|
||||
private String realName;
|
||||
|
||||
@Schema(description = "头像")
|
||||
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)
|
||||
private Integer gender;
|
||||
|
||||
@@ -58,11 +58,11 @@ public class SysUserDTO implements Serializable {
|
||||
@Schema(description = "手机号")
|
||||
private String mobile;
|
||||
|
||||
@Schema(description = "部门ID", required = true)
|
||||
@Schema(description = "部门ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "{sysuser.deptId.require}", groups = DefaultGroup.class)
|
||||
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)
|
||||
private Integer status;
|
||||
|
||||
@@ -84,4 +84,4 @@ public class SysUserDTO implements Serializable {
|
||||
@Schema(description = "部门名称")
|
||||
private String deptName;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -20,6 +20,7 @@ import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||
import xiaozhi.common.utils.ConvertUtils;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.common.utils.ToolUtil;
|
||||
import xiaozhi.modules.sys.dao.SysDictDataDao;
|
||||
import xiaozhi.modules.sys.dao.SysUserDao;
|
||||
@@ -138,7 +139,7 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
|
||||
|
||||
// 设置更新者和创建者名称
|
||||
if (!userIds.isEmpty()) {
|
||||
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
|
||||
List<SysUserEntity> sysUserEntities = sysUserDao.selectByIds(userIds);
|
||||
// 把List转成Map,Map<Long, String>
|
||||
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
|
||||
SysUserEntity::getUsername, (existing, replacement) -> existing));
|
||||
@@ -170,7 +171,7 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
|
||||
|
||||
// 先从Redis获取缓存
|
||||
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) {
|
||||
return cachedData;
|
||||
}
|
||||
@@ -185,4 +186,4 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -128,7 +128,7 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
|
||||
|
||||
// 设置更新者和创建者名称
|
||||
if (!userIds.isEmpty()) {
|
||||
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
|
||||
List<SysUserEntity> sysUserEntities = sysUserDao.selectByIds(userIds);
|
||||
// 把List转成Map,Map<Long, String>
|
||||
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
|
||||
SysUserEntity::getUsername, (existing, replacement) -> existing));
|
||||
@@ -151,4 +151,4 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
|
||||
throw new RenException(ErrorCode.DICT_TYPE_DUPLICATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-12
@@ -287,10 +287,10 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
|
||||
|
||||
try {
|
||||
if (StringUtils.isNotBlank(currentConfig)) {
|
||||
currentMap = JsonUtils.parseObject(currentConfig, Map.class);
|
||||
currentMap = JsonUtils.parseMap(currentConfig);
|
||||
}
|
||||
if (StringUtils.isNotBlank(configJson)) {
|
||||
newMap = JsonUtils.parseObject(configJson, Map.class);
|
||||
newMap = JsonUtils.parseMap(configJson);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
|
||||
@@ -298,8 +298,8 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
|
||||
|
||||
// 检查addressBook功能是否被关闭
|
||||
if (currentMap != null && newMap != null) {
|
||||
Map<String, Object> currentFeatures = (Map<String, Object>) currentMap.get("features");
|
||||
Map<String, Object> newFeatures = (Map<String, Object>) newMap.get("features");
|
||||
Map<?, ?> currentFeatures = Map.class.cast(currentMap.get("features"));
|
||||
Map<?, ?> newFeatures = Map.class.cast(newMap.get("features"));
|
||||
|
||||
if (currentFeatures != null && newFeatures != null) {
|
||||
Object currentAddressBookObj = currentFeatures.get("addressBook");
|
||||
@@ -308,16 +308,14 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
|
||||
Boolean currentEnabled = false;
|
||||
Boolean newEnabled = false;
|
||||
|
||||
if (currentAddressBookObj instanceof Map) {
|
||||
Map<String, Object> currentAddressBook = (Map<String, Object>) currentAddressBookObj;
|
||||
currentEnabled = currentAddressBook.get("enabled") != null
|
||||
? (Boolean) currentAddressBook.get("enabled") : false;
|
||||
if (currentAddressBookObj instanceof Map<?, ?> currentAddressBook) {
|
||||
Object enabled = currentAddressBook.get("enabled");
|
||||
currentEnabled = enabled != null ? Boolean.class.cast(enabled) : false;
|
||||
}
|
||||
|
||||
if (newAddressBookObj instanceof Map) {
|
||||
Map<String, Object> newAddressBook = (Map<String, Object>) newAddressBookObj;
|
||||
newEnabled = newAddressBook.get("enabled") != null
|
||||
? (Boolean) newAddressBook.get("enabled") : false;
|
||||
if (newAddressBookObj instanceof Map<?, ?> newAddressBook) {
|
||||
Object enabled = newAddressBook.get("enabled");
|
||||
newEnabled = enabled != null ? Boolean.class.cast(enabled) : false;
|
||||
}
|
||||
|
||||
// 如果之前是启用状态,现在被禁用,删除所有call_device插件
|
||||
|
||||
@@ -57,6 +57,14 @@ public interface TimbreService extends BaseService<TimbreEntity> {
|
||||
|
||||
List<VoiceDTO> getVoiceNames(String ttsModelId, String voiceName);
|
||||
|
||||
/**
|
||||
* 获取普通音色或克隆音色配置的首个有效语言。
|
||||
*
|
||||
* @param id 音色ID
|
||||
* @return 默认语言;音色不存在或未配置有效语言时返回null
|
||||
*/
|
||||
String getDefaultLanguageById(String id);
|
||||
|
||||
/**
|
||||
* 根据ID获取音色名称
|
||||
*
|
||||
@@ -73,4 +81,4 @@ public interface TimbreService extends BaseService<TimbreEntity> {
|
||||
* @return 音色信息
|
||||
*/
|
||||
VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode);
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -1,6 +1,7 @@
|
||||
package xiaozhi.modules.timbre.service.impl;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -41,6 +42,8 @@ import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
@Service
|
||||
public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity> implements TimbreService {
|
||||
|
||||
private static final Pattern LANGUAGE_SEPARATOR = Pattern.compile("[、;;,,]");
|
||||
|
||||
private final TimbreDao timbreDao;
|
||||
private final VoiceCloneDao voiceCloneDao;
|
||||
private final RedisUtils redisUtils;
|
||||
@@ -114,7 +117,7 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(String[] ids) {
|
||||
baseDao.deleteBatchIds(Arrays.asList(ids));
|
||||
baseDao.deleteByIds(Arrays.asList(ids));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -158,6 +161,32 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
return CollectionUtil.isEmpty(voiceDTOs) ? null : voiceDTOs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDefaultLanguageById(String id) {
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
TimbreEntity timbre = timbreDao.selectById(id);
|
||||
if (timbre != null) {
|
||||
return firstNonBlankLanguage(timbre.getLanguages());
|
||||
}
|
||||
|
||||
VoiceCloneEntity voiceClone = voiceCloneDao.selectById(id);
|
||||
return voiceClone == null ? null : firstNonBlankLanguage(voiceClone.getLanguages());
|
||||
}
|
||||
|
||||
private String firstNonBlankLanguage(String languages) {
|
||||
if (StringUtils.isBlank(languages)) {
|
||||
return null;
|
||||
}
|
||||
return LANGUAGE_SEPARATOR.splitAsStream(languages)
|
||||
.map(StringUtils::trimToNull)
|
||||
.filter(Objects::nonNull)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理是不是tts模型的id
|
||||
*/
|
||||
@@ -214,4 +243,4 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
dto.setIsClone(false); // 设置为普通音色
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
|
||||
|
||||
@Override
|
||||
public void delete(String[] ids) {
|
||||
baseDao.deleteBatchIds(Arrays.asList(ids));
|
||||
baseDao.deleteByIds(Arrays.asList(ids));
|
||||
}
|
||||
|
||||
@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:
|
||||
encoding: utf8
|
||||
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"?>
|
||||
<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" />
|
||||
|
||||
<!-- 使用自定义的初始化监听器确保日志目录存在 -->
|
||||
<define name="LOGBACK_DIR_CHECK" class="ch.qos.logback.core.property.FileExistsPropertyDefiner">
|
||||
<path>${LOG_HOME}</path>
|
||||
<createIfMissing>true</createIfMissing>
|
||||
</define>
|
||||
|
||||
|
||||
<!-- 引入Spring Boot默认配置 -->
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
|
||||
|
||||
|
||||
@@ -89,4 +89,32 @@
|
||||
FOR UPDATE
|
||||
</select>
|
||||
|
||||
<update id="updateSnapshotFields">
|
||||
UPDATE ai_agent
|
||||
SET agent_code = #{agent.agentCode,jdbcType=VARCHAR},
|
||||
agent_name = #{agent.agentName,jdbcType=VARCHAR},
|
||||
asr_model_id = #{agent.asrModelId,jdbcType=VARCHAR},
|
||||
vad_model_id = #{agent.vadModelId,jdbcType=VARCHAR},
|
||||
llm_model_id = #{agent.llmModelId,jdbcType=VARCHAR},
|
||||
slm_model_id = #{agent.slmModelId,jdbcType=VARCHAR},
|
||||
vllm_model_id = #{agent.vllmModelId,jdbcType=VARCHAR},
|
||||
tts_model_id = #{agent.ttsModelId,jdbcType=VARCHAR},
|
||||
tts_voice_id = #{agent.ttsVoiceId,jdbcType=VARCHAR},
|
||||
tts_language = #{agent.ttsLanguage,jdbcType=VARCHAR},
|
||||
tts_volume = #{agent.ttsVolume,jdbcType=INTEGER},
|
||||
tts_rate = #{agent.ttsRate,jdbcType=INTEGER},
|
||||
tts_pitch = #{agent.ttsPitch,jdbcType=INTEGER},
|
||||
mem_model_id = #{agent.memModelId,jdbcType=VARCHAR},
|
||||
intent_model_id = #{agent.intentModelId,jdbcType=VARCHAR},
|
||||
chat_history_conf = #{agent.chatHistoryConf,jdbcType=INTEGER},
|
||||
system_prompt = #{agent.systemPrompt,jdbcType=LONGVARCHAR},
|
||||
summary_memory = #{agent.summaryMemory,jdbcType=LONGVARCHAR},
|
||||
lang_code = #{agent.langCode,jdbcType=VARCHAR},
|
||||
language = #{agent.language,jdbcType=VARCHAR},
|
||||
sort = #{agent.sort,jdbcType=INTEGER},
|
||||
updater = #{agent.updater,jdbcType=BIGINT},
|
||||
updated_at = #{agent.updatedAt,jdbcType=TIMESTAMP}
|
||||
WHERE id = #{agent.id}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
restore_from_snapshot_id AS restoreFromSnapshotId,
|
||||
restore_from_version_no AS restoreFromVersionNo,
|
||||
creator,
|
||||
created_at AS createdAt
|
||||
created_at AS createdAt,
|
||||
redaction_version AS redactionVersion
|
||||
FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{agentId}
|
||||
ORDER BY version_no DESC
|
||||
@@ -37,7 +38,8 @@
|
||||
restore_from_snapshot_id AS restoreFromSnapshotId,
|
||||
restore_from_version_no AS restoreFromVersionNo,
|
||||
creator,
|
||||
created_at AS createdAt
|
||||
created_at AS createdAt,
|
||||
redaction_version AS redactionVersion
|
||||
FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{agentId}
|
||||
AND version_no > #{versionNo}
|
||||
@@ -57,7 +59,8 @@
|
||||
restore_from_snapshot_id,
|
||||
restore_from_version_no,
|
||||
creator,
|
||||
created_at
|
||||
created_at,
|
||||
redaction_version
|
||||
)
|
||||
SELECT #{snapshot.id},
|
||||
#{snapshot.agentId},
|
||||
@@ -69,7 +72,8 @@
|
||||
#{snapshot.restoreFromSnapshotId},
|
||||
#{snapshot.restoreFromVersionNo},
|
||||
#{snapshot.creator},
|
||||
#{snapshot.createdAt}
|
||||
#{snapshot.createdAt},
|
||||
#{snapshot.redactionVersion}
|
||||
FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{snapshot.agentId}
|
||||
</insert>
|
||||
@@ -89,4 +93,31 @@
|
||||
)
|
||||
</delete>
|
||||
|
||||
<select id="selectLegacyRedactionBatch" resultType="xiaozhi.modules.agent.entity.AgentSnapshotEntity">
|
||||
SELECT id,
|
||||
snapshot_data AS snapshotData,
|
||||
redaction_version AS redactionVersion
|
||||
FROM ai_agent_snapshot
|
||||
WHERE redaction_version < #{targetRedactionVersion}
|
||||
AND (#{afterId} IS NULL OR id > #{afterId})
|
||||
ORDER BY id ASC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<update id="updateRedactedSnapshots">
|
||||
UPDATE ai_agent_snapshot
|
||||
SET snapshot_data = CASE id
|
||||
<foreach collection="snapshots" item="snapshot">
|
||||
WHEN #{snapshot.id} THEN #{snapshot.snapshotData}
|
||||
</foreach>
|
||||
ELSE snapshot_data
|
||||
END,
|
||||
redaction_version = #{redactionVersion}
|
||||
WHERE redaction_version < #{redactionVersion}
|
||||
AND id IN
|
||||
<foreach collection="snapshots" item="snapshot" open="(" separator="," close=")">
|
||||
#{snapshot.id}
|
||||
</foreach>
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
WHERE mac_address = #{macAddress} AND target_mac = #{targetMac}
|
||||
</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)
|
||||
VALUES (#{macAddress}, #{targetMac}, #{alias}, #{hasPermission}, #{creator}, NOW(), #{updater}, NOW())
|
||||
</insert>
|
||||
@@ -36,4 +36,4 @@
|
||||
#{mac}
|
||||
</foreach>
|
||||
</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,214 @@
|
||||
package xiaozhi.common.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import org.apache.ibatis.binding.MapperMethod;
|
||||
import org.apache.ibatis.executor.BatchResult;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.apache.ibatis.session.ExecutorType;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import com.baomidou.mybatisplus.core.enums.SqlMethod;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Constants;
|
||||
import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
|
||||
|
||||
class BaseServiceImplTest {
|
||||
private static final String INSERT_STATEMENT = TestMapper.class.getName() + ".insert";
|
||||
private static final String UPDATE_STATEMENT = TestMapper.class.getName() + ".updateById";
|
||||
|
||||
private final TestService service = new TestService();
|
||||
|
||||
@AfterEach
|
||||
void clearTransactionSynchronization() {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void insertBatchFlushesAtTheRequestedBatchSizeAndCommitsWithoutATransaction() {
|
||||
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
|
||||
SqlSession sqlSession = mock(SqlSession.class);
|
||||
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
|
||||
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(1);
|
||||
when(sqlSession.flushStatements())
|
||||
.thenReturn(List.of(batchResult(1, 1)))
|
||||
.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> {
|
||||
@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));
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package xiaozhi.modules.agent.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
|
||||
import xiaozhi.modules.agent.service.AgentSnapshotService;
|
||||
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
class AgentSnapshotRedactionRunnerTest {
|
||||
|
||||
@Test
|
||||
void startupRedactionRunsSynchronouslyAndReportsCompensationWindow(CapturedOutput output) {
|
||||
assertTrue(SmartInitializingSingleton.class.isAssignableFrom(AgentSnapshotRedactionRunner.class));
|
||||
AgentSnapshotService service = mock(AgentSnapshotService.class);
|
||||
AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service);
|
||||
when(service.redactLegacySnapshots()).thenReturn(0L);
|
||||
|
||||
runner.afterSingletonsInstantiated();
|
||||
|
||||
verify(service).redactLegacySnapshots();
|
||||
assertTrue(output.getAll().contains("startup pass completed"));
|
||||
assertTrue(output.getAll().contains("starts after 5000 ms and repeats every 15000 ms"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupRedactionFailureIsLoggedAndPropagatedToKeepStartupFailClosed(CapturedOutput output) {
|
||||
AgentSnapshotService service = mock(AgentSnapshotService.class);
|
||||
AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service);
|
||||
IllegalStateException failure = new IllegalStateException("database unavailable");
|
||||
when(service.redactLegacySnapshots()).thenThrow(failure);
|
||||
|
||||
IllegalStateException thrown = assertThrows(IllegalStateException.class,
|
||||
runner::afterSingletonsInstantiated);
|
||||
|
||||
assertSame(failure, thrown);
|
||||
assertTrue(output.getAll().contains("blocking application startup before it can accept traffic"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollingDeploymentRedactionReportsTriggerCountAndCredentialRotation(CapturedOutput output) {
|
||||
AgentSnapshotService service = mock(AgentSnapshotService.class);
|
||||
AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service);
|
||||
when(service.redactLegacySnapshots()).thenReturn(3L);
|
||||
|
||||
runner.redactLateRollingDeploymentWrites();
|
||||
|
||||
assertTrue(output.getAll().contains("trigger=rolling-deployment migrated=3"));
|
||||
assertTrue(output.getAll().contains("Rotate credentials"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollingDeploymentScheduleKeepsLegacyWriteExposureWindowShort() throws Exception {
|
||||
Method method = AgentSnapshotRedactionRunner.class.getMethod("redactLateRollingDeploymentWrites");
|
||||
Scheduled scheduled = method.getAnnotation(Scheduled.class);
|
||||
|
||||
assertNotNull(scheduled);
|
||||
assertEquals(5_000, scheduled.initialDelay());
|
||||
assertEquals(15_000, scheduled.fixedDelay());
|
||||
assertTrue(scheduled.initialDelay() <= 10_000);
|
||||
assertTrue(scheduled.fixedDelay() <= 30_000);
|
||||
}
|
||||
}
|
||||
+1544
-32
File diff suppressed because it is too large
Load Diff
+48
-17
@@ -2,36 +2,30 @@ package xiaozhi.modules.agent.typehandler;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.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 org.apache.ibatis.type.TypeHandler;
|
||||
import org.apache.ibatis.type.TypeHandlerRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.modules.agent.dto.ContextProviderDTO;
|
||||
|
||||
class ContextProviderListTypeHandlerTest {
|
||||
|
||||
private final ContextProviderListTypeHandler handler = new ContextProviderListTypeHandler();
|
||||
|
||||
@Test
|
||||
void 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
|
||||
void myBatisCanInstantiateHandlerForListField() {
|
||||
TypeHandler<?> typeHandler = new TypeHandlerRegistry().getInstance(List.class,
|
||||
@@ -39,4 +33,41 @@ class ContextProviderListTypeHandlerTest {
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -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;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -11,6 +10,8 @@ import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.sys.dto.SysUserDTO;
|
||||
import xiaozhi.modules.sys.service.SysUserService;
|
||||
@@ -27,11 +28,13 @@ public class DeviceTest {
|
||||
private SysUserService sysUserService;
|
||||
|
||||
@Test
|
||||
public void testSaveUser() {
|
||||
public void testRejectWeakPassword() {
|
||||
SysUserDTO userDTO = new SysUserDTO();
|
||||
userDTO.setUsername("test");
|
||||
userDTO.setPassword(UUID.randomUUID().toString());
|
||||
sysUserService.save(userDTO);
|
||||
userDTO.setPassword("weak-password-123");
|
||||
|
||||
RenException exception = Assertions.assertThrows(RenException.class, () -> sysUserService.save(userDTO));
|
||||
Assertions.assertEquals(ErrorCode.PASSWORD_WEAK_ERROR, exception.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -70,4 +73,4 @@ public class DeviceTest {
|
||||
|
||||
log.info("测试完成");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -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));
|
||||
}
|
||||
}
|
||||
+87
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+180
@@ -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;
|
||||
|
||||
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.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
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.dto.LoginDTO;
|
||||
import xiaozhi.modules.security.dto.SmsVerificationDTO;
|
||||
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
|
||||
import xiaozhi.modules.sys.service.SysUserService;
|
||||
|
||||
@Slf4j
|
||||
@SpringBootTest
|
||||
@@ -19,12 +27,19 @@ class loginControllerTest {
|
||||
@Autowired
|
||||
LoginController loginController;
|
||||
|
||||
@MockitoBean
|
||||
SysUserService sysUserService;
|
||||
|
||||
@Test
|
||||
public void testRegister() {
|
||||
when(sysUserService.getAllowUserRegister()).thenReturn(false);
|
||||
|
||||
LoginDTO loginDTO = new LoginDTO();
|
||||
loginDTO.setUsername("手机号码");
|
||||
loginDTO.setPassword("密码");
|
||||
loginController.register(loginDTO);
|
||||
|
||||
RenException exception = assertThrows(RenException.class, () -> loginController.register(loginDTO));
|
||||
assertEquals(ErrorCode.USER_REGISTER_DISABLED, exception.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -54,4 +69,4 @@ class loginControllerTest {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package xiaozhi.modules.timbre.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.timbre.dao.TimbreDao;
|
||||
import xiaozhi.modules.timbre.entity.TimbreEntity;
|
||||
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
|
||||
class TimbreServiceImplTest {
|
||||
|
||||
@Test
|
||||
void defaultLanguageUsesFirstValidRegularTimbreLanguageWithoutCloneQuery() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
|
||||
TimbreEntity timbre = new TimbreEntity();
|
||||
timbre.setLanguages(",, ; 普通话;粤语");
|
||||
when(timbreDao.selectById("voice-id")).thenReturn(timbre);
|
||||
|
||||
assertEquals("普通话", service.getDefaultLanguageById("voice-id"));
|
||||
|
||||
verify(voiceCloneDao, never()).selectById("voice-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultLanguageFallsBackToCloneTimbre() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
|
||||
VoiceCloneEntity voiceClone = new VoiceCloneEntity();
|
||||
voiceClone.setLanguages("、, English,中文");
|
||||
when(voiceCloneDao.selectById("clone-id")).thenReturn(voiceClone);
|
||||
|
||||
assertEquals("English", service.getDefaultLanguageById("clone-id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delimiterOnlyLanguageConfigurationReturnsNull() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
|
||||
TimbreEntity timbre = new TimbreEntity();
|
||||
timbre.setLanguages(",,、;;;,,");
|
||||
when(timbreDao.selectById("voice-id")).thenReturn(timbre);
|
||||
|
||||
assertNull(service.getDefaultLanguageById("voice-id"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
spring:
|
||||
messages:
|
||||
encoding: UTF-8
|
||||
basename: i18n/messages
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
@@ -19,4 +22,4 @@ renren:
|
||||
logging:
|
||||
level:
|
||||
xiaozhi.modules.device: DEBUG
|
||||
org.springframework.data.redis: DEBUG
|
||||
org.springframework.data.redis: DEBUG
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs",
|
||||
"openapi-ts-request": "openapi-ts",
|
||||
"prepare": "git init && husky",
|
||||
"lint": "eslint",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
|
||||
import { watch, onMounted } from 'vue'
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { usePageAuth } from '@/hooks/usePageAuth'
|
||||
import { useConfigStore } from '@/store'
|
||||
import { t } from '@/i18n'
|
||||
import { useConfigStore } from '@/store'
|
||||
import { useLangStore } from '@/store/lang'
|
||||
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
|
||||
|
||||
@@ -37,9 +37,9 @@ function updateTabBarText() {
|
||||
success: () => {},
|
||||
fail: (err) => {
|
||||
console.log('设置首页tabBar文本失败:', err)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// 设置配网tabBar文本
|
||||
uni.setTabBarItem({
|
||||
index: 1,
|
||||
@@ -47,9 +47,9 @@ function updateTabBarText() {
|
||||
success: () => {},
|
||||
fail: (err) => {
|
||||
console.log('设置配网tabBar文本失败:', err)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// 设置系统tabBar文本
|
||||
uni.setTabBarItem({
|
||||
index: 2,
|
||||
@@ -57,9 +57,10 @@ function updateTabBarText() {
|
||||
success: () => {},
|
||||
fail: (err) => {
|
||||
console.log('设置系统tabBar文本失败:', err)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
console.log('更新tabBar文本时出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import type {
|
||||
Agent,
|
||||
AgentCreateData,
|
||||
AgentDetail,
|
||||
AgentSnapshot,
|
||||
AgentSnapshotPageParams,
|
||||
CorrectWordFile,
|
||||
ModelOption,
|
||||
PageData,
|
||||
RoleTemplate,
|
||||
} from './types'
|
||||
import { http } from '@/http/request/alova'
|
||||
@@ -100,7 +104,7 @@ export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
|
||||
}
|
||||
|
||||
// 更新智能体
|
||||
export function updateAgent(id: string, data: Partial<AgentDetail>) {
|
||||
export function updateAgent(id: string, data: Partial<AgentDetail> & { tagNames?: string[] }) {
|
||||
return http.Put(`/agent/${id}`, data, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
@@ -220,3 +224,63 @@ export function getAllLanguage(modelId: string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取智能体历史版本列表
|
||||
export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) {
|
||||
return http.Get<PageData<AgentSnapshot>>(`/agent/${agentId}/snapshots`, {
|
||||
params,
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
cacheFor: {
|
||||
expire: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取智能体历史版本详情
|
||||
export function getAgentSnapshot(agentId: string, snapshotId: string) {
|
||||
return http.Get<AgentSnapshot>(`/agent/${agentId}/snapshots/${snapshotId}`, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
cacheFor: {
|
||||
expire: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 恢复智能体历史版本
|
||||
export function restoreAgentSnapshot(agentId: string, snapshotId: string, currentStateToken: string) {
|
||||
return http.Post(`/agent/${agentId}/snapshots/${snapshotId}/restore`, { currentStateToken }, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 删除智能体历史版本
|
||||
export function deleteAgentSnapshot(agentId: string, snapshotId: string) {
|
||||
return http.Delete(`/agent/${agentId}/snapshots/${snapshotId}`, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取所有替换词文件
|
||||
export function getCorrectWordFiles() {
|
||||
return http.Get<CorrectWordFile[]>('/correct-word/file/select', {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
cacheFor: {
|
||||
expire: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,10 +44,12 @@ export interface AgentDetail {
|
||||
createdAt: string
|
||||
updater: string
|
||||
updatedAt: string
|
||||
ttsLanguage: string
|
||||
ttsVolume: number
|
||||
ttsRate: number
|
||||
ttsPitch: number
|
||||
ttsLanguage: string | null
|
||||
ttsVolume: number | null
|
||||
ttsRate: number | null
|
||||
ttsPitch: number | null
|
||||
currentVersionNo?: number | null
|
||||
tagNames?: string[]
|
||||
functions: AgentFunction[]
|
||||
contextProviders: Providers[]
|
||||
}
|
||||
@@ -67,6 +69,51 @@ export interface AgentFunction {
|
||||
paramInfo: Record<string, string | number | boolean> | null
|
||||
}
|
||||
|
||||
export interface PageData<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AgentSnapshotData extends Partial<AgentDetail> {
|
||||
correctWordFileIds?: string[]
|
||||
tagNames?: string[]
|
||||
tags?: Array<{
|
||||
tagName?: string
|
||||
[key: string]: any
|
||||
}>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface AgentSnapshot {
|
||||
id: string
|
||||
agentId: string
|
||||
userId?: string
|
||||
versionNo: number
|
||||
changedFields?: string[]
|
||||
fieldOrder?: string[]
|
||||
source?: string
|
||||
restoreFromSnapshotId?: string | null
|
||||
restoreFromVersionNo?: number | null
|
||||
currentStateToken?: string
|
||||
currentSnapshotData?: AgentSnapshotData
|
||||
creator?: string
|
||||
createdAt?: string
|
||||
snapshotData?: AgentSnapshotData
|
||||
afterSnapshotData?: AgentSnapshotData
|
||||
}
|
||||
|
||||
export interface AgentSnapshotPageParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
maxVersionNo?: number
|
||||
}
|
||||
|
||||
export interface CorrectWordFile {
|
||||
id: string
|
||||
fileName: string
|
||||
wordCount?: number
|
||||
}
|
||||
|
||||
// 角色模板数据类型
|
||||
export interface RoleTemplate {
|
||||
id: string
|
||||
@@ -78,6 +125,7 @@ export interface RoleTemplate {
|
||||
vllmModelId: string
|
||||
ttsModelId: string
|
||||
ttsVoiceId: string
|
||||
ttsLanguage?: string | null
|
||||
memModelId: string
|
||||
intentModelId: string
|
||||
chatHistoryConf: number
|
||||
|
||||
+560
-473
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,7 @@ export default {
|
||||
'agent.saving': 'Saving...',
|
||||
'agent.saveSuccess': 'Save successful',
|
||||
'agent.saveFail': 'Save failed',
|
||||
'agent.ttsOptionsLoadFailed': 'Voice options could not be loaded. The previous voice settings were kept.',
|
||||
'agent.loadFail': 'Load failed',
|
||||
'agent.pleaseInputAgentName': 'Please input agent name',
|
||||
'agent.pleaseInputRoleDescription': 'Please input role description',
|
||||
@@ -146,6 +147,92 @@ export default {
|
||||
'agent.speedHint': '-100=Slower, 0=Standard, 100=Faster',
|
||||
'agent.pitchHint': '-100=Lowest, 0=Standard, 100=Highest',
|
||||
|
||||
// Agent snapshots
|
||||
'agentSnapshot.title': 'Version History',
|
||||
'agentSnapshot.empty': 'No versions yet',
|
||||
'agentSnapshot.emptyTip': 'Saved configs will appear here',
|
||||
'agentSnapshot.version': 'Version',
|
||||
'agentSnapshot.createdAt': 'Saved At',
|
||||
'agentSnapshot.source': 'Source',
|
||||
'agentSnapshot.changedFields': 'Changes',
|
||||
'agentSnapshot.view': 'View',
|
||||
'agentSnapshot.restore': 'Restore',
|
||||
'agentSnapshot.delete': 'Delete',
|
||||
'agentSnapshot.loadMore': 'Load More',
|
||||
'agentSnapshot.detailTitle': 'Change Details',
|
||||
'agentSnapshot.restorePreviewTitle': 'Restore Preview',
|
||||
'agentSnapshot.confirmRestore': 'Confirm Restore',
|
||||
'agentSnapshot.currentVersion': 'Latest Snapshot',
|
||||
'agentSnapshot.beforeChange': 'Before',
|
||||
'agentSnapshot.afterChange': 'After',
|
||||
'agentSnapshot.beforeRestore': 'Before Restore',
|
||||
'agentSnapshot.afterRestore': 'After Restore',
|
||||
'agentSnapshot.configValue': 'Config Value',
|
||||
'agentSnapshot.emptyValue': 'None',
|
||||
'agentSnapshot.secretRedacted': 'Secret hidden',
|
||||
'agentSnapshot.redactedValueChanged': 'The value is hidden, but it did change',
|
||||
'agentSnapshot.noChangedContent': 'No displayable changes',
|
||||
'agentSnapshot.recordedChange': 'Changed when recorded',
|
||||
'agentSnapshot.noRestoreNeeded': 'The current configuration already matches this version',
|
||||
'agentSnapshot.unsavedChangesTitle': 'Unsaved changes',
|
||||
'agentSnapshot.unsavedChangesWarning': 'Continuing will discard the unsaved changes on this page.',
|
||||
'agentSnapshot.discardAndRestore': 'Discard and restore',
|
||||
'agentSnapshot.restoreConfirm': 'Restore to version #{version}? The current configuration will remain available in history.',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Restoring to a no-memory version will clear this agent\'s chat history. Please confirm the risk.',
|
||||
'agentSnapshot.restoreChatHistoryDestructiveWarning': 'This restore will permanently delete this agent\'s existing chat history. Chat history is not included in configuration snapshots and cannot be recovered from version history.',
|
||||
'agentSnapshot.restoreSuccess': 'Version restored',
|
||||
'agentSnapshot.restoreFailed': 'Failed to restore version',
|
||||
'agentSnapshot.reloadAfterRestorePending': 'Reloading the restored configuration and tags. Saving is disabled until this completes.',
|
||||
'agentSnapshot.reloadAfterRestoreFailed': 'The restored configuration or tags could not be reloaded. Saving remains disabled to prevent stale data from overwriting the restore. Please retry.',
|
||||
'agentSnapshot.retryReload': 'Reload',
|
||||
'agentSnapshot.mutationBusy': 'The configuration is being saved or reloaded. Wait for it to finish before opening version history or restoring.',
|
||||
'agentSnapshot.deleteConfirm': 'Delete version #{version}? This cannot be undone.',
|
||||
'agentSnapshot.deleteSuccess': 'Version deleted',
|
||||
'agentSnapshot.deleteFailed': 'Failed to delete version',
|
||||
'agentSnapshot.fetchFailed': 'Failed to fetch versions',
|
||||
'agentSnapshot.detailFailed': 'Failed to fetch version details',
|
||||
'agentSnapshot.correctWordCount': '{count} replacement words',
|
||||
'agentSnapshot.source.config': 'Config Save',
|
||||
'agentSnapshot.source.current': 'Current Config',
|
||||
'agentSnapshot.source.restore': 'Restored',
|
||||
'agentSnapshot.source.initial': 'Initial Version',
|
||||
'agentSnapshot.field.initial': 'Initial Snapshot',
|
||||
'agentSnapshot.field.agentCode': 'Agent Code',
|
||||
'agentSnapshot.field.agentName': 'Nickname',
|
||||
'agentSnapshot.field.asrModelId': 'Speech Recognition',
|
||||
'agentSnapshot.field.vadModelId': 'Voice Activity Detection',
|
||||
'agentSnapshot.field.llmModelId': 'Main Language Model',
|
||||
'agentSnapshot.field.slmModelId': 'Small Language Model',
|
||||
'agentSnapshot.field.vllmModelId': 'Vision Model',
|
||||
'agentSnapshot.field.ttsModelId': 'Text-to-Speech',
|
||||
'agentSnapshot.field.ttsVoiceId': 'Voice',
|
||||
'agentSnapshot.field.ttsLanguage': 'Language',
|
||||
'agentSnapshot.field.ttsVolume': 'Volume',
|
||||
'agentSnapshot.field.ttsRate': 'Speed',
|
||||
'agentSnapshot.field.ttsPitch': 'Pitch',
|
||||
'agentSnapshot.field.memModelId': 'Memory Mode',
|
||||
'agentSnapshot.field.intentModelId': 'Intent Recognition',
|
||||
'agentSnapshot.field.chatHistoryConf': 'Chat History Config',
|
||||
'agentSnapshot.field.systemPrompt': 'Role Description',
|
||||
'agentSnapshot.field.summaryMemory': 'Memory',
|
||||
'agentSnapshot.field.langCode': 'Language Code',
|
||||
'agentSnapshot.field.language': 'Interaction Language',
|
||||
'agentSnapshot.field.sort': 'Sort',
|
||||
'agentSnapshot.field.functions': 'Plugins',
|
||||
'agentSnapshot.field.contextProviders': 'Context Sources',
|
||||
'agentSnapshot.field.correctWordFileIds': 'Replacement Words',
|
||||
'agentSnapshot.field.tagNames': 'Agent Tags',
|
||||
'agentSnapshot.chatHistoryConf.none': 'Do not record chat history',
|
||||
'agentSnapshot.chatHistoryConf.text': 'Report text',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': 'Report text and voice',
|
||||
'agentSnapshot.model.Memory_nomem': 'No memory',
|
||||
'agentSnapshot.model.Memory_mem_local_short': 'Local short memory',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Mem0AI memory',
|
||||
'agentSnapshot.model.Memory_mem_report_only': 'Report only',
|
||||
'agentSnapshot.model.Intent_nointent': 'No intent recognition',
|
||||
'agentSnapshot.model.Intent_intent_llm': 'External LLM intent recognition',
|
||||
'agentSnapshot.model.Intent_function_call': 'LLM function calling',
|
||||
|
||||
// Context provider dialog related
|
||||
'contextProviderDialog.title': 'Edit Source',
|
||||
'contextProviderDialog.noContextApi': 'No Context API',
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import type { Language } from '@/store/lang'
|
||||
import { ref } from 'vue'
|
||||
import { useLangStore } from '@/store/lang'
|
||||
import type { Language } from '@/store/lang'
|
||||
|
||||
import de from './de'
|
||||
import en from './en'
|
||||
import pt_BR from './pt_BR'
|
||||
import vi from './vi'
|
||||
// 导入各个语言的翻译文件
|
||||
import zh_CN from './zh_CN'
|
||||
import en from './en'
|
||||
import zh_TW from './zh_TW'
|
||||
import de from './de'
|
||||
import vi from './vi'
|
||||
import pt_BR from './pt_BR'
|
||||
|
||||
// 语言包映射
|
||||
const messages = {
|
||||
zh_CN: zh_CN,
|
||||
zh_CN,
|
||||
en,
|
||||
zh_TW: zh_TW,
|
||||
zh_TW,
|
||||
de,
|
||||
vi,
|
||||
pt_BR: pt_BR,
|
||||
pt_BR,
|
||||
}
|
||||
|
||||
// 当前使用的语言
|
||||
@@ -42,7 +42,7 @@ export function t(key: string, params?: Record<string, string | number>): string
|
||||
|
||||
// 直接查找扁平键名
|
||||
if (langMessages && typeof langMessages === 'object' && key in langMessages) {
|
||||
let value = langMessages[key]
|
||||
const value = langMessages[key]
|
||||
if (typeof value === 'string') {
|
||||
// 处理参数替换
|
||||
if (params) {
|
||||
@@ -76,4 +76,4 @@ export function getSupportedLanguages(): { code: Language, name: string }[] {
|
||||
{ code: 'vi', name: 'Tiếng Việt' },
|
||||
{ code: 'pt_BR', name: 'Português (Brasil)' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@ export default {
|
||||
'login.requiredCaptcha': 'O código de verificação não pode estar vazio',
|
||||
'login.requiredMobile': 'Por favor, insira um número de telefone válido',
|
||||
'login.captchaError': 'Erro no código de verificação gráfico',
|
||||
'login.forgotPassword': 'Esqueceu a Senha',
|
||||
'login.userAgreement': 'Termos de Uso',
|
||||
'login.privacyPolicy': 'Política de Privacidade',
|
||||
'login.forgotPassword': 'Esqueceu a Senha',
|
||||
'login.userAgreement': 'Termos de Uso',
|
||||
'login.privacyPolicy': 'Política de Privacidade',
|
||||
|
||||
// Register page
|
||||
'register.pageTitle': 'Cadastro',
|
||||
@@ -130,6 +130,7 @@ export default {
|
||||
'agent.saving': 'Salvando...',
|
||||
'agent.saveSuccess': 'Salvo com sucesso',
|
||||
'agent.saveFail': 'Falha ao salvar',
|
||||
'agent.ttsOptionsLoadFailed': 'Não foi possível carregar as opções de voz. As configurações anteriores foram mantidas.',
|
||||
'agent.loadFail': 'Falha ao carregar',
|
||||
'agent.pleaseInputAgentName': 'Por favor, insira o nome do agente',
|
||||
'agent.pleaseInputRoleDescription': 'Por favor, insira a descrição do papel',
|
||||
@@ -146,6 +147,92 @@ export default {
|
||||
'agent.speedHint': '-100=Slower, 0=Standard, 100=Faster',
|
||||
'agent.pitchHint': '-100=Lowest, 0=Standard, 100=Highest',
|
||||
|
||||
// Histórico de versões do agente
|
||||
'agentSnapshot.title': 'Histórico de Versões',
|
||||
'agentSnapshot.empty': 'Ainda sem versões',
|
||||
'agentSnapshot.emptyTip': 'Configurações salvas aparecerão aqui',
|
||||
'agentSnapshot.version': 'Versão',
|
||||
'agentSnapshot.createdAt': 'Salvo em',
|
||||
'agentSnapshot.source': 'Origem',
|
||||
'agentSnapshot.changedFields': 'Alterações',
|
||||
'agentSnapshot.view': 'Ver',
|
||||
'agentSnapshot.restore': 'Restaurar',
|
||||
'agentSnapshot.delete': 'Excluir',
|
||||
'agentSnapshot.loadMore': 'Carregar Mais',
|
||||
'agentSnapshot.detailTitle': 'Detalhes da Alteração',
|
||||
'agentSnapshot.restorePreviewTitle': 'Prévia da Restauração',
|
||||
'agentSnapshot.confirmRestore': 'Confirmar Restauração',
|
||||
'agentSnapshot.currentVersion': 'Snapshot mais recente',
|
||||
'agentSnapshot.beforeChange': 'Antes',
|
||||
'agentSnapshot.afterChange': 'Depois',
|
||||
'agentSnapshot.beforeRestore': 'Antes da Restauração',
|
||||
'agentSnapshot.afterRestore': 'Depois da Restauração',
|
||||
'agentSnapshot.configValue': 'Valor da Configuração',
|
||||
'agentSnapshot.emptyValue': 'Nenhum',
|
||||
'agentSnapshot.secretRedacted': 'Segredo oculto',
|
||||
'agentSnapshot.redactedValueChanged': 'O valor está oculto, mas foi alterado',
|
||||
'agentSnapshot.noChangedContent': 'Nenhuma alteração exibível',
|
||||
'agentSnapshot.recordedChange': 'Alterado quando registrado',
|
||||
'agentSnapshot.noRestoreNeeded': 'A configuração atual já corresponde a esta versão',
|
||||
'agentSnapshot.unsavedChangesTitle': 'Alterações não salvas',
|
||||
'agentSnapshot.unsavedChangesWarning': 'Continuar descartará as alterações não salvas desta página.',
|
||||
'agentSnapshot.discardAndRestore': 'Descartar e restaurar',
|
||||
'agentSnapshot.restoreConfirm': 'Restaurar para a versão #{version}? A configuração atual continuará disponível no histórico.',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Restaurar para uma versão sem memória limpará o histórico de chat deste agente. Confirme o risco.',
|
||||
'agentSnapshot.restoreChatHistoryDestructiveWarning': 'Esta restauração excluirá permanentemente o histórico de chat existente deste agente. O histórico de chat não faz parte dos snapshots de configuração e não pode ser recuperado pelo histórico de versões.',
|
||||
'agentSnapshot.restoreSuccess': 'Versão restaurada',
|
||||
'agentSnapshot.restoreFailed': 'Falha ao restaurar versão',
|
||||
'agentSnapshot.reloadAfterRestorePending': 'Recarregando a configuração e as tags restauradas. Não é possível salvar até a conclusão.',
|
||||
'agentSnapshot.reloadAfterRestoreFailed': 'Não foi possível recarregar a configuração ou as tags restauradas. O salvamento permanece desativado para impedir que dados antigos sobrescrevam a restauração. Tente novamente.',
|
||||
'agentSnapshot.retryReload': 'Recarregar',
|
||||
'agentSnapshot.mutationBusy': 'A configuração está sendo salva ou recarregada. Aguarde a conclusão antes de abrir o histórico de versões ou restaurar.',
|
||||
'agentSnapshot.deleteConfirm': 'Excluir versão #{version}? Esta ação não pode ser desfeita.',
|
||||
'agentSnapshot.deleteSuccess': 'Versão excluída',
|
||||
'agentSnapshot.deleteFailed': 'Falha ao excluir versão',
|
||||
'agentSnapshot.fetchFailed': 'Falha ao buscar versões',
|
||||
'agentSnapshot.detailFailed': 'Falha ao buscar detalhes da versão',
|
||||
'agentSnapshot.correctWordCount': '{count} palavras de substituição',
|
||||
'agentSnapshot.source.config': 'Configuração Salva',
|
||||
'agentSnapshot.source.current': 'Configuração Atual',
|
||||
'agentSnapshot.source.restore': 'Restaurado',
|
||||
'agentSnapshot.source.initial': 'Versão Inicial',
|
||||
'agentSnapshot.field.initial': 'Snapshot Inicial',
|
||||
'agentSnapshot.field.agentCode': 'Código do Agente',
|
||||
'agentSnapshot.field.agentName': 'Apelido',
|
||||
'agentSnapshot.field.asrModelId': 'Reconhecimento de Fala',
|
||||
'agentSnapshot.field.vadModelId': 'Detecção de Voz',
|
||||
'agentSnapshot.field.llmModelId': 'Modelo Principal',
|
||||
'agentSnapshot.field.slmModelId': 'Modelo Pequeno',
|
||||
'agentSnapshot.field.vllmModelId': 'Modelo Visual',
|
||||
'agentSnapshot.field.ttsModelId': 'Texto para Fala',
|
||||
'agentSnapshot.field.ttsVoiceId': 'Voz',
|
||||
'agentSnapshot.field.ttsLanguage': 'Idioma',
|
||||
'agentSnapshot.field.ttsVolume': 'Volume',
|
||||
'agentSnapshot.field.ttsRate': 'Velocidade',
|
||||
'agentSnapshot.field.ttsPitch': 'Tonalidade',
|
||||
'agentSnapshot.field.memModelId': 'Modo de Memória',
|
||||
'agentSnapshot.field.intentModelId': 'Reconhecimento de Intenção',
|
||||
'agentSnapshot.field.chatHistoryConf': 'Configuração do Histórico',
|
||||
'agentSnapshot.field.systemPrompt': 'Descrição do Papel',
|
||||
'agentSnapshot.field.summaryMemory': 'Memória',
|
||||
'agentSnapshot.field.langCode': 'Código do Idioma',
|
||||
'agentSnapshot.field.language': 'Idioma de Interação',
|
||||
'agentSnapshot.field.sort': 'Ordenação',
|
||||
'agentSnapshot.field.functions': 'Plugins',
|
||||
'agentSnapshot.field.contextProviders': 'Fontes de Contexto',
|
||||
'agentSnapshot.field.correctWordFileIds': 'Palavras de Substituição',
|
||||
'agentSnapshot.field.tagNames': 'Tags do Agente',
|
||||
'agentSnapshot.chatHistoryConf.none': 'Não registrar histórico de chat',
|
||||
'agentSnapshot.chatHistoryConf.text': 'Reportar texto',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': 'Reportar texto e voz',
|
||||
'agentSnapshot.model.Memory_nomem': 'Sem memória',
|
||||
'agentSnapshot.model.Memory_mem_local_short': 'Memória curta local',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Memória Mem0AI',
|
||||
'agentSnapshot.model.Memory_mem_report_only': 'Apenas reportar',
|
||||
'agentSnapshot.model.Intent_nointent': 'Sem reconhecimento de intenção',
|
||||
'agentSnapshot.model.Intent_intent_llm': 'Reconhecimento por LLM externo',
|
||||
'agentSnapshot.model.Intent_function_call': 'Chamada de função por LLM',
|
||||
|
||||
// Diálogo de provedor de contexto
|
||||
'contextProviderDialog.title': 'Editar Fonte',
|
||||
'contextProviderDialog.noContextApi': 'Sem API de Contexto',
|
||||
|
||||
+561
-474
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,7 @@ export default {
|
||||
'agent.saving': '保存中...',
|
||||
'agent.saveSuccess': '保存成功',
|
||||
'agent.saveFail': '保存失败',
|
||||
'agent.ttsOptionsLoadFailed': '语音选项加载失败,已保留原来的语音配置',
|
||||
'agent.loadFail': '加载失败',
|
||||
'agent.pleaseInputAgentName': '请输入智能体名称',
|
||||
'agent.pleaseInputRoleDescription': '请输入角色介绍',
|
||||
@@ -146,6 +147,92 @@ export default {
|
||||
'agent.speedHint': '-100=最慢, 0=标准, 100=最快',
|
||||
'agent.pitchHint': '-100=最低, 0=标准, 100=最高',
|
||||
|
||||
// 智能体历史版本
|
||||
'agentSnapshot.title': '历史版本',
|
||||
'agentSnapshot.empty': '暂无历史版本',
|
||||
'agentSnapshot.emptyTip': '保存配置后会生成历史版本',
|
||||
'agentSnapshot.version': '版本',
|
||||
'agentSnapshot.createdAt': '保存时间',
|
||||
'agentSnapshot.source': '来源',
|
||||
'agentSnapshot.changedFields': '变更内容',
|
||||
'agentSnapshot.view': '查看',
|
||||
'agentSnapshot.restore': '恢复',
|
||||
'agentSnapshot.delete': '删除',
|
||||
'agentSnapshot.loadMore': '加载更多',
|
||||
'agentSnapshot.detailTitle': '变更详情',
|
||||
'agentSnapshot.restorePreviewTitle': '恢复预览',
|
||||
'agentSnapshot.confirmRestore': '确认恢复',
|
||||
'agentSnapshot.currentVersion': '最新快照',
|
||||
'agentSnapshot.beforeChange': '变化前',
|
||||
'agentSnapshot.afterChange': '变化后',
|
||||
'agentSnapshot.beforeRestore': '恢复前',
|
||||
'agentSnapshot.afterRestore': '恢复后',
|
||||
'agentSnapshot.configValue': '配置值',
|
||||
'agentSnapshot.emptyValue': '无',
|
||||
'agentSnapshot.secretRedacted': '密钥已隐藏',
|
||||
'agentSnapshot.redactedValueChanged': '值已脱敏,但确有变化',
|
||||
'agentSnapshot.noChangedContent': '无可显示变更',
|
||||
'agentSnapshot.recordedChange': '记录时发生变更',
|
||||
'agentSnapshot.noRestoreNeeded': '当前配置与目标版本相同,无需恢复',
|
||||
'agentSnapshot.unsavedChangesTitle': '存在未保存修改',
|
||||
'agentSnapshot.unsavedChangesWarning': '继续恢复会放弃当前页面中尚未保存的修改。',
|
||||
'agentSnapshot.discardAndRestore': '放弃修改并恢复',
|
||||
'agentSnapshot.restoreConfirm': '确定恢复到版本 #{version}?当前配置会保留在历史中。',
|
||||
'agentSnapshot.restoreMemoryWarning': '恢复到无记忆版本会清空该智能体聊天记录,请确认风险。',
|
||||
'agentSnapshot.restoreChatHistoryDestructiveWarning': '此恢复会永久删除该智能体现有的聊天记录。聊天记录不包含在配置快照中,删除后无法通过历史版本恢复。',
|
||||
'agentSnapshot.restoreSuccess': '版本已恢复',
|
||||
'agentSnapshot.restoreFailed': '版本恢复失败',
|
||||
'agentSnapshot.reloadAfterRestorePending': '正在重新加载恢复后的配置和标签,完成前无法保存。',
|
||||
'agentSnapshot.reloadAfterRestoreFailed': '无法重新加载恢复后的配置或标签。为防止旧数据覆盖恢复结果,保存已禁用,请重试。',
|
||||
'agentSnapshot.retryReload': '重新加载',
|
||||
'agentSnapshot.mutationBusy': '配置正在保存或重新加载,请完成后再打开历史版本或恢复。',
|
||||
'agentSnapshot.deleteConfirm': '确定删除版本 #{version}?此操作不可撤销。',
|
||||
'agentSnapshot.deleteSuccess': '历史版本已删除',
|
||||
'agentSnapshot.deleteFailed': '历史版本删除失败',
|
||||
'agentSnapshot.fetchFailed': '获取历史版本失败',
|
||||
'agentSnapshot.detailFailed': '获取版本详情失败',
|
||||
'agentSnapshot.correctWordCount': '共 {count} 个替换词',
|
||||
'agentSnapshot.source.config': '配置保存',
|
||||
'agentSnapshot.source.current': '当前配置',
|
||||
'agentSnapshot.source.restore': '恢复结果',
|
||||
'agentSnapshot.source.initial': '初始版本',
|
||||
'agentSnapshot.field.initial': '初始快照',
|
||||
'agentSnapshot.field.agentCode': '智能体编码',
|
||||
'agentSnapshot.field.agentName': '助手昵称',
|
||||
'agentSnapshot.field.asrModelId': '语音识别',
|
||||
'agentSnapshot.field.vadModelId': '语音活动检测',
|
||||
'agentSnapshot.field.llmModelId': '主语言模型',
|
||||
'agentSnapshot.field.slmModelId': '小参数模型',
|
||||
'agentSnapshot.field.vllmModelId': '视觉大模型',
|
||||
'agentSnapshot.field.ttsModelId': '语音合成',
|
||||
'agentSnapshot.field.ttsVoiceId': '声音音色',
|
||||
'agentSnapshot.field.ttsLanguage': '对话语言',
|
||||
'agentSnapshot.field.ttsVolume': '音量',
|
||||
'agentSnapshot.field.ttsRate': '语速',
|
||||
'agentSnapshot.field.ttsPitch': '音调',
|
||||
'agentSnapshot.field.memModelId': '记忆模式',
|
||||
'agentSnapshot.field.intentModelId': '意图识别',
|
||||
'agentSnapshot.field.chatHistoryConf': '聊天记录配置',
|
||||
'agentSnapshot.field.systemPrompt': '角色介绍',
|
||||
'agentSnapshot.field.summaryMemory': '记忆',
|
||||
'agentSnapshot.field.langCode': '语言编码',
|
||||
'agentSnapshot.field.language': '交互语种',
|
||||
'agentSnapshot.field.sort': '排序',
|
||||
'agentSnapshot.field.functions': '插件',
|
||||
'agentSnapshot.field.contextProviders': '上下文源',
|
||||
'agentSnapshot.field.correctWordFileIds': '替换词',
|
||||
'agentSnapshot.field.tagNames': '智能体标签',
|
||||
'agentSnapshot.chatHistoryConf.none': '不记录聊天记录',
|
||||
'agentSnapshot.chatHistoryConf.text': '上报文字',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': '上报文字+语音',
|
||||
'agentSnapshot.model.Memory_nomem': '无记忆',
|
||||
'agentSnapshot.model.Memory_mem_local_short': '本地短期记忆',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Mem0AI 记忆',
|
||||
'agentSnapshot.model.Memory_mem_report_only': '仅上报',
|
||||
'agentSnapshot.model.Intent_nointent': '无意图识别',
|
||||
'agentSnapshot.model.Intent_intent_llm': '外部大模型意图识别',
|
||||
'agentSnapshot.model.Intent_function_call': '大模型函数调用',
|
||||
|
||||
// 上下文源对话框相关
|
||||
'contextProviderDialog.title': '编辑源',
|
||||
'contextProviderDialog.noContextApi': '暂无上下文API',
|
||||
|
||||
@@ -29,9 +29,9 @@ export default {
|
||||
'login.requiredCaptcha': '驗證碼不能為空',
|
||||
'login.requiredMobile': '請輸入正確的手機號碼',
|
||||
'login.captchaError': '圖形驗證碼錯誤',
|
||||
'login.forgotPassword': '忘記密碼',
|
||||
'login.userAgreement': '用戶協議',
|
||||
'login.privacyPolicy': '隱私政策',
|
||||
'login.forgotPassword': '忘記密碼',
|
||||
'login.userAgreement': '用戶協議',
|
||||
'login.privacyPolicy': '隱私政策',
|
||||
|
||||
// 忘記密碼頁面
|
||||
'retrievePassword.title': '重置密碼',
|
||||
@@ -151,6 +151,7 @@ export default {
|
||||
'agent.saving': '儲存中...',
|
||||
'agent.saveSuccess': '儲存成功',
|
||||
'agent.saveFail': '儲存失敗',
|
||||
'agent.ttsOptionsLoadFailed': '語音選項載入失敗,已保留原來的語音設定',
|
||||
'agent.loadFail': '加載失敗',
|
||||
'agent.pleaseInputAgentName': '請輸入助手暱稱',
|
||||
'agent.pleaseInputRoleDescription': '請輸入角色介紹',
|
||||
@@ -167,6 +168,92 @@ export default {
|
||||
'agent.speedHint': '-100=最慢, 0=標準, 100=最快',
|
||||
'agent.pitchHint': '-100=最低, 0=標準, 100=最高',
|
||||
|
||||
// 智能體歷史版本
|
||||
'agentSnapshot.title': '歷史版本',
|
||||
'agentSnapshot.empty': '暫無歷史版本',
|
||||
'agentSnapshot.emptyTip': '保存配置後會生成歷史版本',
|
||||
'agentSnapshot.version': '版本',
|
||||
'agentSnapshot.createdAt': '保存時間',
|
||||
'agentSnapshot.source': '來源',
|
||||
'agentSnapshot.changedFields': '變更內容',
|
||||
'agentSnapshot.view': '查看',
|
||||
'agentSnapshot.restore': '恢復',
|
||||
'agentSnapshot.delete': '刪除',
|
||||
'agentSnapshot.loadMore': '載入更多',
|
||||
'agentSnapshot.detailTitle': '變更詳情',
|
||||
'agentSnapshot.restorePreviewTitle': '恢復預覽',
|
||||
'agentSnapshot.confirmRestore': '確認恢復',
|
||||
'agentSnapshot.currentVersion': '最新快照',
|
||||
'agentSnapshot.beforeChange': '變化前',
|
||||
'agentSnapshot.afterChange': '變化後',
|
||||
'agentSnapshot.beforeRestore': '恢復前',
|
||||
'agentSnapshot.afterRestore': '恢復後',
|
||||
'agentSnapshot.configValue': '配置值',
|
||||
'agentSnapshot.emptyValue': '無',
|
||||
'agentSnapshot.secretRedacted': '密鑰已隱藏',
|
||||
'agentSnapshot.redactedValueChanged': '值已脫敏,但確有變化',
|
||||
'agentSnapshot.noChangedContent': '無可顯示變更',
|
||||
'agentSnapshot.recordedChange': '記錄時發生變更',
|
||||
'agentSnapshot.noRestoreNeeded': '目前設定與目標版本相同,無需恢復',
|
||||
'agentSnapshot.unsavedChangesTitle': '存在未儲存修改',
|
||||
'agentSnapshot.unsavedChangesWarning': '繼續恢復會放棄目前頁面中尚未儲存的修改。',
|
||||
'agentSnapshot.discardAndRestore': '放棄修改並恢復',
|
||||
'agentSnapshot.restoreConfirm': '確定恢復到版本 #{version}?目前設定會保留在歷史中。',
|
||||
'agentSnapshot.restoreMemoryWarning': '恢復到無記憶版本會清空該智能體聊天記錄,請確認風險。',
|
||||
'agentSnapshot.restoreChatHistoryDestructiveWarning': '此恢復會永久刪除該智能體現有的聊天記錄。聊天記錄不包含在配置快照中,刪除後無法透過歷史版本恢復。',
|
||||
'agentSnapshot.restoreSuccess': '版本已恢復',
|
||||
'agentSnapshot.restoreFailed': '版本恢復失敗',
|
||||
'agentSnapshot.reloadAfterRestorePending': '正在重新載入恢復後的配置和標籤,完成前無法儲存。',
|
||||
'agentSnapshot.reloadAfterRestoreFailed': '無法重新載入恢復後的配置或標籤。為防止舊資料覆蓋恢復結果,儲存已停用,請重試。',
|
||||
'agentSnapshot.retryReload': '重新載入',
|
||||
'agentSnapshot.mutationBusy': '配置正在儲存或重新載入,請完成後再開啟歷史版本或恢復。',
|
||||
'agentSnapshot.deleteConfirm': '確定刪除版本 #{version}?此操作不可復原。',
|
||||
'agentSnapshot.deleteSuccess': '歷史版本已刪除',
|
||||
'agentSnapshot.deleteFailed': '歷史版本刪除失敗',
|
||||
'agentSnapshot.fetchFailed': '獲取歷史版本失敗',
|
||||
'agentSnapshot.detailFailed': '獲取版本詳情失敗',
|
||||
'agentSnapshot.correctWordCount': '共 {count} 個替換詞',
|
||||
'agentSnapshot.source.config': '配置保存',
|
||||
'agentSnapshot.source.current': '當前配置',
|
||||
'agentSnapshot.source.restore': '恢復結果',
|
||||
'agentSnapshot.source.initial': '初始版本',
|
||||
'agentSnapshot.field.initial': '初始快照',
|
||||
'agentSnapshot.field.agentCode': '智能體編碼',
|
||||
'agentSnapshot.field.agentName': '助手暱稱',
|
||||
'agentSnapshot.field.asrModelId': '語音識別',
|
||||
'agentSnapshot.field.vadModelId': '語音活動檢測',
|
||||
'agentSnapshot.field.llmModelId': '主語言模型',
|
||||
'agentSnapshot.field.slmModelId': '小參數模型',
|
||||
'agentSnapshot.field.vllmModelId': '視覺大模型',
|
||||
'agentSnapshot.field.ttsModelId': '語音合成',
|
||||
'agentSnapshot.field.ttsVoiceId': '聲音音色',
|
||||
'agentSnapshot.field.ttsLanguage': '對話語言',
|
||||
'agentSnapshot.field.ttsVolume': '音量',
|
||||
'agentSnapshot.field.ttsRate': '語速',
|
||||
'agentSnapshot.field.ttsPitch': '音調',
|
||||
'agentSnapshot.field.memModelId': '記憶模式',
|
||||
'agentSnapshot.field.intentModelId': '意圖識別',
|
||||
'agentSnapshot.field.chatHistoryConf': '聊天記錄配置',
|
||||
'agentSnapshot.field.systemPrompt': '角色介紹',
|
||||
'agentSnapshot.field.summaryMemory': '記憶',
|
||||
'agentSnapshot.field.langCode': '語言編碼',
|
||||
'agentSnapshot.field.language': '交互語種',
|
||||
'agentSnapshot.field.sort': '排序',
|
||||
'agentSnapshot.field.functions': '插件',
|
||||
'agentSnapshot.field.contextProviders': '上下文源',
|
||||
'agentSnapshot.field.correctWordFileIds': '替換詞',
|
||||
'agentSnapshot.field.tagNames': '智能體標籤',
|
||||
'agentSnapshot.chatHistoryConf.none': '不記錄聊天記錄',
|
||||
'agentSnapshot.chatHistoryConf.text': '上報文字',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': '上報文字+語音',
|
||||
'agentSnapshot.model.Memory_nomem': '無記憶',
|
||||
'agentSnapshot.model.Memory_mem_local_short': '本地短期記憶',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Mem0AI 記憶',
|
||||
'agentSnapshot.model.Memory_mem_report_only': '僅上報',
|
||||
'agentSnapshot.model.Intent_nointent': '無意圖識別',
|
||||
'agentSnapshot.model.Intent_intent_llm': '外部大模型意圖識別',
|
||||
'agentSnapshot.model.Intent_function_call': '大模型函數調用',
|
||||
|
||||
// 上下文源对话框相关
|
||||
'contextProviderDialog.title': '編輯源',
|
||||
'contextProviderDialog.noContextApi': '暫無上下文API',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ConfigProviderThemeVars } from 'wot-design-uni'
|
||||
import type { ConfigProviderThemeVars } from 'wot-design-uni/components/wd-config-provider/types'
|
||||
|
||||
const themeVars: ConfigProviderThemeVars = {
|
||||
// colorTheme: 'red',
|
||||
|
||||
@@ -38,10 +38,8 @@ onLoad(() => {
|
||||
<wd-tabbar
|
||||
v-if="customTabbarEnable"
|
||||
v-model="tabbarStore.curIdx"
|
||||
bordered
|
||||
safe-area-inset-bottom
|
||||
placeholder
|
||||
fixed
|
||||
|
||||
safe-area-inset-bottom bordered placeholder fixed
|
||||
@change="selectTabBar"
|
||||
>
|
||||
<block v-for="(item, idx) in tabbarList" :key="item.path">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ConfigProviderThemeVars } from 'wot-design-uni'
|
||||
import type { ConfigProviderThemeVars } from 'wot-design-uni/components/wd-config-provider/types'
|
||||
import FgTabbar from './fg-tabbar/fg-tabbar.vue'
|
||||
|
||||
const themeVars: ConfigProviderThemeVars = {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { routeInterceptor } from './router/interceptor'
|
||||
|
||||
import store from './store'
|
||||
import '@/style/index.scss'
|
||||
import 'virtual:uno.css'
|
||||
|
||||
// 导入国际化相关功能
|
||||
import { initI18n } from './i18n'
|
||||
import { useLangStore } from './store/lang'
|
||||
|
||||
import { routeInterceptor } from './router/interceptor'
|
||||
import store from './store'
|
||||
|
||||
import '@/style/index.scss'
|
||||
import 'virtual:uno.css'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"globalStyle": {
|
||||
"navigationStyle": "default",
|
||||
"navigationBarTitleText": "智控台",
|
||||
"navigationBarTitleText": "小智",
|
||||
"navigationBarBackgroundColor": "#f8f8f8",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#FFFFFF"
|
||||
@@ -74,6 +74,24 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agent/provider",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationBarTitleText": "编辑源",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agent/speedPitch",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationBarTitleText": "语音设置",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agent/tools",
|
||||
"type": "page",
|
||||
@@ -126,6 +144,42 @@
|
||||
"navigationBarTitleText": "Login"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/privacy-policy-en",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "Privacy Policy"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/privacy-policy-zh",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "隐私政策"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/user-agreement-en",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "User Agreement"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/user-agreement-zh",
|
||||
"type": "page",
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "用户协议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/register/index",
|
||||
"type": "page",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
/* eslint-disable test/no-import-node-test -- zero-dependency source contract gate */
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
const panelSource = await readFile(new URL('./AgentSnapshotPanel.vue', import.meta.url), 'utf8')
|
||||
const editSource = await readFile(new URL('../edit.vue', import.meta.url), 'utf8')
|
||||
|
||||
function sourceBetween(source, startMarker, endMarker) {
|
||||
const start = source.indexOf(startMarker)
|
||||
const end = source.indexOf(endMarker, start + startMarker.length)
|
||||
assert.notEqual(start, -1, `missing start marker: ${startMarker}`)
|
||||
assert.notEqual(end, -1, `missing end marker: ${endMarker}`)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
test('restore enters busy state before confirmations and requires a final destructive confirmation', () => {
|
||||
const restore = sourceBetween(
|
||||
panelSource,
|
||||
'async function confirmRestoreSnapshot()',
|
||||
'async function requestRestoreConfirmation',
|
||||
)
|
||||
const busyIndex = restore.indexOf('restoring.value = true')
|
||||
const unsavedConfirmIndex = restore.indexOf('title: t(\'agentSnapshot.unsavedChangesTitle\')')
|
||||
const destructiveConfirmIndex = restore.indexOf('msg: t(\'agentSnapshot.restoreChatHistoryDestructiveWarning\')')
|
||||
const postIndex = restore.indexOf('await restoreAgentSnapshot(')
|
||||
|
||||
assert.ok(busyIndex >= 0 && busyIndex < unsavedConfirmIndex)
|
||||
assert.ok(destructiveConfirmIndex > unsavedConfirmIndex && destructiveConfirmIndex < postIndex)
|
||||
assert.match(restore, /if \(context\.willClearChatHistory\)/)
|
||||
assert.match(restore.slice(destructiveConfirmIndex, postIndex), /isActiveRestoreAction\(context\)/)
|
||||
const finalMutationGuardIndex = restore.lastIndexOf('if (!isActiveRestoreAction(context))', postIndex)
|
||||
const postBoundaryIndex = restore.indexOf('restorePostActionSequence = context.actionSequence')
|
||||
assert.ok(finalMutationGuardIndex > destructiveConfirmIndex)
|
||||
assert.ok(finalMutationGuardIndex < postBoundaryIndex && postBoundaryIndex < postIndex)
|
||||
})
|
||||
test('restore guards popup exits and stale action completion', () => {
|
||||
assert.match(panelSource, /if \(!value && restoring\.value\) \{\s*return\s*\}/)
|
||||
assert.equal((panelSource.match(/:close-on-click-modal="!restoring"/g) || []).length, 2)
|
||||
assert.match(panelSource, /function closeDetail\(force = false\) \{\s*if \(restoring\.value && !force\)/)
|
||||
assert.match(panelSource, /context\.agentId === props\.agentId/)
|
||||
assert.match(panelSource, /context\.detailRequestSequence === detailRequestSequence/)
|
||||
assert.match(panelSource, /if \(!isActiveRestoreAction\(context\)\) \{\s*return\s*\}\s*toast\.success/)
|
||||
assert.match(panelSource, /mutationBusy\?: boolean/)
|
||||
assert.match(panelSource, /&& !props\.mutationBusy[\s\S]*currentDetail\.value\?\.mode === 'restore'/)
|
||||
assert.match(panelSource, /&& \(!props\.mutationBusy \|\| restorePostActionSequence === context\.actionSequence\)/)
|
||||
assert.match(panelSource, /if \(restorePostInFlight\) \{\s*return\s*\}/)
|
||||
})
|
||||
|
||||
test('post-restore detail and tag reload fail closed before save', () => {
|
||||
const save = sourceBetween(editSource, 'async function saveAgent()', 'function loadPluginFunctions()')
|
||||
const reload = sourceBetween(
|
||||
editSource,
|
||||
'async function reloadAgentAfterSnapshotRestore',
|
||||
'function isActiveSnapshotReload',
|
||||
)
|
||||
|
||||
assert.ok(save.indexOf('if (snapshotReloadBlocked.value)') < save.indexOf('await updateAgent('))
|
||||
assert.match(reload, /snapshotReloadBlocked\.value = true/)
|
||||
assert.match(reload, /Promise\.all\(\[\s*getAgentDetail\(targetAgentId\),\s*getAgentTags\(targetAgentId\)/)
|
||||
assert.ok(reload.indexOf('const [detail, tags] = await Promise.all') < reload.indexOf('applyPersistedAgentDetail('))
|
||||
assert.match(reload, /snapshotReloadFailed\.value = true/)
|
||||
assert.match(editSource, /:disabled="saving \|\| ttsOptionsLoading \|\| snapshotReloadBlocked"/)
|
||||
assert.match(editSource, /v-if="snapshotReloadFailed"[\s\S]*@click="retrySnapshotReload"/)
|
||||
})
|
||||
|
||||
test('parent mutations guard history opening and propagate into every restore gate', () => {
|
||||
const opener = sourceBetween(editSource, 'function openSnapshotPanel()', 'function isSameStringList')
|
||||
const restore = sourceBetween(
|
||||
panelSource,
|
||||
'async function confirmRestoreSnapshot()',
|
||||
'async function requestRestoreConfirmation',
|
||||
)
|
||||
|
||||
assert.match(opener, /if \(saving\.value \|\| snapshotReloadBlocked\.value\) \{[\s\S]*return/)
|
||||
assert.match(editSource, /:disabled="saving \|\| snapshotReloadBlocked"[\s\S]*@click="openSnapshotPanel"/)
|
||||
assert.match(editSource, /:mutation-busy="saving \|\| snapshotReloadBlocked"/)
|
||||
assert.match(restore, /if \(props\.mutationBusy\) \{[\s\S]*return/)
|
||||
const postIndex = restore.indexOf('await restoreAgentSnapshot(')
|
||||
const finalGate = restore.lastIndexOf('if (!isActiveRestoreAction(context))', postIndex)
|
||||
assert.ok(finalGate >= 0 && finalGate < postIndex)
|
||||
})
|
||||
|
||||
test('the latest snapshot hides restore and delete actions', () => {
|
||||
const restoreGate = sourceBetween(
|
||||
panelSource,
|
||||
'function canRestoreSnapshot(row: SnapshotRow)',
|
||||
'function canDeleteSnapshot(row: SnapshotRow)',
|
||||
)
|
||||
const deleteGate = sourceBetween(
|
||||
panelSource,
|
||||
'function canDeleteSnapshot(row: SnapshotRow)',
|
||||
'function buildDetailItems',
|
||||
)
|
||||
|
||||
assert.match(restoreGate, /!!row\?\.id && !row\.isLatestSnapshot/)
|
||||
assert.match(deleteGate, /!!row\?\.id && !row\.isLatestSnapshot/)
|
||||
assert.match(panelSource, /v-if="canRestoreSnapshot\(snapshot\)"/)
|
||||
assert.match(
|
||||
panelSource,
|
||||
/async function previewRestoreSnapshot\(row: SnapshotRow\) \{\s*if \(!canRestoreSnapshot\(row\)\) \{\s*return/,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
export const SNAPSHOT_SECRET_REDACTED = '__SNAPSHOT_SECRET_REDACTED__'
|
||||
|
||||
/** @param {unknown} voices */
|
||||
export function hasUsableTtsVoiceMetadata(voices) {
|
||||
return Array.isArray(voices) && voices.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} currentMemModelId
|
||||
* @param {unknown} targetMemModelId
|
||||
*/
|
||||
export function willRestorePermanentlyDeleteChatHistory(currentMemModelId, targetMemModelId) {
|
||||
return currentMemModelId !== 'Memory_nomem' && targetMemModelId === 'Memory_nomem'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<Record<string, any>>} voices
|
||||
* @param {string} language
|
||||
*/
|
||||
export function filterTtsVoicesByLanguage(voices, language) {
|
||||
if (!language) {
|
||||
return voices
|
||||
}
|
||||
return voices.filter((voice) => {
|
||||
if (typeof voice.languages !== 'string' || !voice.languages.trim()) {
|
||||
return false
|
||||
}
|
||||
return voice.languages
|
||||
.split(/[、;;,,]/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
.includes(language)
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function normalizeSnapshotTtsNumber(value) {
|
||||
if (value === undefined || value === null || String(value).trim() === '') {
|
||||
return null
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Math.trunc(value)
|
||||
}
|
||||
const text = String(value).trim()
|
||||
return /^[+-]?\d+$/.test(text) ? Number.parseInt(text, 10) : text
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize object-key order without changing array order. Context providers
|
||||
* are consumed sequentially by the runtime, so their list order is semantic.
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function normalizeSnapshotOrderedList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value
|
||||
.filter(item => item !== undefined && item !== null)
|
||||
.map(normalizeDisplayObject)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} redactedLabel
|
||||
* @param {string} [parentKey]
|
||||
* @returns {unknown}
|
||||
*/
|
||||
export function redactSnapshotDisplayValue(value, redactedLabel, parentKey = '') {
|
||||
if (value === SNAPSHOT_SECRET_REDACTED || isSensitiveKey(parentKey)) {
|
||||
return redactedLabel
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => redactSnapshotDisplayValue(item, redactedLabel, parentKey))
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const entryNameKey = Object.keys(value).find((key) => {
|
||||
return ['key', 'name'].includes(key.toLowerCase())
|
||||
&& typeof value[key] === 'string'
|
||||
&& isSensitiveKey(value[key])
|
||||
})
|
||||
const entryValueKey = Object.keys(value).find(key => key.toLowerCase() === 'value')
|
||||
return Object.keys(value).sort().reduce((result, key) => {
|
||||
const semanticKey = resolveUrlSemanticKey(parentKey, key)
|
||||
if (entryNameKey && key === entryValueKey) {
|
||||
result[key] = redactedLabel
|
||||
}
|
||||
else {
|
||||
result[key] = redactSnapshotDisplayValue(value[key], redactedLabel, semanticKey)
|
||||
}
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
if (typeof value === 'string' && isSnapshotUrlValue(parentKey, value)) {
|
||||
return redactUrl(value, redactedLabel, parentKey)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function stablePrettyStringify(value) {
|
||||
return JSON.stringify(normalizeDisplayObject(value), null, 2)
|
||||
}
|
||||
|
||||
/** @param {string} language */
|
||||
export function toIntlLocale(language) {
|
||||
return language.replace('_', '-')
|
||||
}
|
||||
|
||||
/** @param {string} key */
|
||||
export function isSensitiveKey(key) {
|
||||
const normalized = String(key).toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
return normalized === 'authorization'
|
||||
|| normalized.includes('authorization')
|
||||
|| normalized.includes('authentication')
|
||||
|| normalized === 'auth'
|
||||
|| normalized.endsWith('auth')
|
||||
|| normalized === 'cookie'
|
||||
|| normalized === 'cookie2'
|
||||
|| normalized === 'setcookie'
|
||||
|| normalized === 'setcookie2'
|
||||
|| normalized.endsWith('cookie')
|
||||
|| normalized === 'session'
|
||||
|| normalized.endsWith('session')
|
||||
|| normalized.includes('sessionid')
|
||||
|| normalized.includes('sessionkey')
|
||||
|| normalized.includes('sessiontoken')
|
||||
|| normalized.includes('sessioncookie')
|
||||
|| normalized.endsWith('sessid')
|
||||
|| normalized === 'token'
|
||||
|| normalized.endsWith('token')
|
||||
|| normalized.includes('apikey')
|
||||
|| normalized.includes('appkey')
|
||||
|| normalized.includes('accesskey')
|
||||
|| normalized.includes('subscriptionkey')
|
||||
|| normalized.includes('privatekey')
|
||||
|| normalized.includes('password')
|
||||
|| normalized.includes('passwd')
|
||||
|| normalized.includes('secret')
|
||||
|| normalized.includes('credential')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @param {string} redactedLabel
|
||||
* @param {string} parentKey
|
||||
*/
|
||||
function redactUrl(value, redactedLabel, parentKey) {
|
||||
const withoutCredentials = value.replace(
|
||||
/^((?:[a-z][a-z0-9+.-]*:)*\/\/)[^/?#\s]*@/i,
|
||||
(match, prefix) => `${prefix}${redactedLabel}@`,
|
||||
)
|
||||
const schemeMatch = /^((?:[a-z][a-z0-9+.-]*:)*\/\/)/i.exec(withoutCredentials)
|
||||
let pathRedacted = withoutCredentials
|
||||
if (schemeMatch) {
|
||||
const scheme = schemeMatch[1]
|
||||
const remainder = withoutCredentials.slice(scheme.length)
|
||||
const suffixIndex = remainder.search(/[?#]/)
|
||||
const authorityAndPath = suffixIndex < 0 ? remainder : remainder.slice(0, suffixIndex)
|
||||
const suffix = suffixIndex < 0 ? '' : remainder.slice(suffixIndex)
|
||||
const pathIndex = authorityAndPath.indexOf('/')
|
||||
const authority = pathIndex < 0 ? authorityAndPath : authorityAndPath.slice(0, pathIndex)
|
||||
const path = pathIndex < 0 ? '' : authorityAndPath.slice(pathIndex)
|
||||
let host = authority.slice(authority.lastIndexOf('@') + 1)
|
||||
if (host.startsWith('[')) {
|
||||
const closingBracket = host.indexOf(']')
|
||||
host = closingBracket > 0 ? host.slice(1, closingBracket) : host
|
||||
}
|
||||
else {
|
||||
host = host.replace(/:\d+$/, '')
|
||||
}
|
||||
host = host.toLowerCase()
|
||||
pathRedacted = `${scheme}${authority}${redactCapabilityPath(path, host, parentKey, redactedLabel)}${suffix}`
|
||||
}
|
||||
else {
|
||||
const suffixIndex = withoutCredentials.search(/[?#]/)
|
||||
const path = suffixIndex < 0 ? withoutCredentials : withoutCredentials.slice(0, suffixIndex)
|
||||
const suffix = suffixIndex < 0 ? '' : withoutCredentials.slice(suffixIndex)
|
||||
pathRedacted = `${redactCapabilityPath(path, '', parentKey, redactedLabel)}${suffix}`
|
||||
}
|
||||
const queryIndex = pathRedacted.search(/[?#]/)
|
||||
if (queryIndex < 0) {
|
||||
return pathRedacted
|
||||
}
|
||||
return `${pathRedacted.slice(0, queryIndex)}${pathRedacted[queryIndex]}${redactedLabel}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability URLs often carry credentials in path segments rather than in
|
||||
* query parameters. Preserve public provider IDs where their formats are
|
||||
* known, and fail closed after generic hook markers.
|
||||
* @param {string} path
|
||||
* @param {string} host
|
||||
* @param {string} parentKey
|
||||
* @param {string} redactedLabel
|
||||
*/
|
||||
function redactCapabilityPath(path, host, parentKey, redactedLabel) {
|
||||
if (!path) {
|
||||
return path
|
||||
}
|
||||
const segments = path.split('/')
|
||||
const normalizedSegments = segments.map(segment => segment.toLowerCase())
|
||||
|
||||
if (host === 'hooks.slack.com' || host === 'hooks.slack-gov.com') {
|
||||
const servicesIndex = normalizedSegments.indexOf('services')
|
||||
const capabilityIndex = servicesIndex >= 0 ? servicesIndex + 3 : -1
|
||||
if (capabilityIndex > 0 && capabilityIndex < segments.length) {
|
||||
segments[capabilityIndex] = redactedLabel
|
||||
return segments.join('/')
|
||||
}
|
||||
}
|
||||
|
||||
if (host === 'discord.com'
|
||||
|| host.endsWith('.discord.com')
|
||||
|| host === 'discordapp.com'
|
||||
|| host.endsWith('.discordapp.com')) {
|
||||
const webhooksIndex = normalizedSegments.indexOf('webhooks')
|
||||
const capabilityIndex = webhooksIndex >= 0 ? webhooksIndex + 2 : -1
|
||||
if (capabilityIndex > 0 && capabilityIndex < segments.length) {
|
||||
segments[capabilityIndex] = redactedLabel
|
||||
return segments.join('/')
|
||||
}
|
||||
}
|
||||
|
||||
if (host === 'api.telegram.org') {
|
||||
const botIndex = segments.findIndex(segment => /^bot[^:]+:.+$/i.test(segment))
|
||||
if (botIndex >= 0) {
|
||||
segments[botIndex] = segments[botIndex].replace(/^bot([^:]+):.+$/i, `bot$1:${redactedLabel}`)
|
||||
return segments.join('/')
|
||||
}
|
||||
}
|
||||
|
||||
const markerIndex = normalizedSegments.findIndex(segment => /^(?:webhooks?|hooks?)$/.test(segment))
|
||||
if (markerIndex >= 0 && markerIndex + 1 < segments.length) {
|
||||
return [...segments.slice(0, markerIndex + 1), redactedLabel].join('/')
|
||||
}
|
||||
|
||||
const normalizedKey = String(parentKey).toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
if (normalizedKey.includes('webhook')) {
|
||||
let lastSegmentIndex = -1
|
||||
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
||||
if (segments[index].length > 0) {
|
||||
lastSegmentIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if (lastSegmentIndex >= 0) {
|
||||
segments[lastSegmentIndex] = redactedLabel
|
||||
return segments.join('/')
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry webhook capability semantics through wrapper objects such as
|
||||
* `{ deliveryWebhook: { options: { target: "https://.../secret" } } }`.
|
||||
* @param {string} parentKey
|
||||
* @param {string} childKey
|
||||
*/
|
||||
function resolveUrlSemanticKey(parentKey, childKey) {
|
||||
if (isWebhookSemanticKey(childKey)) {
|
||||
return childKey
|
||||
}
|
||||
if (isWebhookSemanticKey(parentKey)) {
|
||||
return childKey ? `${parentKey}.${childKey}` : parentKey
|
||||
}
|
||||
return childKey
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
function isWebhookSemanticKey(value) {
|
||||
const normalized = String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
return normalized.includes('webhook')
|
||||
|| normalized === 'hook'
|
||||
|| normalized === 'hooks'
|
||||
|| normalized.endsWith('hook')
|
||||
|| normalized.endsWith('hooks')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} parentKey
|
||||
* @param {string} value
|
||||
*/
|
||||
function isSnapshotUrlValue(parentKey, value) {
|
||||
const normalizedKey = String(parentKey).toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
return normalizedKey.includes('url')
|
||||
|| normalizedKey.endsWith('uri')
|
||||
|| normalizedKey.includes('endpoint')
|
||||
|| isWebhookSemanticKey(parentKey)
|
||||
|| /^(?:[a-z][a-z0-9+.-]*:)*\/\//i.test(value)
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function normalizeDisplayObject(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeDisplayObject)
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return Object.keys(value).sort().reduce((result, key) => {
|
||||
result[key] = normalizeDisplayObject(value[key])
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {value is Record<string, unknown>}
|
||||
*/
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
filterTtsVoicesByLanguage,
|
||||
hasUsableTtsVoiceMetadata,
|
||||
isSensitiveKey,
|
||||
normalizeSnapshotOrderedList,
|
||||
normalizeSnapshotTtsNumber,
|
||||
redactSnapshotDisplayValue,
|
||||
SNAPSHOT_SECRET_REDACTED,
|
||||
stablePrettyStringify,
|
||||
toIntlLocale,
|
||||
willRestorePermanentlyDeleteChatHistory,
|
||||
} from './agentSnapshotUtils.mjs'
|
||||
|
||||
test('rejects empty TTS metadata while keeping language-less voices selectable without a filter', () => {
|
||||
const voices = [{ id: 'voice-without-language', languages: '' }]
|
||||
assert.equal(hasUsableTtsVoiceMetadata([]), false)
|
||||
assert.equal(hasUsableTtsVoiceMetadata(voices), true)
|
||||
assert.deepEqual(filterTtsVoicesByLanguage(voices, ''), voices)
|
||||
assert.deepEqual(filterTtsVoicesByLanguage(voices, 'zh-CN'), [])
|
||||
})
|
||||
|
||||
test('keeps inherited TTS values distinct from explicit zero', () => {
|
||||
assert.equal(normalizeSnapshotTtsNumber(null), null)
|
||||
assert.equal(normalizeSnapshotTtsNumber(''), null)
|
||||
assert.equal(normalizeSnapshotTtsNumber(0), 0)
|
||||
assert.equal(normalizeSnapshotTtsNumber('0'), 0)
|
||||
})
|
||||
|
||||
test('warns only when restoring from a memory mode to no-memory mode', () => {
|
||||
assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_mem_local_short', 'Memory_nomem'), true)
|
||||
assert.equal(willRestorePermanentlyDeleteChatHistory(undefined, 'Memory_nomem'), true)
|
||||
assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_nomem', 'Memory_nomem'), false)
|
||||
assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_mem_local_short', 'Memory_mem0ai'), false)
|
||||
})
|
||||
|
||||
test('keeps context-provider order while stabilizing object keys', () => {
|
||||
const first = normalizeSnapshotOrderedList([
|
||||
{ url: 'https://one.example', headers: { z: 'last', a: 'first' } },
|
||||
{ url: 'https://two.example', headers: {} },
|
||||
])
|
||||
const reversed = normalizeSnapshotOrderedList([
|
||||
{ url: 'https://two.example', headers: {} },
|
||||
{ headers: { a: 'first', z: 'last' }, url: 'https://one.example' },
|
||||
])
|
||||
|
||||
assert.deepEqual(first[0], {
|
||||
headers: { a: 'first', z: 'last' },
|
||||
url: 'https://one.example',
|
||||
})
|
||||
assert.notDeepEqual(first, reversed)
|
||||
})
|
||||
|
||||
test('redacts nested credentials, header entries, URL credentials and query values', () => {
|
||||
const redacted = redactSnapshotDisplayValue({
|
||||
'apiKey': 'api-secret',
|
||||
'Authentication': 'authentication-secret',
|
||||
'X-Auth': 'auth-secret',
|
||||
'PHPSESSID': 'php-session-secret',
|
||||
'Session-Key': 'session-key-secret',
|
||||
'headers': [
|
||||
{ key: 'Authorization', value: 'Bearer secret' },
|
||||
{ key: 'Proxy-Authorization', value: 'Basic secret' },
|
||||
{ key: 'Cookie', value: 'sid=secret' },
|
||||
{ key: 'Set-Cookie', value: 'session=secret' },
|
||||
{ key: 'Content-Type', value: 'application/json' },
|
||||
],
|
||||
'marker': SNAPSHOT_SECRET_REDACTED,
|
||||
'session_id': 'session-secret',
|
||||
'url': 'https://user:pass@example.com/context?access_token=secret#fragment',
|
||||
'endpoint': 'https://example.com/callback?signature=secret',
|
||||
}, '[hidden]')
|
||||
|
||||
assert.deepEqual(redacted, {
|
||||
'apiKey': '[hidden]',
|
||||
'Authentication': '[hidden]',
|
||||
'X-Auth': '[hidden]',
|
||||
'PHPSESSID': '[hidden]',
|
||||
'Session-Key': '[hidden]',
|
||||
'headers': [
|
||||
{ key: 'Authorization', value: '[hidden]' },
|
||||
{ key: 'Proxy-Authorization', value: '[hidden]' },
|
||||
{ key: 'Cookie', value: '[hidden]' },
|
||||
{ key: 'Set-Cookie', value: '[hidden]' },
|
||||
{ key: 'Content-Type', value: 'application/json' },
|
||||
],
|
||||
'marker': '[hidden]',
|
||||
'session_id': '[hidden]',
|
||||
'url': 'https://[hidden]@example.com/context?[hidden]',
|
||||
'endpoint': 'https://example.com/callback?[hidden]',
|
||||
})
|
||||
})
|
||||
|
||||
test('redacts structured key/name entries without relying on property casing', () => {
|
||||
const entries = [
|
||||
{ KEY: 'Authentication', VALUE: 'key secret' },
|
||||
{ Name: 'X-Auth', Value: 'name secret' },
|
||||
{ key: 'public-label', NAME: 'Authorization', value: 'secret selected from either marker' },
|
||||
{ NAME: 'Content-Type', VALUE: 'application/json; charset=utf-8' },
|
||||
]
|
||||
|
||||
assert.deepEqual(redactSnapshotDisplayValue(entries, '[hidden]'), [
|
||||
{ KEY: 'Authentication', VALUE: '[hidden]' },
|
||||
{ Name: 'X-Auth', Value: '[hidden]' },
|
||||
{ key: 'public-label', NAME: 'Authorization', value: '[hidden]' },
|
||||
{ NAME: 'Content-Type', VALUE: 'application/json; charset=utf-8' },
|
||||
])
|
||||
})
|
||||
|
||||
test('redacts capability path values without hiding ordinary REST paths', () => {
|
||||
const redacted = redactSnapshotDisplayValue({
|
||||
slack: 'https://hooks.slack.com/services/T111/B222/capability-value',
|
||||
slackGov: 'https://hooks.slack-gov.com/services/T111/B222/gov-capability-value',
|
||||
discord: 'https://discord.com/api/webhooks/123456/capability-value',
|
||||
telegram: 'https://api.telegram.org/bot123456:capability-value/sendMessage',
|
||||
genericHook: 'https://example.com/api/hook/capability/continuation',
|
||||
genericHooks: 'https://example.com/api/hooks/capability/continuation',
|
||||
genericWebhook: 'https://example.com/api/webhook/capability/continuation',
|
||||
genericWebhooks: 'https://example.com/api/webhooks/capability/continuation',
|
||||
relativeUrl: '/api/webhooks/capability/continuation',
|
||||
callbackWebhookUrl: 'https://example.com/incoming/capability-value',
|
||||
nested: {
|
||||
deliveryWebhook: {
|
||||
options: {
|
||||
target: 'https://example.com/incoming/nested-capability-value',
|
||||
relativeTarget: '/incoming/nested-relative-capability-value',
|
||||
},
|
||||
},
|
||||
},
|
||||
protocolRelativeUrl: '//protocol-user:protocol-pass@example.com/context',
|
||||
jdbcUrl: 'jdbc:mysql://jdbc-user:jdbc-pass@example.com/database',
|
||||
ordinary: 'https://example.com/api/v1/agents/42',
|
||||
}, '[hidden]')
|
||||
|
||||
assert.deepEqual(redacted, {
|
||||
slack: 'https://hooks.slack.com/services/T111/B222/[hidden]',
|
||||
slackGov: 'https://hooks.slack-gov.com/services/T111/B222/[hidden]',
|
||||
discord: 'https://discord.com/api/webhooks/123456/[hidden]',
|
||||
telegram: 'https://api.telegram.org/bot123456:[hidden]/sendMessage',
|
||||
genericHook: 'https://example.com/api/hook/[hidden]',
|
||||
genericHooks: 'https://example.com/api/hooks/[hidden]',
|
||||
genericWebhook: 'https://example.com/api/webhook/[hidden]',
|
||||
genericWebhooks: 'https://example.com/api/webhooks/[hidden]',
|
||||
relativeUrl: '/api/webhooks/[hidden]',
|
||||
callbackWebhookUrl: 'https://example.com/incoming/[hidden]',
|
||||
nested: {
|
||||
deliveryWebhook: {
|
||||
options: {
|
||||
target: 'https://example.com/incoming/[hidden]',
|
||||
relativeTarget: '/incoming/[hidden]',
|
||||
},
|
||||
},
|
||||
},
|
||||
protocolRelativeUrl: '//[hidden]@example.com/context',
|
||||
jdbcUrl: 'jdbc:mysql://[hidden]@example.com/database',
|
||||
ordinary: 'https://example.com/api/v1/agents/42',
|
||||
})
|
||||
})
|
||||
|
||||
test('matches the backend sensitive key variants', () => {
|
||||
for (const key of [
|
||||
'Authentication',
|
||||
'X-Auth',
|
||||
'Proxy-Authorization',
|
||||
'Cookie',
|
||||
'Set-Cookie',
|
||||
'PHPSESSID',
|
||||
'Session-Key',
|
||||
'session_token',
|
||||
'Ocp-Apim-Subscription-Key',
|
||||
]) {
|
||||
assert.equal(isSensitiveKey(key), true, `${key} should be redacted`)
|
||||
}
|
||||
assert.equal(isSensitiveKey('Content-Type'), false)
|
||||
assert.equal(isSensitiveKey('publicKey'), false)
|
||||
})
|
||||
|
||||
test('formats objects stably and maps app locale names to Intl locales', () => {
|
||||
assert.equal(stablePrettyStringify({ z: 1, a: { d: 2, c: 1 } }), '{\n "a": {\n "c": 1,\n "d": 2\n },\n "z": 1\n}')
|
||||
assert.equal(toIntlLocale('pt_BR'), 'pt-BR')
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, updateAgent, updateAgentTags } from '@/api/agent/agent'
|
||||
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, updateAgent } from '@/api/agent/agent'
|
||||
import { t } from '@/i18n'
|
||||
import { usePluginStore, useProvider, useSpeedPitch } from '@/store'
|
||||
import { toast } from '@/utils/toast'
|
||||
import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue'
|
||||
import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs'
|
||||
|
||||
defineOptions({
|
||||
name: 'AgentEdit',
|
||||
@@ -64,6 +66,10 @@ const selectedTemplateId = ref('')
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const showSnapshotPanel = ref(false)
|
||||
const currentVersionNo = ref<number | null>(null)
|
||||
const snapshotReloadBlocked = ref(false)
|
||||
const snapshotReloadFailed = ref(false)
|
||||
|
||||
// 模型选项数据
|
||||
const modelOptions = ref<{
|
||||
@@ -79,9 +85,9 @@ const modelOptions = ref<{
|
||||
})
|
||||
|
||||
// 音色选项数据
|
||||
const voiceOptions = ref([])
|
||||
const voiceOptions = ref<any[]>([])
|
||||
// 保存完整的音色信息
|
||||
const voiceDetails = ref({})
|
||||
const voiceDetails = ref<Record<string, any>>({})
|
||||
|
||||
// 上报模式选项数据
|
||||
const reportOptions = [
|
||||
@@ -110,9 +116,25 @@ const allFunctions = ref<PluginDefinition[]>([])
|
||||
const dynamicTags = ref([])
|
||||
const inputValue = ref('')
|
||||
const inputVisible = ref(false)
|
||||
const languageOptions = ref([])
|
||||
const languageOptions = ref<any[]>([])
|
||||
const isVisibleReport = ref(false)
|
||||
const tempSummaryMemory = ref('')
|
||||
const selectedTtsLanguage = ref('')
|
||||
const ttsLanguageTouched = ref(false)
|
||||
const ttsVoiceTouched = ref(false)
|
||||
const ttsOptionsLoading = ref(false)
|
||||
const ttsOptionsModelId = ref('')
|
||||
const originalTagNames = ref<string[]>([])
|
||||
const originalAgentDetail = ref<AgentDetail | null>(null)
|
||||
let ttsOptionsRequestSequence = 0
|
||||
let agentDetailRequestSequence = 0
|
||||
let agentTagRequestSequence = 0
|
||||
let snapshotReloadSequence = 0
|
||||
|
||||
interface SnapshotRestoreContext {
|
||||
agentId: string
|
||||
actionSequence: number
|
||||
}
|
||||
|
||||
// 音频播放相关
|
||||
const audioRef = ref<UniApp.InnerAudioContext | null>(null)
|
||||
@@ -123,6 +145,91 @@ const pluginStore = usePluginStore()
|
||||
const speedPitchStore = useSpeedPitch()
|
||||
const providerStore = useProvider()
|
||||
|
||||
const EDITABLE_AGENT_FIELDS: Array<keyof AgentDetail> = [
|
||||
'agentName',
|
||||
'systemPrompt',
|
||||
'summaryMemory',
|
||||
'vadModelId',
|
||||
'asrModelId',
|
||||
'llmModelId',
|
||||
'slmModelId',
|
||||
'vllmModelId',
|
||||
'intentModelId',
|
||||
'memModelId',
|
||||
'ttsModelId',
|
||||
'chatHistoryConf',
|
||||
'langCode',
|
||||
'language',
|
||||
'sort',
|
||||
]
|
||||
|
||||
const hasUnsavedChanges = computed(() => {
|
||||
if (loading.value || !originalAgentDetail.value) {
|
||||
return false
|
||||
}
|
||||
return stableSerialize(buildCurrentEditableState()) !== stableSerialize(buildOriginalEditableState())
|
||||
})
|
||||
|
||||
function buildCurrentEditableState() {
|
||||
const original = originalAgentDetail.value as AgentDetail
|
||||
const current = formData.value as Record<string, any>
|
||||
const changedTtsFields = new Set(speedPitchStore.changedFields)
|
||||
return {
|
||||
...pickEditableFields(current),
|
||||
ttsLanguage: ttsLanguageTouched.value ? selectedTtsLanguage.value : original.ttsLanguage,
|
||||
ttsVoiceId: ttsVoiceTouched.value ? current.ttsVoiceId : original.ttsVoiceId,
|
||||
ttsVolume: changedTtsFields.has('ttsVolume') ? speedPitchStore.speedPitch.ttsVolume : original.ttsVolume,
|
||||
ttsRate: changedTtsFields.has('ttsRate') ? speedPitchStore.speedPitch.ttsRate : original.ttsRate,
|
||||
ttsPitch: changedTtsFields.has('ttsPitch') ? speedPitchStore.speedPitch.ttsPitch : original.ttsPitch,
|
||||
functions: normalizeAgentFunctions(current.functions || []),
|
||||
contextProviders: providerStore.providers,
|
||||
tagNames: dynamicTags.value.map((tag: any) => tag.tagName).filter(Boolean).sort(),
|
||||
}
|
||||
}
|
||||
|
||||
function buildOriginalEditableState() {
|
||||
const original = originalAgentDetail.value as AgentDetail
|
||||
return {
|
||||
...pickEditableFields(original as unknown as Record<string, any>),
|
||||
ttsLanguage: original.ttsLanguage,
|
||||
ttsVoiceId: original.ttsVoiceId,
|
||||
ttsVolume: original.ttsVolume,
|
||||
ttsRate: original.ttsRate,
|
||||
ttsPitch: original.ttsPitch,
|
||||
functions: normalizeAgentFunctions(original.functions || []),
|
||||
contextProviders: original.contextProviders || [],
|
||||
tagNames: [...originalTagNames.value].sort(),
|
||||
}
|
||||
}
|
||||
|
||||
function pickEditableFields(data: Record<string, any>) {
|
||||
return EDITABLE_AGENT_FIELDS.reduce<Record<string, any>>((result, field) => {
|
||||
result[field] = data[field]
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
|
||||
function stableSerialize(value: any) {
|
||||
return JSON.stringify(sortObjectKeys(value))
|
||||
}
|
||||
|
||||
function sortObjectKeys(value: any): any {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sortObjectKeys)
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(value).sort().reduce<Record<string, any>>((result, key) => {
|
||||
result[key] = sortObjectKeys(value[key])
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function cloneSerializable<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
|
||||
// tabs
|
||||
const tabList = [
|
||||
{
|
||||
@@ -205,49 +312,97 @@ function handleRegulate() {
|
||||
}
|
||||
|
||||
// 加载智能体详情
|
||||
async function loadAgentDetail() {
|
||||
if (!agentId.value)
|
||||
return
|
||||
async function loadAgentDetail(targetAgentId = agentId.value) {
|
||||
if (!targetAgentId)
|
||||
return false
|
||||
|
||||
const requestId = ++agentDetailRequestSequence
|
||||
invalidateTtsMetadataRequest()
|
||||
try {
|
||||
loading.value = true
|
||||
tempSummaryMemory.value = ''
|
||||
const detail = await getAgentDetail(agentId.value)
|
||||
const normalizedFunctions = normalizeAgentFunctions(detail.functions || [])
|
||||
formData.value = { ...detail, functions: normalizedFunctions }
|
||||
|
||||
// 更新插件store
|
||||
pluginStore.setCurrentAgentId(agentId.value)
|
||||
pluginStore.setCurrentFunctions(normalizedFunctions)
|
||||
|
||||
// 更新语速音调
|
||||
speedPitchStore.updateSpeedPitch({
|
||||
ttsVolume: detail.ttsVolume || 0,
|
||||
ttsRate: detail.ttsRate || 0,
|
||||
ttsPitch: detail.ttsPitch || 0,
|
||||
})
|
||||
|
||||
// 加载上下文配置
|
||||
providerStore.updateProviders(detail.contextProviders || [])
|
||||
|
||||
// 如果有TTS模型,加载对应的音色选项
|
||||
if (detail.ttsModelId) {
|
||||
await fetchAllLanguag(detail.ttsModelId)
|
||||
const detail = await getAgentDetail(targetAgentId)
|
||||
if (!isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 等待模型选项加载完成后再更新显示名称
|
||||
await nextTick()
|
||||
updateDisplayNames()
|
||||
applyPersistedAgentDetail(detail, targetAgentId)
|
||||
await enhanceAgentDetailMetadata(detail, targetAgentId, requestId)
|
||||
return isActiveAgentDetailRequest(targetAgentId, requestId)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载智能体详情失败:', error)
|
||||
toast.error(t('agent.loadFail'))
|
||||
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
console.error('加载智能体详情失败:', error)
|
||||
toast.error(t('agent.loadFail'))
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyPersistedAgentDetail(detail: AgentDetail, targetAgentId: string) {
|
||||
const normalizedFunctions = normalizeAgentFunctions(detail.functions || [])
|
||||
tempSummaryMemory.value = ''
|
||||
ttsLanguageTouched.value = false
|
||||
ttsVoiceTouched.value = false
|
||||
ttsOptionsModelId.value = ''
|
||||
voiceOptions.value = []
|
||||
voiceDetails.value = {}
|
||||
languageOptions.value = []
|
||||
formData.value = { ...detail, functions: normalizedFunctions }
|
||||
originalAgentDetail.value = cloneSerializable({ ...detail, functions: normalizedFunctions })
|
||||
currentVersionNo.value = detail.currentVersionNo || null
|
||||
selectedTtsLanguage.value = detail.ttsLanguage || ''
|
||||
|
||||
pluginStore.setCurrentAgentId(targetAgentId)
|
||||
pluginStore.setCurrentFunctions(normalizedFunctions)
|
||||
speedPitchStore.updateSpeedPitch({
|
||||
ttsVolume: detail.ttsVolume ?? 0,
|
||||
ttsRate: detail.ttsRate ?? 0,
|
||||
ttsPitch: detail.ttsPitch ?? 0,
|
||||
})
|
||||
speedPitchStore.resetChangedFields()
|
||||
providerStore.updateProviders(detail.contextProviders || [])
|
||||
}
|
||||
|
||||
async function enhanceAgentDetailMetadata(detail: AgentDetail, targetAgentId: string, requestId: number) {
|
||||
try {
|
||||
if (detail.ttsModelId) {
|
||||
await fetchAllLanguag(detail.ttsModelId, {
|
||||
preferredLanguage: detail.ttsLanguage,
|
||||
preferredVoiceId: detail.ttsVoiceId,
|
||||
})
|
||||
}
|
||||
else {
|
||||
voiceOptions.value = []
|
||||
voiceDetails.value = {}
|
||||
languageOptions.value = []
|
||||
selectedTtsLanguage.value = ''
|
||||
}
|
||||
await nextTick()
|
||||
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
updateDisplayNames()
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// Persisted agent detail has already loaded successfully. Voice metadata is
|
||||
// display enhancement only and must not keep the post-restore save barrier.
|
||||
console.warn('Failed to enhance agent detail metadata:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveAgentDetailRequest(targetAgentId: string, requestId: number) {
|
||||
return targetAgentId === agentId.value && requestId === agentDetailRequestSequence
|
||||
}
|
||||
|
||||
function invalidateTtsMetadataRequest() {
|
||||
ttsOptionsRequestSequence += 1
|
||||
ttsOptionsLoading.value = false
|
||||
ttsOptionsModelId.value = ''
|
||||
}
|
||||
|
||||
// 获取音色显示名称
|
||||
function getVoiceDisplayName(ttsVoiceId: string) {
|
||||
if (!ttsVoiceId)
|
||||
@@ -344,7 +499,67 @@ async function loadModelOptions() {
|
||||
}
|
||||
|
||||
// 根据语言筛选音色
|
||||
function filterVoicesByLanguage() {
|
||||
interface VoiceSelectionOptions {
|
||||
autoSelectVoice?: boolean
|
||||
preferredLanguage?: string | null
|
||||
preferredVoiceId?: string | null
|
||||
}
|
||||
|
||||
interface TtsSelectionState {
|
||||
modelId: string
|
||||
voiceId: string
|
||||
language: string
|
||||
selectedLanguage: string
|
||||
languageTouched: boolean
|
||||
voiceTouched: boolean
|
||||
optionsModelId: string
|
||||
voiceOptions: any[]
|
||||
voiceDetails: Record<string, any>
|
||||
languageOptions: any[]
|
||||
displayNames: {
|
||||
tts: string
|
||||
voiceprint: string
|
||||
language: string
|
||||
}
|
||||
}
|
||||
|
||||
function captureTtsSelectionState(): TtsSelectionState {
|
||||
return {
|
||||
modelId: formData.value.ttsModelId || '',
|
||||
voiceId: formData.value.ttsVoiceId || '',
|
||||
language: formData.value.ttsLanguage || '',
|
||||
selectedLanguage: selectedTtsLanguage.value,
|
||||
languageTouched: ttsLanguageTouched.value,
|
||||
voiceTouched: ttsVoiceTouched.value,
|
||||
optionsModelId: ttsOptionsModelId.value,
|
||||
voiceOptions: voiceOptions.value,
|
||||
voiceDetails: voiceDetails.value,
|
||||
languageOptions: languageOptions.value,
|
||||
displayNames: {
|
||||
tts: displayNames.value.tts,
|
||||
voiceprint: displayNames.value.voiceprint,
|
||||
language: displayNames.value.language,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function restoreTtsSelectionState(state: TtsSelectionState) {
|
||||
formData.value.ttsModelId = state.modelId
|
||||
formData.value.ttsVoiceId = state.voiceId
|
||||
formData.value.ttsLanguage = state.language
|
||||
selectedTtsLanguage.value = state.selectedLanguage
|
||||
ttsLanguageTouched.value = state.languageTouched
|
||||
ttsVoiceTouched.value = state.voiceTouched
|
||||
ttsOptionsModelId.value = state.optionsModelId
|
||||
voiceOptions.value = state.voiceOptions
|
||||
voiceDetails.value = state.voiceDetails
|
||||
languageOptions.value = state.languageOptions
|
||||
displayNames.value.tts = state.displayNames.tts
|
||||
displayNames.value.voiceprint = state.displayNames.voiceprint
|
||||
displayNames.value.language = state.displayNames.language
|
||||
}
|
||||
|
||||
function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) {
|
||||
if (!voiceDetails.value || Object.keys(voiceDetails.value).length === 0) {
|
||||
voiceOptions.value = []
|
||||
return
|
||||
@@ -353,13 +568,7 @@ function filterVoicesByLanguage() {
|
||||
const allVoices = Object.values(voiceDetails.value) as any[]
|
||||
|
||||
// 根据选中的语言筛选音色
|
||||
const filteredVoices = allVoices.filter((voice) => {
|
||||
if (!voice.languages) {
|
||||
return false
|
||||
}
|
||||
const languagesArray = voice.languages.split(/[、;;,,]/).map(lang => lang.trim()).filter(lang => lang)
|
||||
return languagesArray.includes(formData.value.language)
|
||||
})
|
||||
const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
|
||||
|
||||
voiceOptions.value = filteredVoices.map(voice => ({
|
||||
value: voice.id,
|
||||
@@ -374,26 +583,46 @@ function filterVoicesByLanguage() {
|
||||
const currentVoiceSupportsLanguage = formData.value.ttsVoiceId
|
||||
&& filteredVoices.some(voice => voice.id === formData.value.ttsVoiceId)
|
||||
|
||||
if (!currentVoiceSupportsLanguage) {
|
||||
if (!currentVoiceSupportsLanguage && options.autoSelectVoice) {
|
||||
formData.value.ttsVoiceId = filteredVoices.length > 0 ? filteredVoices[0].id : ''
|
||||
displayNames.value.voiceprint = filteredVoices.length > 0 ? filteredVoices[0].name : ''
|
||||
ttsVoiceTouched.value = true
|
||||
}
|
||||
else {
|
||||
displayNames.value.voiceprint = filteredVoices.find(item => item.id === formData.value.ttsVoiceId)?.name
|
||||
|| getVoiceDisplayName(formData.value.ttsVoiceId)
|
||||
}
|
||||
}
|
||||
|
||||
// 同步到ttsSettings(如果值为null,使用0作为显示默认值,但不修改form中的值)
|
||||
speedPitchStore.updateSpeedPitch({
|
||||
ttsVolume: formData.value.ttsVolume !== null && formData.value.ttsVolume !== undefined ? formData.value.ttsVolume : 0,
|
||||
ttsRate: formData.value.ttsRate !== null && formData.value.ttsRate !== undefined ? formData.value.ttsRate : 0,
|
||||
ttsPitch: formData.value.ttsPitch !== null && formData.value.ttsPitch !== undefined ? formData.value.ttsPitch : 0,
|
||||
})
|
||||
function getVoiceDefaultLanguage(ttsVoiceId: string) {
|
||||
if (!ttsVoiceId || !voiceDetails.value?.[ttsVoiceId]?.languages) {
|
||||
return ''
|
||||
}
|
||||
const languages = voiceDetails.value[ttsVoiceId].languages
|
||||
.split(/[、;;,,]/)
|
||||
.map(lang => lang.trim())
|
||||
.filter(Boolean)
|
||||
return languages[0] || ''
|
||||
}
|
||||
|
||||
// 根据语音合成模型加载语言
|
||||
async function fetchAllLanguag(ttsModelId: string) {
|
||||
async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOptions = {}): Promise<'loaded' | 'failed' | 'stale'> {
|
||||
const requestId = ++ttsOptionsRequestSequence
|
||||
ttsOptionsLoading.value = true
|
||||
try {
|
||||
const res = await getAllLanguage(ttsModelId)
|
||||
if (requestId !== ttsOptionsRequestSequence) {
|
||||
return 'stale'
|
||||
}
|
||||
if (!Array.isArray(res)) {
|
||||
throw new TypeError('Invalid TTS voice metadata')
|
||||
}
|
||||
// An empty response cannot prove that the newly selected model accepts an
|
||||
// empty voice. Until the API exposes an explicit "voice optional"
|
||||
// capability, keep the previous tuple instead of persisting a guess.
|
||||
if (!hasUsableTtsVoiceMetadata(res)) {
|
||||
throw new Error('No TTS voice metadata is available')
|
||||
}
|
||||
// 保存完整的音色信息
|
||||
voiceDetails.value = res.reduce((acc, voice) => {
|
||||
acc[voice.id] = voice
|
||||
@@ -412,21 +641,52 @@ async function fetchAllLanguag(ttsModelId: string) {
|
||||
name: lang,
|
||||
}))
|
||||
|
||||
// 使用后端返回的用户选择的语言,如果没有则使用第一个语言选项
|
||||
if (formData.value.ttsLanguage && languageOptions.value.some(option => option.value === formData.value.ttsLanguage)) {
|
||||
formData.value.language = formData.value.ttsLanguage
|
||||
const requestedLanguage = options.preferredLanguage
|
||||
const preferredVoiceLanguage = options.preferredVoiceId
|
||||
? getVoiceDefaultLanguage(options.preferredVoiceId)
|
||||
: ''
|
||||
// Do not carry a language from the previous model into a provider which
|
||||
// exposes no language dimension.
|
||||
selectedTtsLanguage.value = ''
|
||||
displayNames.value.language = ''
|
||||
// 优先使用调用方指定的语言或音色默认语言,再回退到智能体当前配置
|
||||
if (requestedLanguage && languageOptions.value.some(option => option.value === requestedLanguage)) {
|
||||
selectedTtsLanguage.value = requestedLanguage
|
||||
displayNames.value.language = requestedLanguage
|
||||
}
|
||||
else if (preferredVoiceLanguage && languageOptions.value.some(option => option.value === preferredVoiceLanguage)) {
|
||||
selectedTtsLanguage.value = preferredVoiceLanguage
|
||||
displayNames.value.language = preferredVoiceLanguage
|
||||
}
|
||||
else if (formData.value.ttsLanguage && languageOptions.value.some(option => option.value === formData.value.ttsLanguage)) {
|
||||
selectedTtsLanguage.value = formData.value.ttsLanguage
|
||||
displayNames.value.language = formData.value.ttsLanguage
|
||||
}
|
||||
else if (getVoiceDefaultLanguage(formData.value.ttsVoiceId)) {
|
||||
selectedTtsLanguage.value = getVoiceDefaultLanguage(formData.value.ttsVoiceId)
|
||||
displayNames.value.language = selectedTtsLanguage.value
|
||||
}
|
||||
else if (languageOptions.value.length > 0) {
|
||||
formData.value.language = languageOptions.value[0].value
|
||||
selectedTtsLanguage.value = languageOptions.value[0].value
|
||||
displayNames.value.language = languageOptions.value[0].value
|
||||
}
|
||||
|
||||
// 根据选中的语言筛选音色
|
||||
filterVoicesByLanguage()
|
||||
filterVoicesByLanguage(options)
|
||||
ttsOptionsModelId.value = ttsModelId
|
||||
return 'loaded'
|
||||
}
|
||||
catch {
|
||||
languageOptions.value = []
|
||||
catch (error) {
|
||||
if (requestId === ttsOptionsRequestSequence) {
|
||||
console.error('Failed to load TTS options:', error)
|
||||
ttsOptionsModelId.value = ''
|
||||
}
|
||||
return requestId === ttsOptionsRequestSequence ? 'failed' : 'stale'
|
||||
}
|
||||
finally {
|
||||
if (requestId === ttsOptionsRequestSequence) {
|
||||
ttsOptionsLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +708,10 @@ async function fetchAllLanguag(ttsModelId: string) {
|
||||
// }
|
||||
|
||||
// 选择角色模板
|
||||
function selectRoleTemplate(templateId: string) {
|
||||
async function selectRoleTemplate(templateId: string) {
|
||||
if (ttsOptionsLoading.value) {
|
||||
return
|
||||
}
|
||||
if (selectedTemplateId.value === templateId) {
|
||||
selectedTemplateId.value = ''
|
||||
return
|
||||
@@ -457,6 +720,9 @@ function selectRoleTemplate(templateId: string) {
|
||||
selectedTemplateId.value = templateId
|
||||
const template = roleTemplates.value.find(t => t.id === templateId)
|
||||
if (template) {
|
||||
const previousTtsState = captureTtsSelectionState()
|
||||
const templateTtsLanguage = template.ttsLanguage?.trim() || ''
|
||||
const hasTemplateTtsLanguage = Boolean(templateTtsLanguage)
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
systemPrompt: template.systemPrompt || formData.value.systemPrompt,
|
||||
@@ -469,18 +735,40 @@ function selectRoleTemplate(templateId: string) {
|
||||
memModelId: template.memModelId || formData.value.memModelId,
|
||||
ttsModelId: template.ttsModelId || formData.value.ttsModelId,
|
||||
ttsVoiceId: template.ttsVoiceId || formData.value.ttsVoiceId,
|
||||
ttsLanguage: hasTemplateTtsLanguage ? templateTtsLanguage : formData.value.ttsLanguage,
|
||||
agentName: template.agentName || formData.value.agentName,
|
||||
chatHistoryConf: template.chatHistoryConf || formData.value.chatHistoryConf,
|
||||
summaryMemory: template.summaryMemory || formData.value.summaryMemory,
|
||||
langCode: template.langCode || formData.value.langCode,
|
||||
}
|
||||
fetchAllLanguag(template.ttsModelId || formData.value.ttsModelId)
|
||||
if (hasTemplateTtsLanguage) {
|
||||
selectedTtsLanguage.value = templateTtsLanguage
|
||||
displayNames.value.language = templateTtsLanguage
|
||||
}
|
||||
if (template.ttsModelId || template.ttsVoiceId || hasTemplateTtsLanguage) {
|
||||
const result = await fetchAllLanguag(template.ttsModelId || formData.value.ttsModelId, {
|
||||
autoSelectVoice: true,
|
||||
preferredLanguage: hasTemplateTtsLanguage ? templateTtsLanguage : '',
|
||||
preferredVoiceId: template.ttsVoiceId,
|
||||
})
|
||||
if (result === 'failed') {
|
||||
restoreTtsSelectionState(previousTtsState)
|
||||
toast.warning(t('agent.ttsOptionsLoadFailed'))
|
||||
}
|
||||
else if (result === 'loaded') {
|
||||
ttsLanguageTouched.value = true
|
||||
ttsVoiceTouched.value = true
|
||||
}
|
||||
}
|
||||
updateDisplayNames()
|
||||
}
|
||||
}
|
||||
|
||||
// 打开选择器
|
||||
function openPicker(type: string) {
|
||||
if (ttsOptionsLoading.value && (type === 'tts' || type === 'language' || type === 'voiceprint')) {
|
||||
return
|
||||
}
|
||||
pickerShow.value[type] = true
|
||||
}
|
||||
|
||||
@@ -488,6 +776,7 @@ function openPicker(type: string) {
|
||||
async function onPickerConfirm(type: string, value: any, name: string) {
|
||||
console.log('选择器确认:', type, value, name)
|
||||
|
||||
const previousTtsState = type === 'tts' ? captureTtsSelectionState() : null
|
||||
// 保存显示名称
|
||||
displayNames.value[type] = name
|
||||
|
||||
@@ -526,16 +815,34 @@ async function onPickerConfirm(type: string, value: any, name: string) {
|
||||
tempSummaryMemory.value = ''
|
||||
}
|
||||
break
|
||||
case 'tts':
|
||||
case 'tts': {
|
||||
const preferredLanguage = selectedTtsLanguage.value
|
||||
formData.value.ttsModelId = value
|
||||
await fetchAllLanguag(value)
|
||||
formData.value.ttsVoiceId = ''
|
||||
const result = await fetchAllLanguag(value, { autoSelectVoice: true, preferredLanguage })
|
||||
if (result === 'failed' && previousTtsState) {
|
||||
restoreTtsSelectionState(previousTtsState)
|
||||
toast.warning(t('agent.ttsOptionsLoadFailed'))
|
||||
}
|
||||
else if (result === 'loaded') {
|
||||
ttsLanguageTouched.value = true
|
||||
ttsVoiceTouched.value = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'language':
|
||||
formData.value.language = value
|
||||
filterVoicesByLanguage()
|
||||
selectedTtsLanguage.value = value
|
||||
formData.value.ttsLanguage = value
|
||||
ttsLanguageTouched.value = true
|
||||
filterVoicesByLanguage({ autoSelectVoice: true })
|
||||
break
|
||||
case 'voiceprint':
|
||||
formData.value.ttsVoiceId = value
|
||||
ttsVoiceTouched.value = true
|
||||
if (selectedTtsLanguage.value) {
|
||||
formData.value.ttsLanguage = selectedTtsLanguage.value
|
||||
ttsLanguageTouched.value = true
|
||||
}
|
||||
displayNames.value.voiceprint = name // 确保显示名称正确更新
|
||||
break
|
||||
case 'report':
|
||||
@@ -623,6 +930,28 @@ function getModelDisplayName(modelType: string, modelId: string) {
|
||||
|
||||
// 保存智能体
|
||||
async function saveAgent() {
|
||||
if (saving.value) {
|
||||
return
|
||||
}
|
||||
if (snapshotReloadBlocked.value) {
|
||||
toast.error(t(snapshotReloadFailed.value
|
||||
? 'agentSnapshot.reloadAfterRestoreFailed'
|
||||
: 'agentSnapshot.reloadAfterRestorePending'))
|
||||
return
|
||||
}
|
||||
if (ttsOptionsLoading.value) {
|
||||
return
|
||||
}
|
||||
const ttsSelectionTouched = ttsLanguageTouched.value || ttsVoiceTouched.value
|
||||
const hasLanguageOptions = languageOptions.value.length > 0
|
||||
const hasVoiceOptions = Object.keys(voiceDetails.value).length > 0
|
||||
if (ttsSelectionTouched
|
||||
&& (ttsOptionsModelId.value !== formData.value.ttsModelId
|
||||
|| (hasLanguageOptions && !selectedTtsLanguage.value)
|
||||
|| (hasVoiceOptions && !formData.value.ttsVoiceId))) {
|
||||
toast.warning(t('agent.ttsOptionsLoadFailed'))
|
||||
return
|
||||
}
|
||||
if (!formData.value.agentName?.trim()) {
|
||||
toast.warning(t('agent.pleaseInputAgentName'))
|
||||
return
|
||||
@@ -633,26 +962,46 @@ async function saveAgent() {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await handleUpdateAgentTags()
|
||||
}
|
||||
catch (err) {
|
||||
toast.error(err)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
const tagNames = dynamicTags.value.map(tag => tag.tagName)
|
||||
const tagsChanged = !isSameStringList(tagNames, originalTagNames.value)
|
||||
// 构建保存数据,包含上下文配置和语音设置
|
||||
const saveData = {
|
||||
const saveData: Record<string, any> = {
|
||||
...formData.value,
|
||||
...speedPitchStore.speedPitch,
|
||||
ttsLanguage: formData.value.language,
|
||||
contextProviders: providerStore.providers,
|
||||
functions: normalizeAgentFunctions(formData.value.functions || []),
|
||||
}
|
||||
delete saveData.ttsVolume
|
||||
delete saveData.ttsRate
|
||||
delete saveData.ttsPitch
|
||||
delete saveData.ttsLanguage
|
||||
delete saveData.ttsVoiceId
|
||||
if (ttsLanguageTouched.value) {
|
||||
saveData.ttsLanguage = selectedTtsLanguage.value
|
||||
}
|
||||
if (ttsVoiceTouched.value) {
|
||||
saveData.ttsVoiceId = formData.value.ttsVoiceId
|
||||
}
|
||||
const changedTtsFields = new Set(speedPitchStore.changedFields)
|
||||
if (changedTtsFields.has('ttsVolume')) {
|
||||
saveData.ttsVolume = speedPitchStore.speedPitch.ttsVolume
|
||||
}
|
||||
if (changedTtsFields.has('ttsRate')) {
|
||||
saveData.ttsRate = speedPitchStore.speedPitch.ttsRate
|
||||
}
|
||||
if (changedTtsFields.has('ttsPitch')) {
|
||||
saveData.ttsPitch = speedPitchStore.speedPitch.ttsPitch
|
||||
}
|
||||
if (tagsChanged) {
|
||||
saveData.tagNames = tagNames
|
||||
}
|
||||
await updateAgent(agentId.value, saveData)
|
||||
loadAgentDetail()
|
||||
if (tagsChanged) {
|
||||
originalTagNames.value = [...tagNames]
|
||||
}
|
||||
speedPitchStore.resetChangedFields()
|
||||
await loadAgentDetail()
|
||||
|
||||
toast.success(t('agent.saveSuccess'))
|
||||
}
|
||||
@@ -696,18 +1045,123 @@ function handleTools() {
|
||||
}
|
||||
|
||||
// 获取智能体标签
|
||||
async function loadAgentTags() {
|
||||
async function loadAgentTags(targetAgentId = agentId.value) {
|
||||
if (!targetAgentId) {
|
||||
return false
|
||||
}
|
||||
const requestId = ++agentTagRequestSequence
|
||||
try {
|
||||
const res = await getAgentTags(agentId.value)
|
||||
const res = await getAgentTags(targetAgentId)
|
||||
if (!isActiveAgentTagRequest(targetAgentId, requestId)) {
|
||||
return false
|
||||
}
|
||||
dynamicTags.value = res || []
|
||||
originalTagNames.value = dynamicTags.value.map(tag => tag.tagName)
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
if (isActiveAgentTagRequest(targetAgentId, requestId)) {
|
||||
console.error('加载智能体标签失败:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
catch (error) {}
|
||||
}
|
||||
|
||||
// 更新智能体标签
|
||||
async function handleUpdateAgentTags() {
|
||||
const tagNames = dynamicTags.value.map(tag => tag.tagName)
|
||||
await updateAgentTags(agentId.value, { tagNames })
|
||||
function isActiveAgentTagRequest(targetAgentId: string, requestId: number) {
|
||||
return targetAgentId === agentId.value && requestId === agentTagRequestSequence
|
||||
}
|
||||
|
||||
async function handleSnapshotRestored(context: SnapshotRestoreContext) {
|
||||
if (!context || context.agentId !== agentId.value) {
|
||||
return
|
||||
}
|
||||
await reloadAgentAfterSnapshotRestore(context.agentId)
|
||||
}
|
||||
|
||||
async function reloadAgentAfterSnapshotRestore(targetAgentId = agentId.value) {
|
||||
if (!targetAgentId || targetAgentId !== agentId.value) {
|
||||
return false
|
||||
}
|
||||
const reloadId = ++snapshotReloadSequence
|
||||
const detailRequestId = ++agentDetailRequestSequence
|
||||
const tagRequestId = ++agentTagRequestSequence
|
||||
snapshotReloadBlocked.value = true
|
||||
snapshotReloadFailed.value = false
|
||||
loading.value = true
|
||||
invalidateTtsMetadataRequest()
|
||||
|
||||
try {
|
||||
// Apply detail and tags only after both persisted reads succeed. If either
|
||||
// fails, the old form may remain visible but cannot be saved until retry.
|
||||
const [detail, tags] = await Promise.all([
|
||||
getAgentDetail(targetAgentId),
|
||||
getAgentTags(targetAgentId),
|
||||
])
|
||||
if (!isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
applyPersistedAgentDetail(detail, targetAgentId)
|
||||
dynamicTags.value = tags || []
|
||||
originalTagNames.value = dynamicTags.value.map(tag => tag.tagName)
|
||||
|
||||
// Voice metadata is an optional display enhancement. Its own failure must
|
||||
// not turn a successful persisted detail+tag reload into a blocked form.
|
||||
await enhanceAgentDetailMetadata(detail, targetAgentId, detailRequestId)
|
||||
if (!isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
|
||||
return false
|
||||
}
|
||||
snapshotReloadBlocked.value = false
|
||||
snapshotReloadFailed.value = false
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
if (isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
|
||||
console.error('恢复后重新加载智能体失败:', error)
|
||||
snapshotReloadFailed.value = true
|
||||
toast.error(t('agentSnapshot.reloadAfterRestoreFailed'))
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
if (isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveSnapshotReload(
|
||||
targetAgentId: string,
|
||||
reloadId: number,
|
||||
detailRequestId: number,
|
||||
tagRequestId: number,
|
||||
) {
|
||||
return targetAgentId === agentId.value
|
||||
&& reloadId === snapshotReloadSequence
|
||||
&& detailRequestId === agentDetailRequestSequence
|
||||
&& tagRequestId === agentTagRequestSequence
|
||||
}
|
||||
|
||||
function retrySnapshotReload() {
|
||||
if (!snapshotReloadFailed.value || !agentId.value) {
|
||||
return
|
||||
}
|
||||
void reloadAgentAfterSnapshotRestore(agentId.value)
|
||||
}
|
||||
|
||||
function openSnapshotPanel() {
|
||||
if (saving.value || snapshotReloadBlocked.value) {
|
||||
toast.warning(t('agentSnapshot.mutationBusy'))
|
||||
return
|
||||
}
|
||||
showSnapshotPanel.value = true
|
||||
}
|
||||
|
||||
function isSameStringList(left: string[], right: string[]) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
// 监听store中的插件配置变化
|
||||
@@ -715,6 +1169,24 @@ watch(() => pluginStore.currentFunctions, (newFunctions) => {
|
||||
formData.value.functions = normalizeAgentFunctions(newFunctions || [])
|
||||
}, { deep: true })
|
||||
|
||||
watch(agentId, (currentAgentId, previousAgentId) => {
|
||||
if (currentAgentId === previousAgentId) {
|
||||
return
|
||||
}
|
||||
agentDetailRequestSequence += 1
|
||||
agentTagRequestSequence += 1
|
||||
snapshotReloadSequence += 1
|
||||
invalidateTtsMetadataRequest()
|
||||
loading.value = false
|
||||
snapshotReloadFailed.value = false
|
||||
snapshotReloadBlocked.value = Boolean(currentAgentId)
|
||||
originalAgentDetail.value = null
|
||||
originalTagNames.value = []
|
||||
if (currentAgentId) {
|
||||
void reloadAgentAfterSnapshotRestore(currentAgentId)
|
||||
}
|
||||
}, { flush: 'sync' })
|
||||
|
||||
onMounted(async () => {
|
||||
loadAgentTags()
|
||||
|
||||
@@ -726,7 +1198,7 @@ onMounted(async () => {
|
||||
])
|
||||
|
||||
// 然后加载智能体详情,这样可以正确映射显示名称
|
||||
if (agentId.value) {
|
||||
if (agentId.value && !snapshotReloadBlocked.value) {
|
||||
await loadAgentDetail()
|
||||
}
|
||||
})
|
||||
@@ -734,6 +1206,26 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<view class="bg-[#f5f7fb] px-[20rpx]">
|
||||
<view class="mb-[24rpx] flex items-center justify-between border border-[#eeeeee] rounded-[20rpx] bg-white p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
<view>
|
||||
<text class="block text-[32rpx] text-[#232338] font-bold">
|
||||
{{ t('agent.editTitle') }}
|
||||
</text>
|
||||
<text v-if="currentVersionNo" class="mt-[8rpx] block text-[24rpx] text-[#65686f]">
|
||||
{{ t('agentSnapshot.currentVersion') }} #{{ currentVersionNo }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="saving || snapshotReloadBlocked"
|
||||
custom-class="!bg-[#336cff] !h-[64rpx] !rounded-[32rpx]"
|
||||
@click="openSnapshotPanel"
|
||||
>
|
||||
{{ t('agentSnapshot.title') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 基础信息标题
|
||||
<view class="pb-[20rpx] first:pt-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
@@ -1005,10 +1497,27 @@ onMounted(async () => {
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<view class="mt-[40rpx] p-0">
|
||||
<view
|
||||
v-if="snapshotReloadBlocked"
|
||||
class="mb-[18rpx] rounded-[16rpx] bg-[rgba(245,108,108,0.1)] p-[20rpx] text-[24rpx] text-[#a34848] leading-[1.6]"
|
||||
>
|
||||
<text class="block">
|
||||
{{ t(snapshotReloadFailed ? 'agentSnapshot.reloadAfterRestoreFailed' : 'agentSnapshot.reloadAfterRestorePending') }}
|
||||
</text>
|
||||
<wd-button
|
||||
v-if="snapshotReloadFailed"
|
||||
size="small"
|
||||
type="info"
|
||||
custom-class="mt-[14rpx] !h-[60rpx]"
|
||||
@click="retrySnapshotReload"
|
||||
>
|
||||
{{ t('agentSnapshot.retryReload') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="saving"
|
||||
:disabled="saving || ttsOptionsLoading || snapshotReloadBlocked"
|
||||
custom-class="w-full h-[80rpx] rounded-[16rpx] text-[30rpx] font-semibold bg-[#336cff] active:bg-[#2d5bd1]"
|
||||
@click="saveAgent"
|
||||
>
|
||||
@@ -1109,6 +1618,14 @@ onMounted(async () => {
|
||||
@close="onPickerCancel('report')"
|
||||
@select="({ item }) => onPickerConfirm('report', item.value, item.name)"
|
||||
/>
|
||||
<AgentSnapshotPanel
|
||||
v-model:visible="showSnapshotPanel"
|
||||
:agent-id="agentId"
|
||||
:current-version-no="currentVersionNo"
|
||||
:has-unsaved-changes="hasUnsavedChanges"
|
||||
:mutation-busy="saving || snapshotReloadBlocked"
|
||||
@restored="handleSnapshotRestored"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user