diff --git a/main/manager-api/src/main/java/xiaozhi/common/exception/ErrorCode.java b/main/manager-api/src/main/java/xiaozhi/common/exception/ErrorCode.java index 0da1699b..10144db4 100644 --- a/main/manager-api/src/main/java/xiaozhi/common/exception/ErrorCode.java +++ b/main/manager-api/src/main/java/xiaozhi/common/exception/ErrorCode.java @@ -165,4 +165,14 @@ public interface ErrorCode { int SM2_KEY_NOT_CONFIGURED = 10129; // SM2密钥未配置 int SM2_DECRYPT_ERROR = 10130; // SM2解密失败 int MODEL_TYPE_PROVIDE_CODE_NOT_NULL = 10131; // modelType和provideCode不能为空 + + // 聊天记录相关错误码 + int CHAT_HISTORY_NO_PERMISSION = 10132; // 没有权限查看该智能体的聊天记录 + int CHAT_HISTORY_SESSION_ID_NOT_NULL = 10133; // 会话ID不能为空 + int CHAT_HISTORY_AGENT_ID_NOT_NULL = 10134; // 智能体ID不能为空 + int CHAT_HISTORY_DOWNLOAD_FAILED = 10135; // 聊天记录下载失败 + int DOWNLOAD_LINK_EXPIRED = 10136; // 下载链接已过期或无效 + int DOWNLOAD_LINK_INVALID = 10137; // 下载链接无效 + int CHAT_ROLE_USER = 10138; // 用户角色 + int CHAT_ROLE_AGENT = 10139; // 智能体角色 } diff --git a/main/manager-api/src/main/java/xiaozhi/common/redis/RedisKeys.java b/main/manager-api/src/main/java/xiaozhi/common/redis/RedisKeys.java index 6daa3b2d..751cc601 100644 --- a/main/manager-api/src/main/java/xiaozhi/common/redis/RedisKeys.java +++ b/main/manager-api/src/main/java/xiaozhi/common/redis/RedisKeys.java @@ -139,4 +139,11 @@ public class RedisKeys { return "sms:Validate:Code:" + phone + ":today_count"; } + /** + * 聊天记录UUID映射的Key + */ + public static String getChatHistoryKey(String uuid) { + return "agent:chat:history:" + uuid; + } + } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentChatHistoryController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentChatHistoryController.java index 9d6542cc..810c2fc1 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentChatHistoryController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentChatHistoryController.java @@ -1,5 +1,21 @@ package xiaozhi.modules.agent.controller; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.apache.commons.lang3.StringUtils; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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; @@ -7,18 +23,37 @@ import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import xiaozhi.common.constant.Constant; +import xiaozhi.common.exception.ErrorCode; +import xiaozhi.common.exception.RenException; +import xiaozhi.common.page.PageData; +import xiaozhi.common.redis.RedisKeys; +import xiaozhi.common.redis.RedisUtils; +import xiaozhi.common.user.UserDetail; +import xiaozhi.common.utils.DateUtils; +import xiaozhi.common.utils.MessageUtils; import xiaozhi.common.utils.Result; +import xiaozhi.modules.agent.dto.AgentChatHistoryDTO; import xiaozhi.modules.agent.dto.AgentChatHistoryReportDTO; +import xiaozhi.modules.agent.dto.AgentChatSessionDTO; +import xiaozhi.modules.agent.service.AgentChatHistoryService; +import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.biz.AgentChatHistoryBizService; +import xiaozhi.modules.security.user.SecurityUser; @Tag(name = "智能体聊天历史管理") @RequiredArgsConstructor @RestController @RequestMapping("/agent/chat-history") public class AgentChatHistoryController { + private static final Logger logger = LoggerFactory.getLogger(AgentChatHistoryController.class); private final AgentChatHistoryBizService agentChatHistoryBizService; + private final AgentChatHistoryService agentChatHistoryService; + private final AgentService agentService; + private final RedisUtils redisUtils; /** * 小智服务聊天上报请求 @@ -33,4 +68,182 @@ public class AgentChatHistoryController { Boolean result = agentChatHistoryBizService.report(request); return new Result().ok(result); } + + /** + * 获取聊天记录下载链接 + * + * @param agentId 智能体ID + * @param sessionId 会话ID + * @return UUID作为下载标识 + */ + @Operation(summary = "获取聊天记录下载链接") + @RequiresPermissions("sys:role:normal") + @PostMapping("/getDownloadUrl/{agentId}/{sessionId}") + public Result getDownloadUrl(@PathVariable("agentId") String agentId, + @PathVariable("sessionId") String sessionId) { + // 获取当前用户 + UserDetail user = SecurityUser.getUser(); + // 检查权限 + if (!agentService.checkAgentPermission(agentId, user.getId())) { + throw new RenException(ErrorCode.CHAT_HISTORY_NO_PERMISSION); + } + + // 生成UUID + String uuid = UUID.randomUUID().toString(); + // 存储agentId和sessionId到Redis,格式为agentId:sessionId + redisUtils.set(RedisKeys.getChatHistoryKey(uuid), agentId + ":" + sessionId); + + return new Result().ok(uuid); + } + + /** + * 下载本会话聊天记录 + * + * @param uuid 下载标识 + * @param response HTTP响应 + */ + @Operation(summary = "下载本会话聊天记录") + @GetMapping("/download/{uuid}/current") + public void downloadCurrentSession(@PathVariable("uuid") String uuid, + HttpServletResponse response) { + // 从Redis获取agentId和sessionId + String agentSessionInfo = (String) redisUtils.get(RedisKeys.getChatHistoryKey(uuid)); + if (StringUtils.isBlank(agentSessionInfo)) { + throw new RenException(ErrorCode.DOWNLOAD_LINK_EXPIRED); + } + + try { + // 解析agentId和sessionId + String[] parts = agentSessionInfo.split(":"); + if (parts.length != 2) { + throw new RenException(ErrorCode.DOWNLOAD_LINK_INVALID); + } + String agentId = parts[0]; + String sessionId = parts[1]; + + // 执行下载 + downloadChatHistory(agentId, List.of(sessionId), response); + } finally { + // 下载完成后删除UUID,防止盗刷 + redisUtils.delete(RedisKeys.getChatHistoryKey(uuid)); + } + } + + /** + * 下载本会话及前20条会话聊天记录 + * + * @param uuid 下载标识 + * @param response HTTP响应 + */ + @Operation(summary = "下载本会话及前20条会话聊天记录") + @GetMapping("/download/{uuid}/previous") + public void downloadCurrentSessionWithPrevious(@PathVariable("uuid") String uuid, + HttpServletResponse response) { + // 从Redis获取agentId和sessionId + String agentSessionInfo = (String) redisUtils.get(RedisKeys.getChatHistoryKey(uuid)); + if (StringUtils.isBlank(agentSessionInfo)) { + throw new RenException("下载链接已过期或无效"); + } + + try { + // 解析agentId和sessionId + String[] parts = agentSessionInfo.split(":"); + if (parts.length != 2) { + throw new RenException("下载链接无效"); + } + String agentId = parts[0]; + String sessionId = parts[1]; + + // 获取所有会话列表 + Map params = Map.of( + "agentId", agentId, + Constant.PAGE, 1, + Constant.LIMIT, 1000 // 获取足够多的会话 + ); + PageData sessionPage = agentChatHistoryService.getSessionListByAgentId(params); + List allSessions = sessionPage.getList(); + + // 查找当前会话在列表中的位置 + int currentIndex = -1; + for (int i = 0; i < allSessions.size(); i++) { + if (allSessions.get(i).getSessionId().equals(sessionId)) { + currentIndex = i; + break; + } + } + + // 如果找到了当前会话,收集当前会话及前20条会话ID + List sessionIdsToDownload = new ArrayList<>(); + if (currentIndex != -1) { + // 从当前会话开始,向后(数组后面)取最多20条会话(包括当前会话) + int endIndex = Math.min(allSessions.size() - 1, currentIndex + 20); // 确保不越界 + for (int i = currentIndex; i <= endIndex; i++) { + sessionIdsToDownload.add(allSessions.get(i).getSessionId()); + } + } + + // 如果没有找到当前会话,至少下载当前会话 + if (sessionIdsToDownload.isEmpty()) { + sessionIdsToDownload.add(sessionId); + } + downloadChatHistory(agentId, sessionIdsToDownload, response); + } finally { + // 下载完成后删除UUID,防止盗刷 + redisUtils.delete(RedisKeys.getChatHistoryKey(uuid)); + } + } + + /** + * 下载指定会话的聊天记录 + * + * @param agentId 智能体ID + * @param sessionIds 会话ID列表 + * @param response HTTP响应 + */ + private void downloadChatHistory(String agentId, List sessionIds, HttpServletResponse response) { + try { + // 设置响应头 + response.setContentType("text/plain;charset=UTF-8"); + String fileName = URLEncoder.encode("history.txt", StandardCharsets.UTF_8.toString()); + response.setHeader("Content-Disposition", "attachment;filename=" + fileName); + + // 获取聊天记录并写入响应流 + try (OutputStream out = response.getOutputStream()) { + // 为每个会话生成聊天记录 + for (String sessionId : sessionIds) { + // 获取该会话的所有聊天记录 + List chatHistoryList = agentChatHistoryService + .getChatHistoryBySessionId(agentId, sessionId); + + // 从聊天记录中获取第一条消息的创建时间作为会话时间 + if (!chatHistoryList.isEmpty()) { + Date firstMessageTime = chatHistoryList.get(0).getCreatedAt(); + String sessionTimeStr = DateUtils.format(firstMessageTime, DateUtils.DATE_TIME_PATTERN); + out.write((sessionTimeStr + "\n").getBytes(StandardCharsets.UTF_8)); + } + + for (AgentChatHistoryDTO message : chatHistoryList) { + String role = message.getChatType() == 1 ? MessageUtils.getMessage(ErrorCode.CHAT_ROLE_USER) + : MessageUtils.getMessage(ErrorCode.CHAT_ROLE_AGENT); + String direction = message.getChatType() == 1 ? ">>" : "<<"; + Date messageTime = message.getCreatedAt(); + String messageTimeStr = DateUtils.format(messageTime, DateUtils.DATE_TIME_PATTERN); + String content = message.getContent(); + + String line = "[" + role + "]-[" + messageTimeStr + "]" + direction + ":" + content + "\n"; + out.write(line.getBytes(StandardCharsets.UTF_8)); + } + + // 会话之间添加空行分隔 + if (sessionIds.indexOf(sessionId) < sessionIds.size() - 1) { + out.write("\n".getBytes(StandardCharsets.UTF_8)); + } + } + + out.flush(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java index 9ec04a4e..72b8ea4d 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java @@ -19,8 +19,8 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import lombok.AllArgsConstructor; import xiaozhi.common.constant.Constant; -import xiaozhi.common.exception.RenException; 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; @@ -183,6 +183,9 @@ public class AgentServiceImpl extends BaseServiceImpl 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) { diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java index c015240f..290a279d 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/config/ShiroConfig.java @@ -88,6 +88,7 @@ public class ShiroConfig { // 将config路径使用server服务过滤器 filterMap.put("/config/**", "server"); filterMap.put("/agent/chat-history/report", "server"); + filterMap.put("/agent/chat-history/download/**", "anon"); filterMap.put("/agent/saveMemory/**", "server"); filterMap.put("/agent/play/**", "anon"); filterMap.put("/**", "oauth2"); diff --git a/main/manager-api/src/main/resources/i18n/messages.properties b/main/manager-api/src/main/resources/i18n/messages.properties index 63c9403d..265ab0cf 100644 --- a/main/manager-api/src/main/resources/i18n/messages.properties +++ b/main/manager-api/src/main/resources/i18n/messages.properties @@ -135,4 +135,12 @@ 10124=您的mqtt密钥长度不安全,mqtt密钥必须同时包含大小写字母 10125=您的mqtt密钥包含弱密码 10128=字典标签重复 -10129=modelType和provideCode不能为空 \ No newline at end of file +10129=modelType和provideCode不能为空 +10132=没有权限查看该智能体的聊天记录 +10133=会话ID不能为空 +10134=智能体ID不能为空 +10135=聊天记录下载失败 +10136=下载链接已过期或无效 +10137=下载链接无效 +10138=用户 +10139=智体 \ No newline at end of file diff --git a/main/manager-api/src/main/resources/i18n/messages_en_US.properties b/main/manager-api/src/main/resources/i18n/messages_en_US.properties index a566cab3..49fdc144 100644 --- a/main/manager-api/src/main/resources/i18n/messages_en_US.properties +++ b/main/manager-api/src/main/resources/i18n/messages_en_US.properties @@ -137,4 +137,12 @@ 10128=Dictionary label is duplicated 10129=SM2 key not configured 10130=SM2 decryption failed -10131=modelType and provideCode cannot be empty \ No newline at end of file +10131=modelType and provideCode cannot be empty +10132=No permission to view the chat history of this agent +10133=Session ID cannot be empty +10134=Agent ID cannot be empty +10135=Chat history download failed +10136=Download link expired or invalid +10137=Download link invalid +10138=User +10139=Agent \ No newline at end of file diff --git a/main/manager-api/src/main/resources/i18n/messages_zh_CN.properties b/main/manager-api/src/main/resources/i18n/messages_zh_CN.properties index 119326d0..7695fdf2 100644 --- a/main/manager-api/src/main/resources/i18n/messages_zh_CN.properties +++ b/main/manager-api/src/main/resources/i18n/messages_zh_CN.properties @@ -137,4 +137,12 @@ 10128=字典标签重复 10129=SM2密钥未配置 10130=SM2解密失败 -10131=modelType和provideCode不能为空 \ No newline at end of file +10131=modelType和provideCode不能为空 +10132=没有权限查看该智能体的聊天记录 +10133=会话ID不能为空 +10134=智能体ID不能为空 +10135=聊天记录下载失败 +10136=下载链接已过期或无效 +10137=下载链接无效 +10138=用户 +10139=智体 \ No newline at end of file diff --git a/main/manager-api/src/main/resources/i18n/messages_zh_TW.properties b/main/manager-api/src/main/resources/i18n/messages_zh_TW.properties index 5ad7bb6f..ef66c2bb 100644 --- a/main/manager-api/src/main/resources/i18n/messages_zh_TW.properties +++ b/main/manager-api/src/main/resources/i18n/messages_zh_TW.properties @@ -137,4 +137,12 @@ 10128=字典標籤重複 10129=SM2密鑰未配置 10130=SM2解密失敗 -10131=modelType和provideCode不能為空 \ No newline at end of file +10131=modelType和provideCode不能為空 +10132=沒有權限查看該智能體的聊天記錄 +10133=會話ID不能為空 +10134=智能體ID不能為空 +10135=聊天記錄下載失敗 +10136=下載鏈接已過期或無效 +10137=下載鏈接無效 +10138=用戶 +10139=智體 \ No newline at end of file diff --git a/main/manager-web/src/apis/module/agent.js b/main/manager-web/src/apis/module/agent.js index bcf68a29..4a76514c 100644 --- a/main/manager-web/src/apis/module/agent.js +++ b/main/manager-web/src/apis/module/agent.js @@ -1,5 +1,5 @@ -import RequestService from '../httpRequest'; import { getServiceUrl } from '../api'; +import RequestService from '../httpRequest'; export default { @@ -97,7 +97,7 @@ export default { }); }).send(); }, - + // 新增:获取智能体模板分页列表 getAgentTemplatesPage(params, callback) { RequestService.sendRequest() @@ -208,7 +208,7 @@ export default { }).send(); }, // 获取指定智能体声纹列表 - getAgentVoicePrintList(id,callback) { + getAgentVoicePrintList(id, callback) { RequestService.sendRequest() .url(`${getServiceUrl()}/agent/voice-print/list/${id}`) .method('GET') @@ -218,7 +218,7 @@ export default { }) .networkFail(() => { RequestService.reAjaxFun(() => { - this.getAgentVoicePrintList(id,callback); + this.getAgentVoicePrintList(id, callback); }); }).send(); }, @@ -254,7 +254,7 @@ export default { }).send(); }, // 获取指定智能体用户类型聊天记录 - getRecentlyFiftyByAgentId(id,callback) { + getRecentlyFiftyByAgentId(id, callback) { RequestService.sendRequest() .url(`${getServiceUrl()}/agent/${id}/chat-history/user`) .method('GET') @@ -264,12 +264,12 @@ export default { }) .networkFail(() => { RequestService.reAjaxFun(() => { - this.getRecentlyFiftyByAgentId(id,callback); + this.getRecentlyFiftyByAgentId(id, callback); }); }).send(); }, // 获取指定智能体用户类型聊天记录 - getContentByAudioId(id,callback) { + getContentByAudioId(id, callback) { RequestService.sendRequest() .url(`${getServiceUrl()}/agent/${id}/chat-history/audio`) .method('GET') @@ -279,7 +279,7 @@ export default { }) .networkFail(() => { RequestService.reAjaxFun(() => { - this.getContentByAudioId(id,callback); + this.getContentByAudioId(id, callback); }); }).send(); }, @@ -300,7 +300,7 @@ export default { }); }).send(); }, - + // 更新智能体模板 updateAgentTemplate(templateData, callback) { RequestService.sendRequest() @@ -317,7 +317,7 @@ export default { }); }).send(); }, - + // 删除智能体模板 deleteAgentTemplate(id, callback) { RequestService.sendRequest() @@ -333,7 +333,7 @@ export default { }); }).send(); }, - + // 批量删除智能体模板 batchDeleteAgentTemplate(ids, callback) { RequestService.sendRequest() @@ -366,4 +366,20 @@ export default { }); }).send(); }, + + // 获取聊天记录下载链接UUID + getDownloadUrl(agentId, sessionId, callback) { + RequestService.sendRequest() + .url(`${getServiceUrl()}/agent/chat-history/getDownloadUrl/${agentId}/${sessionId}`) + .method('POST') + .success((res) => { + RequestService.clearRequestTime(); + callback(res); + }) + .networkFail(() => { + RequestService.reAjaxFun(() => { + this.getDownloadUrl(agentId, sessionId, callback); + }); + }).send(); + }, } diff --git a/main/manager-web/src/components/ChatHistoryDialog.vue b/main/manager-web/src/components/ChatHistoryDialog.vue index 5ea46948..12c06621 100644 --- a/main/manager-web/src/components/ChatHistoryDialog.vue +++ b/main/manager-web/src/components/ChatHistoryDialog.vue @@ -1,5 +1,6 @@ @@ -292,6 +301,30 @@ export default { // 返回对应的头像图片 return require(`@/assets/user-avatar${avatarIndex}.png`); + }, + + // 下载本会话聊天记录 + downloadCurrentSession() { + Api.agent.getDownloadUrl(this.agentId, this.currentSessionId, (res) => { + if (res && res.data && res.data.code === 0 && res.data.data) { + const uuid = res.data.data; + window.open(`${Api.getServiceUrl()}/agent/chat-history/download/${uuid}/current`, '_blank'); + } else { + this.$message.error(this.$t('chatHistory.downloadLinkFailed')); + } + }); + }, + + // 下载本会话及前20条会话聊天记录 + downloadCurrentSessionWithPrevious() { + Api.agent.getDownloadUrl(this.agentId, this.currentSessionId, (res) => { + if (res && res.data && res.data.code === 0 && res.data.data) { + const uuid = res.data.data; + window.open(`${Api.getServiceUrl()}/agent/chat-history/download/${uuid}/previous`, '_blank'); + } else { + this.$message.error(this.$t('chatHistory.downloadLinkFailed')); + } + }); } } }; @@ -441,6 +474,22 @@ export default { vertical-align: middle; margin: 0 10px; } + +.download-buttons { + padding: 20px; + display: flex; + gap: 10px; + border-top: 1px solid #eee; + position: absolute; + bottom: 0; + left: 0; + right: 0; + background-color: white; +} + +.download-buttons .el-button { + flex: 1; +}