Merge pull request #2304 from xinnan-tech/his_download

update:下载智能体的聊天记录
This commit is contained in:
欣南科技
2025-10-01 01:33:01 +08:00
committed by GitHub
15 changed files with 362 additions and 21 deletions
@@ -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; // 智能体角色
}
@@ -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;
}
}
@@ -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<Boolean>().ok(result);
}
/**
* 获取聊天记录下载链接
*
* @param agentId 智能体ID
* @param sessionId 会话ID
* @return UUID作为下载标识
*/
@Operation(summary = "获取聊天记录下载链接")
@RequiresPermissions("sys:role:normal")
@PostMapping("/getDownloadUrl/{agentId}/{sessionId}")
public Result<String> 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<String>().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<String, Object> params = Map.of(
"agentId", agentId,
Constant.PAGE, 1,
Constant.LIMIT, 1000 // 获取足够多的会话
);
PageData<AgentChatSessionDTO> sessionPage = agentChatHistoryService.getSessionListByAgentId(params);
List<AgentChatSessionDTO> 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<String> 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<String> 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<AgentChatHistoryDTO> 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();
}
}
}
@@ -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<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) {
@@ -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");
@@ -135,4 +135,12 @@
10124=您的mqtt密钥长度不安全,mqtt密钥必须同时包含大小写字母
10125=您的mqtt密钥包含弱密码
10128=字典标签重复
10129=modelType和provideCode不能为空
10129=modelType和provideCode不能为空
10132=没有权限查看该智能体的聊天记录
10133=会话ID不能为空
10134=智能体ID不能为空
10135=聊天记录下载失败
10136=下载链接已过期或无效
10137=下载链接无效
10138=用户
10139=智体
@@ -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
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
@@ -137,4 +137,12 @@
10128=字典标签重复
10129=SM2密钥未配置
10130=SM2解密失败
10131=modelType和provideCode不能为空
10131=modelType和provideCode不能为空
10132=没有权限查看该智能体的聊天记录
10133=会话ID不能为空
10134=智能体ID不能为空
10135=聊天记录下载失败
10136=下载链接已过期或无效
10137=下载链接无效
10138=用户
10139=智体
@@ -137,4 +137,12 @@
10128=字典標籤重複
10129=SM2密鑰未配置
10130=SM2解密失敗
10131=modelType和provideCode不能為空
10131=modelType和provideCode不能為空
10132=沒有權限查看該智能體的聊天記錄
10133=會話ID不能為空
10134=智能體ID不能為空
10135=聊天記錄下載失敗
10136=下載鏈接已過期或無效
10137=下載鏈接無效
10138=用戶
10139=智體
+27 -11
View File
@@ -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();
},
}
@@ -1,5 +1,6 @@
<template>
<el-dialog :title="$t('chatHistory.with') + agentName + $t('chatHistory.dialogTitle') + (currentMacAddress ? '[' + currentMacAddress + ']' : '')"
<el-dialog
:title="$t('chatHistory.with') + agentName + $t('chatHistory.dialogTitle') + (currentMacAddress ? '[' + currentMacAddress + ']' : '')"
:visible.sync="dialogVisible" width="80%" :before-close="handleClose" custom-class="chat-history-dialog">
<div class="chat-container">
<div class="session-list" @scroll="handleScroll">
@@ -36,6 +37,14 @@
</div>
</div>
</div>
<div v-if="currentSessionId" class="download-buttons">
<el-button type="primary" plain size="small" @click="downloadCurrentSessionWithPrevious">
{{ $t('chatHistory.downloadCurrentWithPreviousSessions') }}
</el-button>
<el-button type="primary" plain size="small" @click="downloadCurrentSession">
{{ $t('chatHistory.downloadCurrentSession') }}
</el-button>
</div>
</el-dialog>
</template>
@@ -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;
}
</style>
<style>
@@ -453,7 +502,7 @@ export default {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 90vh;
height: 98vh;
max-width: 85vw;
border-radius: 12px;
overflow: hidden;
+3
View File
@@ -500,6 +500,9 @@ export default {
'chatHistory.selectSession': 'Please select a session to view chat history',
'chatHistory.today': 'Today',
'chatHistory.yesterday': 'Yesterday',
'chatHistory.downloadCurrentSession': 'Download current session chat history',
'chatHistory.downloadCurrentWithPreviousSessions': 'Download current and previous 20 sessions chat history',
'chatHistory.downloadLinkFailed': 'Failed to get download link',
'cache.status': 'Cache Status',
'cache.cdnEnabled': 'CDN Mode Enabled',
'cache.cdnDisabled': 'CDN Mode Disabled',
+3
View File
@@ -500,6 +500,9 @@ export default {
'chatHistory.selectSession': '请选择会话查看聊天记录',
'chatHistory.today': '今天',
'chatHistory.yesterday': '昨天',
'chatHistory.downloadCurrentSession': '下载本会话聊天记录',
'chatHistory.downloadCurrentWithPreviousSessions': '下载本会话及前20条会话聊天记录',
'chatHistory.downloadLinkFailed': '获取下载链接失败',
'cache.status': '缓存状态',
'cache.cdnEnabled': 'CDN模式已启用',
'cache.cdnDisabled': 'CDN模式已禁用',
+3
View File
@@ -500,6 +500,9 @@ export default {
'chatHistory.selectSession': '請選擇會話查看聊天記錄',
'chatHistory.today': '今天',
'chatHistory.yesterday': '昨天',
'chatHistory.downloadCurrentSession': '下載本會話聊天記錄',
'chatHistory.downloadCurrentWithPreviousSessions': '下載本會話及前20條會話聊天記錄',
'chatHistory.downloadLinkFailed': '獲取下載鏈接失敗',
'cache.status': '緩存狀態',
'cache.cdnEnabled': 'CDN模式已啟用',
'cache.cdnDisabled': 'CDN模式已禁用',
@@ -30,8 +30,9 @@
show-overflow-tooltip>
<template slot-scope="scope">
<div v-if="isSensitiveParam(scope.row.paramCode)">
<span v-if="!scope.row.showValue">{{ maskSensitiveValue(scope.row.paramValue)
}}</span>
<span v-if="!scope.row.showValue">
{{ maskSensitiveValue(scope.row.paramValue) }}
</span>
<span v-else>{{ scope.row.paramValue }}</span>
<el-button size="mini" type="text" @click="toggleSensitiveValue(scope.row)">
{{ scope.row.showValue ? $t('paramManagement.hide') :
@@ -120,7 +121,7 @@ export default {
dialogVisible: false,
dialogTitle: "新增参数",
isAllSelected: false,
sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key"],
sensitive_keys: ["api_key", "personal_access_token", "access_token", "token", "secret", "access_key_secret", "secret_key", "password", "mqtt_signature_key", "private_key"],
paramForm: {
id: null,
paramCode: "",