zhou zhou
2026-03-19 5d16d9a0e7240ff4e6346bfee4890159da5a764e
rsf-admin/src/layout/AiChatDrawer.jsx
@@ -6,6 +6,10 @@
    Box,
    Button,
    Chip,
    Dialog,
    DialogActions,
    DialogContent,
    DialogTitle,
    Divider,
    Drawer,
    IconButton,
@@ -26,7 +30,13 @@
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 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 { clearAiSessionMemory, getAiRuntime, getAiSessions, pinAiSession, removeAiSession, renameAiSession, retainAiSessionLatestRound, streamAiChat } from "@/api/ai/chat";
const DEFAULT_PROMPT_CODE = "home.default";
@@ -51,14 +61,20 @@
    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;
    const runtimeSummary = useMemo(() => {
        return {
            requestId: runtime?.requestId || "--",
            promptName: runtime?.promptName || "--",
            model: runtime?.model || "--",
            mountedMcpCount: runtime?.mountedMcpCount ?? 0,
            recentMessageCount: runtime?.recentMessageCount ?? 0,
            hasSummary: !!runtime?.memorySummary,
            hasFacts: !!runtime?.memoryFacts,
        };
    }, [runtime]);
@@ -77,7 +93,7 @@
    const initializeDrawer = async (targetSessionId = null) => {
        await Promise.all([
            loadRuntime(targetSessionId),
            loadSessions(),
            loadSessions(sessionKeyword),
        ]);
    };
@@ -99,9 +115,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 会话列表失败";
@@ -119,6 +135,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) {
@@ -139,9 +165,88 @@
                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" });
        }
    };
    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" });
        }
@@ -234,8 +339,9 @@
                        }
                        if (eventName === "error") {
                            const message = payload?.message || "AI 对话失败";
                            setDrawerError(message);
                            notify(message, { type: "error" });
                            const displayMessage = payload?.requestId ? `${message} [${payload.requestId}]` : message;
                            setDrawerError(displayMessage);
                            notify(displayMessage, { type: "error" });
                        }
                    },
                }
@@ -252,7 +358,7 @@
            if (completed) {
                await Promise.all([
                    loadRuntime(completedSessionId),
                    loadSessions(),
                    loadSessions(sessionKeyword),
                ]);
            }
        }
@@ -311,6 +417,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}>
@@ -330,16 +447,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"
@@ -363,11 +505,15 @@
                    <Box flex={1} display="flex" flexDirection="column" minHeight={0}>
                        <Box px={2} py={1.5}>
                            <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
                                <Chip size="small" label={`Req: ${runtimeSummary.requestId}`} />
                                <Chip size="small" label={`Session: ${sessionId || "--"}`} />
                                <Chip size="small" label={`Prompt: ${runtimeSummary.promptName}`} />
                                <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) => (
@@ -381,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 运行时信息...
@@ -437,6 +616,11 @@
                        <Divider />
                        <Box px={2} py={1.5}>
                            {usage?.elapsedMs != null && (
                                <Typography variant="caption" color="text.secondary" display="block" mb={0.5}>
                                    Elapsed: {usage.elapsedMs} ms{usage?.firstTokenLatencyMs != null ? ` / First token: ${usage.firstTokenLatencyMs} ms` : ""}
                                </Typography>
                            )}
                            {usage?.totalTokens != null && (
                                <Typography variant="caption" color="text.secondary" display="block" mb={1}>
                                    Tokens: prompt {usage?.promptTokens ?? 0} / completion {usage?.completionTokens ?? 0} / total {usage?.totalTokens ?? 0}
@@ -468,6 +652,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>
    );
};