mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-30 21:23:55 +08:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9f809700 | ||
|
|
7c58fa37b2 | ||
|
|
10a67c772f | ||
|
|
7e8e5d34b8 | ||
|
|
c6bc20c05e | ||
|
|
1998807b69 | ||
|
|
55c0495661 | ||
|
|
dbeda01697 | ||
|
|
023dea2441 | ||
|
|
ad3fb4d8c8 | ||
|
|
176785830a | ||
|
|
d78ecfce6f | ||
|
|
179281e49c | ||
|
|
bc1aa1a089 | ||
|
|
c5b64c3ef0 | ||
|
|
2aa6c5ee49 | ||
|
|
687b6db96b | ||
|
|
a5aee109fe | ||
|
|
8ec5851027 | ||
|
|
48d629c79c | ||
|
|
14e57a1add | ||
|
|
634967b2fe | ||
|
|
e648453c67 | ||
|
|
d11cfc6923 | ||
|
|
32fbbf92fe | ||
|
|
bd981aac79 | ||
|
|
adee070bb0 | ||
|
|
03ec3bac3f | ||
|
|
af5fbb5282 | ||
|
|
9301c1889f | ||
|
|
ef2ed07c1c | ||
|
|
1e2c4c6a28 | ||
|
|
a32500a6a4 | ||
|
|
772af6c918 | ||
|
|
2a917c1e62 | ||
|
|
c32fbe69c5 | ||
|
|
3b6e8f0e4b | ||
|
|
aae2ef2152 | ||
|
|
c82b351457 | ||
|
|
04c10ef225 | ||
|
|
0b8bb728e2 | ||
|
|
35ec3ba803 | ||
|
|
8808b69303 | ||
|
|
fc573a72ea | ||
|
|
59f6566cfb | ||
|
|
d8c97a9f81 | ||
|
|
3d204fb181 | ||
|
|
209fd3fbc8 | ||
|
|
bc0ffd1bd3 | ||
|
|
f0d61442b8 | ||
|
|
e6c2fac7ec | ||
|
|
5a9b5af441 | ||
|
|
af3ca4f609 | ||
|
|
2fae96d60c |
@@ -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>
|
||||
@@ -32,6 +32,7 @@
|
||||
<liquibase-core.version>4.20.0</liquibase-core.version>
|
||||
<aliyun-sms-version>4.1.0</aliyun-sms-version>
|
||||
<okio-version>3.4.0</okio-version>
|
||||
<skipTests>true</skipTests>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@@ -175,13 +176,13 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-boot-starter</artifactId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatisplus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mybatis</groupId>
|
||||
<artifactId>mybatis-spring</artifactId>
|
||||
<version>3.0.3</version>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
|
||||
<version>${mybatisplus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
@@ -275,7 +276,7 @@
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<skipTests>true</skipTests>
|
||||
<skipTests>${skipTests}</skipTests>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
|
||||
@@ -5,7 +5,7 @@ import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import org.apache.ibatis.binding.MapperMethod;
|
||||
import org.apache.ibatis.logging.Log;
|
||||
@@ -181,8 +181,9 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
|
||||
* 执行批量操作
|
||||
*/
|
||||
@SuppressWarnings("deprecation")
|
||||
protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiConsumer<SqlSession, E> consumer) {
|
||||
return SqlHelper.executeBatch(this.currentModelClass(), this.log, list, batchSize, consumer);
|
||||
protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiFunction<SqlSession, E, Integer> operation) {
|
||||
return SqlHelper.executeBatch(SqlHelper.sqlSessionFactory(this.currentModelClass()), this.log, list, batchSize,
|
||||
operation);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -209,7 +210,7 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
|
||||
return executeBatch(entityList, batchSize, (sqlSession, entity) -> {
|
||||
MapperMethod.ParamMap<T> param = new MapperMethod.ParamMap<>();
|
||||
param.put(Constants.ENTITY, entity);
|
||||
sqlSession.update(sqlStatement, param);
|
||||
return sqlSession.update(sqlStatement, param);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,4 +228,4 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
|
||||
public boolean deleteBatchIds(Collection<? extends Serializable> idList) {
|
||||
return SqlHelper.retBool(baseDao.deleteBatchIds(idList));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package xiaozhi.modules.agent.Enums;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import lombok.Getter;
|
||||
import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentEntity;
|
||||
|
||||
@Getter
|
||||
public enum AgentSnapshotField {
|
||||
AGENT_CODE("agentCode", AgentSnapshotDataDTO::getAgentCode, AgentUpdateDTO::getAgentCode,
|
||||
(agent, data) -> agent.setAgentCode(data.getAgentCode())),
|
||||
AGENT_NAME("agentName", AgentSnapshotDataDTO::getAgentName, AgentUpdateDTO::getAgentName,
|
||||
(agent, data) -> agent.setAgentName(data.getAgentName())),
|
||||
ASR_MODEL_ID("asrModelId", AgentSnapshotDataDTO::getAsrModelId, AgentUpdateDTO::getAsrModelId,
|
||||
(agent, data) -> agent.setAsrModelId(data.getAsrModelId())),
|
||||
VAD_MODEL_ID("vadModelId", AgentSnapshotDataDTO::getVadModelId, AgentUpdateDTO::getVadModelId,
|
||||
(agent, data) -> agent.setVadModelId(data.getVadModelId())),
|
||||
LLM_MODEL_ID("llmModelId", AgentSnapshotDataDTO::getLlmModelId, AgentUpdateDTO::getLlmModelId,
|
||||
(agent, data) -> agent.setLlmModelId(data.getLlmModelId())),
|
||||
SLM_MODEL_ID("slmModelId", AgentSnapshotDataDTO::getSlmModelId, AgentUpdateDTO::getSlmModelId,
|
||||
(agent, data) -> agent.setSlmModelId(data.getSlmModelId())),
|
||||
VLLM_MODEL_ID("vllmModelId", AgentSnapshotDataDTO::getVllmModelId, AgentUpdateDTO::getVllmModelId,
|
||||
(agent, data) -> agent.setVllmModelId(data.getVllmModelId())),
|
||||
TTS_MODEL_ID("ttsModelId", AgentSnapshotDataDTO::getTtsModelId, AgentUpdateDTO::getTtsModelId,
|
||||
(agent, data) -> agent.setTtsModelId(data.getTtsModelId())),
|
||||
TTS_VOICE_ID("ttsVoiceId", AgentSnapshotDataDTO::getTtsVoiceId, AgentUpdateDTO::getTtsVoiceId,
|
||||
(agent, data) -> agent.setTtsVoiceId(data.getTtsVoiceId())),
|
||||
TTS_LANGUAGE("ttsLanguage", AgentSnapshotDataDTO::getTtsLanguage, AgentUpdateDTO::getTtsLanguage,
|
||||
(agent, data) -> agent.setTtsLanguage(data.getTtsLanguage())),
|
||||
TTS_VOLUME("ttsVolume", AgentSnapshotDataDTO::getTtsVolume, AgentUpdateDTO::getTtsVolume,
|
||||
(agent, data) -> agent.setTtsVolume(data.getTtsVolume())),
|
||||
TTS_RATE("ttsRate", AgentSnapshotDataDTO::getTtsRate, AgentUpdateDTO::getTtsRate,
|
||||
(agent, data) -> agent.setTtsRate(data.getTtsRate())),
|
||||
TTS_PITCH("ttsPitch", AgentSnapshotDataDTO::getTtsPitch, AgentUpdateDTO::getTtsPitch,
|
||||
(agent, data) -> agent.setTtsPitch(data.getTtsPitch())),
|
||||
MEM_MODEL_ID("memModelId", AgentSnapshotDataDTO::getMemModelId, AgentUpdateDTO::getMemModelId,
|
||||
(agent, data) -> agent.setMemModelId(data.getMemModelId())),
|
||||
INTENT_MODEL_ID("intentModelId", AgentSnapshotDataDTO::getIntentModelId, AgentUpdateDTO::getIntentModelId,
|
||||
(agent, data) -> agent.setIntentModelId(data.getIntentModelId())),
|
||||
CHAT_HISTORY_CONF("chatHistoryConf", AgentSnapshotDataDTO::getChatHistoryConf, AgentUpdateDTO::getChatHistoryConf,
|
||||
(agent, data) -> agent.setChatHistoryConf(data.getChatHistoryConf())),
|
||||
SYSTEM_PROMPT("systemPrompt", AgentSnapshotDataDTO::getSystemPrompt, AgentUpdateDTO::getSystemPrompt,
|
||||
(agent, data) -> agent.setSystemPrompt(data.getSystemPrompt())),
|
||||
SUMMARY_MEMORY("summaryMemory", AgentSnapshotDataDTO::getSummaryMemory, AgentUpdateDTO::getSummaryMemory,
|
||||
(agent, data) -> agent.setSummaryMemory(data.getSummaryMemory())),
|
||||
LANG_CODE("langCode", AgentSnapshotDataDTO::getLangCode, AgentUpdateDTO::getLangCode,
|
||||
(agent, data) -> agent.setLangCode(data.getLangCode())),
|
||||
LANGUAGE("language", AgentSnapshotDataDTO::getLanguage, AgentUpdateDTO::getLanguage,
|
||||
(agent, data) -> agent.setLanguage(data.getLanguage())),
|
||||
SORT("sort", AgentSnapshotDataDTO::getSort, AgentUpdateDTO::getSort,
|
||||
(agent, data) -> agent.setSort(data.getSort())),
|
||||
FUNCTIONS("functions", AgentSnapshotDataDTO::getFunctions, AgentUpdateDTO::getFunctions, null),
|
||||
CONTEXT_PROVIDERS("contextProviders", AgentSnapshotDataDTO::getContextProviders,
|
||||
AgentUpdateDTO::getContextProviders, null),
|
||||
CORRECT_WORD_FILE_IDS("correctWordFileIds", AgentSnapshotDataDTO::getCorrectWordFileIds,
|
||||
AgentUpdateDTO::getCorrectWordFileIds, null),
|
||||
TAG_NAMES("tagNames", AgentSnapshotDataDTO::getTagNames, AgentUpdateDTO::getTagNames, null);
|
||||
|
||||
private final String fieldName;
|
||||
private final Function<AgentSnapshotDataDTO, Object> snapshotGetter;
|
||||
private final Function<AgentUpdateDTO, Object> updateGetter;
|
||||
private final BiConsumer<AgentEntity, AgentSnapshotDataDTO> restoreApplier;
|
||||
|
||||
AgentSnapshotField(String fieldName, Function<AgentSnapshotDataDTO, Object> snapshotGetter,
|
||||
Function<AgentUpdateDTO, Object> updateGetter,
|
||||
BiConsumer<AgentEntity, AgentSnapshotDataDTO> restoreApplier) {
|
||||
this.fieldName = fieldName;
|
||||
this.snapshotGetter = snapshotGetter;
|
||||
this.updateGetter = updateGetter;
|
||||
this.restoreApplier = restoreApplier;
|
||||
}
|
||||
|
||||
public static List<String> names() {
|
||||
return Arrays.stream(values()).map(AgentSnapshotField::getFieldName).toList();
|
||||
}
|
||||
|
||||
public static String canonicalName(String fieldName) {
|
||||
return "tags".equals(fieldName) ? TAG_NAMES.getFieldName() : fieldName;
|
||||
}
|
||||
|
||||
public Object snapshotValue(AgentSnapshotDataDTO data) {
|
||||
return data == null ? null : snapshotGetter.apply(data);
|
||||
}
|
||||
|
||||
public Object updateValue(AgentUpdateDTO data) {
|
||||
return data == null ? null : updateGetter.apply(data);
|
||||
}
|
||||
|
||||
public boolean isRestorableAgentField() {
|
||||
return restoreApplier != null;
|
||||
}
|
||||
|
||||
public void applyTo(AgentEntity agent, AgentSnapshotDataDTO data) {
|
||||
if (restoreApplier != null) {
|
||||
restoreApplier.accept(agent, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-34
@@ -28,6 +28,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.exception.ErrorCode;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
@@ -48,15 +50,10 @@ import xiaozhi.modules.agent.service.AgentTagService;
|
||||
import xiaozhi.modules.agent.service.AgentChatAudioService;
|
||||
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||
import xiaozhi.modules.agent.service.AgentChatSummaryService;
|
||||
import xiaozhi.modules.agent.service.AgentContextProviderService;
|
||||
import xiaozhi.modules.agent.service.AgentPluginMappingService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
import xiaozhi.modules.correctword.service.CorrectWordFileService;
|
||||
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
|
||||
import xiaozhi.modules.agent.vo.AgentInfoVO;
|
||||
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||
import xiaozhi.modules.device.service.DeviceService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
|
||||
@Tag(name = "智能体管理")
|
||||
@@ -64,17 +61,39 @@ import xiaozhi.modules.security.user.SecurityUser;
|
||||
@RestController
|
||||
@RequestMapping("/agent")
|
||||
public class AgentController {
|
||||
private static final long AUDIO_PLAY_TOKEN_EXPIRE_SECONDS = 300L;
|
||||
|
||||
private final AgentService agentService;
|
||||
private final AgentTemplateService agentTemplateService;
|
||||
private final DeviceService deviceService;
|
||||
private final AgentChatHistoryService agentChatHistoryService;
|
||||
private final AgentChatAudioService agentChatAudioService;
|
||||
private final AgentPluginMappingService agentPluginMappingService;
|
||||
private final AgentContextProviderService agentContextProviderService;
|
||||
private final AgentChatSummaryService agentChatSummaryService;
|
||||
private final RedisUtils redisUtils;
|
||||
private final AgentTagService agentTagService;
|
||||
private final CorrectWordFileService correctWordFileService;
|
||||
|
||||
private void requireAgentPermission(String agentId) {
|
||||
if (!agentService.checkAgentPermission(agentId, SecurityUser.getUserId())) {
|
||||
throw new RenException(ErrorCode.NO_PERMISSION);
|
||||
}
|
||||
}
|
||||
|
||||
private String requireSessionAgent(String sessionId) {
|
||||
String agentId = agentChatHistoryService.getAgentIdBySessionId(sessionId);
|
||||
if (StringUtils.isBlank(agentId)) {
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
agentService.getAgentById(agentId);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
private String requireAudioPermission(String audioId) {
|
||||
String agentId = agentChatHistoryService.getAgentIdByAudioId(audioId);
|
||||
if (StringUtils.isBlank(agentId)) {
|
||||
throw new RenException(ErrorCode.NO_PERMISSION);
|
||||
}
|
||||
requireAgentPermission(agentId);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "获取用户智能体列表")
|
||||
@@ -106,7 +125,7 @@ public class AgentController {
|
||||
@Operation(summary = "获取智能体详情")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<AgentInfoVO> getAgentById(@PathVariable("id") String id) {
|
||||
AgentInfoVO agent = agentService.getAgentById(id);
|
||||
AgentInfoVO agent = agentService.getAgentById(id, SecurityUser.getUserId());
|
||||
return ResultUtils.success(agent);
|
||||
}
|
||||
|
||||
@@ -120,20 +139,16 @@ public class AgentController {
|
||||
|
||||
@PutMapping("/saveMemory/{macAddress}")
|
||||
@Operation(summary = "根据设备id更新智能体")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> updateByDeviceId(@PathVariable String macAddress, @RequestBody @Valid AgentMemoryDTO dto) {
|
||||
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
|
||||
if (device == null) {
|
||||
return new Result<>();
|
||||
}
|
||||
AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO();
|
||||
agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory());
|
||||
agentService.updateAgentById(device.getAgentId(), agentUpdateDTO);
|
||||
return new Result<>();
|
||||
agentService.updateAgentMemoryByDeviceMacAddress(macAddress, dto, SecurityUser.getUserId());
|
||||
return new Result<Void>().ok(null);
|
||||
}
|
||||
|
||||
@PostMapping("/chat-summary/{sessionId}/save")
|
||||
@Operation(summary = "根据会话ID生成聊天记录总结并保存(异步执行)")
|
||||
public Result<Void> generateAndSaveChatSummary(@PathVariable String sessionId) {
|
||||
requireSessionAgent(sessionId);
|
||||
try {
|
||||
// 异步执行总结生成任务,立即返回成功响应
|
||||
new Thread(() -> {
|
||||
@@ -155,6 +170,7 @@ public class AgentController {
|
||||
@PostMapping("/chat-title/{sessionId}/generate")
|
||||
@Operation(summary = "根据会话ID生成聊天标题")
|
||||
public Result<Void> generateAndSaveChatTitle(@PathVariable String sessionId) {
|
||||
requireSessionAgent(sessionId);
|
||||
agentChatSummaryService.generateAndSaveChatTitle(sessionId);
|
||||
return new Result<Void>().ok(null);
|
||||
}
|
||||
@@ -163,7 +179,7 @@ public class AgentController {
|
||||
@Operation(summary = "更新智能体")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> update(@PathVariable String id, @RequestBody @Valid AgentUpdateDTO dto) {
|
||||
agentService.updateAgentById(id, dto);
|
||||
agentService.updateAgentById(id, dto, SecurityUser.getUserId());
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
@@ -171,18 +187,7 @@ public class AgentController {
|
||||
@Operation(summary = "删除智能体")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> delete(@PathVariable String id) {
|
||||
// 先删除关联的设备
|
||||
deviceService.deleteByAgentId(id);
|
||||
// 删除关联的聊天记录
|
||||
agentChatHistoryService.deleteByAgentId(id, true, true);
|
||||
// 删除关联的插件
|
||||
agentPluginMappingService.deleteByAgentId(id);
|
||||
// 删除关联的上下文源配置
|
||||
agentContextProviderService.deleteByAgentId(id);
|
||||
// 删除关联的替换词文件关联记录
|
||||
correctWordFileService.deleteMappingsByAgentId(id);
|
||||
// 再删除智能体
|
||||
agentService.deleteById(id);
|
||||
agentService.deleteAgentById(id, SecurityUser.getUserId());
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
@@ -205,6 +210,7 @@ public class AgentController {
|
||||
public Result<PageData<AgentChatSessionDTO>> getAgentSessions(
|
||||
@PathVariable("id") String id,
|
||||
@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
|
||||
requireAgentPermission(id);
|
||||
params.put("agentId", id);
|
||||
PageData<AgentChatSessionDTO> page = agentChatHistoryService.getSessionListByAgentId(params);
|
||||
return new Result<PageData<AgentChatSessionDTO>>().ok(page);
|
||||
@@ -252,6 +258,7 @@ public class AgentController {
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<String> getContentByAudioId(
|
||||
@PathVariable("id") String id) {
|
||||
requireAudioPermission(id);
|
||||
// 查询聊天记录
|
||||
String data = agentChatHistoryService.getContentByAudioId(id);
|
||||
return new Result<String>().ok(data);
|
||||
@@ -261,12 +268,13 @@ public class AgentController {
|
||||
@Operation(summary = "获取音频下载ID")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<String> getAudioId(@PathVariable("audioId") String audioId) {
|
||||
requireAudioPermission(audioId);
|
||||
byte[] audioData = agentChatAudioService.getAudio(audioId);
|
||||
if (audioData == null) {
|
||||
return new Result<String>().error("音频不存在");
|
||||
}
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId);
|
||||
redisUtils.set(RedisKeys.getAgentAudioIdKey(uuid), audioId, AUDIO_PLAY_TOKEN_EXPIRE_SECONDS);
|
||||
return new Result<String>().ok(uuid);
|
||||
}
|
||||
|
||||
@@ -322,6 +330,7 @@ public class AgentController {
|
||||
@Operation(summary = "获取智能体的标签")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<List<AgentTagDTO>> getAgentTags(@PathVariable String id) {
|
||||
requireAgentPermission(id);
|
||||
List<AgentTagDTO> tags = agentTagService.getTagsByAgentId(id);
|
||||
return new Result<List<AgentTagDTO>>().ok(tags);
|
||||
}
|
||||
@@ -330,10 +339,14 @@ public class AgentController {
|
||||
@Operation(summary = "保存智能体的标签")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> saveAgentTags(@PathVariable String id, @RequestBody Map<String, Object> params) {
|
||||
requireAgentPermission(id);
|
||||
List<String> tagIds = (List<String>) params.get("tagIds");
|
||||
List<String> tagNames = (List<String>) params.get("tagNames");
|
||||
agentTagService.saveAgentTags(id, tagIds, tagNames);
|
||||
AgentUpdateDTO dto = new AgentUpdateDTO();
|
||||
dto.setTagIds(tagIds);
|
||||
dto.setTagNames(tagNames);
|
||||
agentService.updateAgentById(id, dto);
|
||||
return new Result<Void>().ok(null);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package xiaozhi.modules.agent.controller;
|
||||
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.springdoc.core.annotations.ParameterObject;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.exception.RenException;
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.user.UserDetail;
|
||||
import xiaozhi.common.utils.Result;
|
||||
import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentSnapshotRestoreDTO;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.AgentSnapshotService;
|
||||
import xiaozhi.modules.agent.vo.AgentSnapshotVO;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
|
||||
@Tag(name = "智能体快照")
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/agent/{agentId}/snapshots")
|
||||
public class AgentSnapshotController {
|
||||
private final AgentSnapshotService agentSnapshotService;
|
||||
private final AgentService agentService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "获取智能体快照列表")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<PageData<AgentSnapshotVO>> page(
|
||||
@PathVariable String agentId,
|
||||
@ParameterObject AgentSnapshotPageDTO params) {
|
||||
checkPermission(agentId);
|
||||
return new Result<PageData<AgentSnapshotVO>>().ok(agentSnapshotService.page(agentId, params));
|
||||
}
|
||||
|
||||
@GetMapping("/{snapshotId}")
|
||||
@Operation(summary = "获取智能体快照详情")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<AgentSnapshotVO> getSnapshot(@PathVariable String agentId, @PathVariable String snapshotId) {
|
||||
checkPermission(agentId);
|
||||
return new Result<AgentSnapshotVO>().ok(agentSnapshotService.getSnapshot(agentId, snapshotId));
|
||||
}
|
||||
|
||||
@PostMapping("/{snapshotId}/restore")
|
||||
@Operation(summary = "恢复智能体快照")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> restore(@PathVariable String agentId, @PathVariable String snapshotId,
|
||||
@RequestBody @Valid AgentSnapshotRestoreDTO request) {
|
||||
checkPermission(agentId);
|
||||
agentSnapshotService.restoreSnapshot(agentId, snapshotId, request.getCurrentStateToken());
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{snapshotId}")
|
||||
@Operation(summary = "删除智能体历史快照")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> deleteSnapshot(@PathVariable String agentId, @PathVariable String snapshotId) {
|
||||
checkPermission(agentId);
|
||||
agentSnapshotService.deleteSnapshot(agentId, snapshotId);
|
||||
return new Result<>();
|
||||
}
|
||||
|
||||
private void checkPermission(String agentId) {
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
if (user == null || !agentService.checkAgentPermission(agentId, user.getId())) {
|
||||
throw new RenException("没有权限访问该智能体快照");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,20 @@ public interface AgentDao extends BaseDao<AgentEntity> {
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
AgentInfoVO selectAgentInfoById(@Param("agentId") String agentId);
|
||||
|
||||
/**
|
||||
* 锁定智能体主记录,用于串行化同一智能体的配置写入
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
AgentEntity selectByIdForUpdate(@Param("agentId") String agentId);
|
||||
|
||||
/**
|
||||
* 精确写入快照覆盖的智能体字段,包括目标快照中的 null 值。
|
||||
* 不更新所属用户、创建信息等不属于快照的字段。
|
||||
*
|
||||
* @param agent 已应用目标快照的智能体
|
||||
* @return 受影响行数
|
||||
*/
|
||||
int updateSnapshotFields(@Param("agent") AgentEntity agent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package xiaozhi.modules.agent.dao;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import xiaozhi.common.dao.BaseDao;
|
||||
import xiaozhi.modules.agent.entity.AgentSnapshotEntity;
|
||||
|
||||
@Mapper
|
||||
public interface AgentSnapshotDao extends BaseDao<AgentSnapshotEntity> {
|
||||
Integer selectMaxVersionNo(@Param("agentId") String agentId);
|
||||
|
||||
AgentSnapshotEntity selectLatestSnapshot(@Param("agentId") String agentId);
|
||||
|
||||
AgentSnapshotEntity selectNextSnapshot(@Param("agentId") String agentId, @Param("versionNo") Integer versionNo);
|
||||
|
||||
int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot);
|
||||
|
||||
int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit);
|
||||
|
||||
List<AgentSnapshotEntity> selectLegacyRedactionBatch(@Param("afterId") String afterId,
|
||||
@Param("limit") int limit,
|
||||
@Param("targetRedactionVersion") int targetRedactionVersion);
|
||||
|
||||
int updateRedactedSnapshots(@Param("snapshots") List<AgentSnapshotEntity> snapshots,
|
||||
@Param("redactionVersion") int redactionVersion);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package xiaozhi.modules.agent.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "智能体快照数据")
|
||||
public class AgentSnapshotDataDTO implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String agentCode;
|
||||
private String agentName;
|
||||
private String asrModelId;
|
||||
private String vadModelId;
|
||||
private String llmModelId;
|
||||
private String slmModelId;
|
||||
private String vllmModelId;
|
||||
private String ttsModelId;
|
||||
private String ttsVoiceId;
|
||||
private String ttsLanguage;
|
||||
private Integer ttsVolume;
|
||||
private Integer ttsRate;
|
||||
private Integer ttsPitch;
|
||||
private String memModelId;
|
||||
private String intentModelId;
|
||||
private Integer chatHistoryConf;
|
||||
private String systemPrompt;
|
||||
private String summaryMemory;
|
||||
private String langCode;
|
||||
private String language;
|
||||
private Integer sort;
|
||||
private List<AgentUpdateDTO.FunctionInfo> functions;
|
||||
private List<ContextProviderDTO> contextProviders;
|
||||
private List<String> correctWordFileIds;
|
||||
private List<String> tagNames;
|
||||
private List<AgentSnapshotTagDTO> tags;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package xiaozhi.modules.agent.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "智能体快照分页查询参数")
|
||||
public class AgentSnapshotPageDTO {
|
||||
@Schema(description = "当前页码,从1开始", example = "1")
|
||||
private Integer page = 1;
|
||||
|
||||
@Schema(description = "每页数量", example = "10")
|
||||
private Integer limit = 10;
|
||||
|
||||
@Schema(description = "版本锚点,只查询小于等于该版本号的历史快照", example = "20")
|
||||
private Integer maxVersionNo;
|
||||
|
||||
public int pageOrDefault() {
|
||||
return page == null || page < 1 ? 1 : page;
|
||||
}
|
||||
|
||||
public int limitOrDefault() {
|
||||
if (limit == null || limit < 1) {
|
||||
return 10;
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package xiaozhi.modules.agent.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "智能体快照恢复请求")
|
||||
public class AgentSnapshotRestoreDTO {
|
||||
@NotBlank
|
||||
@Schema(description = "预览时由服务端生成的当前配置状态指纹")
|
||||
private String currentStateToken;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package xiaozhi.modules.agent.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "智能体快照标签")
|
||||
public class AgentSnapshotTagDTO implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String id;
|
||||
private String tagName;
|
||||
private Integer sort;
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* 智能体更新DTO
|
||||
@@ -91,15 +94,50 @@ public class AgentUpdateDTO implements Serializable {
|
||||
@Schema(description = "替换词文件ID列表", nullable = true)
|
||||
private List<String> correctWordFileIds;
|
||||
|
||||
@Schema(description = "标签名称列表", nullable = true)
|
||||
private List<String> tagNames;
|
||||
|
||||
@Schema(description = "标签ID列表", nullable = true)
|
||||
private List<String> tagIds;
|
||||
|
||||
@Data
|
||||
@Schema(description = "插件函数信息")
|
||||
public static class FunctionInfo implements Serializable {
|
||||
private static final TypeReference<HashMap<String, Object>> PARAM_INFO_TYPE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
@Schema(description = "插件ID", example = "plugin_01")
|
||||
private String pluginId;
|
||||
|
||||
@Schema(description = "函数参数信息", nullable = true)
|
||||
private HashMap<String, Object> paramInfo;
|
||||
private HashMap<String, Object> paramInfo = new HashMap<>();
|
||||
|
||||
public void setParamInfo(Object paramInfo) {
|
||||
this.paramInfo = normalizeParamInfo(paramInfo);
|
||||
}
|
||||
|
||||
private static HashMap<String, Object> normalizeParamInfo(Object paramInfo) {
|
||||
if (paramInfo == null) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
if (paramInfo instanceof String value) {
|
||||
if (value.trim().isEmpty()) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
return JsonUtils.parseObject(value, PARAM_INFO_TYPE);
|
||||
}
|
||||
if (paramInfo instanceof Map<?, ?> value) {
|
||||
HashMap<String, Object> normalized = new HashMap<>();
|
||||
value.forEach((key, val) -> {
|
||||
if (key != null) {
|
||||
normalized.put(String.valueOf(key), val);
|
||||
}
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
return JsonUtils.parseObject(JsonUtils.toJsonString(paramInfo), PARAM_INFO_TYPE);
|
||||
}
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -7,11 +7,11 @@ import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import xiaozhi.modules.agent.dto.ContextProviderDTO;
|
||||
import xiaozhi.modules.agent.typehandler.ContextProviderListTypeHandler;
|
||||
|
||||
@Data
|
||||
@TableName(value = "ai_agent_context_provider", autoResultMap = true)
|
||||
@@ -26,7 +26,7 @@ public class AgentContextProviderEntity {
|
||||
private String agentId;
|
||||
|
||||
@Schema(description = "上下文源配置")
|
||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||
@TableField(typeHandler = ContextProviderListTypeHandler.class)
|
||||
private List<ContextProviderDTO> contextProviders;
|
||||
|
||||
@Schema(description = "创建者")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package xiaozhi.modules.agent.entity;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("ai_agent_snapshot")
|
||||
@Schema(description = "智能体配置快照")
|
||||
public class AgentSnapshotEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_UUID)
|
||||
@Schema(description = "快照ID")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "智能体ID")
|
||||
private String agentId;
|
||||
|
||||
@Schema(description = "所属用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "版本号")
|
||||
private Integer versionNo;
|
||||
|
||||
@Schema(description = "快照数据JSON")
|
||||
private String snapshotData;
|
||||
|
||||
@Schema(description = "变更字段JSON")
|
||||
private String changedFields;
|
||||
|
||||
@Schema(description = "快照来源")
|
||||
private String source;
|
||||
|
||||
@Schema(description = "恢复来源快照ID")
|
||||
private String restoreFromSnapshotId;
|
||||
|
||||
@Schema(description = "恢复来源版本号")
|
||||
private Integer restoreFromVersionNo;
|
||||
|
||||
@Schema(description = "创建者")
|
||||
private Long creator;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private Date createdAt;
|
||||
|
||||
@Schema(description = "快照数据脱敏规则版本")
|
||||
private Integer redactionVersion;
|
||||
}
|
||||
+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);
|
||||
|
||||
/**
|
||||
* 插入智能体
|
||||
*
|
||||
@@ -50,6 +60,13 @@ public interface AgentService extends BaseService<AgentEntity> {
|
||||
*/
|
||||
void deleteAgentByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 删除智能体及其关联数据
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
*/
|
||||
void deleteAgent(String agentId);
|
||||
|
||||
/**
|
||||
* 获取用户智能体列表
|
||||
*
|
||||
@@ -93,6 +110,41 @@ public interface AgentService extends BaseService<AgentEntity> {
|
||||
*/
|
||||
void updateAgentById(String agentId, AgentUpdateDTO dto);
|
||||
|
||||
/**
|
||||
* 更新当前用户有权访问的智能体
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
* @param dto 更新智能体所需的信息
|
||||
* @param userId 当前用户ID
|
||||
*/
|
||||
void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId);
|
||||
|
||||
/**
|
||||
* 根据设备MAC地址更新当前用户有权访问的智能体记忆
|
||||
*
|
||||
* @param macAddress 设备MAC地址
|
||||
* @param dto 智能体记忆
|
||||
* @param userId 当前用户ID
|
||||
*/
|
||||
void updateAgentMemoryByDeviceMacAddress(String macAddress, AgentMemoryDTO dto, Long userId);
|
||||
|
||||
/**
|
||||
* 删除当前用户有权访问的智能体
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
* @param userId 当前用户ID
|
||||
*/
|
||||
void deleteAgentById(String agentId, Long userId);
|
||||
|
||||
/**
|
||||
* 更新智能体
|
||||
*
|
||||
* @param agentId 智能体ID
|
||||
* @param dto 更新智能体所需的信息
|
||||
* @param createSnapshot 是否创建配置快照
|
||||
*/
|
||||
void updateAgentById(String agentId, AgentUpdateDTO dto, boolean createSnapshot);
|
||||
|
||||
/**
|
||||
* 创建智能体
|
||||
*
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package xiaozhi.modules.agent.service;
|
||||
|
||||
import xiaozhi.common.page.PageData;
|
||||
import xiaozhi.common.service.BaseService;
|
||||
import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentSnapshotEntity;
|
||||
import xiaozhi.modules.agent.vo.AgentSnapshotVO;
|
||||
|
||||
public interface AgentSnapshotService extends BaseService<AgentSnapshotEntity> {
|
||||
void createSnapshot(String agentId, String source);
|
||||
|
||||
PageData<AgentSnapshotVO> page(String agentId, AgentSnapshotPageDTO params);
|
||||
|
||||
AgentSnapshotVO getSnapshot(String agentId, String snapshotId);
|
||||
|
||||
void restoreSnapshot(String agentId, String snapshotId, String currentStateToken);
|
||||
|
||||
void deleteSnapshot(String agentId, String snapshotId);
|
||||
|
||||
Integer getCurrentVersionNo(String agentId);
|
||||
|
||||
void deleteByAgentId(String agentId);
|
||||
|
||||
long redactLegacySnapshots();
|
||||
}
|
||||
+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) {
|
||||
|
||||
+28
-2
@@ -14,7 +14,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.baomidou.mybatisplus.spring.repository.CrudRepository;
|
||||
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.page.PageData;
|
||||
@@ -39,7 +39,7 @@ import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryDao, AgentChatHistoryEntity>
|
||||
public class AgentChatHistoryServiceImpl extends CrudRepository<AiAgentChatHistoryDao, AgentChatHistoryEntity>
|
||||
implements AgentChatHistoryService {
|
||||
|
||||
private final AgentChatTitleService agentChatTitleService;
|
||||
@@ -88,6 +88,19 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
|
||||
return ConvertUtils.sourceToTarget(historyList, AgentChatHistoryDTO.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAgentIdBySessionId(String sessionId) {
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
AgentChatHistoryEntity entity = baseMapper.selectOne(
|
||||
new LambdaQueryWrapper<AgentChatHistoryEntity>()
|
||||
.select(AgentChatHistoryEntity::getAgentId)
|
||||
.eq(AgentChatHistoryEntity::getSessionId, sessionId)
|
||||
.last("LIMIT 1"));
|
||||
return entity == null ? null : entity.getAgentId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteByAgentId(String agentId, Boolean deleteAudio, Boolean deleteText) {
|
||||
@@ -176,6 +189,19 @@ public class AgentChatHistoryServiceImpl extends ServiceImpl<AiAgentChatHistoryD
|
||||
return agentChatHistoryEntity == null ? null : agentChatHistoryEntity.getContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAgentIdByAudioId(String audioId) {
|
||||
if (audioId == null || audioId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
AgentChatHistoryEntity entity = baseMapper.selectOne(
|
||||
new LambdaQueryWrapper<AgentChatHistoryEntity>()
|
||||
.select(AgentChatHistoryEntity::getAgentId)
|
||||
.eq(AgentChatHistoryEntity::getAudioId, audioId)
|
||||
.last("LIMIT 1"));
|
||||
return entity == null ? null : entity.getAgentId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAudioOwnedByAgent(String audioId, String agentId) {
|
||||
// 查询是否有指定音频id和智能体id的数据,如果有且只有一条说明此数据属性此智能体
|
||||
|
||||
+2
-2
@@ -118,7 +118,7 @@ public class AgentChatSummaryServiceImpl implements AgentChatSummaryService {
|
||||
{
|
||||
setSummaryMemory(summaryDTO.getSummary());
|
||||
}
|
||||
});
|
||||
}, false);
|
||||
log.info("成功保存会话 {} 的聊天记录总结到智能体 {}", sessionId, agentId);
|
||||
} else {
|
||||
log.info("生成总结失败: {}", summaryDTO.getErrorMessage());
|
||||
@@ -518,4 +518,4 @@ public class AgentChatSummaryServiceImpl implements AgentChatSummaryService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
|
||||
+232
-92
@@ -15,8 +15,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.repository.IRepository;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
@@ -34,6 +34,7 @@ import xiaozhi.modules.agent.dao.AgentDao;
|
||||
import xiaozhi.modules.agent.dao.AgentTagDao;
|
||||
import xiaozhi.modules.agent.dto.AgentCreateDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentTagDTO;
|
||||
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
|
||||
import xiaozhi.modules.agent.entity.AgentContextProviderEntity;
|
||||
@@ -43,9 +44,10 @@ import xiaozhi.modules.agent.entity.AgentTagEntity;
|
||||
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
|
||||
import xiaozhi.modules.agent.service.AgentChatHistoryService;
|
||||
import xiaozhi.modules.agent.service.AgentContextProviderService;
|
||||
import xiaozhi.modules.agent.service.AgentPluginMappingService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.AgentTagService;
|
||||
import xiaozhi.modules.agent.service.AgentPluginMappingService;
|
||||
import xiaozhi.modules.agent.service.AgentService;
|
||||
import xiaozhi.modules.agent.service.AgentSnapshotService;
|
||||
import xiaozhi.modules.agent.service.AgentTagService;
|
||||
import xiaozhi.modules.agent.service.AgentTemplateService;
|
||||
import xiaozhi.modules.agent.vo.AgentInfoVO;
|
||||
import xiaozhi.modules.correctword.service.CorrectWordFileService;
|
||||
@@ -56,9 +58,9 @@ import xiaozhi.modules.model.dto.VoiceDTO;
|
||||
import xiaozhi.modules.model.entity.ModelConfigEntity;
|
||||
import xiaozhi.modules.model.service.ModelConfigService;
|
||||
import xiaozhi.modules.model.service.ModelProviderService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.sys.enums.SuperAdminEnum;
|
||||
import xiaozhi.modules.timbre.service.TimbreService;
|
||||
import xiaozhi.modules.security.user.SecurityUser;
|
||||
import xiaozhi.modules.sys.enums.SuperAdminEnum;
|
||||
import xiaozhi.modules.timbre.service.TimbreService;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
@@ -73,9 +75,10 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
private final AgentChatHistoryService agentChatHistoryService;
|
||||
private final AgentTemplateService agentTemplateService;
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final AgentContextProviderService agentContextProviderService;
|
||||
private final AgentTagService agentTagService;
|
||||
private final CorrectWordFileService correctWordFileService;
|
||||
private final AgentContextProviderService agentContextProviderService;
|
||||
private final AgentTagService agentTagService;
|
||||
private final CorrectWordFileService correctWordFileService;
|
||||
private final AgentSnapshotService agentSnapshotService;
|
||||
|
||||
@Override
|
||||
public PageData<AgentEntity> adminAgentList(Map<String, Object> params) {
|
||||
@@ -92,6 +95,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
if (agent == null) {
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
requireCurrentUserPermissionIfPresent(agent);
|
||||
|
||||
if (agent.getMemModelId() != null && agent.getMemModelId().equals(Constant.MEMORY_NO_MEM)) {
|
||||
agent.setChatHistoryConf(Constant.ChatHistoryConfEnum.IGNORE.getCode());
|
||||
@@ -106,14 +110,74 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
agent.setContextProviders(contextProviderEntity.getContextProviders());
|
||||
}
|
||||
|
||||
// 查询替换词文件ID列表
|
||||
List<String> correctWordFileIds = correctWordFileService.getAgentCorrectWordFileIds(id);
|
||||
agent.setCorrectWordFileIds(correctWordFileIds);
|
||||
|
||||
// 无需额外查询插件列表,已通过SQL查询出来
|
||||
return agent;
|
||||
// 查询替换词文件ID列表
|
||||
List<String> correctWordFileIds = correctWordFileService.getAgentCorrectWordFileIds(id);
|
||||
agent.setCorrectWordFileIds(correctWordFileIds);
|
||||
agent.setCurrentVersionNo(agentSnapshotService.getCurrentVersionNo(id));
|
||||
|
||||
// 无需额外查询插件列表,已通过SQL查询出来
|
||||
return agent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentInfoVO getAgentById(String id, Long userId) {
|
||||
AgentInfoVO agent = getAgentById(id);
|
||||
requireAgentPermission(agent, userId);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private AgentEntity getAgentEntityOrThrow(String agentId) {
|
||||
AgentEntity agent = agentDao.selectById(agentId);
|
||||
if (agent == null) {
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
private boolean isCurrentUserSuperAdmin() {
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
return user != null && Integer.valueOf(SuperAdminEnum.YES.value()).equals(user.getSuperAdmin());
|
||||
}
|
||||
|
||||
private void requireCurrentUserPermissionIfPresent(AgentEntity agent) {
|
||||
Long userId = SecurityUser.getUserId();
|
||||
if (userId != null) {
|
||||
requireAgentPermission(agent, userId);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasAgentPermission(AgentEntity agent, Long userId) {
|
||||
if (agent == null) {
|
||||
return false;
|
||||
}
|
||||
if (isCurrentUserSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return userId != null && userId.equals(agent.getUserId());
|
||||
}
|
||||
|
||||
private void requireAgentPermission(AgentEntity agent, Long userId) {
|
||||
if (!hasAgentPermission(agent, userId)) {
|
||||
throw new RenException(ErrorCode.NO_PERMISSION);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasDevicePermission(DeviceEntity device, Long userId) {
|
||||
if (device == null) {
|
||||
return false;
|
||||
}
|
||||
if (isCurrentUserSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return userId != null && userId.equals(device.getUserId());
|
||||
}
|
||||
|
||||
private void requireDevicePermission(DeviceEntity device, Long userId) {
|
||||
if (!hasDevicePermission(device, userId)) {
|
||||
throw new RenException(ErrorCode.NO_PERMISSION);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean insert(AgentEntity entity) {
|
||||
// 如果ID为空,自动生成一个UUID作为ID
|
||||
@@ -134,15 +198,35 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
return super.insert(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAgentByUserId(Long userId) {
|
||||
UpdateWrapper<AgentEntity> wrapper = new UpdateWrapper<>();
|
||||
wrapper.eq("user_id", userId);
|
||||
baseDao.delete(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AgentDTO> getUserAgents(Long userId, String keyword, String searchType) {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteAgentByUserId(Long userId) {
|
||||
List<AgentEntity> agents = baseDao.selectList(new QueryWrapper<AgentEntity>()
|
||||
.select("id")
|
||||
.eq("user_id", userId));
|
||||
for (AgentEntity agent : agents) {
|
||||
deleteAgent(agent.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteAgent(String agentId) {
|
||||
if (agentDao.selectByIdForUpdate(agentId) == null) {
|
||||
return;
|
||||
}
|
||||
deviceService.deleteByAgentId(agentId);
|
||||
agentChatHistoryService.deleteByAgentId(agentId, true, true);
|
||||
agentPluginMappingService.deleteByAgentId(agentId);
|
||||
agentContextProviderService.deleteByAgentId(agentId);
|
||||
correctWordFileService.deleteMappingsByAgentId(agentId);
|
||||
agentTagService.deleteAgentTags(agentId);
|
||||
agentSnapshotService.deleteByAgentId(agentId);
|
||||
deleteById(agentId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AgentDTO> getUserAgents(Long userId, String keyword, String searchType) {
|
||||
QueryWrapper<AgentEntity> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("user_id", userId).orderByDesc("created_at");
|
||||
|
||||
@@ -253,42 +337,56 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean checkAgentPermission(String agentId, Long userId) {
|
||||
if (SecurityUser.getUser() == null || SecurityUser.getUser().getId() == null) {
|
||||
return false;
|
||||
}
|
||||
// 获取智能体信息
|
||||
AgentEntity agent = getAgentById(agentId);
|
||||
if (agent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果是超级管理员,直接返回true
|
||||
if (SecurityUser.getUser().getSuperAdmin() == SuperAdminEnum.YES.value()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否是智能体的所有者
|
||||
return userId.equals(agent.getUserId());
|
||||
}
|
||||
|
||||
// 根据id更新智能体信息
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateAgentById(String agentId, AgentUpdateDTO dto) {
|
||||
// 先查询现有实体
|
||||
AgentEntity existingEntity = this.getAgentById(agentId);
|
||||
if (existingEntity == null) {
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 只更新提供的非空字段
|
||||
if (dto.getAgentName() != null) {
|
||||
existingEntity.setAgentName(dto.getAgentName());
|
||||
}
|
||||
if (dto.getAgentCode() != null) {
|
||||
existingEntity.setAgentCode(dto.getAgentCode());
|
||||
}
|
||||
public boolean checkAgentPermission(String agentId, Long userId) {
|
||||
AgentEntity agent = agentDao.selectById(agentId);
|
||||
return hasAgentPermission(agent, userId);
|
||||
}
|
||||
|
||||
// 根据id更新智能体信息
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateAgentById(String agentId, AgentUpdateDTO dto) {
|
||||
updateAgentById(agentId, dto, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId) {
|
||||
updateAgentById(agentId, dto, userId, true);
|
||||
}
|
||||
|
||||
// 根据id更新智能体信息
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateAgentById(String agentId, AgentUpdateDTO dto, boolean createSnapshot) {
|
||||
updateAgentById(agentId, dto, null, createSnapshot);
|
||||
}
|
||||
|
||||
private void updateAgentById(String agentId, AgentUpdateDTO dto, Long userId, boolean createSnapshot) {
|
||||
AgentEntity lockedAgent = agentDao.selectByIdForUpdate(agentId);
|
||||
if (lockedAgent == null) {
|
||||
throw new RenException(ErrorCode.AGENT_NOT_FOUND);
|
||||
}
|
||||
if (userId == null) {
|
||||
requireCurrentUserPermissionIfPresent(lockedAgent);
|
||||
} else {
|
||||
requireAgentPermission(lockedAgent, userId);
|
||||
}
|
||||
|
||||
// 锁定后查询现有实体和关联配置
|
||||
AgentEntity existingEntity = this.getAgentById(agentId);
|
||||
if (createSnapshot) {
|
||||
int currentVersionNo = agentSnapshotService.getCurrentVersionNo(agentId);
|
||||
agentSnapshotService.createSnapshot(agentId, currentVersionNo == 0 ? "initial" : "current");
|
||||
}
|
||||
|
||||
// 只更新提供的非空字段
|
||||
if (dto.getAgentName() != null) {
|
||||
existingEntity.setAgentName(dto.getAgentName());
|
||||
}
|
||||
if (dto.getAgentCode() != null) {
|
||||
existingEntity.setAgentCode(dto.getAgentCode());
|
||||
}
|
||||
if (dto.getAsrModelId() != null) {
|
||||
existingEntity.setAsrModelId(dto.getAsrModelId());
|
||||
}
|
||||
@@ -385,10 +483,10 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
.toList();
|
||||
|
||||
if (!toUpdate.isEmpty()) {
|
||||
agentPluginMappingService.updateBatchById(toUpdate);
|
||||
agentPluginMappingService.updateBatchById(toUpdate, IRepository.DEFAULT_BATCH_SIZE);
|
||||
}
|
||||
if (!toInsert.isEmpty()) {
|
||||
agentPluginMappingService.saveBatch(toInsert);
|
||||
agentPluginMappingService.saveBatch(toInsert, IRepository.DEFAULT_BATCH_SIZE);
|
||||
}
|
||||
|
||||
// 5. 删除本次不在提交列表里的插件映射
|
||||
@@ -397,7 +495,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
.map(AgentPluginMapping::getId)
|
||||
.toList();
|
||||
if (!toDelete.isEmpty()) {
|
||||
agentPluginMappingService.removeBatchByIds(toDelete);
|
||||
agentPluginMappingService.removeByIds(toDelete);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,17 +524,48 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
}
|
||||
|
||||
// 更新替换词文件关联
|
||||
if (dto.getCorrectWordFileIds() != null) {
|
||||
correctWordFileService.saveAgentCorrectWords(agentId, dto.getCorrectWordFileIds());
|
||||
}
|
||||
|
||||
boolean b = validateLLMIntentParams(dto.getLlmModelId(), dto.getIntentModelId());
|
||||
if (!b) {
|
||||
throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH);
|
||||
}
|
||||
this.updateById(existingEntity);
|
||||
}
|
||||
if (dto.getCorrectWordFileIds() != null) {
|
||||
correctWordFileService.saveAgentCorrectWords(agentId, dto.getCorrectWordFileIds());
|
||||
}
|
||||
|
||||
// 更新智能体标签
|
||||
if (dto.getTagNames() != null || dto.getTagIds() != null) {
|
||||
agentTagService.saveAgentTags(agentId, dto.getTagIds(), dto.getTagNames());
|
||||
}
|
||||
|
||||
boolean b = validateLLMIntentParams(existingEntity.getLlmModelId(), existingEntity.getIntentModelId());
|
||||
if (!b) {
|
||||
throw new RenException(ErrorCode.LLM_INTENT_PARAMS_MISMATCH);
|
||||
}
|
||||
this.updateById(existingEntity);
|
||||
if (createSnapshot) {
|
||||
agentSnapshotService.createSnapshot(agentId, "config");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateAgentMemoryByDeviceMacAddress(String macAddress, AgentMemoryDTO dto, Long userId) {
|
||||
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
|
||||
if (device == null || StringUtils.isBlank(device.getAgentId()) || dto == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
requireDevicePermission(device, userId);
|
||||
|
||||
AgentUpdateDTO agentUpdateDTO = new AgentUpdateDTO();
|
||||
agentUpdateDTO.setSummaryMemory(dto.getSummaryMemory());
|
||||
updateAgentById(device.getAgentId(), agentUpdateDTO, userId, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteAgentById(String agentId, Long userId) {
|
||||
AgentEntity agent = getAgentEntityOrThrow(agentId);
|
||||
requireAgentPermission(agent, userId);
|
||||
deleteAgent(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证大语言模型和意图识别的参数是否符合匹配
|
||||
*
|
||||
@@ -487,16 +616,22 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
template.setTtsVoiceId(timbre.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entity.setTtsVoiceId(template.getTtsVoiceId());
|
||||
entity.setMemModelId(template.getMemModelId());
|
||||
entity.setIntentModelId(template.getIntentModelId());
|
||||
entity.setSystemPrompt(template.getSystemPrompt());
|
||||
entity.setSummaryMemory(template.getSummaryMemory());
|
||||
|
||||
// 根据记忆模型类型设置默认的chatHistoryConf值
|
||||
if (template.getMemModelId() != null) {
|
||||
}
|
||||
|
||||
entity.setTtsVoiceId(template.getTtsVoiceId());
|
||||
entity.setTtsLanguage(defaultIfBlank(template.getTtsLanguage(),
|
||||
timbreModelService.getDefaultLanguageById(entity.getTtsVoiceId())));
|
||||
entity.setMemModelId(template.getMemModelId());
|
||||
entity.setIntentModelId(template.getIntentModelId());
|
||||
entity.setSystemPrompt(template.getSystemPrompt());
|
||||
entity.setSummaryMemory(template.getSummaryMemory());
|
||||
if (Constant.MEMORY_NO_MEM.equals(entity.getMemModelId())
|
||||
|| Constant.MEMORY_MEM_REPORT_ONLY.equals(entity.getMemModelId())) {
|
||||
entity.setSummaryMemory("");
|
||||
}
|
||||
|
||||
// 根据记忆模型类型设置默认的chatHistoryConf值
|
||||
if (template.getMemModelId() != null) {
|
||||
if (template.getMemModelId().equals("Memory_nomem")) {
|
||||
// 无记忆功能的模型,默认不记录聊天记录
|
||||
entity.setChatHistoryConf(0);
|
||||
@@ -551,13 +686,18 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
mapping.setParamInfo(JsonUtils.toJsonString(paramInfo));
|
||||
mapping.setAgentId(entity.getId());
|
||||
toInsert.add(mapping);
|
||||
}
|
||||
// 保存默认插件
|
||||
agentPluginMappingService.saveBatch(toInsert);
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
private String getDefaultLLMModelId() {
|
||||
}
|
||||
// 保存默认插件
|
||||
agentPluginMappingService.saveBatch(toInsert, IRepository.DEFAULT_BATCH_SIZE);
|
||||
agentSnapshotService.createSnapshot(entity.getId(), "initial");
|
||||
return entity.getId();
|
||||
}
|
||||
|
||||
private String defaultIfBlank(String value, String defaultValue) {
|
||||
return StringUtils.isBlank(value) ? defaultValue : value;
|
||||
}
|
||||
|
||||
private String getDefaultLLMModelId() {
|
||||
try {
|
||||
List<ModelConfigEntity> llmConfigs = modelConfigService.getEnabledModelsByType("LLM");
|
||||
if (llmConfigs == null || llmConfigs.isEmpty()) {
|
||||
@@ -576,4 +716,4 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1772
File diff suppressed because it is too large
Load Diff
+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;
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package xiaozhi.modules.agent.typehandler;
|
||||
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.ibatis.type.BaseTypeHandler;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.modules.agent.dto.ContextProviderDTO;
|
||||
|
||||
/**
|
||||
* JSON type handler for context providers.
|
||||
*
|
||||
* <p>Do not extend MyBatis-Plus {@code AbstractJsonTypeHandler}: its constructor and JSON handler
|
||||
* contract changed in MyBatis-Plus 3.5.6. {@link BaseTypeHandler} is part of MyBatis itself and
|
||||
* keeps this handler compatible with both 3.5.5 and newer MyBatis-Plus releases.</p>
|
||||
*/
|
||||
public class ContextProviderListTypeHandler extends BaseTypeHandler<List<ContextProviderDTO>> {
|
||||
private static final TypeReference<List<ContextProviderDTO>> CONTEXT_PROVIDER_LIST_TYPE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
@Override
|
||||
public void setNonNullParameter(PreparedStatement ps, int i, List<ContextProviderDTO> parameter,
|
||||
JdbcType jdbcType) throws SQLException {
|
||||
ps.setString(i, JsonUtils.toJsonString(parameter));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ContextProviderDTO> getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||
return parseNullable(rs.getString(columnName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ContextProviderDTO> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||
return parseNullable(rs.getString(columnIndex));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ContextProviderDTO> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||
return parseNullable(cs.getString(columnIndex));
|
||||
}
|
||||
|
||||
private List<ContextProviderDTO> parseNullable(String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
return null;
|
||||
}
|
||||
List<ContextProviderDTO> providers = JsonUtils.parseObject(json, CONTEXT_PROVIDER_LIST_TYPE);
|
||||
return providers == null ? Collections.emptyList() : providers;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package xiaozhi.modules.agent.vo;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -20,7 +18,6 @@ import java.util.List;
|
||||
public class AgentInfoVO extends AgentEntity
|
||||
{
|
||||
@Schema(description = "插件列表Id")
|
||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||
private List<AgentPluginMapping> functions;
|
||||
|
||||
@Schema(description = "上下文源配置")
|
||||
@@ -28,4 +25,7 @@ public class AgentInfoVO extends AgentEntity
|
||||
|
||||
@Schema(description = "替换词文件ID列表")
|
||||
private List<String> correctWordFileIds;
|
||||
|
||||
@Schema(description = "当前配置版本号")
|
||||
private Integer currentVersionNo;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package xiaozhi.modules.agent.vo;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO;
|
||||
|
||||
@Data
|
||||
@Schema(description = "智能体配置快照")
|
||||
public class AgentSnapshotVO {
|
||||
private String id;
|
||||
private String agentId;
|
||||
@Schema(description = "所属用户ID,表示该快照归属的智能体所有者")
|
||||
private Long userId;
|
||||
private Integer versionNo;
|
||||
private List<String> changedFields;
|
||||
private List<String> fieldOrder;
|
||||
private String source;
|
||||
@Schema(description = "恢复来源快照ID,仅恢复结果版本有值")
|
||||
private String restoreFromSnapshotId;
|
||||
@Schema(description = "恢复来源版本号,仅恢复结果版本有值")
|
||||
private Integer restoreFromVersionNo;
|
||||
@Schema(description = "创建者,表示触发本次快照写入的操作人")
|
||||
private Long creator;
|
||||
private Date createdAt;
|
||||
private AgentSnapshotDataDTO snapshotData;
|
||||
private AgentSnapshotDataDTO afterSnapshotData;
|
||||
@Schema(description = "恢复预览对应的脱敏当前配置,仅详情接口有值")
|
||||
private AgentSnapshotDataDTO currentSnapshotData;
|
||||
@Schema(description = "恢复预览对应的当前配置状态指纹,仅详情接口有值")
|
||||
private String currentStateToken;
|
||||
}
|
||||
+11
-26
@@ -1,11 +1,10 @@
|
||||
package xiaozhi.modules.device.service.impl;
|
||||
|
||||
import java.util.Date;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
@@ -13,8 +12,8 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import xiaozhi.common.constant.Constant;
|
||||
import xiaozhi.common.redis.RedisKeys;
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
|
||||
@@ -201,36 +200,22 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
result.put("status", "error");
|
||||
|
||||
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
|
||||
String mqttSignatureKey = sysParamsService.getValue("server.mqtt_signature_key", true);
|
||||
String mqttSignatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true);
|
||||
|
||||
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)
|
||||
|| StringUtils.isBlank(mqttSignatureKey) || "null".equals(mqttSignatureKey)) {
|
||||
|| MqttGatewayAuthorization.isMissingSignatureKey(mqttSignatureKey)) {
|
||||
result.put("message", action + "失败,网关配置缺失");
|
||||
return result;
|
||||
}
|
||||
|
||||
String dateStr = new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date());
|
||||
try {
|
||||
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest((dateStr + mqttSignatureKey).getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
String token = hexString.toString();
|
||||
|
||||
String url = "http://" + mqttGatewayUrl + path;
|
||||
String response = HttpRequest.post(url)
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(JSONUtil.toJsonStr(body))
|
||||
.timeout(5000)
|
||||
.execute()
|
||||
.body();
|
||||
String response = MqttGatewayAuthorization.postJson(
|
||||
url,
|
||||
JSONUtil.toJsonStr(body),
|
||||
mqttSignatureKey,
|
||||
Instant.now(),
|
||||
5000);
|
||||
|
||||
if (StringUtils.isNotBlank(response)) {
|
||||
Map<String, Object> gwResult = JSONUtil.parseObj(response);
|
||||
@@ -270,4 +255,4 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
}
|
||||
return newAlias;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-58
@@ -31,15 +31,9 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import cn.hutool.core.date.DatePattern;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.hutool.http.ContentType;
|
||||
import cn.hutool.http.Header;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
@@ -187,14 +181,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.put("clientIds", deviceIds).build();
|
||||
|
||||
if (ToolUtil.isNotEmpty(deviceIds)) {
|
||||
// 发送请求
|
||||
String resultMessage = HttpRequest.post(url)
|
||||
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue())
|
||||
.header(Header.AUTHORIZATION, "Bearer " + generateBearerToken())
|
||||
.body(JSONUtil.toJsonStr(params))
|
||||
.timeout(10000) // 超时,毫秒
|
||||
.execute().body();
|
||||
return resultMessage;
|
||||
return postToMqttGateway(url, params);
|
||||
}
|
||||
// 返回响应
|
||||
return "";
|
||||
@@ -304,15 +291,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
@Override
|
||||
public List<UserShowDeviceListVO> getUserDeviceList(Long userId, String agentId) {
|
||||
List<DeviceEntity> devices = getUserDevices(userId, agentId);
|
||||
return devices.stream().map(device -> {
|
||||
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
|
||||
vo.setDeviceType(device.getBoard());
|
||||
// 设置UTC时间戳供前端使用时区转换
|
||||
if (device.getLastConnectedAt() != null) {
|
||||
vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime());
|
||||
}
|
||||
return vo;
|
||||
}).toList();
|
||||
return devices.stream().map(this::toUserShowDeviceListVO).toList();
|
||||
}
|
||||
|
||||
private UserShowDeviceListVO toUserShowDeviceListVO(DeviceEntity device) {
|
||||
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
|
||||
vo.setDeviceType(device.getBoard());
|
||||
vo.setBoard(device.getBoard());
|
||||
vo.setCreateDateTimestamp(toTimestamp(device.getCreateDate()));
|
||||
vo.setLastConnectedAtTimestamp(toTimestamp(device.getLastConnectedAt()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private Long toTimestamp(Date date) {
|
||||
return date == null ? null : date.getTime();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -385,17 +377,11 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.like(StringUtils.isNotBlank(dto.getKeywords()), "alias", dto.getKeywords()));
|
||||
// 循环处理page获取回来的数据,返回需要的字段
|
||||
List<UserShowDeviceListVO> list = page.getRecords().stream().map(device -> {
|
||||
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
|
||||
UserShowDeviceListVO vo = toUserShowDeviceListVO(device);
|
||||
// 把最后修改的时间,改为简短描述的时间
|
||||
vo.setRecentChatTime(DateUtils.getShortTime(device.getUpdateDate()));
|
||||
sysUserUtilService.assignUsername(device.getUserId(),
|
||||
vo::setBindUserName);
|
||||
vo.setDeviceType(device.getBoard());
|
||||
vo.setBoard(device.getBoard());
|
||||
// 设置UTC时间戳供前端使用时区转换
|
||||
if (device.getLastConnectedAt() != null) {
|
||||
vo.setLastConnectedAtTimestamp(device.getLastConnectedAt().getTime());
|
||||
}
|
||||
return vo;
|
||||
}).toList();
|
||||
// 计算页数
|
||||
@@ -699,20 +685,13 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
return mqtt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成BearerToken
|
||||
*/
|
||||
private String generateBearerToken() {
|
||||
try {
|
||||
String dateStr = DateUtil.format(new Date(), DatePattern.NORM_DATE_PATTERN);
|
||||
String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false);
|
||||
if (ToolUtil.isEmpty(signatureKey)) {
|
||||
return null;
|
||||
}
|
||||
return DigestUtil.sha256Hex(dateStr + signatureKey);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
private String postToMqttGateway(String url, Object requestBody) {
|
||||
String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false);
|
||||
return MqttGatewayAuthorization.postJson(
|
||||
url,
|
||||
JSONUtil.toJsonStr(requestBody),
|
||||
signatureKey,
|
||||
Instant.now());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -773,13 +752,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.put("payload", payload)
|
||||
.build();
|
||||
|
||||
// 发送请求
|
||||
String resultMessage = HttpRequest.post(url)
|
||||
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue())
|
||||
.header(Header.AUTHORIZATION, "Bearer " + generateBearerToken())
|
||||
.body(JSONUtil.toJsonStr(requestBody))
|
||||
.timeout(10000) // 超时,毫秒
|
||||
.execute().body();
|
||||
String resultMessage = postToMqttGateway(url, requestBody);
|
||||
|
||||
// 解析响应
|
||||
if (StringUtils.isBlank(resultMessage)) {
|
||||
@@ -870,13 +843,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
||||
.put("payload", payload)
|
||||
.build();
|
||||
|
||||
// 发送请求
|
||||
String resultMessage = HttpRequest.post(url)
|
||||
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue())
|
||||
.header(Header.AUTHORIZATION, "Bearer " + generateBearerToken())
|
||||
.body(JSONUtil.toJsonStr(requestBody))
|
||||
.timeout(10000) // 超时,毫秒
|
||||
.execute().body();
|
||||
String resultMessage = postToMqttGateway(url, requestBody);
|
||||
|
||||
// 解析响应
|
||||
if (StringUtils.isNotBlank(resultMessage)) {
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,14 @@ public class UserShowDeviceListVO {
|
||||
@Schema(description = "最近对话时间")
|
||||
private String recentChatTime;
|
||||
|
||||
@Schema(description = "最后连接时间(UTC毫秒)")
|
||||
@Schema(description = "最后连接时间戳(毫秒)", type = "string", example = "1783689702000")
|
||||
private Long lastConnectedAtTimestamp;
|
||||
|
||||
@Schema(description = "绑定时间")
|
||||
@Schema(description = "绑定时间戳(毫秒)", type = "string", example = "1783689702000")
|
||||
private Long createDateTimestamp;
|
||||
|
||||
@Schema(description = "绑定时间(兼容字段,请使用 createDateTimestamp)", deprecated = true)
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
|
||||
private Date createDate;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ public interface TimbreService extends BaseService<TimbreEntity> {
|
||||
|
||||
List<VoiceDTO> getVoiceNames(String ttsModelId, String voiceName);
|
||||
|
||||
/**
|
||||
* 获取普通音色或克隆音色配置的首个有效语言。
|
||||
*
|
||||
* @param id 音色ID
|
||||
* @return 默认语言;音色不存在或未配置有效语言时返回null
|
||||
*/
|
||||
String getDefaultLanguageById(String id);
|
||||
|
||||
/**
|
||||
* 根据ID获取音色名称
|
||||
*
|
||||
@@ -73,4 +81,4 @@ public interface TimbreService extends BaseService<TimbreEntity> {
|
||||
* @return 音色信息
|
||||
*/
|
||||
VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -1,6 +1,7 @@
|
||||
package xiaozhi.modules.timbre.service.impl;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -41,6 +42,8 @@ import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
@Service
|
||||
public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity> implements TimbreService {
|
||||
|
||||
private static final Pattern LANGUAGE_SEPARATOR = Pattern.compile("[、;;,,]");
|
||||
|
||||
private final TimbreDao timbreDao;
|
||||
private final VoiceCloneDao voiceCloneDao;
|
||||
private final RedisUtils redisUtils;
|
||||
@@ -158,6 +161,32 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
return CollectionUtil.isEmpty(voiceDTOs) ? null : voiceDTOs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDefaultLanguageById(String id) {
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
TimbreEntity timbre = timbreDao.selectById(id);
|
||||
if (timbre != null) {
|
||||
return firstNonBlankLanguage(timbre.getLanguages());
|
||||
}
|
||||
|
||||
VoiceCloneEntity voiceClone = voiceCloneDao.selectById(id);
|
||||
return voiceClone == null ? null : firstNonBlankLanguage(voiceClone.getLanguages());
|
||||
}
|
||||
|
||||
private String firstNonBlankLanguage(String languages) {
|
||||
if (StringUtils.isBlank(languages)) {
|
||||
return null;
|
||||
}
|
||||
return LANGUAGE_SEPARATOR.splitAsStream(languages)
|
||||
.map(StringUtils::trimToNull)
|
||||
.filter(Objects::nonNull)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理是不是tts模型的id
|
||||
*/
|
||||
@@ -214,4 +243,4 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
||||
dto.setIsClone(false); // 设置为普通音色
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Add language configuration for local FunASR ASR.
|
||||
UPDATE `ai_model_provider`
|
||||
SET `fields` = '[{"key":"model_dir","label":"模型目录","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"},{"key":"language","label":"识别语言","type":"string","default":"auto"}]'
|
||||
WHERE `id` = 'SYSTEM_ASR_FunASR';
|
||||
|
||||
UPDATE `ai_model_config`
|
||||
SET `config_json` = JSON_SET(`config_json`, '$.language', 'auto')
|
||||
WHERE `id` = 'ASR_FunASR'
|
||||
AND JSON_EXTRACT(`config_json`, '$.language') IS NULL;
|
||||
|
||||
-- Update the FunASR local model configuration description to mention the language option.
|
||||
UPDATE `ai_model_config`
|
||||
SET `remark` = 'FunASR本地模型配置说明:
|
||||
1. 需要下载模型文件到xiaozhi-server/models/SenseVoiceSmall目录
|
||||
2. 支持中日韩粤语音识别
|
||||
3. 本地推理,无需网络连接
|
||||
4. 待识别文件保存在tmp/目录
|
||||
5. “识别语言”字段控制识别语种:auto = 自动检测;如需限定只识别中文可设为 zh(en=英语、ja=日语、ko=韩语、yue=粤语)。'
|
||||
WHERE `id` = 'ASR_FunASR';
|
||||
@@ -0,0 +1,20 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- changeset tykechen:202607071530
|
||||
CREATE TABLE IF NOT EXISTS `ai_agent_snapshot` (
|
||||
`id` VARCHAR(32) NOT NULL COMMENT '快照ID',
|
||||
`agent_id` VARCHAR(32) NOT NULL COMMENT '智能体ID',
|
||||
`user_id` BIGINT DEFAULT NULL COMMENT '所属用户ID',
|
||||
`version_no` INT UNSIGNED NOT NULL COMMENT '版本号',
|
||||
`snapshot_data` JSON NOT NULL DEFAULT (JSON_OBJECT()) COMMENT '快照数据',
|
||||
`changed_fields` JSON DEFAULT NULL COMMENT '变更字段',
|
||||
`source` VARCHAR(32) DEFAULT 'config' COMMENT '快照来源',
|
||||
`restore_from_snapshot_id` VARCHAR(32) DEFAULT NULL COMMENT '恢复来源快照ID',
|
||||
`restore_from_version_no` INT UNSIGNED DEFAULT NULL COMMENT '恢复来源版本号',
|
||||
`creator` BIGINT DEFAULT NULL COMMENT '创建者',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_agent_version` (`agent_id`, `version_no`),
|
||||
INDEX `idx_agent_created_at` (`agent_id`, `created_at`),
|
||||
INDEX `idx_snapshot_user_created_at` (`user_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体配置快照表';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- changeset tykechen:202607101200
|
||||
ALTER TABLE `ai_agent_snapshot`
|
||||
ADD COLUMN `redaction_version` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '快照脱敏规则版本' AFTER `created_at`,
|
||||
ADD INDEX `idx_snapshot_redaction_version_id` (`redaction_version`, `id`);
|
||||
|
||||
-- rollback ALTER TABLE `ai_agent_snapshot` DROP INDEX `idx_snapshot_redaction_version_id`, DROP COLUMN `redaction_version`;
|
||||
Executable → Regular
+21
@@ -690,3 +690,24 @@ databaseChangeLog:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202606261131.sql
|
||||
- changeSet:
|
||||
id: 202607011405
|
||||
author: LauraGPT
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607011405.sql
|
||||
- changeSet:
|
||||
id: 202607071530
|
||||
author: tykechen
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607071530.sql
|
||||
- changeSet:
|
||||
id: 202607101200
|
||||
author: tykechen
|
||||
changes:
|
||||
- sqlFile:
|
||||
encoding: utf8
|
||||
path: classpath:db/changelog/202607101200.sql
|
||||
|
||||
@@ -25,9 +25,6 @@
|
||||
<result column="memModelId" property="memModelId"/>
|
||||
<result column="intentModelId" property="intentModelId"/>
|
||||
|
||||
<result column="functions" property="functions"
|
||||
typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
|
||||
|
||||
<result column="chatHistoryConf" property="chatHistoryConf"/>
|
||||
<result column="systemPrompt" property="systemPrompt"/>
|
||||
<result column="summaryMemory" property="summaryMemory"/>
|
||||
@@ -38,6 +35,13 @@
|
||||
<result column="createdAt" property="createdAt"/>
|
||||
<result column="updater" property="updater"/>
|
||||
<result column="updatedAt" property="updatedAt"/>
|
||||
<collection property="functions"
|
||||
ofType="xiaozhi.modules.agent.entity.AgentPluginMapping">
|
||||
<id column="functionId" property="id"/>
|
||||
<result column="functionAgentId" property="agentId"/>
|
||||
<result column="pluginId" property="pluginId"/>
|
||||
<result column="paramInfo" property="paramInfo"/>
|
||||
</collection>
|
||||
</resultMap>
|
||||
|
||||
<select id="selectAgentInfoById" resultMap="AgentInfoMap">
|
||||
@@ -58,19 +62,6 @@
|
||||
a.tts_pitch AS ttsPitch,
|
||||
a.mem_model_id AS memModelId,
|
||||
a.intent_model_id AS intentModelId,
|
||||
COALESCE(
|
||||
(SELECT JSON_ARRAYAGG(
|
||||
JSON_OBJECT(
|
||||
'id', m.id,
|
||||
'agentId', m.agent_id,
|
||||
'pluginId', m.plugin_id,
|
||||
'paramInfo', m.param_info
|
||||
)
|
||||
)
|
||||
FROM ai_agent_plugin_mapping m
|
||||
WHERE m.agent_id = a.id),
|
||||
JSON_ARRAY()
|
||||
) AS functions,
|
||||
a.chat_history_conf AS chatHistoryConf,
|
||||
a.system_prompt AS systemPrompt,
|
||||
a.summary_memory AS summaryMemory,
|
||||
@@ -80,8 +71,50 @@
|
||||
a.creator,
|
||||
a.created_at AS createdAt,
|
||||
a.updater,
|
||||
a.updated_at AS updatedAt
|
||||
a.updated_at AS updatedAt,
|
||||
f.id AS functionId,
|
||||
f.agent_id AS functionAgentId,
|
||||
f.plugin_id AS pluginId,
|
||||
f.param_info AS paramInfo
|
||||
FROM ai_agent a
|
||||
LEFT JOIN ai_agent_plugin_mapping f ON f.agent_id = a.id
|
||||
WHERE a.id = #{agentId}
|
||||
ORDER BY f.id ASC
|
||||
</select>
|
||||
</mapper>
|
||||
|
||||
<select id="selectByIdForUpdate" resultType="xiaozhi.modules.agent.entity.AgentEntity">
|
||||
SELECT *
|
||||
FROM ai_agent
|
||||
WHERE id = #{agentId}
|
||||
FOR UPDATE
|
||||
</select>
|
||||
|
||||
<update id="updateSnapshotFields">
|
||||
UPDATE ai_agent
|
||||
SET agent_code = #{agent.agentCode,jdbcType=VARCHAR},
|
||||
agent_name = #{agent.agentName,jdbcType=VARCHAR},
|
||||
asr_model_id = #{agent.asrModelId,jdbcType=VARCHAR},
|
||||
vad_model_id = #{agent.vadModelId,jdbcType=VARCHAR},
|
||||
llm_model_id = #{agent.llmModelId,jdbcType=VARCHAR},
|
||||
slm_model_id = #{agent.slmModelId,jdbcType=VARCHAR},
|
||||
vllm_model_id = #{agent.vllmModelId,jdbcType=VARCHAR},
|
||||
tts_model_id = #{agent.ttsModelId,jdbcType=VARCHAR},
|
||||
tts_voice_id = #{agent.ttsVoiceId,jdbcType=VARCHAR},
|
||||
tts_language = #{agent.ttsLanguage,jdbcType=VARCHAR},
|
||||
tts_volume = #{agent.ttsVolume,jdbcType=INTEGER},
|
||||
tts_rate = #{agent.ttsRate,jdbcType=INTEGER},
|
||||
tts_pitch = #{agent.ttsPitch,jdbcType=INTEGER},
|
||||
mem_model_id = #{agent.memModelId,jdbcType=VARCHAR},
|
||||
intent_model_id = #{agent.intentModelId,jdbcType=VARCHAR},
|
||||
chat_history_conf = #{agent.chatHistoryConf,jdbcType=INTEGER},
|
||||
system_prompt = #{agent.systemPrompt,jdbcType=LONGVARCHAR},
|
||||
summary_memory = #{agent.summaryMemory,jdbcType=LONGVARCHAR},
|
||||
lang_code = #{agent.langCode,jdbcType=VARCHAR},
|
||||
language = #{agent.language,jdbcType=VARCHAR},
|
||||
sort = #{agent.sort,jdbcType=INTEGER},
|
||||
updater = #{agent.updater,jdbcType=BIGINT},
|
||||
updated_at = #{agent.updatedAt,jdbcType=TIMESTAMP}
|
||||
WHERE id = #{agent.id}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="xiaozhi.modules.agent.dao.AgentSnapshotDao">
|
||||
|
||||
<select id="selectMaxVersionNo" resultType="java.lang.Integer">
|
||||
SELECT COALESCE(MAX(version_no), 0)
|
||||
FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{agentId}
|
||||
</select>
|
||||
|
||||
<select id="selectLatestSnapshot" resultType="xiaozhi.modules.agent.entity.AgentSnapshotEntity">
|
||||
SELECT id,
|
||||
agent_id AS agentId,
|
||||
user_id AS userId,
|
||||
version_no AS versionNo,
|
||||
snapshot_data AS snapshotData,
|
||||
changed_fields AS changedFields,
|
||||
source,
|
||||
restore_from_snapshot_id AS restoreFromSnapshotId,
|
||||
restore_from_version_no AS restoreFromVersionNo,
|
||||
creator,
|
||||
created_at AS createdAt,
|
||||
redaction_version AS redactionVersion
|
||||
FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{agentId}
|
||||
ORDER BY version_no DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectNextSnapshot" resultType="xiaozhi.modules.agent.entity.AgentSnapshotEntity">
|
||||
SELECT id,
|
||||
agent_id AS agentId,
|
||||
user_id AS userId,
|
||||
version_no AS versionNo,
|
||||
snapshot_data AS snapshotData,
|
||||
changed_fields AS changedFields,
|
||||
source,
|
||||
restore_from_snapshot_id AS restoreFromSnapshotId,
|
||||
restore_from_version_no AS restoreFromVersionNo,
|
||||
creator,
|
||||
created_at AS createdAt,
|
||||
redaction_version AS redactionVersion
|
||||
FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{agentId}
|
||||
AND version_no > #{versionNo}
|
||||
ORDER BY version_no ASC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertWithNextVersion">
|
||||
INSERT INTO ai_agent_snapshot (
|
||||
id,
|
||||
agent_id,
|
||||
user_id,
|
||||
version_no,
|
||||
snapshot_data,
|
||||
changed_fields,
|
||||
source,
|
||||
restore_from_snapshot_id,
|
||||
restore_from_version_no,
|
||||
creator,
|
||||
created_at,
|
||||
redaction_version
|
||||
)
|
||||
SELECT #{snapshot.id},
|
||||
#{snapshot.agentId},
|
||||
#{snapshot.userId},
|
||||
COALESCE(MAX(version_no), 0) + 1,
|
||||
#{snapshot.snapshotData},
|
||||
#{snapshot.changedFields},
|
||||
#{snapshot.source},
|
||||
#{snapshot.restoreFromSnapshotId},
|
||||
#{snapshot.restoreFromVersionNo},
|
||||
#{snapshot.creator},
|
||||
#{snapshot.createdAt},
|
||||
#{snapshot.redactionVersion}
|
||||
FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{snapshot.agentId}
|
||||
</insert>
|
||||
|
||||
<delete id="deleteOlderThanKeepLimit">
|
||||
DELETE FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{agentId}
|
||||
AND id NOT IN (
|
||||
SELECT id
|
||||
FROM (
|
||||
SELECT id
|
||||
FROM ai_agent_snapshot
|
||||
WHERE agent_id = #{agentId}
|
||||
ORDER BY version_no DESC
|
||||
LIMIT #{keepLimit}
|
||||
) retained_snapshots
|
||||
)
|
||||
</delete>
|
||||
|
||||
<select id="selectLegacyRedactionBatch" resultType="xiaozhi.modules.agent.entity.AgentSnapshotEntity">
|
||||
SELECT id,
|
||||
snapshot_data AS snapshotData,
|
||||
redaction_version AS redactionVersion
|
||||
FROM ai_agent_snapshot
|
||||
WHERE redaction_version < #{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>
|
||||
@@ -0,0 +1,200 @@
|
||||
package xiaozhi.common.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import org.apache.ibatis.binding.MapperMethod;
|
||||
import org.apache.ibatis.executor.BatchResult;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.apache.ibatis.session.ExecutorType;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import com.baomidou.mybatisplus.core.enums.SqlMethod;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Constants;
|
||||
import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
|
||||
|
||||
class BaseServiceImplTest {
|
||||
private static final String INSERT_STATEMENT = TestMapper.class.getName() + ".insert";
|
||||
private static final String UPDATE_STATEMENT = TestMapper.class.getName() + ".updateById";
|
||||
|
||||
private final TestService service = new TestService();
|
||||
|
||||
@AfterEach
|
||||
void clearTransactionSynchronization() {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void insertBatchFlushesAtTheRequestedBatchSizeAndCommitsWithoutATransaction() {
|
||||
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
|
||||
SqlSession sqlSession = mock(SqlSession.class);
|
||||
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
|
||||
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(1);
|
||||
when(sqlSession.flushStatements())
|
||||
.thenReturn(List.of(batchResult(1, 1)), List.of(batchResult(1)));
|
||||
|
||||
TestEntity first = new TestEntity(1L);
|
||||
TestEntity second = new TestEntity(2L);
|
||||
TestEntity third = new TestEntity(3L);
|
||||
|
||||
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
|
||||
assertTrue(service.insertBatch(List.of(first, second, third), 2));
|
||||
}
|
||||
|
||||
verify(sqlSession).insert(INSERT_STATEMENT, first);
|
||||
verify(sqlSession).insert(INSERT_STATEMENT, second);
|
||||
verify(sqlSession).insert(INSERT_STATEMENT, third);
|
||||
verify(sqlSession, times(2)).flushStatements();
|
||||
verify(sqlSession).commit(true);
|
||||
verify(sqlSession).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateBatchByIdPassesEachEntityAndReturnsSuccessfulFlushResult() {
|
||||
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
|
||||
SqlSession sqlSession = mock(SqlSession.class);
|
||||
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
|
||||
when(sqlSession.update(eq(UPDATE_STATEMENT), any())).thenReturn(1);
|
||||
when(sqlSession.flushStatements()).thenReturn(List.of(batchResult(1, 1)));
|
||||
|
||||
TestEntity first = new TestEntity(1L);
|
||||
TestEntity second = new TestEntity(2L);
|
||||
|
||||
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
|
||||
assertTrue(service.updateBatchById(List.of(first, second), 10));
|
||||
}
|
||||
|
||||
ArgumentCaptor<Object> params = ArgumentCaptor.forClass(Object.class);
|
||||
verify(sqlSession, times(2)).update(eq(UPDATE_STATEMENT), params.capture());
|
||||
assertSame(first, entityFrom(params.getAllValues().get(0)));
|
||||
assertSame(second, entityFrom(params.getAllValues().get(1)));
|
||||
verify(sqlSession).flushStatements();
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchCallbacksReturnActualAffectedRowCounts() {
|
||||
SqlSession sqlSession = mock(SqlSession.class);
|
||||
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(3);
|
||||
when(sqlSession.update(eq(UPDATE_STATEMENT), any())).thenReturn(2);
|
||||
CallbackCapturingTestService capturingService = new CallbackCapturingTestService(sqlSession);
|
||||
TestEntity entity = new TestEntity(1L);
|
||||
|
||||
assertTrue(capturingService.insertBatch(List.of(entity), 7));
|
||||
assertEquals(7, capturingService.batchSize);
|
||||
assertEquals(List.of(3), capturingService.affectedRows);
|
||||
|
||||
assertTrue(capturingService.updateBatchById(List.of(entity), 9));
|
||||
assertEquals(9, capturingService.batchSize);
|
||||
assertEquals(List.of(2), capturingService.affectedRows);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyBatchesReturnFalseWithoutOpeningABatchSession() {
|
||||
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
|
||||
|
||||
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
|
||||
assertFalse(service.insertBatch(List.of(), 10));
|
||||
assertFalse(service.updateBatchById(List.of(), 10));
|
||||
}
|
||||
|
||||
verifyNoInteractions(sqlSessionFactory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeTransactionSynchronizationUsesTransactionAwareCommitAndLifecycle() {
|
||||
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
|
||||
SqlSession sqlSession = mock(SqlSession.class);
|
||||
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
|
||||
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(1);
|
||||
when(sqlSession.flushStatements()).thenReturn(List.of(batchResult(1)));
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
|
||||
try (MockedStatic<SqlHelper> sqlHelper = sqlHelperUsing(sqlSessionFactory)) {
|
||||
assertTrue(service.insertBatch(List.of(new TestEntity(1L)), 1));
|
||||
}
|
||||
|
||||
verify(sqlSession).flushStatements();
|
||||
verify(sqlSession).commit(false);
|
||||
verify(sqlSession, never()).commit(true);
|
||||
verify(sqlSession, never()).close();
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private static MockedStatic<SqlHelper> sqlHelperUsing(SqlSessionFactory sqlSessionFactory) {
|
||||
MockedStatic<SqlHelper> sqlHelper = mockStatic(SqlHelper.class, CALLS_REAL_METHODS);
|
||||
sqlHelper.when(() -> SqlHelper.sqlSessionFactory(TestEntity.class)).thenReturn(sqlSessionFactory);
|
||||
return sqlHelper;
|
||||
}
|
||||
|
||||
private static BatchResult batchResult(int... updateCounts) {
|
||||
BatchResult result = new BatchResult(mock(MappedStatement.class), "batch");
|
||||
result.setUpdateCounts(updateCounts);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Object entityFrom(Object parameter) {
|
||||
assertTrue(parameter instanceof MapperMethod.ParamMap<?>);
|
||||
return ((Map<?, ?>) parameter).get(Constants.ENTITY);
|
||||
}
|
||||
|
||||
private interface TestMapper extends BaseMapper<TestEntity> {
|
||||
}
|
||||
|
||||
private record TestEntity(Long id) {
|
||||
}
|
||||
|
||||
private static class TestService extends BaseServiceImpl<TestMapper, TestEntity> {
|
||||
@Override
|
||||
protected String getSqlStatement(SqlMethod sqlMethod) {
|
||||
return switch (sqlMethod) {
|
||||
case INSERT_ONE -> INSERT_STATEMENT;
|
||||
case UPDATE_BY_ID -> UPDATE_STATEMENT;
|
||||
default -> throw new IllegalArgumentException("Unexpected SQL method: " + sqlMethod);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static class CallbackCapturingTestService extends TestService {
|
||||
private final SqlSession sqlSession;
|
||||
private int batchSize;
|
||||
private List<Integer> affectedRows;
|
||||
|
||||
private CallbackCapturingTestService(SqlSession sqlSession) {
|
||||
this.sqlSession = sqlSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected <E> boolean executeBatch(Collection<E> list, int batchSize,
|
||||
BiFunction<SqlSession, E, Integer> operation) {
|
||||
this.batchSize = batchSize;
|
||||
this.affectedRows = list.stream().map(entity -> operation.apply(sqlSession, entity)).toList();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package xiaozhi.modules.agent.dto;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AgentUpdateDTOTest {
|
||||
|
||||
@Test
|
||||
void functionInfoAcceptsJsonStringParamInfo() {
|
||||
AgentUpdateDTO.FunctionInfo info = new AgentUpdateDTO.FunctionInfo();
|
||||
|
||||
info.setParamInfo("{\"api_key\":\"abc\",\"max_results\":5}");
|
||||
|
||||
assertEquals("abc", info.getParamInfo().get("api_key"));
|
||||
assertEquals(5, info.getParamInfo().get("max_results"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void functionInfoNormalizesMapKeysToStrings() {
|
||||
AgentUpdateDTO.FunctionInfo info = new AgentUpdateDTO.FunctionInfo();
|
||||
Map<Object, Object> params = new LinkedHashMap<>();
|
||||
params.put("api_key", "abc");
|
||||
params.put(42, true);
|
||||
|
||||
info.setParamInfo(params);
|
||||
|
||||
assertEquals("abc", info.getParamInfo().get("api_key"));
|
||||
assertEquals(true, info.getParamInfo().get("42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void functionInfoFallsBackToEmptyMapForBlankParamInfo() {
|
||||
AgentUpdateDTO.FunctionInfo info = new AgentUpdateDTO.FunctionInfo();
|
||||
|
||||
info.setParamInfo(" ");
|
||||
|
||||
assertTrue(info.getParamInfo().isEmpty());
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+1962
File diff suppressed because it is too large
Load Diff
+73
@@ -0,0 +1,73 @@
|
||||
package xiaozhi.modules.agent.typehandler;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.ibatis.type.TypeHandler;
|
||||
import org.apache.ibatis.type.TypeHandlerRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import xiaozhi.common.utils.JsonUtils;
|
||||
import xiaozhi.modules.agent.dto.ContextProviderDTO;
|
||||
|
||||
class ContextProviderListTypeHandlerTest {
|
||||
|
||||
private final ContextProviderListTypeHandler handler = new ContextProviderListTypeHandler();
|
||||
|
||||
@Test
|
||||
void myBatisCanInstantiateHandlerForListField() {
|
||||
TypeHandler<?> typeHandler = new TypeHandlerRegistry().getInstance(List.class,
|
||||
ContextProviderListTypeHandler.class);
|
||||
|
||||
assertInstanceOf(ContextProviderListTypeHandler.class, typeHandler);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resultSetDeserializationKeepsContextProviderDtoElementType() throws Exception {
|
||||
ResultSet resultSet = mock(ResultSet.class);
|
||||
when(resultSet.getString("context_providers"))
|
||||
.thenReturn("[{\"url\":\"https://example.com/context\",\"headers\":{\"Authorization\":\"Bearer token\"}}]");
|
||||
|
||||
List<ContextProviderDTO> providers = handler.getNullableResult(resultSet, "context_providers");
|
||||
|
||||
assertEquals(1, providers.size());
|
||||
assertInstanceOf(ContextProviderDTO.class, providers.get(0));
|
||||
assertEquals("https://example.com/context", providers.get(0).getUrl());
|
||||
assertEquals("Bearer token", providers.get(0).getHeaders().get("Authorization"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlNullRemainsNull() throws Exception {
|
||||
ResultSet resultSet = mock(ResultSet.class);
|
||||
|
||||
assertNull(handler.getNullableResult(resultSet, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serializesProviderListAsJson() throws Exception {
|
||||
ContextProviderDTO provider = new ContextProviderDTO();
|
||||
provider.setUrl("https://example.com/context");
|
||||
PreparedStatement statement = mock(PreparedStatement.class);
|
||||
|
||||
handler.setNonNullParameter(statement, 1, List.of(provider), null);
|
||||
|
||||
ArgumentCaptor<String> json = ArgumentCaptor.forClass(String.class);
|
||||
verify(statement).setString(eq(1), json.capture());
|
||||
List<ContextProviderDTO> serialized = JsonUtils.parseObject(json.getValue(), new TypeReference<>() {
|
||||
});
|
||||
assertEquals(1, serialized.size());
|
||||
assertEquals("https://example.com/context", serialized.get(0).getUrl());
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package xiaozhi.modules.timbre.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import xiaozhi.common.redis.RedisUtils;
|
||||
import xiaozhi.modules.timbre.dao.TimbreDao;
|
||||
import xiaozhi.modules.timbre.entity.TimbreEntity;
|
||||
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
|
||||
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
|
||||
|
||||
class TimbreServiceImplTest {
|
||||
|
||||
@Test
|
||||
void defaultLanguageUsesFirstValidRegularTimbreLanguageWithoutCloneQuery() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
|
||||
TimbreEntity timbre = new TimbreEntity();
|
||||
timbre.setLanguages(",, ; 普通话;粤语");
|
||||
when(timbreDao.selectById("voice-id")).thenReturn(timbre);
|
||||
|
||||
assertEquals("普通话", service.getDefaultLanguageById("voice-id"));
|
||||
|
||||
verify(voiceCloneDao, never()).selectById("voice-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultLanguageFallsBackToCloneTimbre() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
|
||||
VoiceCloneEntity voiceClone = new VoiceCloneEntity();
|
||||
voiceClone.setLanguages("、, English,中文");
|
||||
when(voiceCloneDao.selectById("clone-id")).thenReturn(voiceClone);
|
||||
|
||||
assertEquals("English", service.getDefaultLanguageById("clone-id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delimiterOnlyLanguageConfigurationReturnsNull() {
|
||||
TimbreDao timbreDao = mock(TimbreDao.class);
|
||||
VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class);
|
||||
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class));
|
||||
TimbreEntity timbre = new TimbreEntity();
|
||||
timbre.setLanguages(",,、;;;,,");
|
||||
when(timbreDao.selectById("voice-id")).thenReturn(timbre);
|
||||
|
||||
assertNull(service.getDefaultLanguageById("voice-id"));
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@
|
||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs",
|
||||
"openapi-ts-request": "openapi-ts",
|
||||
"prepare": "git init && husky",
|
||||
"lint": "eslint",
|
||||
|
||||
Generated
+15
@@ -91,6 +91,9 @@ importers:
|
||||
pinia-plugin-persistedstate:
|
||||
specifier: 3.2.1
|
||||
version: 3.2.1(pinia@2.0.36(typescript@5.7.2)(vue@3.5.15(typescript@5.7.2)))
|
||||
sm-crypto:
|
||||
specifier: ^0.3.13
|
||||
version: 0.3.14
|
||||
vue:
|
||||
specifier: ^3.4.21
|
||||
version: 3.5.15(typescript@5.7.2)
|
||||
@@ -4719,6 +4722,9 @@ packages:
|
||||
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
|
||||
hasBin: true
|
||||
|
||||
jsbn@1.1.0:
|
||||
resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==}
|
||||
|
||||
jsdoc-type-pratt-parser@4.1.0:
|
||||
resolution: {integrity: sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -6020,6 +6026,9 @@ packages:
|
||||
resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
sm-crypto@0.3.14:
|
||||
resolution: {integrity: sha512-sR7NuGAJH93Om8keAF2/rj1EqFzEiUey4aKGyDo3nQ2BiVqhgq8UkiIoIp5qhV9w4pG7qArhfqyewcG8AbhXbg==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -12652,6 +12661,8 @@ snapshots:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsbn@1.1.0: {}
|
||||
|
||||
jsdoc-type-pratt-parser@4.1.0: {}
|
||||
|
||||
jsdom@16.7.0:
|
||||
@@ -14145,6 +14156,10 @@ snapshots:
|
||||
ansi-styles: 6.2.1
|
||||
is-fullwidth-code-point: 5.0.0
|
||||
|
||||
sm-crypto@0.3.14:
|
||||
dependencies:
|
||||
jsbn: 1.1.0
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
|
||||
@@ -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
-466
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@ export default {
|
||||
'home.inputPlaceholder': 'e.g. Customer Service Assistant, Voice Assistant, Knowledge Q&A',
|
||||
'home.createError': 'The name length must be between 1 and 64 characters',
|
||||
'home.createNow': 'Create Now',
|
||||
'home.deleteConfirmTitle': 'Delete Agent',
|
||||
'home.confirmDeleteAgent': 'Deletion cannot be undone. Type the agent name "{agentName}" to confirm.',
|
||||
'home.deleteAgentNamePlaceholder': 'Enter agent name',
|
||||
'home.deleteAgentNameMismatch': 'The agent name does not match',
|
||||
'home.deleteAgentPasteForbidden': 'Please type the agent name manually. Pasting is not allowed.',
|
||||
'home.deleteAgentMissingInfo': 'Unable to confirm agent information. Please refresh and try again.',
|
||||
'home.deleteAgentSuccess': 'Agent deleted successfully',
|
||||
'home.justNow': 'Just now',
|
||||
'home.minutesAgo': 'minutes ago',
|
||||
'home.hoursAgo': 'hours ago',
|
||||
@@ -123,6 +130,7 @@ export default {
|
||||
'agent.saving': 'Saving...',
|
||||
'agent.saveSuccess': 'Save successful',
|
||||
'agent.saveFail': 'Save failed',
|
||||
'agent.ttsOptionsLoadFailed': 'Voice options could not be loaded. The previous voice settings were kept.',
|
||||
'agent.loadFail': 'Load failed',
|
||||
'agent.pleaseInputAgentName': 'Please input agent name',
|
||||
'agent.pleaseInputRoleDescription': 'Please input role description',
|
||||
@@ -139,6 +147,92 @@ export default {
|
||||
'agent.speedHint': '-100=Slower, 0=Standard, 100=Faster',
|
||||
'agent.pitchHint': '-100=Lowest, 0=Standard, 100=Highest',
|
||||
|
||||
// Agent snapshots
|
||||
'agentSnapshot.title': 'Version History',
|
||||
'agentSnapshot.empty': 'No versions yet',
|
||||
'agentSnapshot.emptyTip': 'Saved configs will appear here',
|
||||
'agentSnapshot.version': 'Version',
|
||||
'agentSnapshot.createdAt': 'Saved At',
|
||||
'agentSnapshot.source': 'Source',
|
||||
'agentSnapshot.changedFields': 'Changes',
|
||||
'agentSnapshot.view': 'View',
|
||||
'agentSnapshot.restore': 'Restore',
|
||||
'agentSnapshot.delete': 'Delete',
|
||||
'agentSnapshot.loadMore': 'Load More',
|
||||
'agentSnapshot.detailTitle': 'Change Details',
|
||||
'agentSnapshot.restorePreviewTitle': 'Restore Preview',
|
||||
'agentSnapshot.confirmRestore': 'Confirm Restore',
|
||||
'agentSnapshot.currentVersion': 'Latest Snapshot',
|
||||
'agentSnapshot.beforeChange': 'Before',
|
||||
'agentSnapshot.afterChange': 'After',
|
||||
'agentSnapshot.beforeRestore': 'Before Restore',
|
||||
'agentSnapshot.afterRestore': 'After Restore',
|
||||
'agentSnapshot.configValue': 'Config Value',
|
||||
'agentSnapshot.emptyValue': 'None',
|
||||
'agentSnapshot.secretRedacted': 'Secret hidden',
|
||||
'agentSnapshot.redactedValueChanged': 'The value is hidden, but it did change',
|
||||
'agentSnapshot.noChangedContent': 'No displayable changes',
|
||||
'agentSnapshot.recordedChange': 'Changed when recorded',
|
||||
'agentSnapshot.noRestoreNeeded': 'The current configuration already matches this version',
|
||||
'agentSnapshot.unsavedChangesTitle': 'Unsaved changes',
|
||||
'agentSnapshot.unsavedChangesWarning': 'Continuing will discard the unsaved changes on this page.',
|
||||
'agentSnapshot.discardAndRestore': 'Discard and restore',
|
||||
'agentSnapshot.restoreConfirm': 'Restore to version #{version}? The current configuration will remain available in history.',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Restoring to a no-memory version will clear this agent\'s chat history. Please confirm the risk.',
|
||||
'agentSnapshot.restoreChatHistoryDestructiveWarning': 'This restore will permanently delete this agent\'s existing chat history. Chat history is not included in configuration snapshots and cannot be recovered from version history.',
|
||||
'agentSnapshot.restoreSuccess': 'Version restored',
|
||||
'agentSnapshot.restoreFailed': 'Failed to restore version',
|
||||
'agentSnapshot.reloadAfterRestorePending': 'Reloading the restored configuration and tags. Saving is disabled until this completes.',
|
||||
'agentSnapshot.reloadAfterRestoreFailed': 'The restored configuration or tags could not be reloaded. Saving remains disabled to prevent stale data from overwriting the restore. Please retry.',
|
||||
'agentSnapshot.retryReload': 'Reload',
|
||||
'agentSnapshot.mutationBusy': 'The configuration is being saved or reloaded. Wait for it to finish before opening version history or restoring.',
|
||||
'agentSnapshot.deleteConfirm': 'Delete version #{version}? This cannot be undone.',
|
||||
'agentSnapshot.deleteSuccess': 'Version deleted',
|
||||
'agentSnapshot.deleteFailed': 'Failed to delete version',
|
||||
'agentSnapshot.fetchFailed': 'Failed to fetch versions',
|
||||
'agentSnapshot.detailFailed': 'Failed to fetch version details',
|
||||
'agentSnapshot.correctWordCount': '{count} replacement words',
|
||||
'agentSnapshot.source.config': 'Config Save',
|
||||
'agentSnapshot.source.current': 'Current Config',
|
||||
'agentSnapshot.source.restore': 'Restored',
|
||||
'agentSnapshot.source.initial': 'Initial Version',
|
||||
'agentSnapshot.field.initial': 'Initial Snapshot',
|
||||
'agentSnapshot.field.agentCode': 'Agent Code',
|
||||
'agentSnapshot.field.agentName': 'Nickname',
|
||||
'agentSnapshot.field.asrModelId': 'Speech Recognition',
|
||||
'agentSnapshot.field.vadModelId': 'Voice Activity Detection',
|
||||
'agentSnapshot.field.llmModelId': 'Main Language Model',
|
||||
'agentSnapshot.field.slmModelId': 'Small Language Model',
|
||||
'agentSnapshot.field.vllmModelId': 'Vision Model',
|
||||
'agentSnapshot.field.ttsModelId': 'Text-to-Speech',
|
||||
'agentSnapshot.field.ttsVoiceId': 'Voice',
|
||||
'agentSnapshot.field.ttsLanguage': 'Language',
|
||||
'agentSnapshot.field.ttsVolume': 'Volume',
|
||||
'agentSnapshot.field.ttsRate': 'Speed',
|
||||
'agentSnapshot.field.ttsPitch': 'Pitch',
|
||||
'agentSnapshot.field.memModelId': 'Memory Mode',
|
||||
'agentSnapshot.field.intentModelId': 'Intent Recognition',
|
||||
'agentSnapshot.field.chatHistoryConf': 'Chat History Config',
|
||||
'agentSnapshot.field.systemPrompt': 'Role Description',
|
||||
'agentSnapshot.field.summaryMemory': 'Memory',
|
||||
'agentSnapshot.field.langCode': 'Language Code',
|
||||
'agentSnapshot.field.language': 'Interaction Language',
|
||||
'agentSnapshot.field.sort': 'Sort',
|
||||
'agentSnapshot.field.functions': 'Plugins',
|
||||
'agentSnapshot.field.contextProviders': 'Context Sources',
|
||||
'agentSnapshot.field.correctWordFileIds': 'Replacement Words',
|
||||
'agentSnapshot.field.tagNames': 'Agent Tags',
|
||||
'agentSnapshot.chatHistoryConf.none': 'Do not record chat history',
|
||||
'agentSnapshot.chatHistoryConf.text': 'Report text',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': 'Report text and voice',
|
||||
'agentSnapshot.model.Memory_nomem': 'No memory',
|
||||
'agentSnapshot.model.Memory_mem_local_short': 'Local short memory',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Mem0AI memory',
|
||||
'agentSnapshot.model.Memory_mem_report_only': 'Report only',
|
||||
'agentSnapshot.model.Intent_nointent': 'No intent recognition',
|
||||
'agentSnapshot.model.Intent_intent_llm': 'External LLM intent recognition',
|
||||
'agentSnapshot.model.Intent_function_call': 'LLM function calling',
|
||||
|
||||
// Context provider dialog related
|
||||
'contextProviderDialog.title': 'Edit Source',
|
||||
'contextProviderDialog.noContextApi': 'No Context API',
|
||||
|
||||
@@ -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',
|
||||
@@ -71,6 +71,13 @@ export default {
|
||||
'home.inputPlaceholder': 'ex: Assistente de Atendimento, Assistente de Voz, Perguntas e Respostas',
|
||||
'home.createError': 'O comprimento do nome deve estar entre 1 e 64 caracteres',
|
||||
'home.createNow': 'Criar Agora',
|
||||
'home.deleteConfirmTitle': 'Excluir Agente',
|
||||
'home.confirmDeleteAgent': 'A exclusão não pode ser desfeita. Digite o nome do agente "{agentName}" para confirmar.',
|
||||
'home.deleteAgentNamePlaceholder': 'Digite o nome do agente',
|
||||
'home.deleteAgentNameMismatch': 'O nome do agente não corresponde',
|
||||
'home.deleteAgentPasteForbidden': 'Digite o nome do agente manualmente. Colar não é permitido.',
|
||||
'home.deleteAgentMissingInfo': 'Não foi possível confirmar as informações do agente. Atualize a página e tente novamente.',
|
||||
'home.deleteAgentSuccess': 'Agente excluído com sucesso',
|
||||
'home.justNow': 'Agora mesmo',
|
||||
'home.minutesAgo': 'minutos atrás',
|
||||
'home.hoursAgo': 'horas atrás',
|
||||
@@ -123,6 +130,7 @@ export default {
|
||||
'agent.saving': 'Salvando...',
|
||||
'agent.saveSuccess': 'Salvo com sucesso',
|
||||
'agent.saveFail': 'Falha ao salvar',
|
||||
'agent.ttsOptionsLoadFailed': 'Não foi possível carregar as opções de voz. As configurações anteriores foram mantidas.',
|
||||
'agent.loadFail': 'Falha ao carregar',
|
||||
'agent.pleaseInputAgentName': 'Por favor, insira o nome do agente',
|
||||
'agent.pleaseInputRoleDescription': 'Por favor, insira a descrição do papel',
|
||||
@@ -139,6 +147,92 @@ export default {
|
||||
'agent.speedHint': '-100=Slower, 0=Standard, 100=Faster',
|
||||
'agent.pitchHint': '-100=Lowest, 0=Standard, 100=Highest',
|
||||
|
||||
// Histórico de versões do agente
|
||||
'agentSnapshot.title': 'Histórico de Versões',
|
||||
'agentSnapshot.empty': 'Ainda sem versões',
|
||||
'agentSnapshot.emptyTip': 'Configurações salvas aparecerão aqui',
|
||||
'agentSnapshot.version': 'Versão',
|
||||
'agentSnapshot.createdAt': 'Salvo em',
|
||||
'agentSnapshot.source': 'Origem',
|
||||
'agentSnapshot.changedFields': 'Alterações',
|
||||
'agentSnapshot.view': 'Ver',
|
||||
'agentSnapshot.restore': 'Restaurar',
|
||||
'agentSnapshot.delete': 'Excluir',
|
||||
'agentSnapshot.loadMore': 'Carregar Mais',
|
||||
'agentSnapshot.detailTitle': 'Detalhes da Alteração',
|
||||
'agentSnapshot.restorePreviewTitle': 'Prévia da Restauração',
|
||||
'agentSnapshot.confirmRestore': 'Confirmar Restauração',
|
||||
'agentSnapshot.currentVersion': 'Snapshot mais recente',
|
||||
'agentSnapshot.beforeChange': 'Antes',
|
||||
'agentSnapshot.afterChange': 'Depois',
|
||||
'agentSnapshot.beforeRestore': 'Antes da Restauração',
|
||||
'agentSnapshot.afterRestore': 'Depois da Restauração',
|
||||
'agentSnapshot.configValue': 'Valor da Configuração',
|
||||
'agentSnapshot.emptyValue': 'Nenhum',
|
||||
'agentSnapshot.secretRedacted': 'Segredo oculto',
|
||||
'agentSnapshot.redactedValueChanged': 'O valor está oculto, mas foi alterado',
|
||||
'agentSnapshot.noChangedContent': 'Nenhuma alteração exibível',
|
||||
'agentSnapshot.recordedChange': 'Alterado quando registrado',
|
||||
'agentSnapshot.noRestoreNeeded': 'A configuração atual já corresponde a esta versão',
|
||||
'agentSnapshot.unsavedChangesTitle': 'Alterações não salvas',
|
||||
'agentSnapshot.unsavedChangesWarning': 'Continuar descartará as alterações não salvas desta página.',
|
||||
'agentSnapshot.discardAndRestore': 'Descartar e restaurar',
|
||||
'agentSnapshot.restoreConfirm': 'Restaurar para a versão #{version}? A configuração atual continuará disponível no histórico.',
|
||||
'agentSnapshot.restoreMemoryWarning': 'Restaurar para uma versão sem memória limpará o histórico de chat deste agente. Confirme o risco.',
|
||||
'agentSnapshot.restoreChatHistoryDestructiveWarning': 'Esta restauração excluirá permanentemente o histórico de chat existente deste agente. O histórico de chat não faz parte dos snapshots de configuração e não pode ser recuperado pelo histórico de versões.',
|
||||
'agentSnapshot.restoreSuccess': 'Versão restaurada',
|
||||
'agentSnapshot.restoreFailed': 'Falha ao restaurar versão',
|
||||
'agentSnapshot.reloadAfterRestorePending': 'Recarregando a configuração e as tags restauradas. Não é possível salvar até a conclusão.',
|
||||
'agentSnapshot.reloadAfterRestoreFailed': 'Não foi possível recarregar a configuração ou as tags restauradas. O salvamento permanece desativado para impedir que dados antigos sobrescrevam a restauração. Tente novamente.',
|
||||
'agentSnapshot.retryReload': 'Recarregar',
|
||||
'agentSnapshot.mutationBusy': 'A configuração está sendo salva ou recarregada. Aguarde a conclusão antes de abrir o histórico de versões ou restaurar.',
|
||||
'agentSnapshot.deleteConfirm': 'Excluir versão #{version}? Esta ação não pode ser desfeita.',
|
||||
'agentSnapshot.deleteSuccess': 'Versão excluída',
|
||||
'agentSnapshot.deleteFailed': 'Falha ao excluir versão',
|
||||
'agentSnapshot.fetchFailed': 'Falha ao buscar versões',
|
||||
'agentSnapshot.detailFailed': 'Falha ao buscar detalhes da versão',
|
||||
'agentSnapshot.correctWordCount': '{count} palavras de substituição',
|
||||
'agentSnapshot.source.config': 'Configuração Salva',
|
||||
'agentSnapshot.source.current': 'Configuração Atual',
|
||||
'agentSnapshot.source.restore': 'Restaurado',
|
||||
'agentSnapshot.source.initial': 'Versão Inicial',
|
||||
'agentSnapshot.field.initial': 'Snapshot Inicial',
|
||||
'agentSnapshot.field.agentCode': 'Código do Agente',
|
||||
'agentSnapshot.field.agentName': 'Apelido',
|
||||
'agentSnapshot.field.asrModelId': 'Reconhecimento de Fala',
|
||||
'agentSnapshot.field.vadModelId': 'Detecção de Voz',
|
||||
'agentSnapshot.field.llmModelId': 'Modelo Principal',
|
||||
'agentSnapshot.field.slmModelId': 'Modelo Pequeno',
|
||||
'agentSnapshot.field.vllmModelId': 'Modelo Visual',
|
||||
'agentSnapshot.field.ttsModelId': 'Texto para Fala',
|
||||
'agentSnapshot.field.ttsVoiceId': 'Voz',
|
||||
'agentSnapshot.field.ttsLanguage': 'Idioma',
|
||||
'agentSnapshot.field.ttsVolume': 'Volume',
|
||||
'agentSnapshot.field.ttsRate': 'Velocidade',
|
||||
'agentSnapshot.field.ttsPitch': 'Tonalidade',
|
||||
'agentSnapshot.field.memModelId': 'Modo de Memória',
|
||||
'agentSnapshot.field.intentModelId': 'Reconhecimento de Intenção',
|
||||
'agentSnapshot.field.chatHistoryConf': 'Configuração do Histórico',
|
||||
'agentSnapshot.field.systemPrompt': 'Descrição do Papel',
|
||||
'agentSnapshot.field.summaryMemory': 'Memória',
|
||||
'agentSnapshot.field.langCode': 'Código do Idioma',
|
||||
'agentSnapshot.field.language': 'Idioma de Interação',
|
||||
'agentSnapshot.field.sort': 'Ordenação',
|
||||
'agentSnapshot.field.functions': 'Plugins',
|
||||
'agentSnapshot.field.contextProviders': 'Fontes de Contexto',
|
||||
'agentSnapshot.field.correctWordFileIds': 'Palavras de Substituição',
|
||||
'agentSnapshot.field.tagNames': 'Tags do Agente',
|
||||
'agentSnapshot.chatHistoryConf.none': 'Não registrar histórico de chat',
|
||||
'agentSnapshot.chatHistoryConf.text': 'Reportar texto',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': 'Reportar texto e voz',
|
||||
'agentSnapshot.model.Memory_nomem': 'Sem memória',
|
||||
'agentSnapshot.model.Memory_mem_local_short': 'Memória curta local',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Memória Mem0AI',
|
||||
'agentSnapshot.model.Memory_mem_report_only': 'Apenas reportar',
|
||||
'agentSnapshot.model.Intent_nointent': 'Sem reconhecimento de intenção',
|
||||
'agentSnapshot.model.Intent_intent_llm': 'Reconhecimento por LLM externo',
|
||||
'agentSnapshot.model.Intent_function_call': 'Chamada de função por LLM',
|
||||
|
||||
// Diálogo de provedor de contexto
|
||||
'contextProviderDialog.title': 'Editar Fonte',
|
||||
'contextProviderDialog.noContextApi': 'Sem API de Contexto',
|
||||
|
||||
+561
-467
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@ export default {
|
||||
'home.inputPlaceholder': '例如:客服助手、语音助理、知识问答',
|
||||
'home.createError': '名称长度必须在 1 到 64 个字符之间',
|
||||
'home.createNow': '立即创建',
|
||||
'home.deleteConfirmTitle': '删除智能体',
|
||||
'home.confirmDeleteAgent': '删除后无法恢复。请输入智能体名称“{agentName}”确认删除。',
|
||||
'home.deleteAgentNamePlaceholder': '请输入智能体名称',
|
||||
'home.deleteAgentNameMismatch': '输入的智能体名称不匹配',
|
||||
'home.deleteAgentPasteForbidden': '请手动输入智能体名称,不允许粘贴',
|
||||
'home.deleteAgentMissingInfo': '无法确认智能体信息,请刷新后重试',
|
||||
'home.deleteAgentSuccess': '智能体删除成功',
|
||||
'home.justNow': '刚刚',
|
||||
'home.minutesAgo': '分钟前',
|
||||
'home.hoursAgo': '小时前',
|
||||
@@ -123,6 +130,7 @@ export default {
|
||||
'agent.saving': '保存中...',
|
||||
'agent.saveSuccess': '保存成功',
|
||||
'agent.saveFail': '保存失败',
|
||||
'agent.ttsOptionsLoadFailed': '语音选项加载失败,已保留原来的语音配置',
|
||||
'agent.loadFail': '加载失败',
|
||||
'agent.pleaseInputAgentName': '请输入智能体名称',
|
||||
'agent.pleaseInputRoleDescription': '请输入角色介绍',
|
||||
@@ -139,6 +147,92 @@ export default {
|
||||
'agent.speedHint': '-100=最慢, 0=标准, 100=最快',
|
||||
'agent.pitchHint': '-100=最低, 0=标准, 100=最高',
|
||||
|
||||
// 智能体历史版本
|
||||
'agentSnapshot.title': '历史版本',
|
||||
'agentSnapshot.empty': '暂无历史版本',
|
||||
'agentSnapshot.emptyTip': '保存配置后会生成历史版本',
|
||||
'agentSnapshot.version': '版本',
|
||||
'agentSnapshot.createdAt': '保存时间',
|
||||
'agentSnapshot.source': '来源',
|
||||
'agentSnapshot.changedFields': '变更内容',
|
||||
'agentSnapshot.view': '查看',
|
||||
'agentSnapshot.restore': '恢复',
|
||||
'agentSnapshot.delete': '删除',
|
||||
'agentSnapshot.loadMore': '加载更多',
|
||||
'agentSnapshot.detailTitle': '变更详情',
|
||||
'agentSnapshot.restorePreviewTitle': '恢复预览',
|
||||
'agentSnapshot.confirmRestore': '确认恢复',
|
||||
'agentSnapshot.currentVersion': '最新快照',
|
||||
'agentSnapshot.beforeChange': '变化前',
|
||||
'agentSnapshot.afterChange': '变化后',
|
||||
'agentSnapshot.beforeRestore': '恢复前',
|
||||
'agentSnapshot.afterRestore': '恢复后',
|
||||
'agentSnapshot.configValue': '配置值',
|
||||
'agentSnapshot.emptyValue': '无',
|
||||
'agentSnapshot.secretRedacted': '密钥已隐藏',
|
||||
'agentSnapshot.redactedValueChanged': '值已脱敏,但确有变化',
|
||||
'agentSnapshot.noChangedContent': '无可显示变更',
|
||||
'agentSnapshot.recordedChange': '记录时发生变更',
|
||||
'agentSnapshot.noRestoreNeeded': '当前配置与目标版本相同,无需恢复',
|
||||
'agentSnapshot.unsavedChangesTitle': '存在未保存修改',
|
||||
'agentSnapshot.unsavedChangesWarning': '继续恢复会放弃当前页面中尚未保存的修改。',
|
||||
'agentSnapshot.discardAndRestore': '放弃修改并恢复',
|
||||
'agentSnapshot.restoreConfirm': '确定恢复到版本 #{version}?当前配置会保留在历史中。',
|
||||
'agentSnapshot.restoreMemoryWarning': '恢复到无记忆版本会清空该智能体聊天记录,请确认风险。',
|
||||
'agentSnapshot.restoreChatHistoryDestructiveWarning': '此恢复会永久删除该智能体现有的聊天记录。聊天记录不包含在配置快照中,删除后无法通过历史版本恢复。',
|
||||
'agentSnapshot.restoreSuccess': '版本已恢复',
|
||||
'agentSnapshot.restoreFailed': '版本恢复失败',
|
||||
'agentSnapshot.reloadAfterRestorePending': '正在重新加载恢复后的配置和标签,完成前无法保存。',
|
||||
'agentSnapshot.reloadAfterRestoreFailed': '无法重新加载恢复后的配置或标签。为防止旧数据覆盖恢复结果,保存已禁用,请重试。',
|
||||
'agentSnapshot.retryReload': '重新加载',
|
||||
'agentSnapshot.mutationBusy': '配置正在保存或重新加载,请完成后再打开历史版本或恢复。',
|
||||
'agentSnapshot.deleteConfirm': '确定删除版本 #{version}?此操作不可撤销。',
|
||||
'agentSnapshot.deleteSuccess': '历史版本已删除',
|
||||
'agentSnapshot.deleteFailed': '历史版本删除失败',
|
||||
'agentSnapshot.fetchFailed': '获取历史版本失败',
|
||||
'agentSnapshot.detailFailed': '获取版本详情失败',
|
||||
'agentSnapshot.correctWordCount': '共 {count} 个替换词',
|
||||
'agentSnapshot.source.config': '配置保存',
|
||||
'agentSnapshot.source.current': '当前配置',
|
||||
'agentSnapshot.source.restore': '恢复结果',
|
||||
'agentSnapshot.source.initial': '初始版本',
|
||||
'agentSnapshot.field.initial': '初始快照',
|
||||
'agentSnapshot.field.agentCode': '智能体编码',
|
||||
'agentSnapshot.field.agentName': '助手昵称',
|
||||
'agentSnapshot.field.asrModelId': '语音识别',
|
||||
'agentSnapshot.field.vadModelId': '语音活动检测',
|
||||
'agentSnapshot.field.llmModelId': '主语言模型',
|
||||
'agentSnapshot.field.slmModelId': '小参数模型',
|
||||
'agentSnapshot.field.vllmModelId': '视觉大模型',
|
||||
'agentSnapshot.field.ttsModelId': '语音合成',
|
||||
'agentSnapshot.field.ttsVoiceId': '声音音色',
|
||||
'agentSnapshot.field.ttsLanguage': '对话语言',
|
||||
'agentSnapshot.field.ttsVolume': '音量',
|
||||
'agentSnapshot.field.ttsRate': '语速',
|
||||
'agentSnapshot.field.ttsPitch': '音调',
|
||||
'agentSnapshot.field.memModelId': '记忆模式',
|
||||
'agentSnapshot.field.intentModelId': '意图识别',
|
||||
'agentSnapshot.field.chatHistoryConf': '聊天记录配置',
|
||||
'agentSnapshot.field.systemPrompt': '角色介绍',
|
||||
'agentSnapshot.field.summaryMemory': '记忆',
|
||||
'agentSnapshot.field.langCode': '语言编码',
|
||||
'agentSnapshot.field.language': '交互语种',
|
||||
'agentSnapshot.field.sort': '排序',
|
||||
'agentSnapshot.field.functions': '插件',
|
||||
'agentSnapshot.field.contextProviders': '上下文源',
|
||||
'agentSnapshot.field.correctWordFileIds': '替换词',
|
||||
'agentSnapshot.field.tagNames': '智能体标签',
|
||||
'agentSnapshot.chatHistoryConf.none': '不记录聊天记录',
|
||||
'agentSnapshot.chatHistoryConf.text': '上报文字',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': '上报文字+语音',
|
||||
'agentSnapshot.model.Memory_nomem': '无记忆',
|
||||
'agentSnapshot.model.Memory_mem_local_short': '本地短期记忆',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Mem0AI 记忆',
|
||||
'agentSnapshot.model.Memory_mem_report_only': '仅上报',
|
||||
'agentSnapshot.model.Intent_nointent': '无意图识别',
|
||||
'agentSnapshot.model.Intent_intent_llm': '外部大模型意图识别',
|
||||
'agentSnapshot.model.Intent_function_call': '大模型函数调用',
|
||||
|
||||
// 上下文源对话框相关
|
||||
'contextProviderDialog.title': '编辑源',
|
||||
'contextProviderDialog.noContextApi': '暂无上下文API',
|
||||
|
||||
@@ -29,9 +29,9 @@ export default {
|
||||
'login.requiredCaptcha': '驗證碼不能為空',
|
||||
'login.requiredMobile': '請輸入正確的手機號碼',
|
||||
'login.captchaError': '圖形驗證碼錯誤',
|
||||
'login.forgotPassword': '忘記密碼',
|
||||
'login.userAgreement': '用戶協議',
|
||||
'login.privacyPolicy': '隱私政策',
|
||||
'login.forgotPassword': '忘記密碼',
|
||||
'login.userAgreement': '用戶協議',
|
||||
'login.privacyPolicy': '隱私政策',
|
||||
|
||||
// 忘記密碼頁面
|
||||
'retrievePassword.title': '重置密碼',
|
||||
@@ -92,6 +92,13 @@ export default {
|
||||
'home.inputPlaceholder': '例如:客服助手、語音助理、知識問答',
|
||||
'home.createError': '暱稱長度必須在 1 到 64 個字元之間。',
|
||||
'home.createNow': '立即創建',
|
||||
'home.deleteConfirmTitle': '刪除智能體',
|
||||
'home.confirmDeleteAgent': '刪除後無法復原。請輸入智能體名稱「{agentName}」確認刪除。',
|
||||
'home.deleteAgentNamePlaceholder': '請輸入智能體名稱',
|
||||
'home.deleteAgentNameMismatch': '輸入的智能體名稱不匹配',
|
||||
'home.deleteAgentPasteForbidden': '請手動輸入智能體名稱,不允許貼上',
|
||||
'home.deleteAgentMissingInfo': '無法確認智能體資訊,請重新整理後再試',
|
||||
'home.deleteAgentSuccess': '智能體刪除成功',
|
||||
'home.justNow': '剛剛',
|
||||
'home.minutesAgo': '分鐘前',
|
||||
'home.hoursAgo': '小時前',
|
||||
@@ -144,6 +151,7 @@ export default {
|
||||
'agent.saving': '儲存中...',
|
||||
'agent.saveSuccess': '儲存成功',
|
||||
'agent.saveFail': '儲存失敗',
|
||||
'agent.ttsOptionsLoadFailed': '語音選項載入失敗,已保留原來的語音設定',
|
||||
'agent.loadFail': '加載失敗',
|
||||
'agent.pleaseInputAgentName': '請輸入助手暱稱',
|
||||
'agent.pleaseInputRoleDescription': '請輸入角色介紹',
|
||||
@@ -160,6 +168,92 @@ export default {
|
||||
'agent.speedHint': '-100=最慢, 0=標準, 100=最快',
|
||||
'agent.pitchHint': '-100=最低, 0=標準, 100=最高',
|
||||
|
||||
// 智能體歷史版本
|
||||
'agentSnapshot.title': '歷史版本',
|
||||
'agentSnapshot.empty': '暫無歷史版本',
|
||||
'agentSnapshot.emptyTip': '保存配置後會生成歷史版本',
|
||||
'agentSnapshot.version': '版本',
|
||||
'agentSnapshot.createdAt': '保存時間',
|
||||
'agentSnapshot.source': '來源',
|
||||
'agentSnapshot.changedFields': '變更內容',
|
||||
'agentSnapshot.view': '查看',
|
||||
'agentSnapshot.restore': '恢復',
|
||||
'agentSnapshot.delete': '刪除',
|
||||
'agentSnapshot.loadMore': '載入更多',
|
||||
'agentSnapshot.detailTitle': '變更詳情',
|
||||
'agentSnapshot.restorePreviewTitle': '恢復預覽',
|
||||
'agentSnapshot.confirmRestore': '確認恢復',
|
||||
'agentSnapshot.currentVersion': '最新快照',
|
||||
'agentSnapshot.beforeChange': '變化前',
|
||||
'agentSnapshot.afterChange': '變化後',
|
||||
'agentSnapshot.beforeRestore': '恢復前',
|
||||
'agentSnapshot.afterRestore': '恢復後',
|
||||
'agentSnapshot.configValue': '配置值',
|
||||
'agentSnapshot.emptyValue': '無',
|
||||
'agentSnapshot.secretRedacted': '密鑰已隱藏',
|
||||
'agentSnapshot.redactedValueChanged': '值已脫敏,但確有變化',
|
||||
'agentSnapshot.noChangedContent': '無可顯示變更',
|
||||
'agentSnapshot.recordedChange': '記錄時發生變更',
|
||||
'agentSnapshot.noRestoreNeeded': '目前設定與目標版本相同,無需恢復',
|
||||
'agentSnapshot.unsavedChangesTitle': '存在未儲存修改',
|
||||
'agentSnapshot.unsavedChangesWarning': '繼續恢復會放棄目前頁面中尚未儲存的修改。',
|
||||
'agentSnapshot.discardAndRestore': '放棄修改並恢復',
|
||||
'agentSnapshot.restoreConfirm': '確定恢復到版本 #{version}?目前設定會保留在歷史中。',
|
||||
'agentSnapshot.restoreMemoryWarning': '恢復到無記憶版本會清空該智能體聊天記錄,請確認風險。',
|
||||
'agentSnapshot.restoreChatHistoryDestructiveWarning': '此恢復會永久刪除該智能體現有的聊天記錄。聊天記錄不包含在配置快照中,刪除後無法透過歷史版本恢復。',
|
||||
'agentSnapshot.restoreSuccess': '版本已恢復',
|
||||
'agentSnapshot.restoreFailed': '版本恢復失敗',
|
||||
'agentSnapshot.reloadAfterRestorePending': '正在重新載入恢復後的配置和標籤,完成前無法儲存。',
|
||||
'agentSnapshot.reloadAfterRestoreFailed': '無法重新載入恢復後的配置或標籤。為防止舊資料覆蓋恢復結果,儲存已停用,請重試。',
|
||||
'agentSnapshot.retryReload': '重新載入',
|
||||
'agentSnapshot.mutationBusy': '配置正在儲存或重新載入,請完成後再開啟歷史版本或恢復。',
|
||||
'agentSnapshot.deleteConfirm': '確定刪除版本 #{version}?此操作不可復原。',
|
||||
'agentSnapshot.deleteSuccess': '歷史版本已刪除',
|
||||
'agentSnapshot.deleteFailed': '歷史版本刪除失敗',
|
||||
'agentSnapshot.fetchFailed': '獲取歷史版本失敗',
|
||||
'agentSnapshot.detailFailed': '獲取版本詳情失敗',
|
||||
'agentSnapshot.correctWordCount': '共 {count} 個替換詞',
|
||||
'agentSnapshot.source.config': '配置保存',
|
||||
'agentSnapshot.source.current': '當前配置',
|
||||
'agentSnapshot.source.restore': '恢復結果',
|
||||
'agentSnapshot.source.initial': '初始版本',
|
||||
'agentSnapshot.field.initial': '初始快照',
|
||||
'agentSnapshot.field.agentCode': '智能體編碼',
|
||||
'agentSnapshot.field.agentName': '助手暱稱',
|
||||
'agentSnapshot.field.asrModelId': '語音識別',
|
||||
'agentSnapshot.field.vadModelId': '語音活動檢測',
|
||||
'agentSnapshot.field.llmModelId': '主語言模型',
|
||||
'agentSnapshot.field.slmModelId': '小參數模型',
|
||||
'agentSnapshot.field.vllmModelId': '視覺大模型',
|
||||
'agentSnapshot.field.ttsModelId': '語音合成',
|
||||
'agentSnapshot.field.ttsVoiceId': '聲音音色',
|
||||
'agentSnapshot.field.ttsLanguage': '對話語言',
|
||||
'agentSnapshot.field.ttsVolume': '音量',
|
||||
'agentSnapshot.field.ttsRate': '語速',
|
||||
'agentSnapshot.field.ttsPitch': '音調',
|
||||
'agentSnapshot.field.memModelId': '記憶模式',
|
||||
'agentSnapshot.field.intentModelId': '意圖識別',
|
||||
'agentSnapshot.field.chatHistoryConf': '聊天記錄配置',
|
||||
'agentSnapshot.field.systemPrompt': '角色介紹',
|
||||
'agentSnapshot.field.summaryMemory': '記憶',
|
||||
'agentSnapshot.field.langCode': '語言編碼',
|
||||
'agentSnapshot.field.language': '交互語種',
|
||||
'agentSnapshot.field.sort': '排序',
|
||||
'agentSnapshot.field.functions': '插件',
|
||||
'agentSnapshot.field.contextProviders': '上下文源',
|
||||
'agentSnapshot.field.correctWordFileIds': '替換詞',
|
||||
'agentSnapshot.field.tagNames': '智能體標籤',
|
||||
'agentSnapshot.chatHistoryConf.none': '不記錄聊天記錄',
|
||||
'agentSnapshot.chatHistoryConf.text': '上報文字',
|
||||
'agentSnapshot.chatHistoryConf.textVoice': '上報文字+語音',
|
||||
'agentSnapshot.model.Memory_nomem': '無記憶',
|
||||
'agentSnapshot.model.Memory_mem_local_short': '本地短期記憶',
|
||||
'agentSnapshot.model.Memory_mem0ai': 'Mem0AI 記憶',
|
||||
'agentSnapshot.model.Memory_mem_report_only': '僅上報',
|
||||
'agentSnapshot.model.Intent_nointent': '無意圖識別',
|
||||
'agentSnapshot.model.Intent_intent_llm': '外部大模型意圖識別',
|
||||
'agentSnapshot.model.Intent_function_call': '大模型函數調用',
|
||||
|
||||
// 上下文源对话框相关
|
||||
'contextProviderDialog.title': '編輯源',
|
||||
'contextProviderDialog.noContextApi': '暫無上下文API',
|
||||
|
||||
@@ -1,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 = [
|
||||
{
|
||||
@@ -169,6 +276,28 @@ function handleInputConfirm() {
|
||||
// 是否禁用历史记忆输入框
|
||||
const isMemoryDisabled = computed(() => formData.value.memModelId !== 'Memory_mem_local_short')
|
||||
|
||||
function normalizeFunctionParams(paramInfo: any) {
|
||||
if (!paramInfo)
|
||||
return {}
|
||||
if (typeof paramInfo === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(paramInfo)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
return typeof paramInfo === 'object' && !Array.isArray(paramInfo) ? { ...paramInfo } : {}
|
||||
}
|
||||
|
||||
function normalizeAgentFunctions(functions: any[] = []) {
|
||||
return functions.map(item => ({
|
||||
...item,
|
||||
paramInfo: normalizeFunctionParams(item.paramInfo),
|
||||
}))
|
||||
}
|
||||
|
||||
// 打开上下文源编辑弹窗
|
||||
function openContextProviderDialog() {
|
||||
uni.navigateTo({
|
||||
@@ -183,48 +312,97 @@ function handleRegulate() {
|
||||
}
|
||||
|
||||
// 加载智能体详情
|
||||
async function loadAgentDetail() {
|
||||
if (!agentId.value)
|
||||
return
|
||||
async function loadAgentDetail(targetAgentId = agentId.value) {
|
||||
if (!targetAgentId)
|
||||
return false
|
||||
|
||||
const requestId = ++agentDetailRequestSequence
|
||||
invalidateTtsMetadataRequest()
|
||||
try {
|
||||
loading.value = true
|
||||
tempSummaryMemory.value = ''
|
||||
const detail = await getAgentDetail(agentId.value)
|
||||
formData.value = { ...detail }
|
||||
|
||||
// 更新插件store
|
||||
pluginStore.setCurrentAgentId(agentId.value)
|
||||
pluginStore.setCurrentFunctions(detail.functions || [])
|
||||
|
||||
// 更新语速音调
|
||||
speedPitchStore.updateSpeedPitch({
|
||||
ttsVolume: detail.ttsVolume || 0,
|
||||
ttsRate: detail.ttsRate || 0,
|
||||
ttsPitch: detail.ttsPitch || 0,
|
||||
})
|
||||
|
||||
// 加载上下文配置
|
||||
providerStore.updateProviders(detail.contextProviders || [])
|
||||
|
||||
// 如果有TTS模型,加载对应的音色选项
|
||||
if (detail.ttsModelId) {
|
||||
await fetchAllLanguag(detail.ttsModelId)
|
||||
const detail = await getAgentDetail(targetAgentId)
|
||||
if (!isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 等待模型选项加载完成后再更新显示名称
|
||||
await nextTick()
|
||||
updateDisplayNames()
|
||||
applyPersistedAgentDetail(detail, targetAgentId)
|
||||
await enhanceAgentDetailMetadata(detail, targetAgentId, requestId)
|
||||
return isActiveAgentDetailRequest(targetAgentId, requestId)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载智能体详情失败:', error)
|
||||
toast.error(t('agent.loadFail'))
|
||||
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
console.error('加载智能体详情失败:', error)
|
||||
toast.error(t('agent.loadFail'))
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyPersistedAgentDetail(detail: AgentDetail, targetAgentId: string) {
|
||||
const normalizedFunctions = normalizeAgentFunctions(detail.functions || [])
|
||||
tempSummaryMemory.value = ''
|
||||
ttsLanguageTouched.value = false
|
||||
ttsVoiceTouched.value = false
|
||||
ttsOptionsModelId.value = ''
|
||||
voiceOptions.value = []
|
||||
voiceDetails.value = {}
|
||||
languageOptions.value = []
|
||||
formData.value = { ...detail, functions: normalizedFunctions }
|
||||
originalAgentDetail.value = cloneSerializable({ ...detail, functions: normalizedFunctions })
|
||||
currentVersionNo.value = detail.currentVersionNo || null
|
||||
selectedTtsLanguage.value = detail.ttsLanguage || ''
|
||||
|
||||
pluginStore.setCurrentAgentId(targetAgentId)
|
||||
pluginStore.setCurrentFunctions(normalizedFunctions)
|
||||
speedPitchStore.updateSpeedPitch({
|
||||
ttsVolume: detail.ttsVolume ?? 0,
|
||||
ttsRate: detail.ttsRate ?? 0,
|
||||
ttsPitch: detail.ttsPitch ?? 0,
|
||||
})
|
||||
speedPitchStore.resetChangedFields()
|
||||
providerStore.updateProviders(detail.contextProviders || [])
|
||||
}
|
||||
|
||||
async function enhanceAgentDetailMetadata(detail: AgentDetail, targetAgentId: string, requestId: number) {
|
||||
try {
|
||||
if (detail.ttsModelId) {
|
||||
await fetchAllLanguag(detail.ttsModelId, {
|
||||
preferredLanguage: detail.ttsLanguage,
|
||||
preferredVoiceId: detail.ttsVoiceId,
|
||||
})
|
||||
}
|
||||
else {
|
||||
voiceOptions.value = []
|
||||
voiceDetails.value = {}
|
||||
languageOptions.value = []
|
||||
selectedTtsLanguage.value = ''
|
||||
}
|
||||
await nextTick()
|
||||
if (isActiveAgentDetailRequest(targetAgentId, requestId)) {
|
||||
updateDisplayNames()
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// Persisted agent detail has already loaded successfully. Voice metadata is
|
||||
// display enhancement only and must not keep the post-restore save barrier.
|
||||
console.warn('Failed to enhance agent detail metadata:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveAgentDetailRequest(targetAgentId: string, requestId: number) {
|
||||
return targetAgentId === agentId.value && requestId === agentDetailRequestSequence
|
||||
}
|
||||
|
||||
function invalidateTtsMetadataRequest() {
|
||||
ttsOptionsRequestSequence += 1
|
||||
ttsOptionsLoading.value = false
|
||||
ttsOptionsModelId.value = ''
|
||||
}
|
||||
|
||||
// 获取音色显示名称
|
||||
function getVoiceDisplayName(ttsVoiceId: string) {
|
||||
if (!ttsVoiceId)
|
||||
@@ -321,7 +499,67 @@ async function loadModelOptions() {
|
||||
}
|
||||
|
||||
// 根据语言筛选音色
|
||||
function filterVoicesByLanguage() {
|
||||
interface VoiceSelectionOptions {
|
||||
autoSelectVoice?: boolean
|
||||
preferredLanguage?: string | null
|
||||
preferredVoiceId?: string | null
|
||||
}
|
||||
|
||||
interface TtsSelectionState {
|
||||
modelId: string
|
||||
voiceId: string
|
||||
language: string
|
||||
selectedLanguage: string
|
||||
languageTouched: boolean
|
||||
voiceTouched: boolean
|
||||
optionsModelId: string
|
||||
voiceOptions: any[]
|
||||
voiceDetails: Record<string, any>
|
||||
languageOptions: any[]
|
||||
displayNames: {
|
||||
tts: string
|
||||
voiceprint: string
|
||||
language: string
|
||||
}
|
||||
}
|
||||
|
||||
function captureTtsSelectionState(): TtsSelectionState {
|
||||
return {
|
||||
modelId: formData.value.ttsModelId || '',
|
||||
voiceId: formData.value.ttsVoiceId || '',
|
||||
language: formData.value.ttsLanguage || '',
|
||||
selectedLanguage: selectedTtsLanguage.value,
|
||||
languageTouched: ttsLanguageTouched.value,
|
||||
voiceTouched: ttsVoiceTouched.value,
|
||||
optionsModelId: ttsOptionsModelId.value,
|
||||
voiceOptions: voiceOptions.value,
|
||||
voiceDetails: voiceDetails.value,
|
||||
languageOptions: languageOptions.value,
|
||||
displayNames: {
|
||||
tts: displayNames.value.tts,
|
||||
voiceprint: displayNames.value.voiceprint,
|
||||
language: displayNames.value.language,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function restoreTtsSelectionState(state: TtsSelectionState) {
|
||||
formData.value.ttsModelId = state.modelId
|
||||
formData.value.ttsVoiceId = state.voiceId
|
||||
formData.value.ttsLanguage = state.language
|
||||
selectedTtsLanguage.value = state.selectedLanguage
|
||||
ttsLanguageTouched.value = state.languageTouched
|
||||
ttsVoiceTouched.value = state.voiceTouched
|
||||
ttsOptionsModelId.value = state.optionsModelId
|
||||
voiceOptions.value = state.voiceOptions
|
||||
voiceDetails.value = state.voiceDetails
|
||||
languageOptions.value = state.languageOptions
|
||||
displayNames.value.tts = state.displayNames.tts
|
||||
displayNames.value.voiceprint = state.displayNames.voiceprint
|
||||
displayNames.value.language = state.displayNames.language
|
||||
}
|
||||
|
||||
function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) {
|
||||
if (!voiceDetails.value || Object.keys(voiceDetails.value).length === 0) {
|
||||
voiceOptions.value = []
|
||||
return
|
||||
@@ -330,13 +568,7 @@ function filterVoicesByLanguage() {
|
||||
const allVoices = Object.values(voiceDetails.value) as any[]
|
||||
|
||||
// 根据选中的语言筛选音色
|
||||
const filteredVoices = allVoices.filter((voice) => {
|
||||
if (!voice.languages) {
|
||||
return false
|
||||
}
|
||||
const languagesArray = voice.languages.split(/[、;;,,]/).map(lang => lang.trim()).filter(lang => lang)
|
||||
return languagesArray.includes(formData.value.language)
|
||||
})
|
||||
const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
|
||||
|
||||
voiceOptions.value = filteredVoices.map(voice => ({
|
||||
value: voice.id,
|
||||
@@ -351,26 +583,46 @@ function filterVoicesByLanguage() {
|
||||
const currentVoiceSupportsLanguage = formData.value.ttsVoiceId
|
||||
&& filteredVoices.some(voice => voice.id === formData.value.ttsVoiceId)
|
||||
|
||||
if (!currentVoiceSupportsLanguage) {
|
||||
if (!currentVoiceSupportsLanguage && options.autoSelectVoice) {
|
||||
formData.value.ttsVoiceId = filteredVoices.length > 0 ? filteredVoices[0].id : ''
|
||||
displayNames.value.voiceprint = filteredVoices.length > 0 ? filteredVoices[0].name : ''
|
||||
ttsVoiceTouched.value = true
|
||||
}
|
||||
else {
|
||||
displayNames.value.voiceprint = filteredVoices.find(item => item.id === formData.value.ttsVoiceId)?.name
|
||||
|| getVoiceDisplayName(formData.value.ttsVoiceId)
|
||||
}
|
||||
}
|
||||
|
||||
// 同步到ttsSettings(如果值为null,使用0作为显示默认值,但不修改form中的值)
|
||||
speedPitchStore.updateSpeedPitch({
|
||||
ttsVolume: formData.value.ttsVolume !== null && formData.value.ttsVolume !== undefined ? formData.value.ttsVolume : 0,
|
||||
ttsRate: formData.value.ttsRate !== null && formData.value.ttsRate !== undefined ? formData.value.ttsRate : 0,
|
||||
ttsPitch: formData.value.ttsPitch !== null && formData.value.ttsPitch !== undefined ? formData.value.ttsPitch : 0,
|
||||
})
|
||||
function getVoiceDefaultLanguage(ttsVoiceId: string) {
|
||||
if (!ttsVoiceId || !voiceDetails.value?.[ttsVoiceId]?.languages) {
|
||||
return ''
|
||||
}
|
||||
const languages = voiceDetails.value[ttsVoiceId].languages
|
||||
.split(/[、;;,,]/)
|
||||
.map(lang => lang.trim())
|
||||
.filter(Boolean)
|
||||
return languages[0] || ''
|
||||
}
|
||||
|
||||
// 根据语音合成模型加载语言
|
||||
async function fetchAllLanguag(ttsModelId: string) {
|
||||
async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOptions = {}): Promise<'loaded' | 'failed' | 'stale'> {
|
||||
const requestId = ++ttsOptionsRequestSequence
|
||||
ttsOptionsLoading.value = true
|
||||
try {
|
||||
const res = await getAllLanguage(ttsModelId)
|
||||
if (requestId !== ttsOptionsRequestSequence) {
|
||||
return 'stale'
|
||||
}
|
||||
if (!Array.isArray(res)) {
|
||||
throw new TypeError('Invalid TTS voice metadata')
|
||||
}
|
||||
// An empty response cannot prove that the newly selected model accepts an
|
||||
// empty voice. Until the API exposes an explicit "voice optional"
|
||||
// capability, keep the previous tuple instead of persisting a guess.
|
||||
if (!hasUsableTtsVoiceMetadata(res)) {
|
||||
throw new Error('No TTS voice metadata is available')
|
||||
}
|
||||
// 保存完整的音色信息
|
||||
voiceDetails.value = res.reduce((acc, voice) => {
|
||||
acc[voice.id] = voice
|
||||
@@ -389,21 +641,52 @@ async function fetchAllLanguag(ttsModelId: string) {
|
||||
name: lang,
|
||||
}))
|
||||
|
||||
// 使用后端返回的用户选择的语言,如果没有则使用第一个语言选项
|
||||
if (formData.value.ttsLanguage && languageOptions.value.some(option => option.value === formData.value.ttsLanguage)) {
|
||||
formData.value.language = formData.value.ttsLanguage
|
||||
const requestedLanguage = options.preferredLanguage
|
||||
const preferredVoiceLanguage = options.preferredVoiceId
|
||||
? getVoiceDefaultLanguage(options.preferredVoiceId)
|
||||
: ''
|
||||
// Do not carry a language from the previous model into a provider which
|
||||
// exposes no language dimension.
|
||||
selectedTtsLanguage.value = ''
|
||||
displayNames.value.language = ''
|
||||
// 优先使用调用方指定的语言或音色默认语言,再回退到智能体当前配置
|
||||
if (requestedLanguage && languageOptions.value.some(option => option.value === requestedLanguage)) {
|
||||
selectedTtsLanguage.value = requestedLanguage
|
||||
displayNames.value.language = requestedLanguage
|
||||
}
|
||||
else if (preferredVoiceLanguage && languageOptions.value.some(option => option.value === preferredVoiceLanguage)) {
|
||||
selectedTtsLanguage.value = preferredVoiceLanguage
|
||||
displayNames.value.language = preferredVoiceLanguage
|
||||
}
|
||||
else if (formData.value.ttsLanguage && languageOptions.value.some(option => option.value === formData.value.ttsLanguage)) {
|
||||
selectedTtsLanguage.value = formData.value.ttsLanguage
|
||||
displayNames.value.language = formData.value.ttsLanguage
|
||||
}
|
||||
else if (getVoiceDefaultLanguage(formData.value.ttsVoiceId)) {
|
||||
selectedTtsLanguage.value = getVoiceDefaultLanguage(formData.value.ttsVoiceId)
|
||||
displayNames.value.language = selectedTtsLanguage.value
|
||||
}
|
||||
else if (languageOptions.value.length > 0) {
|
||||
formData.value.language = languageOptions.value[0].value
|
||||
selectedTtsLanguage.value = languageOptions.value[0].value
|
||||
displayNames.value.language = languageOptions.value[0].value
|
||||
}
|
||||
|
||||
// 根据选中的语言筛选音色
|
||||
filterVoicesByLanguage()
|
||||
filterVoicesByLanguage(options)
|
||||
ttsOptionsModelId.value = ttsModelId
|
||||
return 'loaded'
|
||||
}
|
||||
catch {
|
||||
languageOptions.value = []
|
||||
catch (error) {
|
||||
if (requestId === ttsOptionsRequestSequence) {
|
||||
console.error('Failed to load TTS options:', error)
|
||||
ttsOptionsModelId.value = ''
|
||||
}
|
||||
return requestId === ttsOptionsRequestSequence ? 'failed' : 'stale'
|
||||
}
|
||||
finally {
|
||||
if (requestId === ttsOptionsRequestSequence) {
|
||||
ttsOptionsLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +708,10 @@ async function fetchAllLanguag(ttsModelId: string) {
|
||||
// }
|
||||
|
||||
// 选择角色模板
|
||||
function selectRoleTemplate(templateId: string) {
|
||||
async function selectRoleTemplate(templateId: string) {
|
||||
if (ttsOptionsLoading.value) {
|
||||
return
|
||||
}
|
||||
if (selectedTemplateId.value === templateId) {
|
||||
selectedTemplateId.value = ''
|
||||
return
|
||||
@@ -434,6 +720,9 @@ function selectRoleTemplate(templateId: string) {
|
||||
selectedTemplateId.value = templateId
|
||||
const template = roleTemplates.value.find(t => t.id === templateId)
|
||||
if (template) {
|
||||
const previousTtsState = captureTtsSelectionState()
|
||||
const templateTtsLanguage = template.ttsLanguage?.trim() || ''
|
||||
const hasTemplateTtsLanguage = Boolean(templateTtsLanguage)
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
systemPrompt: template.systemPrompt || formData.value.systemPrompt,
|
||||
@@ -446,18 +735,40 @@ function selectRoleTemplate(templateId: string) {
|
||||
memModelId: template.memModelId || formData.value.memModelId,
|
||||
ttsModelId: template.ttsModelId || formData.value.ttsModelId,
|
||||
ttsVoiceId: template.ttsVoiceId || formData.value.ttsVoiceId,
|
||||
ttsLanguage: hasTemplateTtsLanguage ? templateTtsLanguage : formData.value.ttsLanguage,
|
||||
agentName: template.agentName || formData.value.agentName,
|
||||
chatHistoryConf: template.chatHistoryConf || formData.value.chatHistoryConf,
|
||||
summaryMemory: template.summaryMemory || formData.value.summaryMemory,
|
||||
langCode: template.langCode || formData.value.langCode,
|
||||
}
|
||||
fetchAllLanguag(template.ttsModelId || formData.value.ttsModelId)
|
||||
if (hasTemplateTtsLanguage) {
|
||||
selectedTtsLanguage.value = templateTtsLanguage
|
||||
displayNames.value.language = templateTtsLanguage
|
||||
}
|
||||
if (template.ttsModelId || template.ttsVoiceId || hasTemplateTtsLanguage) {
|
||||
const result = await fetchAllLanguag(template.ttsModelId || formData.value.ttsModelId, {
|
||||
autoSelectVoice: true,
|
||||
preferredLanguage: hasTemplateTtsLanguage ? templateTtsLanguage : '',
|
||||
preferredVoiceId: template.ttsVoiceId,
|
||||
})
|
||||
if (result === 'failed') {
|
||||
restoreTtsSelectionState(previousTtsState)
|
||||
toast.warning(t('agent.ttsOptionsLoadFailed'))
|
||||
}
|
||||
else if (result === 'loaded') {
|
||||
ttsLanguageTouched.value = true
|
||||
ttsVoiceTouched.value = true
|
||||
}
|
||||
}
|
||||
updateDisplayNames()
|
||||
}
|
||||
}
|
||||
|
||||
// 打开选择器
|
||||
function openPicker(type: string) {
|
||||
if (ttsOptionsLoading.value && (type === 'tts' || type === 'language' || type === 'voiceprint')) {
|
||||
return
|
||||
}
|
||||
pickerShow.value[type] = true
|
||||
}
|
||||
|
||||
@@ -465,6 +776,7 @@ function openPicker(type: string) {
|
||||
async function onPickerConfirm(type: string, value: any, name: string) {
|
||||
console.log('选择器确认:', type, value, name)
|
||||
|
||||
const previousTtsState = type === 'tts' ? captureTtsSelectionState() : null
|
||||
// 保存显示名称
|
||||
displayNames.value[type] = name
|
||||
|
||||
@@ -503,16 +815,34 @@ async function onPickerConfirm(type: string, value: any, name: string) {
|
||||
tempSummaryMemory.value = ''
|
||||
}
|
||||
break
|
||||
case 'tts':
|
||||
case 'tts': {
|
||||
const preferredLanguage = selectedTtsLanguage.value
|
||||
formData.value.ttsModelId = value
|
||||
await fetchAllLanguag(value)
|
||||
formData.value.ttsVoiceId = ''
|
||||
const result = await fetchAllLanguag(value, { autoSelectVoice: true, preferredLanguage })
|
||||
if (result === 'failed' && previousTtsState) {
|
||||
restoreTtsSelectionState(previousTtsState)
|
||||
toast.warning(t('agent.ttsOptionsLoadFailed'))
|
||||
}
|
||||
else if (result === 'loaded') {
|
||||
ttsLanguageTouched.value = true
|
||||
ttsVoiceTouched.value = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'language':
|
||||
formData.value.language = value
|
||||
filterVoicesByLanguage()
|
||||
selectedTtsLanguage.value = value
|
||||
formData.value.ttsLanguage = value
|
||||
ttsLanguageTouched.value = true
|
||||
filterVoicesByLanguage({ autoSelectVoice: true })
|
||||
break
|
||||
case 'voiceprint':
|
||||
formData.value.ttsVoiceId = value
|
||||
ttsVoiceTouched.value = true
|
||||
if (selectedTtsLanguage.value) {
|
||||
formData.value.ttsLanguage = selectedTtsLanguage.value
|
||||
ttsLanguageTouched.value = true
|
||||
}
|
||||
displayNames.value.voiceprint = name // 确保显示名称正确更新
|
||||
break
|
||||
case 'report':
|
||||
@@ -600,6 +930,28 @@ function getModelDisplayName(modelType: string, modelId: string) {
|
||||
|
||||
// 保存智能体
|
||||
async function saveAgent() {
|
||||
if (saving.value) {
|
||||
return
|
||||
}
|
||||
if (snapshotReloadBlocked.value) {
|
||||
toast.error(t(snapshotReloadFailed.value
|
||||
? 'agentSnapshot.reloadAfterRestoreFailed'
|
||||
: 'agentSnapshot.reloadAfterRestorePending'))
|
||||
return
|
||||
}
|
||||
if (ttsOptionsLoading.value) {
|
||||
return
|
||||
}
|
||||
const ttsSelectionTouched = ttsLanguageTouched.value || ttsVoiceTouched.value
|
||||
const hasLanguageOptions = languageOptions.value.length > 0
|
||||
const hasVoiceOptions = Object.keys(voiceDetails.value).length > 0
|
||||
if (ttsSelectionTouched
|
||||
&& (ttsOptionsModelId.value !== formData.value.ttsModelId
|
||||
|| (hasLanguageOptions && !selectedTtsLanguage.value)
|
||||
|| (hasVoiceOptions && !formData.value.ttsVoiceId))) {
|
||||
toast.warning(t('agent.ttsOptionsLoadFailed'))
|
||||
return
|
||||
}
|
||||
if (!formData.value.agentName?.trim()) {
|
||||
toast.warning(t('agent.pleaseInputAgentName'))
|
||||
return
|
||||
@@ -610,25 +962,46 @@ async function saveAgent() {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await handleUpdateAgentTags()
|
||||
}
|
||||
catch (err) {
|
||||
toast.error(err)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
const tagNames = dynamicTags.value.map(tag => tag.tagName)
|
||||
const tagsChanged = !isSameStringList(tagNames, originalTagNames.value)
|
||||
// 构建保存数据,包含上下文配置和语音设置
|
||||
const saveData = {
|
||||
const saveData: Record<string, any> = {
|
||||
...formData.value,
|
||||
...speedPitchStore.speedPitch,
|
||||
ttsLanguage: formData.value.language,
|
||||
contextProviders: providerStore.providers,
|
||||
functions: normalizeAgentFunctions(formData.value.functions || []),
|
||||
}
|
||||
delete saveData.ttsVolume
|
||||
delete saveData.ttsRate
|
||||
delete saveData.ttsPitch
|
||||
delete saveData.ttsLanguage
|
||||
delete saveData.ttsVoiceId
|
||||
if (ttsLanguageTouched.value) {
|
||||
saveData.ttsLanguage = selectedTtsLanguage.value
|
||||
}
|
||||
if (ttsVoiceTouched.value) {
|
||||
saveData.ttsVoiceId = formData.value.ttsVoiceId
|
||||
}
|
||||
const changedTtsFields = new Set(speedPitchStore.changedFields)
|
||||
if (changedTtsFields.has('ttsVolume')) {
|
||||
saveData.ttsVolume = speedPitchStore.speedPitch.ttsVolume
|
||||
}
|
||||
if (changedTtsFields.has('ttsRate')) {
|
||||
saveData.ttsRate = speedPitchStore.speedPitch.ttsRate
|
||||
}
|
||||
if (changedTtsFields.has('ttsPitch')) {
|
||||
saveData.ttsPitch = speedPitchStore.speedPitch.ttsPitch
|
||||
}
|
||||
if (tagsChanged) {
|
||||
saveData.tagNames = tagNames
|
||||
}
|
||||
await updateAgent(agentId.value, saveData)
|
||||
loadAgentDetail()
|
||||
if (tagsChanged) {
|
||||
originalTagNames.value = [...tagNames]
|
||||
}
|
||||
speedPitchStore.resetChangedFields()
|
||||
await loadAgentDetail()
|
||||
|
||||
toast.success(t('agent.saveSuccess'))
|
||||
}
|
||||
@@ -663,7 +1036,7 @@ function handleTools() {
|
||||
|
||||
// 确保store中有最新数据
|
||||
pluginStore.setCurrentAgentId(agentId.value)
|
||||
pluginStore.setCurrentFunctions(formData.value.functions || [])
|
||||
pluginStore.setCurrentFunctions(normalizeAgentFunctions(formData.value.functions || []))
|
||||
pluginStore.setAllFunctions(allFunctions.value)
|
||||
|
||||
uni.navigateTo({
|
||||
@@ -672,25 +1045,148 @@ function handleTools() {
|
||||
}
|
||||
|
||||
// 获取智能体标签
|
||||
async function loadAgentTags() {
|
||||
async function loadAgentTags(targetAgentId = agentId.value) {
|
||||
if (!targetAgentId) {
|
||||
return false
|
||||
}
|
||||
const requestId = ++agentTagRequestSequence
|
||||
try {
|
||||
const res = await getAgentTags(agentId.value)
|
||||
const res = await getAgentTags(targetAgentId)
|
||||
if (!isActiveAgentTagRequest(targetAgentId, requestId)) {
|
||||
return false
|
||||
}
|
||||
dynamicTags.value = res || []
|
||||
originalTagNames.value = dynamicTags.value.map(tag => tag.tagName)
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
if (isActiveAgentTagRequest(targetAgentId, requestId)) {
|
||||
console.error('加载智能体标签失败:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
catch (error) {}
|
||||
}
|
||||
|
||||
// 更新智能体标签
|
||||
async function handleUpdateAgentTags() {
|
||||
const tagNames = dynamicTags.value.map(tag => tag.tagName)
|
||||
await updateAgentTags(agentId.value, { tagNames })
|
||||
function isActiveAgentTagRequest(targetAgentId: string, requestId: number) {
|
||||
return targetAgentId === agentId.value && requestId === agentTagRequestSequence
|
||||
}
|
||||
|
||||
async function handleSnapshotRestored(context: SnapshotRestoreContext) {
|
||||
if (!context || context.agentId !== agentId.value) {
|
||||
return
|
||||
}
|
||||
await reloadAgentAfterSnapshotRestore(context.agentId)
|
||||
}
|
||||
|
||||
async function reloadAgentAfterSnapshotRestore(targetAgentId = agentId.value) {
|
||||
if (!targetAgentId || targetAgentId !== agentId.value) {
|
||||
return false
|
||||
}
|
||||
const reloadId = ++snapshotReloadSequence
|
||||
const detailRequestId = ++agentDetailRequestSequence
|
||||
const tagRequestId = ++agentTagRequestSequence
|
||||
snapshotReloadBlocked.value = true
|
||||
snapshotReloadFailed.value = false
|
||||
loading.value = true
|
||||
invalidateTtsMetadataRequest()
|
||||
|
||||
try {
|
||||
// Apply detail and tags only after both persisted reads succeed. If either
|
||||
// fails, the old form may remain visible but cannot be saved until retry.
|
||||
const [detail, tags] = await Promise.all([
|
||||
getAgentDetail(targetAgentId),
|
||||
getAgentTags(targetAgentId),
|
||||
])
|
||||
if (!isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
applyPersistedAgentDetail(detail, targetAgentId)
|
||||
dynamicTags.value = tags || []
|
||||
originalTagNames.value = dynamicTags.value.map(tag => tag.tagName)
|
||||
|
||||
// Voice metadata is an optional display enhancement. Its own failure must
|
||||
// not turn a successful persisted detail+tag reload into a blocked form.
|
||||
await enhanceAgentDetailMetadata(detail, targetAgentId, detailRequestId)
|
||||
if (!isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
|
||||
return false
|
||||
}
|
||||
snapshotReloadBlocked.value = false
|
||||
snapshotReloadFailed.value = false
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
if (isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
|
||||
console.error('恢复后重新加载智能体失败:', error)
|
||||
snapshotReloadFailed.value = true
|
||||
toast.error(t('agentSnapshot.reloadAfterRestoreFailed'))
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
if (isActiveSnapshotReload(targetAgentId, reloadId, detailRequestId, tagRequestId)) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveSnapshotReload(
|
||||
targetAgentId: string,
|
||||
reloadId: number,
|
||||
detailRequestId: number,
|
||||
tagRequestId: number,
|
||||
) {
|
||||
return targetAgentId === agentId.value
|
||||
&& reloadId === snapshotReloadSequence
|
||||
&& detailRequestId === agentDetailRequestSequence
|
||||
&& tagRequestId === agentTagRequestSequence
|
||||
}
|
||||
|
||||
function retrySnapshotReload() {
|
||||
if (!snapshotReloadFailed.value || !agentId.value) {
|
||||
return
|
||||
}
|
||||
void reloadAgentAfterSnapshotRestore(agentId.value)
|
||||
}
|
||||
|
||||
function openSnapshotPanel() {
|
||||
if (saving.value || snapshotReloadBlocked.value) {
|
||||
toast.warning(t('agentSnapshot.mutationBusy'))
|
||||
return
|
||||
}
|
||||
showSnapshotPanel.value = true
|
||||
}
|
||||
|
||||
function isSameStringList(left: string[], right: string[]) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
// 监听store中的插件配置变化
|
||||
watch(() => pluginStore.currentFunctions, (newFunctions) => {
|
||||
formData.value.functions = newFunctions
|
||||
formData.value.functions = normalizeAgentFunctions(newFunctions || [])
|
||||
}, { deep: true })
|
||||
|
||||
watch(agentId, (currentAgentId, previousAgentId) => {
|
||||
if (currentAgentId === previousAgentId) {
|
||||
return
|
||||
}
|
||||
agentDetailRequestSequence += 1
|
||||
agentTagRequestSequence += 1
|
||||
snapshotReloadSequence += 1
|
||||
invalidateTtsMetadataRequest()
|
||||
loading.value = false
|
||||
snapshotReloadFailed.value = false
|
||||
snapshotReloadBlocked.value = Boolean(currentAgentId)
|
||||
originalAgentDetail.value = null
|
||||
originalTagNames.value = []
|
||||
if (currentAgentId) {
|
||||
void reloadAgentAfterSnapshotRestore(currentAgentId)
|
||||
}
|
||||
}, { flush: 'sync' })
|
||||
|
||||
onMounted(async () => {
|
||||
loadAgentTags()
|
||||
|
||||
@@ -702,7 +1198,7 @@ onMounted(async () => {
|
||||
])
|
||||
|
||||
// 然后加载智能体详情,这样可以正确映射显示名称
|
||||
if (agentId.value) {
|
||||
if (agentId.value && !snapshotReloadBlocked.value) {
|
||||
await loadAgentDetail()
|
||||
}
|
||||
})
|
||||
@@ -710,6 +1206,26 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<view class="bg-[#f5f7fb] px-[20rpx]">
|
||||
<view class="mb-[24rpx] flex items-center justify-between border border-[#eeeeee] rounded-[20rpx] bg-white p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
<view>
|
||||
<text class="block text-[32rpx] text-[#232338] font-bold">
|
||||
{{ t('agent.editTitle') }}
|
||||
</text>
|
||||
<text v-if="currentVersionNo" class="mt-[8rpx] block text-[24rpx] text-[#65686f]">
|
||||
{{ t('agentSnapshot.currentVersion') }} #{{ currentVersionNo }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="saving || snapshotReloadBlocked"
|
||||
custom-class="!bg-[#336cff] !h-[64rpx] !rounded-[32rpx]"
|
||||
@click="openSnapshotPanel"
|
||||
>
|
||||
{{ t('agentSnapshot.title') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 基础信息标题
|
||||
<view class="pb-[20rpx] first:pt-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
@@ -981,10 +1497,27 @@ onMounted(async () => {
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<view class="mt-[40rpx] p-0">
|
||||
<view
|
||||
v-if="snapshotReloadBlocked"
|
||||
class="mb-[18rpx] rounded-[16rpx] bg-[rgba(245,108,108,0.1)] p-[20rpx] text-[24rpx] text-[#a34848] leading-[1.6]"
|
||||
>
|
||||
<text class="block">
|
||||
{{ t(snapshotReloadFailed ? 'agentSnapshot.reloadAfterRestoreFailed' : 'agentSnapshot.reloadAfterRestorePending') }}
|
||||
</text>
|
||||
<wd-button
|
||||
v-if="snapshotReloadFailed"
|
||||
size="small"
|
||||
type="info"
|
||||
custom-class="mt-[14rpx] !h-[60rpx]"
|
||||
@click="retrySnapshotReload"
|
||||
>
|
||||
{{ t('agentSnapshot.retryReload') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="saving"
|
||||
:disabled="saving || ttsOptionsLoading || snapshotReloadBlocked"
|
||||
custom-class="w-full h-[80rpx] rounded-[16rpx] text-[30rpx] font-semibold bg-[#336cff] active:bg-[#2d5bd1]"
|
||||
@click="saveAgent"
|
||||
>
|
||||
@@ -1085,6 +1618,14 @@ onMounted(async () => {
|
||||
@close="onPickerCancel('report')"
|
||||
@select="({ item }) => onPickerConfirm('report', item.value, item.name)"
|
||||
/>
|
||||
<AgentSnapshotPanel
|
||||
v-model:visible="showSnapshotPanel"
|
||||
:agent-id="agentId"
|
||||
:current-version-no="currentVersionNo"
|
||||
:has-unsaved-changes="hasUnsavedChanges"
|
||||
:mutation-busy="saving || snapshotReloadBlocked"
|
||||
@restored="handleSnapshotRestored"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import CustomTabs from '@/components/custom-tabs/index.vue'
|
||||
import { t } from '@/i18n'
|
||||
import ChatHistory from '@/pages/chat-history/index.vue'
|
||||
|
||||
@@ -17,6 +17,7 @@ defineOptions({
|
||||
})
|
||||
|
||||
const speedPitchStore = useSpeedPitch()
|
||||
const SPEED_PITCH_FIELDS = ['ttsVolume', 'ttsRate', 'ttsPitch'] as const
|
||||
|
||||
const localSettings = ref({
|
||||
ttsVolume: 0,
|
||||
@@ -25,7 +26,10 @@ const localSettings = ref({
|
||||
})
|
||||
|
||||
function handleConfirm() {
|
||||
speedPitchStore.updateSpeedPitch(localSettings.value)
|
||||
const changedFields = SPEED_PITCH_FIELDS.filter((field) => {
|
||||
return localSettings.value[field] !== speedPitchStore.speedPitch[field]
|
||||
})
|
||||
speedPitchStore.updateSpeedPitch(localSettings.value, { changedFields })
|
||||
goBack()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { useMessage } from 'wot-design-uni/components/wd-message-box'
|
||||
import { getMcpAddress, getMcpTools } from '@/api/agent/agent'
|
||||
import { t } from '@/i18n'
|
||||
import { usePluginStore } from '@/store'
|
||||
@@ -41,17 +41,32 @@ const tempParams = ref<Record<string, any>>({})
|
||||
const arrayTextCache = ref<Record<string, string>>({})
|
||||
const jsonTextCache = ref<Record<string, string>>({})
|
||||
|
||||
function normalizeFunctionParams(paramInfo: any, fallback: Record<string, any> = {}) {
|
||||
if (!paramInfo)
|
||||
return { ...fallback }
|
||||
if (typeof paramInfo === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(paramInfo)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : { ...fallback }
|
||||
}
|
||||
catch {
|
||||
return { ...fallback }
|
||||
}
|
||||
}
|
||||
return typeof paramInfo === 'object' && !Array.isArray(paramInfo) ? { ...fallback, ...paramInfo } : { ...fallback }
|
||||
}
|
||||
|
||||
async function mergeFunctions() {
|
||||
selectedList.value = functions.value.map((mapping) => {
|
||||
const meta = allFunctions.value.find(f => f.id === mapping.pluginId)
|
||||
if (!meta) {
|
||||
return { id: mapping.pluginId, name: mapping.pluginId, params: {} }
|
||||
return { id: mapping.pluginId, name: mapping.pluginId, params: normalizeFunctionParams(mapping.paramInfo) }
|
||||
}
|
||||
|
||||
return {
|
||||
id: mapping.pluginId,
|
||||
name: meta.name,
|
||||
params: mapping.paramInfo || { ...meta.params },
|
||||
params: normalizeFunctionParams(mapping.paramInfo, meta.params),
|
||||
fieldsMeta: meta.fieldsMeta,
|
||||
}
|
||||
})
|
||||
@@ -91,7 +106,7 @@ function selectFunction(func: any) {
|
||||
selectedList.value = [...selectedList.value, {
|
||||
id: func.id,
|
||||
name: func.name,
|
||||
params: { ...func.params },
|
||||
params: normalizeFunctionParams(func.params),
|
||||
fieldsMeta: func.fieldsMeta,
|
||||
}]
|
||||
|
||||
@@ -118,7 +133,7 @@ function editFunction(func: any) {
|
||||
currentFunction.value = func
|
||||
|
||||
// 直接使用当前函数的参数
|
||||
tempParams.value = { ...func.params }
|
||||
tempParams.value = normalizeFunctionParams(func.params)
|
||||
|
||||
// 初始化文本缓存
|
||||
if (func.fieldsMeta) {
|
||||
@@ -252,7 +267,7 @@ function getFieldRemark(field: any) {
|
||||
watch(() => selectedList.value, (newSelectedList) => {
|
||||
const finalFunctions = newSelectedList.map(f => ({
|
||||
pluginId: f.id,
|
||||
paramInfo: f.params,
|
||||
paramInfo: normalizeFunctionParams(f.params),
|
||||
}))
|
||||
pluginStore.updateFunctions(finalFunctions)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { useToast } from 'wot-design-uni/components/wd-toast'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
// 类型定义
|
||||
@@ -228,15 +228,15 @@ async function generateAndPlay() {
|
||||
generating.value = true
|
||||
|
||||
try {
|
||||
console.log(t('deviceConfig.generatingUltrasonicConfigAudio') + '...')
|
||||
console.log(`${t('deviceConfig.generatingUltrasonicConfigAudio')}...`)
|
||||
|
||||
// 准备配网数据 - 参考HTML文件格式
|
||||
const dataStr = `${props.selectedNetwork.ssid}\n${props.password}`
|
||||
const textBytes = stringToBytes(dataStr)
|
||||
const fullBytes = [...START_BYTES, ...textBytes, checksum(textBytes), ...END_BYTES]
|
||||
|
||||
console.log(t('deviceConfig.configData') + ':', { ssid: props.selectedNetwork.ssid, password: props.password })
|
||||
console.log(t('deviceConfig.dataBytesLength') + ':', textBytes.length)
|
||||
console.log(`${t('deviceConfig.configData')}:`, { ssid: props.selectedNetwork.ssid, password: props.password })
|
||||
console.log(`${t('deviceConfig.dataBytesLength')}:`, textBytes.length)
|
||||
|
||||
// 转换为比特流
|
||||
let bits: number[] = []
|
||||
@@ -244,7 +244,7 @@ async function generateAndPlay() {
|
||||
bits = bits.concat(toBits(b))
|
||||
})
|
||||
|
||||
console.log(t('deviceConfig.bitStreamLength') + ':', bits.length)
|
||||
console.log(`${t('deviceConfig.bitStreamLength')}:`, bits.length)
|
||||
|
||||
// AFSK调制 - 减少采样率降低文件大小
|
||||
const reducedSampleRate = 22050 // 降低采样率
|
||||
@@ -267,7 +267,7 @@ async function generateAndPlay() {
|
||||
const base64 = arrayBufferToBase64(wavBuffer)
|
||||
const dataUri = `data:audio/wav;base64,${base64}`
|
||||
|
||||
console.log(t('deviceConfig.base64Length') + ':', base64.length, t('deviceConfig.about'), Math.round(base64.length / 1024), 'KB')
|
||||
console.log(`${t('deviceConfig.base64Length')}:`, base64.length, t('deviceConfig.about'), Math.round(base64.length / 1024), 'KB')
|
||||
|
||||
// 检查数据大小
|
||||
if (base64.length > 1024 * 1024) { // 超过1MB
|
||||
@@ -277,7 +277,7 @@ async function generateAndPlay() {
|
||||
audioFilePath.value = dataUri
|
||||
audioGenerated.value = true
|
||||
|
||||
console.log(t('deviceConfig.audioGenerationSuccess') + ',比特流长度:', bits.length, t('deviceConfig.samplePoints') + ':', floatBuf.length)
|
||||
console.log(`${t('deviceConfig.audioGenerationSuccess')},比特流长度:`, bits.length, `${t('deviceConfig.samplePoints')}:`, floatBuf.length)
|
||||
|
||||
toast.success(t('deviceConfig.soundWaveGenerationSuccess'))
|
||||
|
||||
@@ -287,8 +287,8 @@ async function generateAndPlay() {
|
||||
}, 800) // 增加延迟时间
|
||||
}
|
||||
catch (error) {
|
||||
console.error(t('deviceConfig.audioGenerationFailed') + ':', error)
|
||||
toast.error(`${t('deviceConfig.soundWaveGenerationFailed')}: ${error.message || error}`)
|
||||
console.error(`${t('deviceConfig.audioGenerationFailed')}:`, error)
|
||||
toast.error(`${t('deviceConfig.soundWaveGenerationFailed')}: ${error.message || error}`)
|
||||
}
|
||||
finally {
|
||||
generating.value = false
|
||||
@@ -384,7 +384,7 @@ async function playAudio() {
|
||||
})
|
||||
|
||||
innerAudioContext.onError((error) => {
|
||||
console.error(t('deviceConfig.audioPlaybackFailed') + ':', error)
|
||||
console.error(`${t('deviceConfig.audioPlaybackFailed')}:`, error)
|
||||
playing.value = false
|
||||
|
||||
let errorMsg = t('deviceConfig.audioPlaybackFailed')
|
||||
@@ -417,10 +417,10 @@ async function playAudio() {
|
||||
}, 300)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(t('deviceConfig.audioPlaybackError') + ':', error)
|
||||
playing.value = false
|
||||
await cleanupAudio()
|
||||
toast.error(`${t('deviceConfig.playbackFailed')}: ${error.message}`)
|
||||
console.error(`${t('deviceConfig.audioPlaybackError')}:`, error)
|
||||
playing.value = false
|
||||
await cleanupAudio()
|
||||
toast.error(`${t('deviceConfig.playbackFailed')}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +433,7 @@ async function cleanupAudio() {
|
||||
console.log(t('deviceConfig.cleaningUpAudioContext'))
|
||||
}
|
||||
catch (e) {
|
||||
console.log(t('deviceConfig.cleaningUpAudioContextFailed') + ':', e)
|
||||
console.log(`${t('deviceConfig.cleaningUpAudioContextFailed')}:`, e)
|
||||
}
|
||||
finally {
|
||||
audioContext.value = null
|
||||
@@ -483,38 +483,38 @@ async function stopAudio() {
|
||||
:disabled="!canGenerate"
|
||||
@click="generateAndPlay"
|
||||
>
|
||||
{{ generating ? t('deviceConfig.generating') : '🎵 ' + t('deviceConfig.generateAndPlaySoundWave') }}
|
||||
</wd-button>
|
||||
{{ generating ? t('deviceConfig.generating') : `🎵 ${t('deviceConfig.generateAndPlaySoundWave')}` }}
|
||||
</wd-button>
|
||||
|
||||
<wd-button
|
||||
v-if="audioGenerated"
|
||||
type="success"
|
||||
size="large"
|
||||
block
|
||||
:loading="playing"
|
||||
@click="playAudio"
|
||||
>
|
||||
{{ playing ? t('deviceConfig.playing') : '🔊 ' + t('deviceConfig.playSoundWave') }}
|
||||
</wd-button>
|
||||
<wd-button
|
||||
v-if="audioGenerated"
|
||||
type="success"
|
||||
size="large"
|
||||
block
|
||||
:loading="playing"
|
||||
@click="playAudio"
|
||||
>
|
||||
{{ playing ? t('deviceConfig.playing') : `🔊 ${t('deviceConfig.playSoundWave')}` }}
|
||||
</wd-button>
|
||||
|
||||
<wd-button
|
||||
v-if="playing"
|
||||
type="warning"
|
||||
size="large"
|
||||
block
|
||||
@click="stopAudio"
|
||||
>
|
||||
⏹️ {{ t('deviceConfig.stopPlaying') }}
|
||||
</wd-button>
|
||||
<wd-button
|
||||
v-if="playing"
|
||||
type="warning"
|
||||
size="large"
|
||||
block
|
||||
@click="stopAudio"
|
||||
>
|
||||
⏹️ {{ t('deviceConfig.stopPlaying') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 音频控制选项 -->
|
||||
<view v-if="audioGenerated" class="audio-options">
|
||||
<view class="option-item">
|
||||
<wd-checkbox v-model="autoLoop">
|
||||
{{ t('deviceConfig.autoLoopPlaySoundWave') }}
|
||||
</wd-checkbox>
|
||||
</view>
|
||||
<wd-checkbox v-model="autoLoop">
|
||||
{{ t('deviceConfig.autoLoopPlaySoundWave') }}
|
||||
</wd-checkbox>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 音频播放器 -->
|
||||
@@ -531,33 +531,33 @@ async function stopAudio() {
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<view class="help-section">
|
||||
<view class="help-title">
|
||||
{{ t('deviceConfig.ultrasonicConfigInstructions') }}
|
||||
</view>
|
||||
<view class="help-content">
|
||||
<text class="help-item">
|
||||
1. {{ t('deviceConfig.ensureWifiNetworkSelectedAndPasswordEntered') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
2. {{ t('deviceConfig.clickGenerateAndPlaySoundWave') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
3. {{ t('deviceConfig.bringPhoneCloseToXiaozhiDevice') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
4. {{ t('deviceConfig.duringAudioPlaybackXiaozhiWillReceive') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
5. {{ t('deviceConfig.afterConfigSuccessDeviceWillConnect') }}
|
||||
</text>
|
||||
<text class="help-tip">
|
||||
{{ t('deviceConfig.usesAfskModulation') }}
|
||||
</text>
|
||||
<text class="help-tip">
|
||||
{{ t('deviceConfig.ensureModeratePhoneVolume') }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="help-title">
|
||||
{{ t('deviceConfig.ultrasonicConfigInstructions') }}
|
||||
</view>
|
||||
<view class="help-content">
|
||||
<text class="help-item">
|
||||
1. {{ t('deviceConfig.ensureWifiNetworkSelectedAndPasswordEntered') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
2. {{ t('deviceConfig.clickGenerateAndPlaySoundWave') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
3. {{ t('deviceConfig.bringPhoneCloseToXiaozhiDevice') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
4. {{ t('deviceConfig.duringAudioPlaybackXiaozhiWillReceive') }}
|
||||
</text>
|
||||
<text class="help-item">
|
||||
5. {{ t('deviceConfig.afterConfigSuccessDeviceWillConnect') }}
|
||||
</text>
|
||||
<text class="help-tip">
|
||||
{{ t('deviceConfig.usesAfskModulation') }}
|
||||
</text>
|
||||
<text class="help-tip">
|
||||
{{ t('deviceConfig.ensureModeratePhoneVolume') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { useToast } from 'wot-design-uni/components/wd-toast'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
// 类型定义
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import UltrasonicConfig from './components/ultrasonic-config.vue'
|
||||
import WifiConfig from './components/wifi-config.vue'
|
||||
@@ -68,12 +68,10 @@ function onNetworkSelected(network: WiFiNetwork | null, password: string) {
|
||||
function onConnectionStatusChange(connected: boolean) {
|
||||
console.log('ESP32连接状态:', connected)
|
||||
}
|
||||
|
||||
// 在组件挂载后设置导航栏标题
|
||||
import { onMounted } from 'vue'
|
||||
onMounted(() => {
|
||||
uni.setNavigationBarTitle({
|
||||
title: t('deviceConfig.pageTitle')
|
||||
title: t('deviceConfig.pageTitle'),
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -86,18 +84,18 @@ onMounted(() => {
|
||||
<!-- 配网方式选择 -->
|
||||
<view class="pb-[20rpx] first:pt-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
{{ t('deviceConfig.configMethod') }}
|
||||
</text>
|
||||
{{ t('deviceConfig.configMethod') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:border-[#336cff] active:bg-[#eef3ff]" @click="showConfigTypeSelector">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
{{ t('deviceConfig.configMethod') }}
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ configType === 'wifi' ? t('deviceConfig.wifiConfig') : t('deviceConfig.ultrasonicConfig') }}
|
||||
</text>
|
||||
{{ t('deviceConfig.configMethod') }}
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ configType === 'wifi' ? t('deviceConfig.wifiConfig') : t('deviceConfig.ultrasonicConfig') }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -105,8 +103,8 @@ onMounted(() => {
|
||||
<!-- WiFi网络选择 -->
|
||||
<view class="pb-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
{{ t('deviceConfig.networkConfig') }}
|
||||
</text>
|
||||
{{ t('deviceConfig.networkConfig') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Device, FirmwareType } from '@/api/device'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { useMessage } from 'wot-design-uni/components/wd-message-box'
|
||||
import { bindDevice, bindDeviceManual, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
|
||||
import { t } from '@/i18n'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
@@ -9,23 +9,23 @@
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { useConfigStore } from "@/store";
|
||||
import { getEnvBaseUrl, sm2Encrypt } from "@/utils";
|
||||
import { toast } from "@/utils/toast";
|
||||
// 导入国际化相关功能
|
||||
import { t, initI18n } from "@/i18n";
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
// 导入API接口
|
||||
import { retrievePassword, sendSmsCode } from "@/api/auth";
|
||||
import { retrievePassword, sendSmsCode } from '@/api/auth'
|
||||
// 导入国际化相关功能
|
||||
import { initI18n, t } from '@/i18n'
|
||||
import { useConfigStore } from '@/store'
|
||||
import { getEnvBaseUrl, sm2Encrypt } from '@/utils'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets;
|
||||
let systemInfo;
|
||||
let safeAreaInsets
|
||||
let systemInfo
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序使用新的API
|
||||
systemInfo = uni.getWindowInfo();
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
@@ -33,147 +33,148 @@ safeAreaInsets = systemInfo.safeArea
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null;
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
// 其他平台继续使用uni API
|
||||
systemInfo = uni.getSystemInfoSync();
|
||||
safeAreaInsets = systemInfo.safeAreaInsets;
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
|
||||
// 表单数据
|
||||
interface ForgotPasswordData {
|
||||
mobile: string;
|
||||
captcha: string;
|
||||
captchaId: string;
|
||||
mobileCaptcha: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
areaCode: string;
|
||||
mobile: string
|
||||
captcha: string
|
||||
captchaId: string
|
||||
mobileCaptcha: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
areaCode: string
|
||||
}
|
||||
|
||||
const formData = ref<ForgotPasswordData>({
|
||||
mobile: "",
|
||||
captcha: "",
|
||||
captchaId: "",
|
||||
mobileCaptcha: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
areaCode: "+86",
|
||||
});
|
||||
mobile: '',
|
||||
captcha: '',
|
||||
captchaId: '',
|
||||
mobileCaptcha: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
areaCode: '+86',
|
||||
})
|
||||
|
||||
// 验证码图片
|
||||
const captchaImage = ref("");
|
||||
const loading = ref(false);
|
||||
const captchaImage = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
// 获取配置store
|
||||
const configStore = useConfigStore();
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// State for area code action sheet
|
||||
const showAreaCodeSheet = ref(false);
|
||||
const selectedAreaCode = ref("+86");
|
||||
const showAreaCodeSheet = ref(false)
|
||||
const selectedAreaCode = ref('+86')
|
||||
// 短信验证码倒计时
|
||||
const smsCountdown = ref(0)
|
||||
const smsLoading = ref(false)
|
||||
const areaCodeList = computed(() =>
|
||||
(configStore.config.mobileAreaList || []).map((item) => {
|
||||
return {
|
||||
value: item.key,
|
||||
label: `${item.name} (${item.key})`,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const canSendMobileCaptcha = computed(() => {
|
||||
const mobile = formData.value.mobile;
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
return phoneRegex.test(mobile) && smsCountdown.value === 0;
|
||||
});
|
||||
const mobile = formData.value.mobile
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
return phoneRegex.test(mobile) && smsCountdown.value === 0
|
||||
})
|
||||
|
||||
// SM2公钥
|
||||
const sm2PublicKey = computed(() => {
|
||||
return configStore.config.sm2PublicKey;
|
||||
});
|
||||
|
||||
// 短信验证码倒计时
|
||||
const smsCountdown = ref(0);
|
||||
const smsLoading = ref(false);
|
||||
return configStore.config.sm2PublicKey
|
||||
})
|
||||
|
||||
// 打开区号选择弹窗
|
||||
function openAreaCodeSheet() {
|
||||
showAreaCodeSheet.value = true;
|
||||
showAreaCodeSheet.value = true
|
||||
}
|
||||
|
||||
// 选择区号
|
||||
function selectAreaCode(item: { value: string; label: string }) {
|
||||
selectedAreaCode.value = item.value;
|
||||
formData.value.areaCode = item.value;
|
||||
showAreaCodeSheet.value = false;
|
||||
function selectAreaCode(item: { value: string, label: string }) {
|
||||
selectedAreaCode.value = item.value
|
||||
formData.value.areaCode = item.value
|
||||
showAreaCodeSheet.value = false
|
||||
}
|
||||
|
||||
// 关闭区号选择弹窗
|
||||
function closeAreaCodeSheet() {
|
||||
showAreaCodeSheet.value = false;
|
||||
showAreaCodeSheet.value = false
|
||||
}
|
||||
|
||||
// 生成UUID
|
||||
function generateUUID() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取图形验证码
|
||||
async function refreshCaptcha() {
|
||||
const uuid = generateUUID();
|
||||
formData.value.captchaId = uuid;
|
||||
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`;
|
||||
const uuid = generateUUID()
|
||||
formData.value.captchaId = uuid
|
||||
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`
|
||||
}
|
||||
|
||||
// 发送短信验证码
|
||||
async function handleSendSmsCode() {
|
||||
// 手机号格式验证
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
if (!phoneRegex.test(formData.value.mobile)) {
|
||||
toast.warning(t("retrievePassword.inputCorrectMobile"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.inputCorrectMobile'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.captcha) {
|
||||
toast.warning(t("retrievePassword.captchaRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.captchaRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
smsLoading.value = true;
|
||||
smsLoading.value = true
|
||||
// 将手机号转换为国际格式
|
||||
const internationalPhone = formData.value.areaCode + formData.value.mobile;
|
||||
const internationalPhone = formData.value.areaCode + formData.value.mobile
|
||||
// 调用发送短信验证码API
|
||||
await sendSmsCode({
|
||||
phone: internationalPhone,
|
||||
captcha: formData.value.captcha,
|
||||
captchaId: formData.value.captchaId
|
||||
captchaId: formData.value.captchaId,
|
||||
})
|
||||
|
||||
toast.success(t("retrievePassword.captchaSendSuccess"));
|
||||
toast.success(t('retrievePassword.captchaSendSuccess'))
|
||||
|
||||
// 开始倒计时
|
||||
smsCountdown.value = 60;
|
||||
smsCountdown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
smsCountdown.value--;
|
||||
smsCountdown.value--
|
||||
if (smsCountdown.value <= 0) {
|
||||
clearInterval(timer);
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error: any) {
|
||||
}, 1000)
|
||||
}
|
||||
catch (error: any) {
|
||||
// 处理验证码错误
|
||||
if (error.message.includes("请求错误[10067]")) {
|
||||
toast.warning(t("login.captchaError"));
|
||||
if (error.message.includes('请求错误[10067]')) {
|
||||
toast.warning(t('login.captchaError'))
|
||||
}
|
||||
// 发送失败重新获取图形验证码
|
||||
refreshCaptcha();
|
||||
} finally {
|
||||
smsLoading.value = false;
|
||||
refreshCaptcha()
|
||||
}
|
||||
finally {
|
||||
smsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,64 +182,65 @@ async function handleSendSmsCode() {
|
||||
async function handleResetPassword() {
|
||||
// 表单验证
|
||||
if (!formData.value.mobile) {
|
||||
toast.warning(t("retrievePassword.mobileRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.mobileRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// 手机号格式验证
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
if (!phoneRegex.test(formData.value.mobile)) {
|
||||
toast.warning(t("retrievePassword.inputCorrectMobile"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.inputCorrectMobile'))
|
||||
return
|
||||
}
|
||||
|
||||
// 将手机号转换为国际格式
|
||||
const internationalPhone = formData.value.areaCode + formData.value.mobile;
|
||||
const internationalPhone = formData.value.areaCode + formData.value.mobile
|
||||
|
||||
if (!formData.value.captcha) {
|
||||
toast.warning(t("retrievePassword.captchaRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.captchaRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.mobileCaptcha) {
|
||||
toast.warning(t("retrievePassword.mobileCaptchaRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.mobileCaptchaRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.newPassword) {
|
||||
toast.warning(t("retrievePassword.newPasswordRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.newPasswordRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.confirmPassword) {
|
||||
toast.warning(t("retrievePassword.confirmNewPasswordRequired"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.confirmNewPasswordRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.value.newPassword !== formData.value.confirmPassword) {
|
||||
toast.warning(t("retrievePassword.passwordsNotMatch"));
|
||||
return;
|
||||
toast.warning(t('retrievePassword.passwordsNotMatch'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
|
||||
// 检查SM2公钥是否配置
|
||||
if (!sm2PublicKey.value) {
|
||||
toast.warning(t("sm2.publicKeyNotConfigured"));
|
||||
return;
|
||||
toast.warning(t('sm2.publicKeyNotConfigured'))
|
||||
return
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
let encryptedPassword;
|
||||
let encryptedPassword
|
||||
try {
|
||||
// 拼接图形验证码和新密码进行加密
|
||||
const captchaAndPassword = formData.value.captcha + formData.value.newPassword;
|
||||
encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword);
|
||||
} catch (error) {
|
||||
console.error("密码加密失败:", error);
|
||||
toast.warning(t("sm2.encryptionFailed"));
|
||||
return;
|
||||
const captchaAndPassword = formData.value.captcha + formData.value.newPassword
|
||||
encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('密码加密失败:', error)
|
||||
toast.warning(t('sm2.encryptionFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
// 调用重置密码API
|
||||
@@ -246,53 +248,56 @@ async function handleResetPassword() {
|
||||
phone: internationalPhone,
|
||||
code: formData.value.mobileCaptcha,
|
||||
password: encryptedPassword,
|
||||
captchaId: formData.value.captchaId
|
||||
captchaId: formData.value.captchaId,
|
||||
})
|
||||
|
||||
toast.success(t("retrievePassword.passwordUpdateSuccess"));
|
||||
toast.success(t('retrievePassword.passwordUpdateSuccess'))
|
||||
|
||||
// 跳转到登录页
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({
|
||||
url: "/pages/login/index",
|
||||
});
|
||||
}, 1000);
|
||||
} catch (error: any) {
|
||||
url: '/pages/login/index',
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
catch (error: any) {
|
||||
// 处理验证码错误
|
||||
if (error.message.includes("请求错误[10067]")) {
|
||||
toast.warning(t("login.captchaError"));
|
||||
if (error.message.includes('请求错误[10067]')) {
|
||||
toast.warning(t('login.captchaError'))
|
||||
}
|
||||
// 重置失败重新获取验证码
|
||||
refreshCaptcha();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
refreshCaptcha()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回登录
|
||||
function goBack() {
|
||||
uni.redirectTo({
|
||||
url: "/pages/login/index",
|
||||
});
|
||||
url: '/pages/login/index',
|
||||
})
|
||||
}
|
||||
|
||||
// 页面加载时获取验证码
|
||||
onLoad(() => {
|
||||
refreshCaptcha();
|
||||
});
|
||||
refreshCaptcha()
|
||||
})
|
||||
|
||||
// 组件挂载时确保配置已加载
|
||||
onMounted(async () => {
|
||||
if (!configStore.config.name) {
|
||||
try {
|
||||
await configStore.fetchPublicConfig();
|
||||
} catch (error) {
|
||||
console.error("获取配置失败:", error);
|
||||
await configStore.fetchPublicConfig()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
}
|
||||
}
|
||||
// 初始化国际化
|
||||
initI18n();
|
||||
});
|
||||
initI18n()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -471,7 +476,7 @@ onMounted(async () => {
|
||||
min-height: 100vh;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
@@ -713,10 +718,7 @@ onMounted(async () => {
|
||||
margin-bottom: 30rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
background-color: var(
|
||||
--wot-button-primary-bg-color,
|
||||
var(--wot-color-theme, #4d80f0)
|
||||
);
|
||||
background-color: var(--wot-button-primary-bg-color, var(--wot-color-theme, #4d80f0));
|
||||
text-align: center;
|
||||
line-height: 80rpx;
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Agent } from '@/api/agent/types'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
// 在组件挂载后设置导航栏标题
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { useMessage } from 'wot-design-uni/components/wd-message-box'
|
||||
import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js'
|
||||
import { createAgent, deleteAgent, getAgentList } from '@/api/agent/agent'
|
||||
import { t } from '@/i18n'
|
||||
@@ -53,6 +53,14 @@ const pagingRef = ref()
|
||||
useZPaging(pagingRef)
|
||||
// 消息组件
|
||||
const message = useMessage()
|
||||
const deleteDialogVisible = ref(false)
|
||||
const deleteTargetAgent = ref<Agent | null>(null)
|
||||
const deleteConfirmText = ref('')
|
||||
const deleteAgentLoading = ref(false)
|
||||
|
||||
const isDeleteAgentNameMatched = computed(() => {
|
||||
return !!deleteTargetAgent.value?.agentName && deleteConfirmText.value === deleteTargetAgent.value.agentName
|
||||
})
|
||||
|
||||
// z-paging查询列表数据
|
||||
async function queryList(pageNo: number, pageSize: number) {
|
||||
@@ -89,19 +97,53 @@ async function handleCreateAgent(agentName: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteAgentDialog(agent: Agent) {
|
||||
if (!agent.id || !agent.agentName) {
|
||||
toast.error(t('home.deleteAgentMissingInfo'))
|
||||
return
|
||||
}
|
||||
|
||||
deleteTargetAgent.value = agent
|
||||
deleteConfirmText.value = ''
|
||||
deleteDialogVisible.value = true
|
||||
}
|
||||
|
||||
function closeDeleteAgentDialog() {
|
||||
if (deleteAgentLoading.value)
|
||||
return
|
||||
|
||||
deleteDialogVisible.value = false
|
||||
deleteTargetAgent.value = null
|
||||
deleteConfirmText.value = ''
|
||||
}
|
||||
|
||||
function handleDeleteAgentPaste() {
|
||||
toast.warning(t('home.deleteAgentPasteForbidden'))
|
||||
}
|
||||
|
||||
// 删除智能体
|
||||
async function handleDeleteAgent(agent: Agent) {
|
||||
async function handleDeleteAgent() {
|
||||
if (!deleteTargetAgent.value || !isDeleteAgentNameMatched.value || deleteAgentLoading.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await deleteAgent(agent.id)
|
||||
deleteAgentLoading.value = true
|
||||
await deleteAgent(deleteTargetAgent.value.id)
|
||||
// 删除成功后刷新列表
|
||||
pagingRef.value.reload()
|
||||
toast.success(`${t('home.agentName')}${t('message.deleteSuccess')}`)
|
||||
toast.success(t('home.deleteAgentSuccess'))
|
||||
deleteDialogVisible.value = false
|
||||
deleteTargetAgent.value = null
|
||||
deleteConfirmText.value = ''
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除智能体失败:', error)
|
||||
const errorMessage = error?.message || t('message.deleteFail')
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
finally {
|
||||
deleteAgentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 进入编辑页面
|
||||
@@ -125,7 +167,7 @@ function openCreateDialog() {
|
||||
msg: '',
|
||||
inputPlaceholder: t('home.inputPlaceholder'),
|
||||
inputValue: '',
|
||||
inputPattern: /^.{1,64}$/i,
|
||||
inputPattern: /^.{1,64}$/,
|
||||
inputError: t('home.createError'),
|
||||
confirmButtonText: t('home.createNow'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
@@ -254,7 +296,7 @@ onMounted(() => {
|
||||
|
||||
<template #right>
|
||||
<view class="swipe-actions">
|
||||
<view class="action-btn delete-btn" @click.stop="handleDeleteAgent(agent)">
|
||||
<view class="action-btn delete-btn" @click.stop="openDeleteAgentDialog(agent)">
|
||||
<wd-icon name="delete" />
|
||||
<text>{{ t('home.delete') }}</text>
|
||||
</view>
|
||||
@@ -283,6 +325,79 @@ onMounted(() => {
|
||||
<!-- MessageBox 组件 -->
|
||||
<wd-message-box />
|
||||
</z-paging>
|
||||
|
||||
<wd-popup
|
||||
v-model="deleteDialogVisible"
|
||||
position="center"
|
||||
custom-style="width: 88%; max-width: 420px; border-radius: 16px;"
|
||||
:close-on-click-modal="!deleteAgentLoading"
|
||||
safe-area-inset-bottom
|
||||
@close="closeDeleteAgentDialog"
|
||||
>
|
||||
<view class="delete-agent-dialog">
|
||||
<view class="delete-agent-header">
|
||||
<text class="delete-agent-title">
|
||||
{{ t('home.deleteConfirmTitle') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="delete-agent-body">
|
||||
<view class="delete-agent-content">
|
||||
<wd-icon name="warning" custom-class="delete-agent-warning" />
|
||||
<view class="delete-agent-message">
|
||||
<text
|
||||
class="delete-agent-copy-guard delete-agent-tip"
|
||||
@copy.stop.prevent
|
||||
@cut.stop.prevent
|
||||
@contextmenu.stop.prevent
|
||||
@longpress.stop.prevent
|
||||
>
|
||||
{{ t('home.confirmDeleteAgent', { agentName: deleteTargetAgent?.agentName || '' }) }}
|
||||
</text>
|
||||
<view
|
||||
class="delete-agent-copy-guard delete-agent-target"
|
||||
@copy.stop.prevent
|
||||
@cut.stop.prevent
|
||||
@contextmenu.stop.prevent
|
||||
@longpress.stop.prevent
|
||||
>
|
||||
<text>{{ deleteTargetAgent?.agentName || '' }}</text>
|
||||
</view>
|
||||
|
||||
<input
|
||||
v-model="deleteConfirmText"
|
||||
class="delete-agent-input"
|
||||
type="text"
|
||||
:maxlength="deleteTargetAgent?.agentName?.length || 64"
|
||||
:placeholder="t('home.deleteAgentNamePlaceholder')"
|
||||
:disabled="deleteAgentLoading"
|
||||
@paste.stop.prevent="handleDeleteAgentPaste"
|
||||
@drop.stop.prevent="handleDeleteAgentPaste"
|
||||
@contextmenu.stop.prevent
|
||||
>
|
||||
<text v-if="deleteConfirmText && !isDeleteAgentNameMatched" class="delete-agent-helper">
|
||||
{{ t('home.deleteAgentNameMismatch') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="delete-agent-footer">
|
||||
<wd-button type="info" custom-class="delete-agent-button" :disabled="deleteAgentLoading" @click="closeDeleteAgentDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</wd-button>
|
||||
<wd-button
|
||||
type="primary"
|
||||
custom-class="delete-agent-button"
|
||||
:loading="deleteAgentLoading"
|
||||
:disabled="!isDeleteAgentNameMatched"
|
||||
@click="handleDeleteAgent"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -587,4 +702,103 @@ onMounted(() => {
|
||||
text-align: center;
|
||||
border-top: 1rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
.delete-agent-dialog {
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.delete-agent-header {
|
||||
padding: 32rpx 32rpx 20rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.delete-agent-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #232338;
|
||||
}
|
||||
|
||||
.delete-agent-body {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.delete-agent-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
:deep(.delete-agent-warning) {
|
||||
margin-top: 4rpx;
|
||||
color: #e6a23c;
|
||||
font-size: 40rpx;
|
||||
}
|
||||
|
||||
.delete-agent-message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.delete-agent-tip {
|
||||
display: block;
|
||||
color: #606266;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.delete-agent-target {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-top: 20rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
border: 1rpx solid #ebeef5;
|
||||
border-radius: 12rpx;
|
||||
background: #f5f7fa;
|
||||
color: #303133;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.delete-agent-copy-guard {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.delete-agent-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
margin-top: 28rpx;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid #eeeeee;
|
||||
border-radius: 12rpx;
|
||||
background: #f5f7fb;
|
||||
color: #232338;
|
||||
font-size: 28rpx;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.delete-agent-helper {
|
||||
display: block;
|
||||
min-height: 32rpx;
|
||||
margin-top: 10rpx;
|
||||
color: #f56c6c;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.delete-agent-footer {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx 32rpx 32rpx;
|
||||
border-top: 1rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
:deep(.delete-agent-button) {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -401,7 +401,9 @@ onMounted(async () => {
|
||||
<text class="policy-link" @click="goToUserAgreement">
|
||||
{{ t('login.userAgreement') }}
|
||||
</text>
|
||||
<text class="policy-divider">|</text>
|
||||
<text class="policy-divider">
|
||||
{{ '|' }}
|
||||
</text>
|
||||
<text class="policy-link" @click="goToPrivacyPolicy">
|
||||
{{ t('login.privacyPolicy') }}
|
||||
</text>
|
||||
|
||||
@@ -9,15 +9,14 @@
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { register, sendSmsCode } from '@/api/auth';
|
||||
import { useConfigStore } from '@/store';
|
||||
import { getEnvBaseUrl } from '@/utils';
|
||||
import { toast } from '@/utils/toast';
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { register, sendSmsCode } from '@/api/auth'
|
||||
// 导入国际化相关功能
|
||||
import { t, initI18n } from '@/i18n';
|
||||
import { initI18n, t } from '@/i18n'
|
||||
import { useConfigStore } from '@/store'
|
||||
// 导入SM2加密工具
|
||||
import { sm2Encrypt } from '@/utils';
|
||||
import { getEnvBaseUrl, sm2Encrypt } from '@/utils'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets
|
||||
@@ -135,9 +134,9 @@ function generateUUID() {
|
||||
|
||||
// 获取验证码
|
||||
async function refreshCaptcha() {
|
||||
const uuid = generateUUID();
|
||||
formData.value.captchaId = uuid;
|
||||
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`;
|
||||
const uuid = generateUUID()
|
||||
formData.value.captchaId = uuid
|
||||
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`
|
||||
}
|
||||
|
||||
// 发送短信验证码
|
||||
@@ -253,7 +252,8 @@ async function handleRegister() {
|
||||
// 拼接验证码和密码
|
||||
const captchaAndPassword = formData.value.captcha + formData.value.password
|
||||
encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword)
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
console.error('密码加密失败:', error)
|
||||
toast.warning(t('sm2.encryptionFailed'))
|
||||
return
|
||||
@@ -275,7 +275,7 @@ async function handleRegister() {
|
||||
// 跳转到登录页
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({
|
||||
url: '/pages/login/index'
|
||||
url: '/pages/login/index',
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
@@ -299,7 +299,7 @@ async function handleRegister() {
|
||||
// 返回登录
|
||||
function goBack() {
|
||||
uni.redirectTo({
|
||||
url: '/pages/login/index'
|
||||
url: '/pages/login/index',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -321,8 +321,6 @@ onMounted(async () => {
|
||||
// 初始化国际化
|
||||
initI18n()
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -385,41 +383,41 @@ onMounted(async () => {
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.password"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterPassword')"
|
||||
show-password
|
||||
:maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
v-model="formData.password"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterPassword')"
|
||||
show-password
|
||||
:maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.confirmPassword"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.confirmPassword')"
|
||||
show-password
|
||||
:maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
v-model="formData.confirmPassword"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.confirmPassword')"
|
||||
show-password
|
||||
:maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper captcha-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.captcha"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterCode')"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<view class="captcha-image" @click="refreshCaptcha">
|
||||
<image :src="captchaImage" class="captcha-img" />
|
||||
</view>
|
||||
v-model="formData.captcha"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterCode')"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<view class="captcha-image" @click="refreshCaptcha">
|
||||
<image :src="captchaImage" class="captcha-img" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -427,21 +425,21 @@ onMounted(async () => {
|
||||
<view v-if="enableMobileRegister" class="input-group">
|
||||
<view class="input-wrapper sms-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.mobileCaptcha"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterCode')"
|
||||
type="number"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<wd-button
|
||||
:loading="smsLoading"
|
||||
:disabled="smsCountdown > 0"
|
||||
custom-class="sms-btn"
|
||||
@click="sendSmsVerification"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s` : t('register.getCode') }}
|
||||
</wd-button>
|
||||
v-model="formData.mobileCaptcha"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
:placeholder="t('register.enterCode')"
|
||||
type="number"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<wd-button
|
||||
:loading="smsLoading"
|
||||
:disabled="smsCountdown > 0"
|
||||
custom-class="sms-btn"
|
||||
@click="sendSmsVerification"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s` : t('register.getCode') }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -462,7 +460,6 @@ onMounted(async () => {
|
||||
{{ t('register.loginNow') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -508,8 +505,6 @@ onMounted(async () => {
|
||||
</view>
|
||||
</view>
|
||||
</wd-action-sheet>
|
||||
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Language } from '@/store/lang'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { useToast } from 'wot-design-uni/components/wd-toast'
|
||||
import { changeLanguage, getCurrentLanguage, getSupportedLanguages, t } from '@/i18n'
|
||||
import { useConfigStore } from '@/store'
|
||||
import { clearServerBaseUrlOverride, getEnvBaseUrl, getServerBaseUrlOverride, setServerBaseUrlOverride } from '@/utils'
|
||||
@@ -265,7 +265,7 @@ function showAbout() {
|
||||
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
|
||||
content: t('settings.aboutContent', {
|
||||
appName: import.meta.env.VITE_APP_TITLE,
|
||||
version: '0.9.5'
|
||||
version: '0.9.5',
|
||||
}),
|
||||
showCancel: false,
|
||||
confirmText: t('common.confirm'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ChatHistory, CreateSpeakerData, VoicePrint } from '@/api/voiceprint'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { useMessage } from 'wot-design-uni/components/wd-message-box'
|
||||
import { useToast } from 'wot-design-uni/components/wd-toast'
|
||||
import { createVoicePrint, deleteVoicePrint, getAudioDownloadId, getChatHistory, getVoicePrintList, updateVoicePrint } from '@/api/voiceprint'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PublicConfig } from '@/api/auth'
|
||||
import { getPublicConfig } from '@/api/auth'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getPublicConfig } from '@/api/auth'
|
||||
|
||||
// 初始化状态
|
||||
const initialConfigState: PublicConfig = {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 支持的语言类型
|
||||
export type Language = 'zh_CN' | 'en' | 'zh_TW' | 'de' | 'vi' | 'pt_BR'
|
||||
|
||||
export interface LangStore {
|
||||
currentLang: Language
|
||||
currentLang: Ref<Language>
|
||||
changeLang: (lang: Language) => void
|
||||
}
|
||||
|
||||
@@ -37,4 +38,4 @@ export const useLangStore = defineStore(
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const SPEED_PITCH_FIELDS = ['ttsVolume', 'ttsRate', 'ttsPitch'] as const
|
||||
|
||||
type SpeedPitchField = typeof SPEED_PITCH_FIELDS[number]
|
||||
type SpeedPitchSettings = Record<SpeedPitchField, number>
|
||||
|
||||
export const useSpeedPitch = defineStore('speedPitch', () => {
|
||||
const speedPitch = ref({
|
||||
const speedPitch = ref<SpeedPitchSettings>({
|
||||
ttsVolume: 0,
|
||||
ttsRate: 0,
|
||||
ttsPitch: 0,
|
||||
})
|
||||
const changedFields = ref<SpeedPitchField[]>([])
|
||||
|
||||
const updateSpeedPitch = (val: typeof speedPitch.value) => {
|
||||
const updateSpeedPitch = (val: SpeedPitchSettings, options: { changedFields?: SpeedPitchField[] } = {}) => {
|
||||
speedPitch.value = val
|
||||
if (options.changedFields) {
|
||||
changedFields.value = Array.from(new Set([
|
||||
...changedFields.value,
|
||||
...options.changedFields,
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
const resetChangedFields = () => {
|
||||
changedFields.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
speedPitch,
|
||||
changedFields,
|
||||
updateSpeedPitch,
|
||||
resetChangedFields,
|
||||
}
|
||||
}, {
|
||||
persist: {
|
||||
|
||||
@@ -201,7 +201,7 @@ export function getEnvBaseUploadUrl() {
|
||||
|
||||
/**
|
||||
* 生成SM2密钥对(十六进制格式)
|
||||
* @returns {Object} 包含公钥和私钥的对象
|
||||
* @returns {object} 包含公钥和私钥的对象
|
||||
*/
|
||||
export function generateSm2KeyPairHex() {
|
||||
// 使用sm-crypto库生成SM2密钥对
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import type { ExtractPropTypes } from 'vue'
|
||||
import type { actionSheetProps } from 'wot-design-uni/components/wd-action-sheet/types'
|
||||
import type { buttonProps } from 'wot-design-uni/components/wd-button/types'
|
||||
import type { checkboxProps } from 'wot-design-uni/components/wd-checkbox/types'
|
||||
import type { configProviderProps } from 'wot-design-uni/components/wd-config-provider/types'
|
||||
import type { fabProps } from 'wot-design-uni/components/wd-fab/types'
|
||||
import type { iconProps } from 'wot-design-uni/components/wd-icon/types'
|
||||
import type { imgProps } from 'wot-design-uni/components/wd-img/types'
|
||||
import type { inputProps } from 'wot-design-uni/components/wd-input/types'
|
||||
import type { loadingProps } from 'wot-design-uni/components/wd-loading/types'
|
||||
import type { messageBoxProps } from 'wot-design-uni/components/wd-message-box/types'
|
||||
import type { navbarProps } from 'wot-design-uni/components/wd-navbar/types'
|
||||
import type { pickerProps } from 'wot-design-uni/components/wd-picker/types'
|
||||
import type { popupProps } from 'wot-design-uni/components/wd-popup/types'
|
||||
import type { segmentedProps } from 'wot-design-uni/components/wd-segmented/types'
|
||||
import type { sliderProps } from 'wot-design-uni/components/wd-slider/types'
|
||||
import type { statusTipProps } from 'wot-design-uni/components/wd-status-tip/types'
|
||||
import type { swipeActionProps } from 'wot-design-uni/components/wd-swipe-action/types'
|
||||
import type { switchProps } from 'wot-design-uni/components/wd-switch/types'
|
||||
import type { tabbarItemProps } from 'wot-design-uni/components/wd-tabbar-item/types'
|
||||
import type { tabbarProps } from 'wot-design-uni/components/wd-tabbar/types'
|
||||
import type { tagProps } from 'wot-design-uni/components/wd-tag/types'
|
||||
import type { toastProps } from 'wot-design-uni/components/wd-toast/types'
|
||||
|
||||
type TypedGlobalComponent<Props> = new () => { $props: Partial<Props> }
|
||||
|
||||
// wot-design-uni 1.9.1 exposes raw Vue source through its global declarations.
|
||||
// Rebuild lightweight global components from the published prop objects without
|
||||
// pulling the package's raw Vue source into vue-tsc. Partial preserves Vue's
|
||||
// runtime defaults while retaining prop names and value types.
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
WdActionSheet: TypedGlobalComponent<ExtractPropTypes<typeof actionSheetProps>>
|
||||
WdButton: TypedGlobalComponent<ExtractPropTypes<typeof buttonProps>>
|
||||
WdCheckbox: TypedGlobalComponent<ExtractPropTypes<typeof checkboxProps>>
|
||||
WdConfigProvider: TypedGlobalComponent<ExtractPropTypes<typeof configProviderProps>>
|
||||
WdFab: TypedGlobalComponent<ExtractPropTypes<typeof fabProps>>
|
||||
WdIcon: TypedGlobalComponent<ExtractPropTypes<typeof iconProps>>
|
||||
WdImg: TypedGlobalComponent<ExtractPropTypes<typeof imgProps>>
|
||||
WdInput: TypedGlobalComponent<ExtractPropTypes<typeof inputProps>>
|
||||
WdLoading: TypedGlobalComponent<ExtractPropTypes<typeof loadingProps>>
|
||||
WdMessageBox: TypedGlobalComponent<ExtractPropTypes<typeof messageBoxProps>>
|
||||
WdNavbar: TypedGlobalComponent<ExtractPropTypes<typeof navbarProps>>
|
||||
WdPicker: TypedGlobalComponent<ExtractPropTypes<typeof pickerProps>>
|
||||
WdPopup: TypedGlobalComponent<ExtractPropTypes<typeof popupProps>>
|
||||
WdSegmented: TypedGlobalComponent<ExtractPropTypes<typeof segmentedProps>>
|
||||
WdSlider: TypedGlobalComponent<ExtractPropTypes<typeof sliderProps>>
|
||||
WdStatusTip: TypedGlobalComponent<ExtractPropTypes<typeof statusTipProps>>
|
||||
WdSwipeAction: TypedGlobalComponent<ExtractPropTypes<typeof swipeActionProps>>
|
||||
WdSwitch: TypedGlobalComponent<ExtractPropTypes<typeof switchProps>>
|
||||
WdTabbar: TypedGlobalComponent<ExtractPropTypes<typeof tabbarProps>>
|
||||
WdTabbarItem: TypedGlobalComponent<ExtractPropTypes<typeof tabbarItemProps>>
|
||||
WdTag: TypedGlobalComponent<ExtractPropTypes<typeof tagProps>>
|
||||
WdToast: TypedGlobalComponent<ExtractPropTypes<typeof toastProps>>
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
"@dcloudio/types",
|
||||
"@uni-helper/uni-types",
|
||||
"@types/wechat-miniprogram",
|
||||
"wot-design-uni/global.d.ts",
|
||||
"z-paging/types",
|
||||
"./src/typings.d.ts"
|
||||
],
|
||||
@@ -31,6 +30,7 @@
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.js",
|
||||
"src/**/*.mjs",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.jsx",
|
||||
|
||||
@@ -116,7 +116,7 @@ export default async ({ command, mode }) => {
|
||||
&& mode === 'production'
|
||||
&& visualizer({
|
||||
filename: './node_modules/.cache/visualizer/stats.html',
|
||||
open: true,
|
||||
open: false,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
|
||||
Generated
+10
@@ -11096,6 +11096,16 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-3.4.0.tgz",
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
|
||||
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/validate-npm-package-license": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"check:i18n": "node scripts/check-i18n.js",
|
||||
"test:snapshot": "node --test src/components/agentSnapshotDisplayUtils.test.mjs src/apis/module/agentSnapshotApi.test.mjs",
|
||||
"build": "vue-cli-service build",
|
||||
"test:unit": "node --test tests/*.test.mjs",
|
||||
"analyze": "cross-env ANALYZE=true vue-cli-service build"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const locales = [
|
||||
{ name: 'zh_CN', file: 'src/i18n/zh_CN.js' },
|
||||
{ name: 'zh_TW', file: 'src/i18n/zh_TW.js' },
|
||||
{ name: 'en', file: 'src/i18n/en.js' },
|
||||
{ name: 'de', file: 'src/i18n/de.js' },
|
||||
{ name: 'vi', file: 'src/i18n/vi.js' },
|
||||
{ name: 'pt_BR', file: 'src/i18n/pt_BR.js' },
|
||||
];
|
||||
|
||||
function readLocale(locale) {
|
||||
const filePath = path.join(rootDir, locale.file);
|
||||
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
||||
const keys = [];
|
||||
const seen = new Map();
|
||||
const duplicates = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const match = line.match(/^\s*['"]([^'"]+)['"]\s*:/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = match[1];
|
||||
keys.push(key);
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, []);
|
||||
}
|
||||
seen.get(key).push(index + 1);
|
||||
});
|
||||
|
||||
seen.forEach((lineNumbers, key) => {
|
||||
if (lineNumbers.length > 1) {
|
||||
duplicates.push({ key, lineNumbers });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...locale,
|
||||
filePath,
|
||||
keys,
|
||||
keySet: new Set(keys),
|
||||
duplicates,
|
||||
};
|
||||
}
|
||||
|
||||
function diffKeys(left, right) {
|
||||
return [...left].filter((key) => !right.has(key)).sort();
|
||||
}
|
||||
|
||||
function formatKeyList(keys) {
|
||||
return keys.length ? keys.map((key) => ` - ${key}`).join('\n') : '';
|
||||
}
|
||||
|
||||
function main() {
|
||||
const results = locales.map(readLocale);
|
||||
const base = results.find((locale) => locale.name === 'zh_CN');
|
||||
const errors = [];
|
||||
|
||||
results.forEach((locale) => {
|
||||
locale.duplicates.forEach(({ key, lineNumbers }) => {
|
||||
errors.push(
|
||||
`${locale.file} has duplicate key "${key}" on lines ${lineNumbers.join(', ')}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
results
|
||||
.filter((locale) => locale !== base)
|
||||
.forEach((locale) => {
|
||||
const missing = diffKeys(base.keySet, locale.keySet);
|
||||
const extra = diffKeys(locale.keySet, base.keySet);
|
||||
|
||||
if (missing.length) {
|
||||
errors.push(`${locale.file} is missing keys from ${base.file}:\n${formatKeyList(missing)}`);
|
||||
}
|
||||
|
||||
if (extra.length) {
|
||||
errors.push(`${locale.file} has keys that do not exist in ${base.file}:\n${formatKeyList(extra)}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`i18n check failed with ${errors.length} issue(s):`);
|
||||
console.error(errors.join('\n\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const keyCount = base.keySet.size;
|
||||
console.log(`i18n check passed: ${results.length} locale files, ${keyCount} keys each.`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,6 +1,41 @@
|
||||
import { getServiceUrl } from '../api';
|
||||
import RequestService from '../httpRequest';
|
||||
|
||||
const CALLBACK_RETRY_LIMIT = 10;
|
||||
const CALLBACK_RETRY_DELAY_MS = 2000;
|
||||
const CALLBACK_RETRY_WINDOW_MS = CALLBACK_RETRY_LIMIT * CALLBACK_RETRY_DELAY_MS;
|
||||
|
||||
function retryCallbackRequest(retry, retryCount, onTerminalFailure, error, retryStartedAt) {
|
||||
if (!onTerminalFailure) {
|
||||
RequestService.reAjaxFun(() => retry(retryCount + 1))
|
||||
return
|
||||
}
|
||||
const startedAt = retryStartedAt || Date.now()
|
||||
if (retryCount >= CALLBACK_RETRY_LIMIT || Date.now() - startedAt >= CALLBACK_RETRY_WINDOW_MS) {
|
||||
RequestService.clearRequestTime()
|
||||
onTerminalFailure(error)
|
||||
return
|
||||
}
|
||||
setTimeout(() => retry(retryCount + 1, startedAt), CALLBACK_RETRY_DELAY_MS)
|
||||
}
|
||||
|
||||
function attachTerminalFailure(request, onTerminalFailure) {
|
||||
if (onTerminalFailure) {
|
||||
request.fail((error) => {
|
||||
RequestService.clearRequestTime()
|
||||
onTerminalFailure(error)
|
||||
})
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
function terminateCallbackRequest(onTerminalFailure, error) {
|
||||
RequestService.clearRequestTime()
|
||||
if (onTerminalFailure) {
|
||||
onTerminalFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
// 获取智能体列表
|
||||
@@ -50,8 +85,9 @@ export default {
|
||||
}).send();
|
||||
},
|
||||
// 获取智能体配置
|
||||
getDeviceConfig(agentId, callback) {
|
||||
RequestService.sendRequest()
|
||||
getDeviceConfig(agentId, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
|
||||
const retryWindowStartedAt = retryStartedAt || Date.now()
|
||||
const request = RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/${agentId}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
@@ -60,10 +96,21 @@ export default {
|
||||
})
|
||||
.networkFail((err) => {
|
||||
console.error('获取配置失败:', err);
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getDeviceConfig(agentId, callback);
|
||||
});
|
||||
}).send();
|
||||
retryCallbackRequest(
|
||||
(nextRetryCount, nextRetryStartedAt) => this.getDeviceConfig(
|
||||
agentId,
|
||||
callback,
|
||||
onTerminalFailure,
|
||||
nextRetryCount,
|
||||
nextRetryStartedAt
|
||||
),
|
||||
retryCount,
|
||||
onTerminalFailure,
|
||||
err,
|
||||
retryWindowStartedAt
|
||||
)
|
||||
})
|
||||
attachTerminalFailure(request, onTerminalFailure).send();
|
||||
},
|
||||
// 配置智能体
|
||||
updateAgentConfig(agentId, configData, callback) {
|
||||
@@ -81,6 +128,92 @@ export default {
|
||||
});
|
||||
}).send();
|
||||
},
|
||||
// 获取智能体配置快照列表
|
||||
getAgentSnapshots(agentId, params, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
|
||||
const retryWindowStartedAt = retryStartedAt || Date.now()
|
||||
const request = RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/${agentId}/snapshots`)
|
||||
.method('GET')
|
||||
.data(params)
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((error) => {
|
||||
retryCallbackRequest(
|
||||
(nextRetryCount, nextRetryStartedAt) => this.getAgentSnapshots(
|
||||
agentId,
|
||||
params,
|
||||
callback,
|
||||
onTerminalFailure,
|
||||
nextRetryCount,
|
||||
nextRetryStartedAt
|
||||
),
|
||||
retryCount,
|
||||
onTerminalFailure,
|
||||
error,
|
||||
retryWindowStartedAt
|
||||
)
|
||||
})
|
||||
attachTerminalFailure(request, onTerminalFailure).send();
|
||||
},
|
||||
// 获取智能体配置快照详情
|
||||
getAgentSnapshot(agentId, snapshotId, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
|
||||
const retryWindowStartedAt = retryStartedAt || Date.now()
|
||||
const request = RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((error) => {
|
||||
retryCallbackRequest(
|
||||
(nextRetryCount, nextRetryStartedAt) => this.getAgentSnapshot(
|
||||
agentId,
|
||||
snapshotId,
|
||||
callback,
|
||||
onTerminalFailure,
|
||||
nextRetryCount,
|
||||
nextRetryStartedAt
|
||||
),
|
||||
retryCount,
|
||||
onTerminalFailure,
|
||||
error,
|
||||
retryWindowStartedAt
|
||||
)
|
||||
})
|
||||
attachTerminalFailure(request, onTerminalFailure).send();
|
||||
},
|
||||
// 恢复智能体配置快照
|
||||
restoreAgentSnapshot(agentId, snapshotId, currentStateToken, callback, onTerminalFailure) {
|
||||
const request = RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}/restore`)
|
||||
.method('POST')
|
||||
.data({ currentStateToken })
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((error) => {
|
||||
terminateCallbackRequest(onTerminalFailure, error)
|
||||
})
|
||||
attachTerminalFailure(request, onTerminalFailure).send();
|
||||
},
|
||||
// 删除智能体配置快照
|
||||
deleteAgentSnapshot(agentId, snapshotId, callback, onTerminalFailure) {
|
||||
const request = RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/${agentId}/snapshots/${snapshotId}`)
|
||||
.method('DELETE')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail((error) => {
|
||||
terminateCallbackRequest(onTerminalFailure, error)
|
||||
})
|
||||
attachTerminalFailure(request, onTerminalFailure).send();
|
||||
},
|
||||
// 新增方法:获取智能体模板
|
||||
getAgentTemplate(callback) { // 移除templateName参数
|
||||
RequestService.sendRequest()
|
||||
@@ -402,19 +535,31 @@ export default {
|
||||
}).send();
|
||||
},
|
||||
// 获取智能体标签
|
||||
getAgentTags(agentId, callback) {
|
||||
RequestService.sendRequest()
|
||||
getAgentTags(agentId, callback, onTerminalFailure, retryCount = 0, retryStartedAt = 0) {
|
||||
const retryWindowStartedAt = retryStartedAt || Date.now()
|
||||
const request = RequestService.sendRequest()
|
||||
.url(`${getServiceUrl()}/agent/${agentId}/tags`)
|
||||
.method('GET')
|
||||
.success((res) => {
|
||||
RequestService.clearRequestTime();
|
||||
callback(res);
|
||||
})
|
||||
.networkFail(() => {
|
||||
RequestService.reAjaxFun(() => {
|
||||
this.getAgentTags(agentId, callback);
|
||||
});
|
||||
}).send();
|
||||
.networkFail((error) => {
|
||||
retryCallbackRequest(
|
||||
(nextRetryCount, nextRetryStartedAt) => this.getAgentTags(
|
||||
agentId,
|
||||
callback,
|
||||
onTerminalFailure,
|
||||
nextRetryCount,
|
||||
nextRetryStartedAt
|
||||
),
|
||||
retryCount,
|
||||
onTerminalFailure,
|
||||
error,
|
||||
retryWindowStartedAt
|
||||
)
|
||||
})
|
||||
attachTerminalFailure(request, onTerminalFailure).send();
|
||||
},
|
||||
// 保存智能体标签
|
||||
saveAgentTags(agentId, tags, callback) {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* eslint-disable test/no-import-node-test -- zero-dependency API regression gate */
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const agentApiSource = await readFile(new URL("./agent.js", import.meta.url), "utf8");
|
||||
const snapshotDialogSource = await readFile(
|
||||
new URL("../../components/AgentSnapshotDialog.vue", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
function sourceBetween(source, startMarker, endMarker) {
|
||||
const start = source.indexOf(startMarker);
|
||||
const end = source.indexOf(endMarker, start);
|
||||
assert.notEqual(start, -1, `missing source marker: ${startMarker}`);
|
||||
assert.notEqual(end, -1, `missing source marker: ${endMarker}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("snapshot restore sends the preview token once without an automatic replay", () => {
|
||||
const source = sourceBetween(
|
||||
agentApiSource,
|
||||
"restoreAgentSnapshot(agentId",
|
||||
"// 删除智能体配置快照"
|
||||
);
|
||||
|
||||
assert.match(source, /restoreAgentSnapshot\(agentId, snapshotId, currentStateToken, callback, onTerminalFailure\)/);
|
||||
assert.match(source, /\.method\('POST'\)/);
|
||||
assert.match(source, /\.data\(\{ currentStateToken \}\)/);
|
||||
assert.match(source, /terminateCallbackRequest\(onTerminalFailure, error\)/);
|
||||
assert.doesNotMatch(source, /retryCallbackRequest|RequestService\.reAjaxFun|this\.restoreAgentSnapshot/);
|
||||
});
|
||||
|
||||
test("snapshot deletion terminates on network failure without an automatic replay", () => {
|
||||
const source = sourceBetween(
|
||||
agentApiSource,
|
||||
"deleteAgentSnapshot(agentId",
|
||||
"// 新增方法:获取智能体模板"
|
||||
);
|
||||
|
||||
assert.match(source, /deleteAgentSnapshot\(agentId, snapshotId, callback, onTerminalFailure\)/);
|
||||
assert.match(source, /\.method\('DELETE'\)/);
|
||||
assert.match(source, /terminateCallbackRequest\(onTerminalFailure, error\)/);
|
||||
assert.doesNotMatch(source, /retryCallbackRequest|RequestService\.reAjaxFun|this\.deleteAgentSnapshot/);
|
||||
});
|
||||
|
||||
test("restore preview uses the state data and token from one snapshot-detail response", () => {
|
||||
const source = sourceBetween(
|
||||
snapshotDialogSource,
|
||||
"restoreSnapshot(row)",
|
||||
"decorateSnapshotRows(rows)"
|
||||
);
|
||||
|
||||
assert.match(source, /this\.fetchSnapshotDetail\(row\.id\)\.then\(\(targetSnapshot\)/);
|
||||
assert.match(source, /beforeSnapshotData: targetSnapshot\.currentSnapshotData/);
|
||||
assert.match(source, /hasValidCurrentStateToken\(targetSnapshot\.currentStateToken\)/);
|
||||
assert.doesNotMatch(source, /fetchCurrentAgentData|fetchCurrentAgentTags|Promise\.all/);
|
||||
});
|
||||
|
||||
test("snapshot dialogs cannot close while a restore request is in flight", () => {
|
||||
const guardedDialogs = snapshotDialogSource.match(/:before-close="guardRestoreInFlightClose"/g) || [];
|
||||
const hiddenCloseButtons = snapshotDialogSource.match(/:show-close="!restoring"/g) || [];
|
||||
assert.equal(guardedDialogs.length, 2);
|
||||
assert.equal(hiddenCloseButtons.length, 2);
|
||||
assert.match(
|
||||
snapshotDialogSource,
|
||||
/class="snapshot-footer-button snapshot-footer-cancel"[\s\S]*?:disabled="restoring"[\s\S]*?@click="closeRestorePreview"/
|
||||
);
|
||||
|
||||
const closeMethods = sourceBetween(
|
||||
snapshotDialogSource,
|
||||
" close() {",
|
||||
" open() {"
|
||||
);
|
||||
assert.match(closeMethods, /close\(\) \{\s+if \(this\.restoring\) \{\s+return;/);
|
||||
assert.match(closeMethods, /guardRestoreInFlightClose\(done\) \{\s+if \(this\.restoring\) \{\s+return;[\s\S]*?done\(\);/);
|
||||
assert.match(closeMethods, /closeRestorePreview\(\) \{\s+if \(this\.restoring\) \{\s+return;[\s\S]*?this\.restorePreviewVisible = false;/);
|
||||
});
|
||||
|
||||
test("destructive restore requires a second explicit warning before the POST", () => {
|
||||
const source = sourceBetween(
|
||||
snapshotDialogSource,
|
||||
" confirmRestoreSnapshot() {",
|
||||
" deleteSnapshot(row) {"
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!this\.restoreWillClearChatHistory\) \{\s+this\.submitRestoreSnapshot\(snapshotId, currentStateToken\);\s+return;\s+\}/
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/this\.\$confirm\(\s+this\.\$t\("agentSnapshot\.restoreMemoryDestructiveWarning"\),[\s\S]*?type: "error"[\s\S]*?\)\.then\(\(\) => \{[\s\S]*?this\.submitRestoreSnapshot\(snapshotId, currentStateToken, requestSeq\);/
|
||||
);
|
||||
assert.match(source, /if \(this\.restoring \|\| !this\.restorePreviewRow\) \{\s+return;/);
|
||||
});
|
||||
|
||||
test("a terminal restore failure invalidates the atomic preview instead of replaying it", () => {
|
||||
const source = sourceBetween(
|
||||
snapshotDialogSource,
|
||||
" submitRestoreSnapshot(snapshotId",
|
||||
" deleteSnapshot(row) {"
|
||||
);
|
||||
|
||||
assert.match(source, /else \{\s+this\.invalidateRestorePreview\(\);\s+this\.\$message\.error\(this\.restoreFailedMessage\(data\)\);/);
|
||||
assert.match(source, /\}, \(\) => \{[\s\S]*?this\.invalidateRestorePreview\(\);\s+this\.\$message\.error\(this\.\$t\("agentSnapshot\.restoreFailed"\)\);/);
|
||||
assert.match(source, /invalidateRestorePreview\(\) \{[\s\S]*?this\.restorePreviewSnapshot = null;[\s\S]*?this\.restorePreviewRow = null;/);
|
||||
});
|
||||
|
||||
test("the latest snapshot does not expose a restore action", () => {
|
||||
const source = sourceBetween(
|
||||
snapshotDialogSource,
|
||||
" canRestoreSnapshot(row) {",
|
||||
" canDeleteSnapshot(row) {"
|
||||
);
|
||||
|
||||
assert.match(source, /!!row\?\.id && !row\.isLatestSnapshot/);
|
||||
assert.match(snapshotDialogSource, /v-if="canRestoreSnapshot\(scope\.row\)"/);
|
||||
assert.match(
|
||||
snapshotDialogSource,
|
||||
/restoreSnapshot\(row\) \{\s+if \(!this\.canRestoreSnapshot\(row\)\) \{\s+return;/
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user