package com.vincent.rsf.server.ai.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.vincent.rsf.framework.common.Cools; import com.vincent.rsf.framework.exception.CoolException; import com.vincent.rsf.server.ai.config.AiDefaults; import com.vincent.rsf.server.ai.dto.AiChatMemoryDto; import com.vincent.rsf.server.ai.dto.AiChatMessageDto; import com.vincent.rsf.server.ai.dto.AiChatSessionPinRequest; import com.vincent.rsf.server.ai.dto.AiChatSessionRenameRequest; import com.vincent.rsf.server.ai.dto.AiChatSessionDto; import com.vincent.rsf.server.ai.entity.AiChatMessage; import com.vincent.rsf.server.ai.entity.AiChatSession; import com.vincent.rsf.server.ai.mapper.AiChatMessageMapper; import com.vincent.rsf.server.ai.mapper.AiChatSessionMapper; import com.vincent.rsf.server.ai.service.AiChatMemoryService; import com.vincent.rsf.server.system.enums.StatusType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.Date; import java.util.List; @Service @RequiredArgsConstructor public class AiChatMemoryServiceImpl implements AiChatMemoryService { private final AiChatSessionMapper aiChatSessionMapper; private final AiChatMessageMapper aiChatMessageMapper; /** * 读取会话记忆快照。 * 返回结果同时包含完整落库历史、短期记忆窗口以及摘要/事实记忆, * 便于调用方按不同用途选择数据粒度。 */ @Override public AiChatMemoryDto getMemory(Long userId, Long tenantId, String promptCode, Long sessionId) { ensureIdentity(userId, tenantId); String resolvedPromptCode = requirePromptCode(promptCode); AiChatSession session = sessionId == null ? findLatestSession(userId, tenantId, resolvedPromptCode) : getSession(sessionId, userId, tenantId, resolvedPromptCode); if (session == null) { return AiChatMemoryDto.builder() .sessionId(null) .memorySummary(null) .memoryFacts(null) .recentMessageCount(0) .persistedMessages(List.of()) .shortMemoryMessages(List.of()) .build(); } List persistedMessages = listMessages(session.getId()); List shortMemoryMessages = tailMessagesByRounds(persistedMessages, AiDefaults.MEMORY_RECENT_ROUNDS); return AiChatMemoryDto.builder() .sessionId(session.getId()) .memorySummary(session.getMemorySummary()) .memoryFacts(session.getMemoryFacts()) .recentMessageCount(shortMemoryMessages.size()) .persistedMessages(persistedMessages) .shortMemoryMessages(shortMemoryMessages) .build(); } /** * 查询当前用户在某个 Prompt 下的会话列表。 * 列表只返回用于侧边栏展示的摘要信息,不返回完整对话内容。 */ @Override public List listSessions(Long userId, Long tenantId, String promptCode, String keyword) { ensureIdentity(userId, tenantId); String resolvedPromptCode = requirePromptCode(promptCode); List sessions = aiChatSessionMapper.selectList(new LambdaQueryWrapper() .eq(AiChatSession::getUserId, userId) .eq(AiChatSession::getTenantId, tenantId) .eq(AiChatSession::getPromptCode, resolvedPromptCode) .eq(AiChatSession::getDeleted, 0) .eq(AiChatSession::getStatus, StatusType.ENABLE.val) .like(StringUtils.hasText(keyword), AiChatSession::getTitle, keyword == null ? null : keyword.trim()) .orderByDesc(AiChatSession::getPinned) .orderByDesc(AiChatSession::getLastMessageTime) .orderByDesc(AiChatSession::getId)); if (Cools.isEmpty(sessions)) { return List.of(); } List result = new ArrayList<>(); for (AiChatSession session : sessions) { result.add(buildSessionDto(session)); } return result; } /** * 解析本轮请求应该落到哪个会话。 * 如果前端带了 sessionId 则做归属校验并复用;否则自动创建新会话。 */ @Override public AiChatSession resolveSession(Long userId, Long tenantId, String promptCode, Long sessionId, String titleSeed) { ensureIdentity(userId, tenantId); String resolvedPromptCode = requirePromptCode(promptCode); if (sessionId != null) { return getSession(sessionId, userId, tenantId, resolvedPromptCode); } Date now = new Date(); AiChatSession session = new AiChatSession() .setTitle(buildSessionTitle(titleSeed)) .setPromptCode(resolvedPromptCode) .setUserId(userId) .setTenantId(tenantId) .setLastMessageTime(now) .setPinned(0) .setStatus(StatusType.ENABLE.val) .setDeleted(0) .setCreateBy(userId) .setCreateTime(now) .setUpdateBy(userId) .setUpdateTime(now); aiChatSessionMapper.insert(session); return session; } /** * 落库保存一整轮对话。 * 这里会顺序写入本轮用户消息和模型回复,并在最后刷新会话标题、最后活跃时间和记忆画像。 */ @Override public void saveRound(AiChatSession session, Long userId, Long tenantId, List memoryMessages, String assistantContent) { if (session == null || session.getId() == null) { throw new CoolException("AI 会话不存在"); } ensureIdentity(userId, tenantId); List normalizedMessages = normalizeMessages(memoryMessages); if (normalizedMessages.isEmpty()) { throw new CoolException("本轮没有可保存的对话消息"); } int nextSeqNo = findNextSeqNo(session.getId()); Date now = new Date(); for (AiChatMessageDto message : normalizedMessages) { aiChatMessageMapper.insert(buildMessageEntity(session.getId(), nextSeqNo++, message.getRole(), message.getContent(), userId, tenantId, now)); } if (StringUtils.hasText(assistantContent)) { aiChatMessageMapper.insert(buildMessageEntity(session.getId(), nextSeqNo, "assistant", assistantContent, userId, tenantId, now)); } AiChatSession update = new AiChatSession() .setId(session.getId()) .setTitle(resolveUpdatedTitle(session.getTitle(), normalizedMessages)) .setLastMessageTime(now) .setUpdateBy(userId) .setUpdateTime(now); aiChatSessionMapper.updateById(update); refreshMemoryProfile(session.getId(), userId); } /** 删除整个会话及其消息。 */ @Override public void removeSession(Long userId, Long tenantId, Long sessionId) { ensureIdentity(userId, tenantId); if (sessionId == null) { throw new CoolException("AI 会话 ID 不能为空"); } AiChatSession session = aiChatSessionMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatSession::getId, sessionId) .eq(AiChatSession::getUserId, userId) .eq(AiChatSession::getTenantId, tenantId) .eq(AiChatSession::getDeleted, 0) .last("limit 1")); if (session == null) { throw new CoolException("AI 会话不存在或无权删除"); } Date now = new Date(); AiChatSession updateSession = new AiChatSession() .setId(sessionId) .setDeleted(1) .setUpdateBy(userId) .setUpdateTime(now); aiChatSessionMapper.updateById(updateSession); List messages = aiChatMessageMapper.selectList(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, sessionId) .eq(AiChatMessage::getDeleted, 0)); for (AiChatMessage message : messages) { AiChatMessage updateMessage = new AiChatMessage() .setId(message.getId()) .setDeleted(1); aiChatMessageMapper.updateById(updateMessage); } } /** 更新会话标题并返回最新会话摘要。 */ @Override public AiChatSessionDto renameSession(Long userId, Long tenantId, Long sessionId, AiChatSessionRenameRequest request) { ensureIdentity(userId, tenantId); if (request == null || !StringUtils.hasText(request.getTitle())) { throw new CoolException("会话标题不能为空"); } AiChatSession session = requireOwnedSession(sessionId, userId, tenantId); Date now = new Date(); AiChatSession update = new AiChatSession() .setId(sessionId) .setTitle(buildSessionTitle(request.getTitle())) .setUpdateBy(userId) .setUpdateTime(now); aiChatSessionMapper.updateById(update); return buildSessionDto(requireOwnedSession(sessionId, userId, tenantId)); } /** 更新会话置顶状态。 */ @Override public AiChatSessionDto pinSession(Long userId, Long tenantId, Long sessionId, AiChatSessionPinRequest request) { ensureIdentity(userId, tenantId); if (request == null || request.getPinned() == null) { throw new CoolException("置顶状态不能为空"); } AiChatSession session = requireOwnedSession(sessionId, userId, tenantId); Date now = new Date(); AiChatSession update = new AiChatSession() .setId(sessionId) .setPinned(Boolean.TRUE.equals(request.getPinned()) ? 1 : 0) .setUpdateBy(userId) .setUpdateTime(now); aiChatSessionMapper.updateById(update); return buildSessionDto(requireOwnedSession(sessionId, userId, tenantId)); } /** 清空某个会话的全部消息和派生记忆字段。 */ @Override public void clearSessionMemory(Long userId, Long tenantId, Long sessionId) { ensureIdentity(userId, tenantId); AiChatSession session = requireOwnedSession(sessionId, userId, tenantId); List messages = aiChatMessageMapper.selectList(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, sessionId) .eq(AiChatMessage::getDeleted, 0)); for (AiChatMessage message : messages) { aiChatMessageMapper.updateById(new AiChatMessage() .setId(message.getId()) .setDeleted(1)); } aiChatSessionMapper.updateById(new AiChatSession() .setId(sessionId) .setMemorySummary(null) .setMemoryFacts(null) .setUpdateBy(userId) .setUpdateTime(new Date()) .setLastMessageTime(session.getCreateTime())); } /** 只保留最近一轮问答,用于手动裁剪长会话。 */ @Override public void retainLatestRound(Long userId, Long tenantId, Long sessionId) { ensureIdentity(userId, tenantId); requireOwnedSession(sessionId, userId, tenantId); List records = listMessageRecords(sessionId); if (records.isEmpty()) { return; } List retained = tailMessageRecordsByRounds(records, 1); for (AiChatMessage message : records) { boolean shouldKeep = retained.stream().anyMatch(item -> item.getId().equals(message.getId())); if (!shouldKeep) { aiChatMessageMapper.updateById(new AiChatMessage() .setId(message.getId()) .setDeleted(1)); } } refreshMemoryProfile(sessionId, userId); } private AiChatSession findLatestSession(Long userId, Long tenantId, String promptCode) { return aiChatSessionMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatSession::getUserId, userId) .eq(AiChatSession::getTenantId, tenantId) .eq(AiChatSession::getPromptCode, promptCode) .eq(AiChatSession::getDeleted, 0) .eq(AiChatSession::getStatus, StatusType.ENABLE.val) .orderByDesc(AiChatSession::getLastMessageTime) .orderByDesc(AiChatSession::getId) .last("limit 1")); } private AiChatSession getSession(Long sessionId, Long userId, Long tenantId, String promptCode) { AiChatSession session = aiChatSessionMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatSession::getId, sessionId) .eq(AiChatSession::getUserId, userId) .eq(AiChatSession::getTenantId, tenantId) .eq(AiChatSession::getPromptCode, promptCode) .eq(AiChatSession::getDeleted, 0) .eq(AiChatSession::getStatus, StatusType.ENABLE.val) .last("limit 1")); if (session == null) { throw new CoolException("AI 会话不存在或无权访问"); } return session; } private AiChatSession requireOwnedSession(Long sessionId, Long userId, Long tenantId) { if (sessionId == null) { throw new CoolException("AI 会话 ID 不能为空"); } AiChatSession session = aiChatSessionMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatSession::getId, sessionId) .eq(AiChatSession::getUserId, userId) .eq(AiChatSession::getTenantId, tenantId) .eq(AiChatSession::getDeleted, 0) .eq(AiChatSession::getStatus, StatusType.ENABLE.val) .last("limit 1")); if (session == null) { throw new CoolException("AI 会话不存在或无权访问"); } return session; } private List listMessages(Long sessionId) { List records = listMessageRecords(sessionId); if (Cools.isEmpty(records)) { return List.of(); } List messages = new ArrayList<>(); for (AiChatMessage record : records) { if (!StringUtils.hasText(record.getContent())) { continue; } AiChatMessageDto item = new AiChatMessageDto(); item.setRole(record.getRole()); item.setContent(record.getContent()); messages.add(item); } return messages; } private List normalizeMessages(List memoryMessages) { /** 清洗前端上传的内存消息,只允许 user/assistant 两类角色落库。 */ List normalized = new ArrayList<>(); if (Cools.isEmpty(memoryMessages)) { return normalized; } for (AiChatMessageDto item : memoryMessages) { if (item == null || !StringUtils.hasText(item.getContent())) { continue; } String role = item.getRole() == null ? "user" : item.getRole().toLowerCase(); if ("system".equals(role)) { continue; } AiChatMessageDto normalizedItem = new AiChatMessageDto(); normalizedItem.setRole("assistant".equals(role) ? "assistant" : "user"); normalizedItem.setContent(item.getContent().trim()); normalized.add(normalizedItem); } return normalized; } private List listMessageRecords(Long sessionId) { return aiChatMessageMapper.selectList(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, sessionId) .eq(AiChatMessage::getDeleted, 0) .orderByAsc(AiChatMessage::getSeqNo) .orderByAsc(AiChatMessage::getId)); } private int findNextSeqNo(Long sessionId) { AiChatMessage lastMessage = aiChatMessageMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, sessionId) .eq(AiChatMessage::getDeleted, 0) .orderByDesc(AiChatMessage::getSeqNo) .orderByDesc(AiChatMessage::getId) .last("limit 1")); return lastMessage == null || lastMessage.getSeqNo() == null ? 1 : lastMessage.getSeqNo() + 1; } private AiChatMessage buildMessageEntity(Long sessionId, int seqNo, String role, String content, Long userId, Long tenantId, Date createTime) { return new AiChatMessage() .setSessionId(sessionId) .setSeqNo(seqNo) .setRole(role) .setContent(content) .setContentLength(content == null ? 0 : content.length()) .setUserId(userId) .setTenantId(tenantId) .setDeleted(0) .setCreateBy(userId) .setCreateTime(createTime); } private String resolveUpdatedTitle(String currentTitle, List memoryMessages) { if (StringUtils.hasText(currentTitle)) { return currentTitle; } for (AiChatMessageDto item : memoryMessages) { if ("user".equals(item.getRole()) && StringUtils.hasText(item.getContent())) { return buildSessionTitle(item.getContent()); } } return null; } private String buildSessionTitle(String titleSeed) { /** * 把首轮用户问题压缩成适合作为会话标题的短摘要。 * 这里会去掉换行、连续空白,并优先在自然语义断点处截断。 */ if (!StringUtils.hasText(titleSeed)) { throw new CoolException("AI 会话标题不能为空"); } String title = titleSeed.trim() .replace("\r", " ") .replace("\n", " ") .replaceAll("\\s+", " "); int punctuationIndex = findSummaryBreakIndex(title); if (punctuationIndex > 0) { title = title.substring(0, punctuationIndex).trim(); } return title.length() > 48 ? title.substring(0, 48) : title; } private int findSummaryBreakIndex(String title) { String[] separators = {"。", "!", "?", ".", "!", "?"}; int result = -1; for (String separator : separators) { int index = title.indexOf(separator); if (index > 0 && (result < 0 || index < result)) { result = index; } } return result; } private AiChatSessionDto buildSessionDto(AiChatSession session) { AiChatMessage lastMessage = aiChatMessageMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, session.getId()) .eq(AiChatMessage::getDeleted, 0) .orderByDesc(AiChatMessage::getSeqNo) .orderByDesc(AiChatMessage::getId) .last("limit 1")); return AiChatSessionDto.builder() .sessionId(session.getId()) .title(session.getTitle()) .promptCode(session.getPromptCode()) .pinned(session.getPinned() != null && session.getPinned() == 1) .lastMessagePreview(buildLastMessagePreview(lastMessage)) .lastMessageTime(session.getLastMessageTime()) .build(); } private String buildLastMessagePreview(AiChatMessage message) { if (message == null || !StringUtils.hasText(message.getContent())) { return null; } String preview = message.getContent().trim() .replace("\r", " ") .replace("\n", " ") .replaceAll("\\s+", " "); String prefix = "assistant".equalsIgnoreCase(message.getRole()) ? "AI: " : "你: "; String normalized = prefix + preview; return normalized.length() > 80 ? normalized.substring(0, 80) : normalized; } private void refreshMemoryProfile(Long sessionId, Long userId) { /** * 重新计算会话的摘要记忆和关键事实。 * 这是“持久化消息”和“模型上下文治理”之间的桥梁方法。 */ List messages = listMessages(sessionId); List shortMemoryMessages = tailMessagesByRounds(messages, AiDefaults.MEMORY_RECENT_ROUNDS); List historyMessages = messages.size() > shortMemoryMessages.size() ? messages.subList(0, messages.size() - shortMemoryMessages.size()) : List.of(); String memorySummary = historyMessages.size() >= AiDefaults.MEMORY_SUMMARY_TRIGGER_MESSAGES ? buildMemorySummary(historyMessages) : null; String memoryFacts = buildMemoryFacts(messages); AiChatMessage lastMessage = aiChatMessageMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, sessionId) .eq(AiChatMessage::getDeleted, 0) .orderByDesc(AiChatMessage::getSeqNo) .orderByDesc(AiChatMessage::getId) .last("limit 1")); aiChatSessionMapper.updateById(new AiChatSession() .setId(sessionId) .setMemorySummary(memorySummary) .setMemoryFacts(memoryFacts) .setLastMessageTime(lastMessage == null ? null : lastMessage.getCreateTime()) .setUpdateBy(userId) .setUpdateTime(new Date())); } private List tailMessagesByRounds(List source, int rounds) { /** 按“用户发言轮次”裁剪最近消息,而不是简单按条数截断。 */ if (Cools.isEmpty(source) || rounds <= 0) { return List.of(); } int userCount = 0; int startIndex = source.size(); for (int i = source.size() - 1; i >= 0; i--) { AiChatMessageDto item = source.get(i); startIndex = i; if (item != null && "user".equalsIgnoreCase(item.getRole())) { userCount++; if (userCount >= rounds) { break; } } } return new ArrayList<>(source.subList(Math.max(0, startIndex), source.size())); } private List tailMessageRecordsByRounds(List source, int rounds) { if (Cools.isEmpty(source) || rounds <= 0) { return List.of(); } int userCount = 0; int startIndex = source.size(); for (int i = source.size() - 1; i >= 0; i--) { AiChatMessage item = source.get(i); startIndex = i; if (item != null && "user".equalsIgnoreCase(item.getRole())) { userCount++; if (userCount >= rounds) { break; } } } return new ArrayList<>(source.subList(Math.max(0, startIndex), source.size())); } private String buildMemorySummary(List historyMessages) { /** 为较早历史生成可直接插入系统消息的文本摘要。 */ StringBuilder builder = new StringBuilder("较早对话摘要:\n"); for (AiChatMessageDto item : historyMessages) { if (item == null || !StringUtils.hasText(item.getContent())) { continue; } String prefix = "assistant".equalsIgnoreCase(item.getRole()) ? "- AI: " : "- 用户: "; String content = compactText(item.getContent(), 120); if (!StringUtils.hasText(content)) { continue; } builder.append(prefix).append(content).append("\n"); if (builder.length() >= AiDefaults.MEMORY_SUMMARY_MAX_LENGTH) { break; } } return compactText(builder.toString(), AiDefaults.MEMORY_SUMMARY_MAX_LENGTH); } private String buildMemoryFacts(List messages) { /** 从最近用户关注点中提炼关键事实,作为轻量持久记忆。 */ if (Cools.isEmpty(messages)) { return null; } StringBuilder builder = new StringBuilder("关键事实:\n"); int userFacts = 0; for (int i = messages.size() - 1; i >= 0 && userFacts < 4; i--) { AiChatMessageDto item = messages.get(i); if (item == null || !"user".equalsIgnoreCase(item.getRole()) || !StringUtils.hasText(item.getContent())) { continue; } builder.append("- 用户关注: ").append(compactText(item.getContent(), 100)).append("\n"); userFacts++; } return userFacts == 0 ? null : compactText(builder.toString(), AiDefaults.MEMORY_FACTS_MAX_LENGTH); } private String compactText(String content, int maxLength) { if (!StringUtils.hasText(content)) { return null; } String normalized = content.trim() .replace("\r", " ") .replace("\n", " ") .replaceAll("\\s+", " "); return normalized.length() > maxLength ? normalized.substring(0, maxLength) : normalized; } private void ensureIdentity(Long userId, Long tenantId) { if (userId == null) { throw new CoolException("当前登录用户不存在"); } if (tenantId == null) { throw new CoolException("当前租户不存在"); } } private String requirePromptCode(String promptCode) { if (!StringUtils.hasText(promptCode)) { throw new CoolException("Prompt 编码不能为空"); } return promptCode; } }