zhou zhou
15 小时以前 5e40dee0e0a4e4cff4a1aafca2444f61c39cbf32
#AI.会话能力增强
2个文件已添加
10个文件已修改
366 ■■■■■ 已修改文件
rsf-admin/src/api/ai/chat.js 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/layout/AiChatDrawer.jsx 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java 19 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatSessionDto.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatSessionPinRequest.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatSessionRenameRequest.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatSession.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatMemoryService.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatService.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java 16 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
version/db/ai_feature.sql 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/api/ai/chat.js
@@ -13,9 +13,9 @@
    throw new Error(msg || "获取 AI 运行时信息失败");
};
export const getAiSessions = async (promptCode = "home.default") => {
export const getAiSessions = async (promptCode = "home.default", keyword = "") => {
    const res = await request.get("ai/chat/sessions", {
        params: { promptCode },
        params: { promptCode, keyword },
    });
    const { code, msg, data } = res.data;
    if (code === 200) {
@@ -33,6 +33,24 @@
    throw new Error(msg || "删除 AI 会话失败");
};
export const renameAiSession = async (sessionId, title) => {
    const res = await request.post(`ai/chat/session/rename/${sessionId}`, { title });
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "重命名 AI 会话失败");
};
export const pinAiSession = async (sessionId, pinned) => {
    const res = await request.post(`ai/chat/session/pin/${sessionId}`, { pinned });
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "更新 AI 会话置顶状态失败");
};
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
@@ -6,6 +6,10 @@
    Box,
    Button,
    Chip,
    Dialog,
    DialogActions,
    DialogContent,
    DialogTitle,
    Divider,
    Drawer,
    IconButton,
@@ -26,7 +30,11 @@
import CloseIcon from "@mui/icons-material/Close";
import AddCommentOutlinedIcon from "@mui/icons-material/AddCommentOutlined";
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
import { getAiRuntime, getAiSessions, removeAiSession, streamAiChat } from "@/api/ai/chat";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
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";
const DEFAULT_PROMPT_CODE = "home.default";
@@ -51,6 +59,8 @@
    const [streaming, setStreaming] = useState(false);
    const [usage, setUsage] = useState(null);
    const [drawerError, setDrawerError] = useState("");
    const [sessionKeyword, setSessionKeyword] = useState("");
    const [renameDialog, setRenameDialog] = useState({ open: false, sessionId: null, title: "" });
    const promptCode = runtime?.promptCode || DEFAULT_PROMPT_CODE;
@@ -78,7 +88,7 @@
    const initializeDrawer = async (targetSessionId = null) => {
        await Promise.all([
            loadRuntime(targetSessionId),
            loadSessions(),
            loadSessions(sessionKeyword),
        ]);
    };
@@ -100,9 +110,9 @@
        }
    };
    const loadSessions = async () => {
    const loadSessions = async (keyword = sessionKeyword) => {
        try {
            const data = await getAiSessions(DEFAULT_PROMPT_CODE);
            const data = await getAiSessions(DEFAULT_PROMPT_CODE, keyword);
            setSessions(data);
        } catch (error) {
            const message = error.message || "获取 AI 会话列表失败";
@@ -120,6 +130,16 @@
        setUsage(null);
        setDrawerError("");
    };
    useEffect(() => {
        if (!open) {
            return;
        }
        const timer = window.setTimeout(() => {
            loadSessions(sessionKeyword);
        }, 250);
        return () => window.clearTimeout(timer);
    }, [sessionKeyword, open]);
    const handleSwitchSession = async (targetSessionId) => {
        if (streaming || targetSessionId === sessionId) {
@@ -140,9 +160,52 @@
                startNewSession();
                await loadRuntime(null);
            }
            await loadSessions();
            await loadSessions(sessionKeyword);
        } catch (error) {
            const message = error.message || "删除 AI 会话失败";
            setDrawerError(message);
            notify(message, { type: "error" });
        }
    };
    const handlePinSession = async (targetSessionId, pinned) => {
        if (streaming || !targetSessionId) {
            return;
        }
        try {
            await pinAiSession(targetSessionId, pinned);
            notify(pinned ? "会话已置顶" : "会话已取消置顶");
            await loadSessions(sessionKeyword);
        } catch (error) {
            const message = error.message || "更新会话置顶状态失败";
            setDrawerError(message);
            notify(message, { type: "error" });
        }
    };
    const openRenameDialog = (item) => {
        setRenameDialog({
            open: true,
            sessionId: item?.sessionId || null,
            title: item?.title || "",
        });
    };
    const closeRenameDialog = () => {
        setRenameDialog({ open: false, sessionId: null, title: "" });
    };
    const handleRenameSubmit = async () => {
        if (streaming || !renameDialog.sessionId) {
            return;
        }
        try {
            await renameAiSession(renameDialog.sessionId, renameDialog.title);
            notify("会话已重命名");
            closeRenameDialog();
            await loadSessions(sessionKeyword);
        } catch (error) {
            const message = error.message || "重命名会话失败";
            setDrawerError(message);
            notify(message, { type: "error" });
        }
@@ -254,7 +317,7 @@
            if (completed) {
                await Promise.all([
                    loadRuntime(completedSessionId),
                    loadSessions(),
                    loadSessions(sessionKeyword),
                ]);
            }
        }
@@ -313,6 +376,17 @@
                                    新建会话
                                </Button>
                            </Stack>
                            <TextField
                                value={sessionKeyword}
                                onChange={(event) => setSessionKeyword(event.target.value)}
                                fullWidth
                                size="small"
                                placeholder="搜索会话标题"
                                InputProps={{
                                    startAdornment: <SearchOutlinedIcon fontSize="small" sx={{ mr: 1, color: "text.secondary" }} />,
                                }}
                                sx={{ mb: 1.25 }}
                            />
                            <Paper variant="outlined" sx={{ overflow: "hidden" }}>
                                {!sessions.length ? (
                                    <Box px={1.5} py={1.25}>
@@ -332,16 +406,41 @@
                                            >
                                                <ListItemText
                                                    primary={item.title || `会话 ${item.sessionId}`}
                                                    secondary={item.lastMessageTime || `Session ${item.sessionId}`}
                                                    secondary={item.lastMessagePreview || item.lastMessageTime || `Session ${item.sessionId}`}
                                                    primaryTypographyProps={{
                                                        noWrap: true,
                                                        fontSize: 14,
                                                        fontWeight: item.pinned ? 700 : 400,
                                                    }}
                                                    secondaryTypographyProps={{
                                                        noWrap: true,
                                                        fontSize: 12,
                                                    }}
                                                />
                                                <IconButton
                                                    size="small"
                                                    edge="end"
                                                    disabled={streaming}
                                                    onClick={(event) => {
                                                        event.stopPropagation();
                                                        handlePinSession(item.sessionId, !item.pinned);
                                                    }}
                                                    title={item.pinned ? "取消置顶" : "置顶会话"}
                                                >
                                                    {item.pinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
                                                </IconButton>
                                                <IconButton
                                                    size="small"
                                                    edge="end"
                                                    disabled={streaming}
                                                    onClick={(event) => {
                                                        event.stopPropagation();
                                                        openRenameDialog(item);
                                                    }}
                                                    title="重命名会话"
                                                >
                                                    <EditOutlinedIcon fontSize="small" />
                                                </IconButton>
                                                <IconButton
                                                    size="small"
                                                    edge="end"
@@ -476,6 +575,25 @@
                    </Box>
                </Box>
            </Box>
            <Dialog open={renameDialog.open} onClose={closeRenameDialog} fullWidth maxWidth="xs">
                <DialogTitle>重命名会话</DialogTitle>
                <DialogContent>
                    <TextField
                        value={renameDialog.title}
                        onChange={(event) => setRenameDialog((prev) => ({ ...prev, title: event.target.value }))}
                        autoFocus
                        margin="dense"
                        label="会话标题"
                        fullWidth
                    />
                </DialogContent>
                <DialogActions>
                    <Button onClick={closeRenameDialog}>取消</Button>
                    <Button onClick={handleRenameSubmit} variant="contained" disabled={streaming || !renameDialog.title.trim()}>
                        保存
                    </Button>
                </DialogActions>
            </Dialog>
        </Drawer>
    );
};
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiChatController.java
@@ -1,6 +1,8 @@
package com.vincent.rsf.server.ai.controller;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.ai.dto.AiChatSessionPinRequest;
import com.vincent.rsf.server.ai.dto.AiChatSessionRenameRequest;
import com.vincent.rsf.server.ai.dto.AiChatRequest;
import com.vincent.rsf.server.ai.service.AiChatService;
import com.vincent.rsf.server.system.controller.BaseController;
@@ -30,8 +32,9 @@
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/ai/chat/sessions")
    public R sessions(@RequestParam(required = false) String promptCode) {
        return R.ok().add(aiChatService.listSessions(promptCode, getLoginUserId(), getTenantId()));
    public R sessions(@RequestParam(required = false) String promptCode,
                      @RequestParam(required = false) String keyword) {
        return R.ok().add(aiChatService.listSessions(promptCode, keyword, getLoginUserId(), getTenantId()));
    }
    @PreAuthorize("isAuthenticated()")
@@ -42,6 +45,18 @@
    }
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/ai/chat/session/rename/{sessionId}")
    public R renameSession(@PathVariable Long sessionId, @RequestBody AiChatSessionRenameRequest request) {
        return R.ok("Update Success").add(aiChatService.renameSession(sessionId, request, getLoginUserId(), getTenantId()));
    }
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/ai/chat/session/pin/{sessionId}")
    public R pinSession(@PathVariable Long sessionId, @RequestBody AiChatSessionPinRequest request) {
        return R.ok("Update Success").add(aiChatService.pinSession(sessionId, request, getLoginUserId(), getTenantId()));
    }
    @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/AiChatSessionDto.java
@@ -16,6 +16,10 @@
    private String promptCode;
    private Boolean pinned;
    private String lastMessagePreview;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date lastMessageTime;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatSessionPinRequest.java
New file
@@ -0,0 +1,9 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Data;
@Data
public class AiChatSessionPinRequest {
    private Boolean pinned;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatSessionRenameRequest.java
New file
@@ -0,0 +1,9 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Data;
@Data
public class AiChatSessionRenameRequest {
    private String title;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiChatSession.java
@@ -40,6 +40,9 @@
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date lastMessageTime;
    @ApiModelProperty(value = "是否置顶")
    private Integer pinned;
    @ApiModelProperty(value = "状态")
    private Integer status;
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatMemoryService.java
@@ -3,6 +3,8 @@
import com.vincent.rsf.server.ai.dto.AiChatMemoryDto;
import com.vincent.rsf.server.ai.dto.AiChatMessageDto;
import com.vincent.rsf.server.ai.dto.AiChatSessionDto;
import com.vincent.rsf.server.ai.dto.AiChatSessionPinRequest;
import com.vincent.rsf.server.ai.dto.AiChatSessionRenameRequest;
import com.vincent.rsf.server.ai.entity.AiChatSession;
import java.util.List;
@@ -11,11 +13,15 @@
    AiChatMemoryDto getMemory(Long userId, Long tenantId, String promptCode, Long sessionId);
    List<AiChatSessionDto> listSessions(Long userId, Long tenantId, String promptCode);
    List<AiChatSessionDto> listSessions(Long userId, Long tenantId, String promptCode, String keyword);
    AiChatSession resolveSession(Long userId, Long tenantId, String promptCode, Long sessionId, String titleSeed);
    void saveRound(AiChatSession session, Long userId, Long tenantId, List<AiChatMessageDto> memoryMessages, String assistantContent);
    void removeSession(Long userId, Long tenantId, Long sessionId);
    AiChatSessionDto renameSession(Long userId, Long tenantId, Long sessionId, AiChatSessionRenameRequest request);
    AiChatSessionDto pinSession(Long userId, Long tenantId, Long sessionId, AiChatSessionPinRequest request);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiChatService.java
@@ -3,6 +3,8 @@
import com.vincent.rsf.server.ai.dto.AiChatRequest;
import com.vincent.rsf.server.ai.dto.AiChatRuntimeDto;
import com.vincent.rsf.server.ai.dto.AiChatSessionDto;
import com.vincent.rsf.server.ai.dto.AiChatSessionPinRequest;
import com.vincent.rsf.server.ai.dto.AiChatSessionRenameRequest;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
@@ -11,9 +13,13 @@
    AiChatRuntimeDto getRuntime(String promptCode, Long sessionId, Long userId, Long tenantId);
    List<AiChatSessionDto> listSessions(String promptCode, Long userId, Long tenantId);
    List<AiChatSessionDto> listSessions(String promptCode, String keyword, Long userId, Long tenantId);
    SseEmitter stream(AiChatRequest request, Long userId, Long tenantId);
    void removeSession(Long sessionId, Long userId, Long tenantId);
    AiChatSessionDto renameSession(Long sessionId, AiChatSessionRenameRequest request, Long userId, Long tenantId);
    AiChatSessionDto pinSession(Long sessionId, AiChatSessionPinRequest request, Long userId, Long tenantId);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatMemoryServiceImpl.java
@@ -5,6 +5,8 @@
import com.vincent.rsf.framework.exception.CoolException;
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;
@@ -19,6 +21,7 @@
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
@Service
@RequiredArgsConstructor
@@ -47,7 +50,7 @@
    }
    @Override
    public List<AiChatSessionDto> listSessions(Long userId, Long tenantId, String promptCode) {
    public List<AiChatSessionDto> listSessions(Long userId, Long tenantId, String promptCode, String keyword) {
        ensureIdentity(userId, tenantId);
        String resolvedPromptCode = requirePromptCode(promptCode);
        List<AiChatSession> sessions = aiChatSessionMapper.selectList(new LambdaQueryWrapper<AiChatSession>()
@@ -56,6 +59,8 @@
                .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)) {
@@ -63,12 +68,7 @@
        }
        List<AiChatSessionDto> result = new ArrayList<>();
        for (AiChatSession session : sessions) {
            result.add(AiChatSessionDto.builder()
                    .sessionId(session.getId())
                    .title(session.getTitle())
                    .promptCode(session.getPromptCode())
                    .lastMessageTime(session.getLastMessageTime())
                    .build());
            result.add(buildSessionDto(session));
        }
        return result;
    }
@@ -87,6 +87,7 @@
                .setUserId(userId)
                .setTenantId(tenantId)
                .setLastMessageTime(now)
                .setPinned(0)
                .setStatus(StatusType.ENABLE.val)
                .setDeleted(0)
                .setCreateBy(userId)
@@ -157,6 +158,40 @@
        }
    }
    @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));
    }
    private AiChatSession findLatestSession(Long userId, Long tenantId, String promptCode) {
        return aiChatSessionMapper.selectOne(new LambdaQueryWrapper<AiChatSession>()
                .eq(AiChatSession::getUserId, userId)
@@ -175,6 +210,23 @@
                .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<AiChatSession>()
                .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"));
@@ -266,8 +318,57 @@
        if (!StringUtils.hasText(titleSeed)) {
            throw new CoolException("AI 会话标题不能为空");
        }
        String title = titleSeed.trim().replace("\r", " ").replace("\n", " ");
        return title.length() > 60 ? title.substring(0, 60) : title;
        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<AiChatMessage>()
                .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 ensureIdentity(Long userId, Long tenantId) {
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
@@ -12,6 +12,8 @@
import com.vincent.rsf.server.ai.dto.AiChatRuntimeDto;
import com.vincent.rsf.server.ai.dto.AiChatStatusDto;
import com.vincent.rsf.server.ai.dto.AiChatSessionDto;
import com.vincent.rsf.server.ai.dto.AiChatSessionPinRequest;
import com.vincent.rsf.server.ai.dto.AiChatSessionRenameRequest;
import com.vincent.rsf.server.ai.dto.AiResolvedConfig;
import com.vincent.rsf.server.ai.entity.AiParam;
import com.vincent.rsf.server.ai.entity.AiPrompt;
@@ -98,9 +100,9 @@
    }
    @Override
    public List<AiChatSessionDto> listSessions(String promptCode, Long userId, Long tenantId) {
    public List<AiChatSessionDto> listSessions(String promptCode, String keyword, Long userId, Long tenantId) {
        AiResolvedConfig config = aiConfigResolverService.resolve(promptCode, tenantId);
        return aiChatMemoryService.listSessions(userId, tenantId, config.getPromptCode());
        return aiChatMemoryService.listSessions(userId, tenantId, config.getPromptCode(), keyword);
    }
    @Override
@@ -109,6 +111,16 @@
    }
    @Override
    public AiChatSessionDto renameSession(Long sessionId, AiChatSessionRenameRequest request, Long userId, Long tenantId) {
        return aiChatMemoryService.renameSession(userId, tenantId, sessionId, request);
    }
    @Override
    public AiChatSessionDto pinSession(Long sessionId, AiChatSessionPinRequest request, Long userId, Long tenantId) {
        return aiChatMemoryService.pinSession(userId, tenantId, sessionId, request);
    }
    @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);
version/db/ai_feature.sql
@@ -76,6 +76,7 @@
  `user_id` bigint(20) NOT NULL COMMENT '用户 ID',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户',
  `last_message_time` datetime DEFAULT NULL COMMENT '最后消息时间',
  `pinned` tinyint(1) DEFAULT '0' COMMENT '是否置顶',
  `status` int(11) DEFAULT '1' COMMENT '状态',
  `deleted` int(11) DEFAULT '0' COMMENT '删除标记',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
@@ -117,6 +118,22 @@
EXECUTE builtin_code_stmt;
DEALLOCATE PREPARE builtin_code_stmt;
SET @chat_session_pinned_exists := (
  SELECT COUNT(1)
  FROM `information_schema`.`COLUMNS`
  WHERE `TABLE_SCHEMA` = DATABASE()
    AND `TABLE_NAME` = 'sys_ai_chat_session'
    AND `COLUMN_NAME` = 'pinned'
);
SET @chat_session_pinned_sql := IF(
  @chat_session_pinned_exists = 0,
  'ALTER TABLE `sys_ai_chat_session` ADD COLUMN `pinned` tinyint(1) DEFAULT ''0'' COMMENT ''是否置顶'' AFTER `last_message_time`',
  'SELECT 1'
);
PREPARE chat_session_pinned_stmt FROM @chat_session_pinned_sql;
EXECUTE chat_session_pinned_stmt;
DEALLOCATE PREPARE chat_session_pinned_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`)