zhou zhou
昨天 287a666e1b2bb155e86aa88ebace201d1e8a51f6
rsf-admin/src/layout/AiChatDrawer.jsx
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useNotify } from "react-admin";
import { useNotify, useTranslate } from "react-admin";
import {
    Alert,
    Box,
@@ -43,17 +43,14 @@
const DEFAULT_PROMPT_CODE = "home.default";
const quickLinks = [
    { label: "AI 参数", path: "/aiParam", icon: <SettingsSuggestOutlinedIcon fontSize="small" /> },
    { label: "Prompt", path: "/aiPrompt", icon: <PsychologyAltOutlinedIcon fontSize="small" /> },
    { label: "MCP", path: "/aiMcpMount", icon: <CableOutlinedIcon fontSize="small" /> },
];
const AiChatDrawer = ({ open, onClose }) => {
    const navigate = useNavigate();
    const location = useLocation();
    const notify = useNotify();
    const translate = useTranslate();
    const abortRef = useRef(null);
    const messagesContainerRef = useRef(null);
    const messagesBottomRef = useRef(null);
    const [runtime, setRuntime] = useState(null);
    const [sessionId, setSessionId] = useState(null);
    const [sessions, setSessions] = useState([]);
@@ -68,6 +65,12 @@
    const [drawerError, setDrawerError] = useState("");
    const [sessionKeyword, setSessionKeyword] = useState("");
    const [renameDialog, setRenameDialog] = useState({ open: false, sessionId: null, title: "" });
    const quickLinks = useMemo(() => ([
        { label: translate("menu.aiParam"), path: "/aiParam", icon: <SettingsSuggestOutlinedIcon fontSize="small" /> },
        { label: translate("menu.aiPrompt"), path: "/aiPrompt", icon: <PsychologyAltOutlinedIcon fontSize="small" /> },
        { label: translate("menu.aiMcpMount"), path: "/aiMcpMount", icon: <CableOutlinedIcon fontSize="small" /> },
    ]), [translate]);
    const promptCode = runtime?.promptCode || DEFAULT_PROMPT_CODE;
@@ -95,6 +98,16 @@
        stopStream(false);
    }, []);
    useEffect(() => {
        if (!open) {
            return;
        }
        const timer = window.requestAnimationFrame(() => {
            scrollMessagesToBottom();
        });
        return () => window.cancelAnimationFrame(timer);
    }, [open, messages, streaming]);
    const initializeDrawer = async (targetSessionId = null) => {
        setToolEvents([]);
        setExpandedToolIds([]);
@@ -115,7 +128,7 @@
            setPersistedMessages(historyMessages);
            setMessages(historyMessages);
        } catch (error) {
            const message = error.message || "获取 AI 运行时失败";
            const message = error.message || translate("ai.drawer.runtimeFailed");
            setDrawerError(message);
        } finally {
            setLoadingRuntime(false);
@@ -127,7 +140,7 @@
            const data = await getAiSessions(DEFAULT_PROMPT_CODE, keyword);
            setSessions(data);
        } catch (error) {
            const message = error.message || "获取 AI 会话列表失败";
            const message = error.message || translate("ai.drawer.sessionListFailed");
            setDrawerError(message);
        }
    };
@@ -171,14 +184,14 @@
        }
        try {
            await removeAiSession(targetSessionId);
            notify("会话已删除");
            notify(translate("ai.drawer.sessionDeleted"));
            if (targetSessionId === sessionId) {
                startNewSession();
                await loadRuntime(null);
            }
            await loadSessions(sessionKeyword);
        } catch (error) {
            const message = error.message || "删除 AI 会话失败";
            const message = error.message || translate("ai.drawer.deleteSessionFailed");
            setDrawerError(message);
            notify(message, { type: "error" });
        }
@@ -190,10 +203,10 @@
        }
        try {
            await pinAiSession(targetSessionId, pinned);
            notify(pinned ? "会话已置顶" : "会话已取消置顶");
            notify(translate(pinned ? "ai.drawer.pinned" : "ai.drawer.unpinned"));
            await loadSessions(sessionKeyword);
        } catch (error) {
            const message = error.message || "更新会话置顶状态失败";
            const message = error.message || translate("ai.drawer.pinFailed");
            setDrawerError(message);
            notify(message, { type: "error" });
        }
@@ -217,11 +230,11 @@
        }
        try {
            await renameAiSession(renameDialog.sessionId, renameDialog.title);
            notify("会话已重命名");
            notify(translate("ai.drawer.renamed"));
            closeRenameDialog();
            await loadSessions(sessionKeyword);
        } catch (error) {
            const message = error.message || "重命名会话失败";
            const message = error.message || translate("ai.drawer.renameFailed");
            setDrawerError(message);
            notify(message, { type: "error" });
        }
@@ -233,13 +246,13 @@
        }
        try {
            await clearAiSessionMemory(sessionId);
            notify("会话记忆已清空");
            notify(translate("ai.drawer.memoryCleared"));
            await Promise.all([
                loadRuntime(sessionId),
                loadSessions(sessionKeyword),
            ]);
        } catch (error) {
            const message = error.message || "清空会话记忆失败";
            const message = error.message || translate("ai.drawer.clearMemoryFailed");
            setDrawerError(message);
            notify(message, { type: "error" });
        }
@@ -251,13 +264,13 @@
        }
        try {
            await retainAiSessionLatestRound(sessionId);
            notify("已仅保留当前轮记忆");
            notify(translate("ai.drawer.retainLatestRoundSuccess"));
            await Promise.all([
                loadRuntime(sessionId),
                loadSessions(sessionKeyword),
            ]);
        } catch (error) {
            const message = error.message || "保留当前轮记忆失败";
            const message = error.message || translate("ai.drawer.retainLatestRoundFailed");
            setDrawerError(message);
            notify(message, { type: "error" });
        }
@@ -269,8 +282,18 @@
            abortRef.current = null;
            setStreaming(false);
            if (showTip) {
                notify("已停止当前对话输出");
                notify(translate("ai.drawer.stopSuccess"));
            }
        }
    };
    const scrollMessagesToBottom = () => {
        if (messagesBottomRef.current) {
            messagesBottomRef.current.scrollIntoView({ block: "end" });
            return;
        }
        if (messagesContainerRef.current) {
            messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
        }
    };
@@ -380,7 +403,7 @@
                            }
                        }
                        if (eventName === "error") {
                            const message = payload?.message || "AI 对话失败";
                            const message = payload?.message || translate("ai.drawer.chatFailed");
                            const displayMessage = payload?.requestId ? `${message} [${payload.requestId}]` : message;
                            setDrawerError(displayMessage);
                            notify(displayMessage, { type: "error" });
@@ -390,7 +413,7 @@
            );
        } catch (error) {
            if (error?.name !== "AbortError") {
                const message = error.message || "AI 对话失败";
                const message = error.message || translate("ai.drawer.chatFailed");
                setDrawerError(message);
                notify(message, { type: "error" });
            }
@@ -432,12 +455,12 @@
                <Stack direction="row" alignItems="center" spacing={1} px={2} py={1.5}>
                    <SmartToyOutlinedIcon color="primary" />
                    <Typography variant="h6" flex={1}>
                        AI 对话
                        {translate("ai.drawer.title")}
                    </Typography>
                    <IconButton size="small" onClick={startNewSession} title="新建会话" disabled={streaming}>
                    <IconButton size="small" onClick={startNewSession} title={translate("ai.drawer.newSession")} disabled={streaming}>
                        <AddCommentOutlinedIcon fontSize="small" />
                    </IconButton>
                    <IconButton size="small" onClick={onClose} title="关闭">
                    <IconButton size="small" onClick={onClose} title={translate("ai.common.close")}>
                        <CloseIcon fontSize="small" />
                    </IconButton>
                </Stack>
@@ -454,9 +477,9 @@
                    >
                        <Box px={2} py={1.5}>
                            <Stack direction="row" alignItems="center" justifyContent="space-between" mb={1}>
                                <Typography variant="subtitle2">会话列表</Typography>
                                <Typography variant="subtitle2">{translate("ai.drawer.sessionList")}</Typography>
                                <Button size="small" onClick={startNewSession} disabled={streaming}>
                                    新建会话
                                    {translate("ai.drawer.newSession")}
                                </Button>
                            </Stack>
                            <TextField
@@ -464,7 +487,7 @@
                                onChange={(event) => setSessionKeyword(event.target.value)}
                                fullWidth
                                size="small"
                                placeholder="搜索会话标题"
                                placeholder={translate("ai.drawer.searchPlaceholder")}
                                InputProps={{
                                    startAdornment: <SearchOutlinedIcon fontSize="small" sx={{ mr: 1, color: "text.secondary" }} />,
                                }}
@@ -474,7 +497,7 @@
                                {!sessions.length ? (
                                    <Box px={1.5} py={1.25}>
                                        <Typography variant="body2" color="text.secondary">
                                            暂无历史会话
                                            {translate("ai.drawer.noSessions")}
                                        </Typography>
                                    </Box>
                                ) : (
@@ -488,8 +511,8 @@
                                                alignItems="flex-start"
                                            >
                                                <ListItemText
                                                    primary={item.title || `会话 ${item.sessionId}`}
                                                    secondary={item.lastMessagePreview || item.lastMessageTime || `Session ${item.sessionId}`}
                                                    primary={item.title || translate("ai.drawer.sessionTitle", { id: item.sessionId })}
                                                    secondary={item.lastMessagePreview || item.lastMessageTime || translate("ai.drawer.sessionMetric", { id: item.sessionId })}
                                                    primaryTypographyProps={{
                                                        noWrap: true,
                                                        fontSize: 14,
@@ -508,7 +531,7 @@
                                                        event.stopPropagation();
                                                        handlePinSession(item.sessionId, !item.pinned);
                                                    }}
                                                    title={item.pinned ? "取消置顶" : "置顶会话"}
                                                    title={translate(item.pinned ? "ai.drawer.unpinAction" : "ai.drawer.pinAction")}
                                                >
                                                    {item.pinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
                                                </IconButton>
@@ -520,7 +543,7 @@
                                                        event.stopPropagation();
                                                        openRenameDialog(item);
                                                    }}
                                                    title="重命名会话"
                                                    title={translate("ai.drawer.renameAction")}
                                                >
                                                    <EditOutlinedIcon fontSize="small" />
                                                </IconButton>
@@ -532,7 +555,7 @@
                                                        event.stopPropagation();
                                                        handleDeleteSession(item.sessionId);
                                                    }}
                                                    title="删除会话"
                                                    title={translate("ai.drawer.deleteAction")}
                                                >
                                                    <DeleteOutlineOutlinedIcon fontSize="small" />
                                                </IconButton>
@@ -554,13 +577,13 @@
                    >
                        <Box px={2} py={1.5} display="flex" flexDirection="column" minHeight={0}>
                            <Typography variant="subtitle2" mb={1}>
                                工具调用轨迹
                                {translate("ai.drawer.toolTrace")}
                            </Typography>
                            <Paper variant="outlined" sx={{ flex: 1, minHeight: { xs: 140, md: 0 }, overflow: "hidden", bgcolor: "grey.50" }}>
                                {!toolEvents.length ? (
                                    <Box px={1.5} py={1.25}>
                                        <Typography variant="body2" color="text.secondary">
                                            当前轮未触发工具调用
                                            {translate("ai.drawer.noToolTrace")}
                                        </Typography>
                                    </Box>
                                ) : (
@@ -577,12 +600,12 @@
                                            >
                                                <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
                                                    <Typography variant="body2" fontWeight={700}>
                                                        {item.toolName || "未知工具"}
                                                        {item.toolName || translate("ai.drawer.unknownTool")}
                                                    </Typography>
                                                    <Chip
                                                        size="small"
                                                        color={item.status === "FAILED" ? "error" : item.status === "COMPLETED" ? "success" : "info"}
                                                        label={item.status === "FAILED" ? "失败" : item.status === "COMPLETED" ? "完成" : "执行中"}
                                                        label={translate(item.status === "FAILED" ? "ai.drawer.toolStatusFailed" : item.status === "COMPLETED" ? "ai.drawer.toolStatusCompleted" : "ai.drawer.toolStatusRunning")}
                                                    />
                                                    {item.durationMs != null && (
                                                        <Typography variant="caption" color="text.secondary">
@@ -598,24 +621,24 @@
                                                                : <ExpandMoreOutlinedIcon fontSize="small" />}
                                                            sx={{ ml: "auto", minWidth: "auto", px: 0.5 }}
                                                        >
                                                            {expandedToolIds.includes(item.toolCallId) ? "收起详情" : "查看详情"}
                                                            {expandedToolIds.includes(item.toolCallId) ? translate("ai.drawer.collapseDetail") : translate("ai.drawer.viewDetail")}
                                                        </Button>
                                                    )}
                                                </Stack>
                                                <Collapse in={expandedToolIds.includes(item.toolCallId)} timeout="auto" unmountOnExit>
                                                    {!!item.inputSummary && (
                                                        <Typography variant="caption" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
                                                            入参: {item.inputSummary}
                                                            {translate("ai.drawer.toolInput", { value: item.inputSummary })}
                                                        </Typography>
                                                    )}
                                                    {!!item.outputSummary && (
                                                        <Typography variant="caption" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
                                                            结果摘要: {item.outputSummary}
                                                            {translate("ai.drawer.toolOutput", { value: item.outputSummary })}
                                                        </Typography>
                                                    )}
                                                    {!!item.errorMessage && (
                                                        <Typography variant="caption" color="error.main" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
                                                            错误: {item.errorMessage}
                                                            {translate("ai.drawer.toolError", { value: item.errorMessage })}
                                                        </Typography>
                                                    )}
                                                </Collapse>
@@ -630,15 +653,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 ? "有事实" : "无事实"} />
                                <Chip size="small" label={translate("ai.drawer.requestMetric", { value: runtimeSummary.requestId })} />
                                <Chip size="small" label={translate("ai.drawer.sessionMetric", { id: sessionId || "--" })} />
                                <Chip size="small" label={translate("ai.drawer.promptMetric", { value: runtimeSummary.promptName })} />
                                <Chip size="small" label={translate("ai.drawer.modelMetric", { value: runtimeSummary.model })} />
                                <Chip size="small" label={translate("ai.drawer.mcpMetric", { value: runtimeSummary.mountedMcpCount })} />
                                <Chip size="small" label={translate("ai.drawer.historyMetric", { value: persistedMessages.length })} />
                                <Chip size="small" label={translate("ai.drawer.recentMetric", { value: runtimeSummary.recentMessageCount })} />
                                <Chip size="small" color={runtimeSummary.hasSummary ? "success" : "default"} label={translate(runtimeSummary.hasSummary ? "ai.drawer.hasSummary" : "ai.drawer.noSummary")} />
                                <Chip size="small" color={runtimeSummary.hasFacts ? "info" : "default"} label={translate(runtimeSummary.hasFacts ? "ai.drawer.hasFacts" : "ai.drawer.noFacts")} />
                            </Stack>
                            <Stack direction="row" spacing={1} mt={1.5} flexWrap="wrap" useFlexGap>
                                {quickLinks.map((item) => (
@@ -659,7 +682,7 @@
                                    onClick={handleRetainLatestRound}
                                    disabled={!sessionId || streaming}
                                >
                                    仅保留当前轮
                                    {translate("ai.drawer.retainLatestRound")}
                                </Button>
                                <Button
                                    size="small"
@@ -669,7 +692,7 @@
                                    onClick={handleClearMemory}
                                    disabled={!sessionId || streaming}
                                >
                                    清空记忆
                                    {translate("ai.drawer.clearMemory")}
                                </Button>
                            </Stack>
                            {!!runtime?.memorySummary && (
@@ -688,7 +711,7 @@
                            )}
                            {loadingRuntime && (
                                <Typography variant="body2" color="text.secondary" mt={1}>
                                    正在加载 AI 运行时信息...
                                    {translate("ai.drawer.loadingRuntime")}
                                </Typography>
                            )}
                            {!!drawerError && (
@@ -700,11 +723,20 @@
                        <Divider />
                        <Box flex={1} overflow="auto" px={2} py={2} display="flex" flexDirection="column" gap={1.5}>
                        <Box
                            ref={messagesContainerRef}
                            flex={1}
                            overflow="auto"
                            px={2}
                            py={2}
                            display="flex"
                            flexDirection="column"
                            gap={1.5}
                        >
                            {!messages.length && (
                                <Paper variant="outlined" sx={{ p: 2, bgcolor: "grey.50" }}>
                                    <Typography variant="body2" color="text.secondary">
                                        这里会通过 SSE 流式返回 AI 回复。你也可以先去上面的快捷入口维护参数、Prompt 和 MCP 挂载。
                                        {translate("ai.drawer.emptyHint")}
                                    </Typography>
                                </Paper>
                            )}
@@ -728,14 +760,15 @@
                                        }}
                                    >
                                        <Typography variant="caption" display="block" sx={{ opacity: 0.72, mb: 0.5 }}>
                                            {message.role === "user" ? "你" : "AI"}
                                            {message.role === "user" ? translate("ai.drawer.userRole") : translate("ai.drawer.assistantRole")}
                                        </Typography>
                                        <Typography variant="body2">
                                            {message.content || (streaming && index === messages.length - 1 ? "思考中..." : "")}
                                            {message.content || (streaming && index === messages.length - 1 ? translate("ai.drawer.thinking") : "")}
                                        </Typography>
                                    </Paper>
                                </Box>
                            ))}
                            <Box ref={messagesBottomRef} sx={{ height: 1 }} />
                        </Box>
                        <Divider />
@@ -743,12 +776,17 @@
                        <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` : ""}
                                    {translate("ai.drawer.elapsedMetric", { value: usage.elapsedMs })}
                                    {usage?.firstTokenLatencyMs != null ? ` / ${translate("ai.drawer.firstTokenMetric", { value: usage.firstTokenLatencyMs })}` : ""}
                                </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}
                                    {translate("ai.drawer.tokenMetric", {
                                        prompt: usage?.promptTokens ?? 0,
                                        completion: usage?.completionTokens ?? 0,
                                        total: usage?.totalTokens ?? 0,
                                    })}
                                </Typography>
                            )}
                            <TextField
@@ -759,17 +797,17 @@
                                multiline
                                minRows={3}
                                maxRows={6}
                                placeholder="输入你的问题,按 Enter 发送,Shift + Enter 换行"
                                placeholder={translate("ai.drawer.inputPlaceholder")}
                            />
                            <Stack direction="row" spacing={1} justifyContent="flex-end" mt={1.25}>
                                <Button onClick={() => setInput("")}>清空输入</Button>
                                <Button onClick={() => setInput("")}>{translate("ai.drawer.clearInput")}</Button>
                                {streaming ? (
                                    <Button variant="outlined" color="warning" startIcon={<StopCircleOutlinedIcon />} onClick={() => stopStream(true)}>
                                        停止
                                        {translate("ai.drawer.stop")}
                                    </Button>
                                ) : (
                                    <Button variant="contained" startIcon={<SendRoundedIcon />} onClick={handleSend}>
                                        发送
                                        {translate("ai.drawer.send")}
                                    </Button>
                                )}
                            </Stack>
@@ -778,21 +816,21 @@
                </Box>
            </Box>
            <Dialog open={renameDialog.open} onClose={closeRenameDialog} fullWidth maxWidth="xs">
                <DialogTitle>重命名会话</DialogTitle>
                <DialogTitle>{translate("ai.drawer.renameDialogTitle")}</DialogTitle>
                <DialogContent>
                    <TextField
                        value={renameDialog.title}
                        onChange={(event) => setRenameDialog((prev) => ({ ...prev, title: event.target.value }))}
                        autoFocus
                        margin="dense"
                        label="会话标题"
                        label={translate("ai.drawer.sessionTitleField")}
                        fullWidth
                    />
                </DialogContent>
                <DialogActions>
                    <Button onClick={closeRenameDialog}>取消</Button>
                    <Button onClick={closeRenameDialog}>{translate("ai.common.cancel")}</Button>
                    <Button onClick={handleRenameSubmit} variant="contained" disabled={streaming || !renameDialog.title.trim()}>
                        保存
                        {translate("ai.common.save")}
                    </Button>
                </DialogActions>
            </Dialog>