zhou zhou
5 小时以前 5d16d9a0e7240ff4e6346bfee4890159da5a764e
#AI.记忆治理
13个文件已修改
406 ■■■■■ 已修改文件
rsf-admin/src/api/ai/chat.js 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/layout/AiChatDrawer.jsx 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMemoryDto.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRuntimeDto.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatMessage.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatSession.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatMemoryService.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatService.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java 28 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
version/db/ai_feature.sql 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/api/ai/chat.js
@@ -51,6 +51,24 @@
    throw new Error(msg || "更新 AI 会话置顶状态失败");
};
export const clearAiSessionMemory = async (sessionId) => {
    const res = await request.post(`ai/chat/session/memory/clear/${sessionId}`);
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "清空 AI 会话记忆失败");
};
export const retainAiSessionLatestRound = async (sessionId) => {
    const res = await request.post(`ai/chat/session/memory/retain-latest/${sessionId}`);
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "仅保留当前轮记忆失败");
};
export const streamAiChat = async (payload, { signal, onEvent } = {}) => {
    const token = getToken();
    const response = await fetch(`${PREFIX_BASE_URL}ai/chat/stream`, {
rsf-admin/src/layout/AiChatDrawer.jsx
@@ -31,10 +31,12 @@
import AddCommentOutlinedIcon from "@mui/icons-material/AddCommentOutlined";
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import AutoDeleteOutlinedIcon from "@mui/icons-material/AutoDeleteOutlined";
import HistoryOutlinedIcon from "@mui/icons-material/HistoryOutlined";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import PushPinIcon from "@mui/icons-material/PushPin";
import SearchOutlinedIcon from "@mui/icons-material/SearchOutlined";
import { getAiRuntime, getAiSessions, pinAiSession, removeAiSession, renameAiSession, streamAiChat } from "@/api/ai/chat";
import { clearAiSessionMemory, getAiRuntime, getAiSessions, pinAiSession, removeAiSession, renameAiSession, retainAiSessionLatestRound, streamAiChat } from "@/api/ai/chat";
const DEFAULT_PROMPT_CODE = "home.default";
@@ -70,6 +72,9 @@
            promptName: runtime?.promptName || "--",
            model: runtime?.model || "--",
            mountedMcpCount: runtime?.mountedMcpCount ?? 0,
            recentMessageCount: runtime?.recentMessageCount ?? 0,
            hasSummary: !!runtime?.memorySummary,
            hasFacts: !!runtime?.memoryFacts,
        };
    }, [runtime]);
@@ -206,6 +211,42 @@
            await loadSessions(sessionKeyword);
        } catch (error) {
            const message = error.message || "重命名会话失败";
            setDrawerError(message);
            notify(message, { type: "error" });
        }
    };
    const handleClearMemory = async () => {
        if (streaming || !sessionId) {
            return;
        }
        try {
            await clearAiSessionMemory(sessionId);
            notify("会话记忆已清空");
            await Promise.all([
                loadRuntime(sessionId),
                loadSessions(sessionKeyword),
            ]);
        } catch (error) {
            const message = error.message || "清空会话记忆失败";
            setDrawerError(message);
            notify(message, { type: "error" });
        }
    };
    const handleRetainLatestRound = async () => {
        if (streaming || !sessionId) {
            return;
        }
        try {
            await retainAiSessionLatestRound(sessionId);
            notify("已仅保留当前轮记忆");
            await Promise.all([
                loadRuntime(sessionId),
                loadSessions(sessionKeyword),
            ]);
        } catch (error) {
            const message = error.message || "保留当前轮记忆失败";
            setDrawerError(message);
            notify(message, { type: "error" });
        }
@@ -470,6 +511,9 @@
                                <Chip size="small" label={`Model: ${runtimeSummary.model}`} />
                                <Chip size="small" label={`MCP: ${runtimeSummary.mountedMcpCount}`} />
                                <Chip size="small" label={`History: ${persistedMessages.length}`} />
                                <Chip size="small" label={`Recent: ${runtimeSummary.recentMessageCount}`} />
                                <Chip size="small" color={runtimeSummary.hasSummary ? "success" : "default"} label={runtimeSummary.hasSummary ? "有摘要" : "无摘要"} />
                                <Chip size="small" color={runtimeSummary.hasFacts ? "info" : "default"} label={runtimeSummary.hasFacts ? "有事实" : "无事实"} />
                            </Stack>
                            <Stack direction="row" spacing={1} mt={1.5} flexWrap="wrap" useFlexGap>
                                {quickLinks.map((item) => (
@@ -483,7 +527,40 @@
                                        {item.label}
                                    </Button>
                                ))}
                                <Button
                                    size="small"
                                    variant="outlined"
                                    startIcon={<HistoryOutlinedIcon />}
                                    onClick={handleRetainLatestRound}
                                    disabled={!sessionId || streaming}
                                >
                                    仅保留当前轮
                                </Button>
                                <Button
                                    size="small"
                                    variant="outlined"
                                    color="warning"
                                    startIcon={<AutoDeleteOutlinedIcon />}
                                    onClick={handleClearMemory}
                                    disabled={!sessionId || streaming}
                                >
                                    清空记忆
                                </Button>
                            </Stack>
                            {!!runtime?.memorySummary && (
                                <Alert severity="info" sx={{ mt: 1.5 }}>
                                    <Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
                                        {runtime.memorySummary}
                                    </Typography>
                                </Alert>
                            )}
                            {!!runtime?.memoryFacts && (
                                <Alert severity="success" sx={{ mt: 1.5 }}>
                                    <Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
                                        {runtime.memoryFacts}
                                    </Typography>
                                </Alert>
                            )}
                            {loadingRuntime && (
                                <Typography variant="body2" color="text.secondary" mt={1}>
                                    正在加载 AI 运行时信息...
rsf-server/src/main/java/com/vincent/rsf/server/ai/config/AiDefaults.java
@@ -18,4 +18,8 @@
    public static final int DEFAULT_TIMEOUT_MS = 60000;
    public static final double DEFAULT_TEMPERATURE = 0.7D;
    public static final double DEFAULT_TOP_P = 1.0D;
    public static final int MEMORY_RECENT_ROUNDS = 6;
    public static final int MEMORY_SUMMARY_TRIGGER_MESSAGES = 12;
    public static final int MEMORY_SUMMARY_MAX_LENGTH = 1200;
    public static final int MEMORY_FACTS_MAX_LENGTH = 600;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java
@@ -57,6 +57,20 @@
    }
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/ai/chat/session/memory/clear/{sessionId}")
    public R clearSessionMemory(@PathVariable Long sessionId) {
        aiChatService.clearSessionMemory(sessionId, getLoginUserId(), getTenantId());
        return R.ok("Clear Success").add(sessionId);
    }
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/ai/chat/session/memory/retain-latest/{sessionId}")
    public R retainLatestRound(@PathVariable Long sessionId) {
        aiChatService.retainLatestRound(sessionId, getLoginUserId(), getTenantId());
        return R.ok("Retain Success").add(sessionId);
    }
    @PreAuthorize("isAuthenticated()")
    @PostMapping(value = "/ai/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream(@RequestBody AiChatRequest request) {
        String requestId = StringUtils.hasText(request.getRequestId())
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatMemoryDto.java
@@ -11,5 +11,13 @@
    private Long sessionId;
    private String memorySummary;
    private String memoryFacts;
    private Integer recentMessageCount;
    private List<AiChatMessageDto> persistedMessages;
    private List<AiChatMessageDto> shortMemoryMessages;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatRuntimeDto.java
@@ -27,5 +27,11 @@
    private List<String> mountErrors;
    private String memorySummary;
    private String memoryFacts;
    private Integer recentMessageCount;
    private List<AiChatMessageDto> persistedMessages;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatMessage.java
@@ -35,6 +35,9 @@
    @ApiModelProperty(value = "消息内容")
    private String content;
    @ApiModelProperty(value = "内容长度")
    private Integer contentLength;
    @ApiModelProperty(value = "用户 ID")
    private Long userId;
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatSession.java
@@ -40,6 +40,12 @@
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date lastMessageTime;
    @ApiModelProperty(value = "记忆摘要")
    private String memorySummary;
    @ApiModelProperty(value = "关键事实")
    private String memoryFacts;
    @ApiModelProperty(value = "是否置顶")
    private Integer pinned;
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatMemoryService.java
@@ -24,4 +24,8 @@
    AiChatSessionDto renameSession(Long userId, Long tenantId, Long sessionId, AiChatSessionRenameRequest request);
    AiChatSessionDto pinSession(Long userId, Long tenantId, Long sessionId, AiChatSessionPinRequest request);
    void clearSessionMemory(Long userId, Long tenantId, Long sessionId);
    void retainLatestRound(Long userId, Long tenantId, Long sessionId);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatService.java
@@ -22,4 +22,8 @@
    AiChatSessionDto renameSession(Long sessionId, AiChatSessionRenameRequest request, Long userId, Long tenantId);
    AiChatSessionDto pinSession(Long sessionId, AiChatSessionPinRequest request, Long userId, Long tenantId);
    void clearSessionMemory(Long sessionId, Long userId, Long tenantId);
    void retainLatestRound(Long sessionId, Long userId, Long tenantId);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java
@@ -3,6 +3,7 @@
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;
@@ -21,7 +22,6 @@
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
@Service
@RequiredArgsConstructor
@@ -40,12 +40,22 @@
        if (session == null) {
            return AiChatMemoryDto.builder()
                    .sessionId(null)
                    .memorySummary(null)
                    .memoryFacts(null)
                    .recentMessageCount(0)
                    .persistedMessages(List.of())
                    .shortMemoryMessages(List.of())
                    .build();
        }
        List<AiChatMessageDto> persistedMessages = listMessages(session.getId());
        List<AiChatMessageDto> shortMemoryMessages = tailMessagesByRounds(persistedMessages, AiDefaults.MEMORY_RECENT_ROUNDS);
        return AiChatMemoryDto.builder()
                .sessionId(session.getId())
                .persistedMessages(listMessages(session.getId()))
                .memorySummary(session.getMemorySummary())
                .memoryFacts(session.getMemoryFacts())
                .recentMessageCount(shortMemoryMessages.size())
                .persistedMessages(persistedMessages)
                .shortMemoryMessages(shortMemoryMessages)
                .build();
    }
@@ -123,6 +133,7 @@
                .setUpdateBy(userId)
                .setUpdateTime(now);
        aiChatSessionMapper.updateById(update);
        refreshMemoryProfile(session.getId(), userId);
    }
    @Override
@@ -192,6 +203,47 @@
        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<AiChatMessage> messages = aiChatMessageMapper.selectList(new LambdaQueryWrapper<AiChatMessage>()
                .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<AiChatMessage> records = listMessageRecords(sessionId);
        if (records.isEmpty()) {
            return;
        }
        List<AiChatMessage> 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<AiChatSession>()
                .eq(AiChatSession::getUserId, userId)
@@ -237,11 +289,7 @@
    }
    private List<AiChatMessageDto> listMessages(Long sessionId) {
        List<AiChatMessage> records = aiChatMessageMapper.selectList(new LambdaQueryWrapper<AiChatMessage>()
                .eq(AiChatMessage::getSessionId, sessionId)
                .eq(AiChatMessage::getDeleted, 0)
                .orderByAsc(AiChatMessage::getSeqNo)
                .orderByAsc(AiChatMessage::getId));
        List<AiChatMessage> records = listMessageRecords(sessionId);
        if (Cools.isEmpty(records)) {
            return List.of();
        }
@@ -279,6 +327,14 @@
        return normalized;
    }
    private List<AiChatMessage> listMessageRecords(Long sessionId) {
        return aiChatMessageMapper.selectList(new LambdaQueryWrapper<AiChatMessage>()
                .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<AiChatMessage>()
                .eq(AiChatMessage::getSessionId, sessionId)
@@ -295,6 +351,7 @@
                .setSeqNo(seqNo)
                .setRole(role)
                .setContent(content)
                .setContentLength(content == null ? 0 : content.length())
                .setUserId(userId)
                .setTenantId(tenantId)
                .setDeleted(0)
@@ -371,6 +428,116 @@
        return normalized.length() > 80 ? normalized.substring(0, 80) : normalized;
    }
    private void refreshMemoryProfile(Long sessionId, Long userId) {
        List<AiChatMessageDto> messages = listMessages(sessionId);
        List<AiChatMessageDto> shortMemoryMessages = tailMessagesByRounds(messages, AiDefaults.MEMORY_RECENT_ROUNDS);
        List<AiChatMessageDto> 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<AiChatMessage>()
                .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<AiChatMessageDto> tailMessagesByRounds(List<AiChatMessageDto> 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<AiChatMessage> tailMessageRecordsByRounds(List<AiChatMessage> 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<AiChatMessageDto> 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<AiChatMessageDto> 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("当前登录用户不存在");
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
@@ -95,6 +95,9 @@
                .mountedMcpCount(config.getMcpMounts().size())
                .mountedMcpNames(config.getMcpMounts().stream().map(item -> item.getName()).toList())
                .mountErrors(List.of())
                .memorySummary(memory.getMemorySummary())
                .memoryFacts(memory.getMemoryFacts())
                .recentMessageCount(memory.getRecentMessageCount())
                .persistedMessages(memory.getPersistedMessages())
                .build();
    }
@@ -121,6 +124,16 @@
    }
    @Override
    public void clearSessionMemory(Long sessionId, Long userId, Long tenantId) {
        aiChatMemoryService.clearSessionMemory(userId, tenantId, sessionId);
    }
    @Override
    public void retainLatestRound(Long sessionId, Long userId, Long tenantId) {
        aiChatMemoryService.retainLatestRound(userId, tenantId, sessionId);
    }
    @Override
    public SseEmitter stream(AiChatRequest request, Long userId, Long tenantId) {
        SseEmitter emitter = new SseEmitter(AiDefaults.SSE_TIMEOUT_MS);
        CompletableFuture.runAsync(() -> doStream(request, userId, tenantId, emitter), aiChatTaskExecutor);
@@ -141,7 +154,7 @@
            AiChatSession session = resolveSession(request, userId, tenantId, config.getPromptCode());
            sessionId = session.getId();
            AiChatMemoryDto memory = loadMemory(userId, tenantId, config.getPromptCode(), session.getId());
            List<AiChatMessageDto> mergedMessages = mergeMessages(memory.getPersistedMessages(), request.getMessages());
            List<AiChatMessageDto> mergedMessages = mergeMessages(memory.getShortMemoryMessages(), request.getMessages());
            try (McpMountRuntimeFactory.McpMountRuntime runtime = createRuntime(config, userId)) {
                emitStrict(emitter, "start", AiChatRuntimeDto.builder()
                        .requestId(requestId)
@@ -153,6 +166,9 @@
                        .mountedMcpCount(runtime.getMountedCount())
                        .mountedMcpNames(runtime.getMountedNames())
                        .mountErrors(runtime.getErrors())
                        .memorySummary(memory.getMemorySummary())
                        .memoryFacts(memory.getMemoryFacts())
                        .recentMessageCount(memory.getRecentMessageCount())
                        .persistedMessages(memory.getPersistedMessages())
                        .build());
                emitSafely(emitter, "status", AiChatStatusDto.builder()
@@ -167,7 +183,7 @@
                        requestId, userId, tenantId, session.getId(), resolvedModel);
                Prompt prompt = new Prompt(
                        buildPromptMessages(mergedMessages, config.getPrompt(), request.getMetadata()),
                        buildPromptMessages(memory, mergedMessages, config.getPrompt(), request.getMetadata()),
                        buildChatOptions(config.getAiParam(), runtime.getToolCallbacks(), userId, request.getMetadata())
                );
                OpenAiChatModel chatModel = createChatModel(config.getAiParam());
@@ -400,7 +416,7 @@
        return builder.build();
    }
    private List<Message> buildPromptMessages(List<AiChatMessageDto> sourceMessages, AiPrompt aiPrompt, Map<String, Object> metadata) {
    private List<Message> buildPromptMessages(AiChatMemoryDto memory, List<AiChatMessageDto> sourceMessages, AiPrompt aiPrompt, Map<String, Object> metadata) {
        if (Cools.isEmpty(sourceMessages)) {
            throw new CoolException("对话消息不能为空");
        }
@@ -408,6 +424,12 @@
        if (StringUtils.hasText(aiPrompt.getSystemPrompt())) {
            messages.add(new SystemMessage(aiPrompt.getSystemPrompt()));
        }
        if (memory != null && StringUtils.hasText(memory.getMemorySummary())) {
            messages.add(new SystemMessage("历史摘要:\n" + memory.getMemorySummary()));
        }
        if (memory != null && StringUtils.hasText(memory.getMemoryFacts())) {
            messages.add(new SystemMessage("关键事实:\n" + memory.getMemoryFacts()));
        }
        int lastUserIndex = -1;
        for (int i = 0; i < sourceMessages.size(); i++) {
            AiChatMessageDto item = sourceMessages.get(i);
version/db/ai_feature.sql
@@ -76,6 +76,8 @@
  `user_id` bigint(20) NOT NULL COMMENT '用户 ID',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户',
  `last_message_time` datetime DEFAULT NULL COMMENT '最后消息时间',
  `memory_summary` longtext COMMENT '记忆摘要',
  `memory_facts` text COMMENT '关键事实',
  `pinned` tinyint(1) DEFAULT '0' COMMENT '是否置顶',
  `status` int(11) DEFAULT '1' COMMENT '状态',
  `deleted` int(11) DEFAULT '0' COMMENT '删除标记',
@@ -93,6 +95,7 @@
  `seq_no` int(11) NOT NULL COMMENT '消息序号',
  `role` varchar(32) NOT NULL COMMENT '消息角色',
  `content` longtext COMMENT '消息内容',
  `content_length` int(11) DEFAULT NULL COMMENT '内容长度',
  `user_id` bigint(20) NOT NULL COMMENT '用户 ID',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户',
  `deleted` int(11) DEFAULT '0' COMMENT '删除标记',
@@ -134,6 +137,54 @@
EXECUTE chat_session_pinned_stmt;
DEALLOCATE PREPARE chat_session_pinned_stmt;
SET @chat_session_summary_exists := (
  SELECT COUNT(1)
  FROM `information_schema`.`COLUMNS`
  WHERE `TABLE_SCHEMA` = DATABASE()
    AND `TABLE_NAME` = 'sys_ai_chat_session'
    AND `COLUMN_NAME` = 'memory_summary'
);
SET @chat_session_summary_sql := IF(
  @chat_session_summary_exists = 0,
  'ALTER TABLE `sys_ai_chat_session` ADD COLUMN `memory_summary` longtext COMMENT ''记忆摘要'' AFTER `last_message_time`',
  'SELECT 1'
);
PREPARE chat_session_summary_stmt FROM @chat_session_summary_sql;
EXECUTE chat_session_summary_stmt;
DEALLOCATE PREPARE chat_session_summary_stmt;
SET @chat_session_facts_exists := (
  SELECT COUNT(1)
  FROM `information_schema`.`COLUMNS`
  WHERE `TABLE_SCHEMA` = DATABASE()
    AND `TABLE_NAME` = 'sys_ai_chat_session'
    AND `COLUMN_NAME` = 'memory_facts'
);
SET @chat_session_facts_sql := IF(
  @chat_session_facts_exists = 0,
  'ALTER TABLE `sys_ai_chat_session` ADD COLUMN `memory_facts` text COMMENT ''关键事实'' AFTER `memory_summary`',
  'SELECT 1'
);
PREPARE chat_session_facts_stmt FROM @chat_session_facts_sql;
EXECUTE chat_session_facts_stmt;
DEALLOCATE PREPARE chat_session_facts_stmt;
SET @chat_message_length_exists := (
  SELECT COUNT(1)
  FROM `information_schema`.`COLUMNS`
  WHERE `TABLE_SCHEMA` = DATABASE()
    AND `TABLE_NAME` = 'sys_ai_chat_message'
    AND `COLUMN_NAME` = 'content_length'
);
SET @chat_message_length_sql := IF(
  @chat_message_length_exists = 0,
  'ALTER TABLE `sys_ai_chat_message` ADD COLUMN `content_length` int(11) DEFAULT NULL COMMENT ''内容长度'' AFTER `content`',
  'SELECT 1'
);
PREPARE chat_message_length_stmt FROM @chat_message_length_sql;
EXECUTE chat_message_length_stmt;
DEALLOCATE PREPARE chat_message_length_stmt;
BEGIN;
INSERT INTO `sys_ai_prompt`
(`id`, `name`, `code`, `scene`, `system_prompt`, `user_prompt_template`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`)