| | |
| | | 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, |
| | | Button, |
| | | Chip, |
| | | Collapse, |
| | | Dialog, |
| | | DialogActions, |
| | | DialogContent, |
| | | DialogTitle, |
| | | Divider, |
| | | Drawer, |
| | | IconButton, |
| | |
| | | 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 ExpandMoreOutlinedIcon from "@mui/icons-material/ExpandMoreOutlined"; |
| | | import ExpandLessOutlinedIcon from "@mui/icons-material/ExpandLessOutlined"; |
| | | import { clearAiSessionMemory, getAiRuntime, getAiSessions, pinAiSession, removeAiSession, renameAiSession, retainAiSessionLatestRound, streamAiChat } from "@/api/ai/chat"; |
| | | |
| | | 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([]); |
| | | const [persistedMessages, setPersistedMessages] = useState([]); |
| | | const [messages, setMessages] = useState([]); |
| | | const [toolEvents, setToolEvents] = useState([]); |
| | | const [expandedToolIds, setExpandedToolIds] = useState([]); |
| | | const [input, setInput] = useState(""); |
| | | const [loadingRuntime, setLoadingRuntime] = useState(false); |
| | | 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 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; |
| | | |
| | | 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]); |
| | | |
| | |
| | | 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([]); |
| | | await Promise.all([ |
| | | loadRuntime(targetSessionId), |
| | | loadSessions(), |
| | | loadSessions(sessionKeyword), |
| | | ]); |
| | | }; |
| | | |
| | |
| | | setPersistedMessages(historyMessages); |
| | | setMessages(historyMessages); |
| | | } catch (error) { |
| | | const message = error.message || "获取 AI 运行时失败"; |
| | | const message = error.message || translate("ai.drawer.runtimeFailed"); |
| | | setDrawerError(message); |
| | | } finally { |
| | | setLoadingRuntime(false); |
| | | } |
| | | }; |
| | | |
| | | 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 会话列表失败"; |
| | | const message = error.message || translate("ai.drawer.sessionListFailed"); |
| | | setDrawerError(message); |
| | | } |
| | | }; |
| | |
| | | setSessionId(null); |
| | | setPersistedMessages([]); |
| | | setMessages([]); |
| | | setToolEvents([]); |
| | | setExpandedToolIds([]); |
| | | 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) { |
| | | return; |
| | | } |
| | | setUsage(null); |
| | | setToolEvents([]); |
| | | setExpandedToolIds([]); |
| | | await loadRuntime(targetSessionId); |
| | | }; |
| | | |
| | |
| | | } |
| | | try { |
| | | await removeAiSession(targetSessionId); |
| | | notify("会话已删除"); |
| | | notify(translate("ai.drawer.sessionDeleted")); |
| | | if (targetSessionId === sessionId) { |
| | | startNewSession(); |
| | | await loadRuntime(null); |
| | | } |
| | | await loadSessions(); |
| | | await loadSessions(sessionKeyword); |
| | | } catch (error) { |
| | | const message = error.message || "删除 AI 会话失败"; |
| | | const message = error.message || translate("ai.drawer.deleteSessionFailed"); |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | | }; |
| | | |
| | | const handlePinSession = async (targetSessionId, pinned) => { |
| | | if (streaming || !targetSessionId) { |
| | | return; |
| | | } |
| | | try { |
| | | await pinAiSession(targetSessionId, pinned); |
| | | notify(translate(pinned ? "ai.drawer.pinned" : "ai.drawer.unpinned")); |
| | | await loadSessions(sessionKeyword); |
| | | } catch (error) { |
| | | const message = error.message || translate("ai.drawer.pinFailed"); |
| | | 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(translate("ai.drawer.renamed")); |
| | | closeRenameDialog(); |
| | | await loadSessions(sessionKeyword); |
| | | } catch (error) { |
| | | const message = error.message || translate("ai.drawer.renameFailed"); |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | | }; |
| | | |
| | | const handleClearMemory = async () => { |
| | | if (streaming || !sessionId) { |
| | | return; |
| | | } |
| | | try { |
| | | await clearAiSessionMemory(sessionId); |
| | | notify(translate("ai.drawer.memoryCleared")); |
| | | await Promise.all([ |
| | | loadRuntime(sessionId), |
| | | loadSessions(sessionKeyword), |
| | | ]); |
| | | } catch (error) { |
| | | const message = error.message || translate("ai.drawer.clearMemoryFailed"); |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | | }; |
| | | |
| | | const handleRetainLatestRound = async () => { |
| | | if (streaming || !sessionId) { |
| | | return; |
| | | } |
| | | try { |
| | | await retainAiSessionLatestRound(sessionId); |
| | | notify(translate("ai.drawer.retainLatestRoundSuccess")); |
| | | await Promise.all([ |
| | | loadRuntime(sessionId), |
| | | loadSessions(sessionKeyword), |
| | | ]); |
| | | } catch (error) { |
| | | const message = error.message || translate("ai.drawer.retainLatestRoundFailed"); |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | |
| | | 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; |
| | | } |
| | | }; |
| | | |
| | |
| | | return next; |
| | | }; |
| | | |
| | | const upsertToolEvent = (payload) => { |
| | | if (!payload?.toolCallId) { |
| | | return; |
| | | } |
| | | setToolEvents((prev) => { |
| | | const index = prev.findIndex((item) => item.toolCallId === payload.toolCallId); |
| | | if (index < 0) { |
| | | return [...prev, payload]; |
| | | } |
| | | const next = [...prev]; |
| | | next[index] = { ...next[index], ...payload }; |
| | | return next; |
| | | }); |
| | | }; |
| | | |
| | | const toggleToolEventExpanded = (toolCallId) => { |
| | | if (!toolCallId) { |
| | | return; |
| | | } |
| | | setExpandedToolIds((prev) => ( |
| | | prev.includes(toolCallId) |
| | | ? prev.filter((item) => item !== toolCallId) |
| | | : [...prev, toolCallId] |
| | | )); |
| | | }; |
| | | |
| | | const handleSend = async () => { |
| | | const content = input.trim(); |
| | | if (!content || streaming) { |
| | |
| | | setInput(""); |
| | | setUsage(null); |
| | | setDrawerError(""); |
| | | setToolEvents([]); |
| | | setExpandedToolIds([]); |
| | | setMessages(ensureAssistantPlaceholder(nextMessages)); |
| | | setStreaming(true); |
| | | |
| | |
| | | if (eventName === "delta") { |
| | | appendAssistantDelta(payload?.content || ""); |
| | | } |
| | | if (eventName === "tool_start" || eventName === "tool_result" || eventName === "tool_error") { |
| | | upsertToolEvent(payload); |
| | | } |
| | | if (eventName === "done") { |
| | | setUsage(payload); |
| | | completed = true; |
| | |
| | | } |
| | | } |
| | | if (eventName === "error") { |
| | | const message = payload?.message || "AI 对话失败"; |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | const message = payload?.message || translate("ai.drawer.chatFailed"); |
| | | const displayMessage = payload?.requestId ? `${message} [${payload.requestId}]` : message; |
| | | setDrawerError(displayMessage); |
| | | notify(displayMessage, { type: "error" }); |
| | | } |
| | | }, |
| | | } |
| | | ); |
| | | } 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" }); |
| | | } |
| | |
| | | if (completed) { |
| | | await Promise.all([ |
| | | loadRuntime(completedSessionId), |
| | | loadSessions(), |
| | | loadSessions(sessionKeyword), |
| | | ]); |
| | | } |
| | | } |
| | |
| | | "& .MuiDrawer-paper": { |
| | | top: 0, |
| | | height: "100vh", |
| | | width: { xs: "100vw", md: "50vw" }, |
| | | width: { xs: "100vw", md: "70vw" }, |
| | | }, |
| | | }} |
| | | > |
| | |
| | | <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> |
| | |
| | | > |
| | | <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 |
| | | value={sessionKeyword} |
| | | onChange={(event) => setSessionKeyword(event.target.value)} |
| | | fullWidth |
| | | size="small" |
| | | placeholder={translate("ai.drawer.searchPlaceholder")} |
| | | 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}> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | 暂无历史会话 |
| | | {translate("ai.drawer.noSessions")} |
| | | </Typography> |
| | | </Box> |
| | | ) : ( |
| | |
| | | alignItems="flex-start" |
| | | > |
| | | <ListItemText |
| | | primary={item.title || `会话 ${item.sessionId}`} |
| | | secondary={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, |
| | | fontWeight: item.pinned ? 700 : 400, |
| | | }} |
| | | secondaryTypographyProps={{ |
| | | noWrap: true, |
| | |
| | | disabled={streaming} |
| | | onClick={(event) => { |
| | | event.stopPropagation(); |
| | | handlePinSession(item.sessionId, !item.pinned); |
| | | }} |
| | | title={translate(item.pinned ? "ai.drawer.unpinAction" : "ai.drawer.pinAction")} |
| | | > |
| | | {item.pinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />} |
| | | </IconButton> |
| | | <IconButton |
| | | size="small" |
| | | edge="end" |
| | | disabled={streaming} |
| | | onClick={(event) => { |
| | | event.stopPropagation(); |
| | | openRenameDialog(item); |
| | | }} |
| | | title={translate("ai.drawer.renameAction")} |
| | | > |
| | | <EditOutlinedIcon fontSize="small" /> |
| | | </IconButton> |
| | | <IconButton |
| | | size="small" |
| | | edge="end" |
| | | disabled={streaming} |
| | | onClick={(event) => { |
| | | event.stopPropagation(); |
| | | handleDeleteSession(item.sessionId); |
| | | }} |
| | | title="删除会话" |
| | | title={translate("ai.drawer.deleteAction")} |
| | | > |
| | | <DeleteOutlineOutlinedIcon fontSize="small" /> |
| | | </IconButton> |
| | |
| | | </Box> |
| | | </Box> |
| | | |
| | | <Box |
| | | width={{ xs: "100%", md: 280 }} |
| | | borderRight={{ xs: "none", md: "1px solid rgba(224, 224, 224, 1)" }} |
| | | borderBottom={{ xs: "1px solid rgba(224, 224, 224, 1)", md: "none" }} |
| | | display="flex" |
| | | flexDirection="column" |
| | | minHeight={0} |
| | | > |
| | | <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> |
| | | ) : ( |
| | | <Stack spacing={1} sx={{ p: 1.25, maxHeight: { xs: 220, md: "calc(100vh - 180px)" }, overflow: "auto" }}> |
| | | {toolEvents.map((item) => ( |
| | | <Paper |
| | | key={item.toolCallId} |
| | | variant="outlined" |
| | | sx={{ |
| | | p: 1.25, |
| | | bgcolor: item.status === "FAILED" ? "error.lighter" : "common.white", |
| | | borderColor: item.status === "FAILED" ? "error.light" : "divider", |
| | | }} |
| | | > |
| | | <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap> |
| | | <Typography variant="body2" fontWeight={700}> |
| | | {item.toolName || translate("ai.drawer.unknownTool")} |
| | | </Typography> |
| | | <Chip |
| | | size="small" |
| | | color={item.status === "FAILED" ? "error" : item.status === "COMPLETED" ? "success" : "info"} |
| | | 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"> |
| | | {item.durationMs} ms |
| | | </Typography> |
| | | )} |
| | | {(item.inputSummary || item.outputSummary || item.errorMessage) && ( |
| | | <Button |
| | | size="small" |
| | | onClick={() => toggleToolEventExpanded(item.toolCallId)} |
| | | endIcon={expandedToolIds.includes(item.toolCallId) |
| | | ? <ExpandLessOutlinedIcon fontSize="small" /> |
| | | : <ExpandMoreOutlinedIcon fontSize="small" />} |
| | | sx={{ ml: "auto", minWidth: "auto", px: 0.5 }} |
| | | > |
| | | {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" }}> |
| | | {translate("ai.drawer.toolInput", { value: item.inputSummary })} |
| | | </Typography> |
| | | )} |
| | | {!!item.outputSummary && ( |
| | | <Typography variant="caption" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}> |
| | | {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" }}> |
| | | {translate("ai.drawer.toolError", { value: item.errorMessage })} |
| | | </Typography> |
| | | )} |
| | | </Collapse> |
| | | </Paper> |
| | | ))} |
| | | </Stack> |
| | | )} |
| | | </Paper> |
| | | </Box> |
| | | </Box> |
| | | |
| | | <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={`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={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) => ( |
| | |
| | | {item.label} |
| | | </Button> |
| | | ))} |
| | | <Button |
| | | size="small" |
| | | variant="outlined" |
| | | startIcon={<HistoryOutlinedIcon />} |
| | | onClick={handleRetainLatestRound} |
| | | disabled={!sessionId || streaming} |
| | | > |
| | | {translate("ai.drawer.retainLatestRound")} |
| | | </Button> |
| | | <Button |
| | | size="small" |
| | | variant="outlined" |
| | | color="warning" |
| | | startIcon={<AutoDeleteOutlinedIcon />} |
| | | onClick={handleClearMemory} |
| | | disabled={!sessionId || streaming} |
| | | > |
| | | {translate("ai.drawer.clearMemory")} |
| | | </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 运行时信息... |
| | | {translate("ai.drawer.loadingRuntime")} |
| | | </Typography> |
| | | )} |
| | | {!!drawerError && ( |
| | |
| | | |
| | | <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> |
| | | )} |
| | |
| | | }} |
| | | > |
| | | <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 /> |
| | | |
| | | <Box px={2} py={1.5}> |
| | | {usage?.elapsedMs != null && ( |
| | | <Typography variant="caption" color="text.secondary" display="block" mb={0.5}> |
| | | {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 |
| | |
| | | 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> |
| | |
| | | </Box> |
| | | </Box> |
| | | </Box> |
| | | <Dialog open={renameDialog.open} onClose={closeRenameDialog} fullWidth maxWidth="xs"> |
| | | <DialogTitle>{translate("ai.drawer.renameDialogTitle")}</DialogTitle> |
| | | <DialogContent> |
| | | <TextField |
| | | value={renameDialog.title} |
| | | onChange={(event) => setRenameDialog((prev) => ({ ...prev, title: event.target.value }))} |
| | | autoFocus |
| | | margin="dense" |
| | | label={translate("ai.drawer.sessionTitleField")} |
| | | fullWidth |
| | | /> |
| | | </DialogContent> |
| | | <DialogActions> |
| | | <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> |
| | | </Drawer> |
| | | ); |
| | | }; |