import React, { useEffect, useMemo, useRef, useState } from "react";
|
import { useLocation, useNavigate } from "react-router-dom";
|
import { useNotify, useTranslate } from "react-admin";
|
import {
|
Alert,
|
Box,
|
Button,
|
Chip,
|
Collapse,
|
Dialog,
|
DialogActions,
|
DialogContent,
|
DialogTitle,
|
Divider,
|
Drawer,
|
IconButton,
|
List,
|
ListItemButton,
|
ListItemText,
|
Paper,
|
Stack,
|
TextField,
|
Typography,
|
} from "@mui/material";
|
import SmartToyOutlinedIcon from "@mui/icons-material/SmartToyOutlined";
|
import SendRoundedIcon from "@mui/icons-material/SendRounded";
|
import StopCircleOutlinedIcon from "@mui/icons-material/StopCircleOutlined";
|
import SettingsSuggestOutlinedIcon from "@mui/icons-material/SettingsSuggestOutlined";
|
import PsychologyAltOutlinedIcon from "@mui/icons-material/PsychologyAltOutlined";
|
import CableOutlinedIcon from "@mui/icons-material/CableOutlined";
|
import CloseIcon from "@mui/icons-material/Close";
|
import AddCommentOutlinedIcon from "@mui/icons-material/AddCommentOutlined";
|
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import AutoDeleteOutlinedIcon from "@mui/icons-material/AutoDeleteOutlined";
|
import HistoryOutlinedIcon from "@mui/icons-material/HistoryOutlined";
|
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
import PushPinIcon from "@mui/icons-material/PushPin";
|
import SearchOutlinedIcon from "@mui/icons-material/SearchOutlined";
|
import 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 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]);
|
|
useEffect(() => {
|
if (open) {
|
initializeDrawer();
|
} else {
|
stopStream(false);
|
}
|
}, [open]);
|
|
useEffect(() => () => {
|
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(sessionKeyword),
|
]);
|
};
|
|
const loadRuntime = async (targetSessionId = null) => {
|
setLoadingRuntime(true);
|
setDrawerError("");
|
try {
|
const data = await getAiRuntime(DEFAULT_PROMPT_CODE, targetSessionId);
|
const historyMessages = data?.persistedMessages || [];
|
setRuntime(data);
|
setSessionId(data?.sessionId || null);
|
setPersistedMessages(historyMessages);
|
setMessages(historyMessages);
|
} catch (error) {
|
const message = error.message || translate("ai.drawer.runtimeFailed");
|
setDrawerError(message);
|
} finally {
|
setLoadingRuntime(false);
|
}
|
};
|
|
const loadSessions = async (keyword = sessionKeyword) => {
|
try {
|
const data = await getAiSessions(DEFAULT_PROMPT_CODE, keyword);
|
setSessions(data);
|
} catch (error) {
|
const message = error.message || translate("ai.drawer.sessionListFailed");
|
setDrawerError(message);
|
}
|
};
|
|
const startNewSession = () => {
|
if (streaming) {
|
return;
|
}
|
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);
|
};
|
|
const handleDeleteSession = async (targetSessionId) => {
|
if (streaming || !targetSessionId) {
|
return;
|
}
|
try {
|
await removeAiSession(targetSessionId);
|
notify(translate("ai.drawer.sessionDeleted"));
|
if (targetSessionId === sessionId) {
|
startNewSession();
|
await loadRuntime(null);
|
}
|
await loadSessions(sessionKeyword);
|
} catch (error) {
|
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" });
|
}
|
};
|
|
const stopStream = (showTip = true) => {
|
if (abortRef.current) {
|
abortRef.current.abort();
|
abortRef.current = null;
|
setStreaming(false);
|
if (showTip) {
|
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;
|
}
|
};
|
|
const appendAssistantDelta = (delta) => {
|
setMessages((prev) => {
|
const next = [...prev];
|
const last = next[next.length - 1];
|
if (last && last.role === "assistant") {
|
next[next.length - 1] = {
|
...last,
|
content: `${last.content || ""}${delta}`,
|
};
|
return next;
|
}
|
next.push({ role: "assistant", content: delta });
|
return next;
|
});
|
};
|
|
const ensureAssistantPlaceholder = (seedMessages) => {
|
const next = [...seedMessages];
|
const last = next[next.length - 1];
|
if (!last || last.role !== "assistant") {
|
next.push({ role: "assistant", content: "" });
|
}
|
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) {
|
return;
|
}
|
const memoryMessages = [{ role: "user", content }];
|
const nextMessages = [...messages, ...memoryMessages];
|
setInput("");
|
setUsage(null);
|
setDrawerError("");
|
setToolEvents([]);
|
setExpandedToolIds([]);
|
setMessages(ensureAssistantPlaceholder(nextMessages));
|
setStreaming(true);
|
|
const controller = new AbortController();
|
abortRef.current = controller;
|
|
let completed = false;
|
let completedSessionId = sessionId;
|
|
try {
|
await streamAiChat(
|
{
|
sessionId,
|
promptCode,
|
messages: memoryMessages,
|
metadata: {
|
path: location.pathname,
|
},
|
},
|
{
|
signal: controller.signal,
|
onEvent: (eventName, payload) => {
|
if (eventName === "start") {
|
setRuntime(payload);
|
if (payload?.sessionId) {
|
setSessionId(payload.sessionId);
|
completedSessionId = payload.sessionId;
|
}
|
}
|
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 (payload?.sessionId) {
|
completedSessionId = payload.sessionId;
|
}
|
}
|
if (eventName === "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 || translate("ai.drawer.chatFailed");
|
setDrawerError(message);
|
notify(message, { type: "error" });
|
}
|
} finally {
|
abortRef.current = null;
|
setStreaming(false);
|
if (completed) {
|
await Promise.all([
|
loadRuntime(completedSessionId),
|
loadSessions(sessionKeyword),
|
]);
|
}
|
}
|
};
|
|
const handleKeyDown = (event) => {
|
if (event.key === "Enter" && !event.shiftKey) {
|
event.preventDefault();
|
handleSend();
|
}
|
};
|
|
return (
|
<Drawer
|
anchor="right"
|
open={open}
|
onClose={onClose}
|
ModalProps={{ keepMounted: true }}
|
sx={{
|
zIndex: 1400,
|
"& .MuiDrawer-paper": {
|
top: 0,
|
height: "100vh",
|
width: { xs: "100vw", md: "70vw" },
|
},
|
}}
|
>
|
<Box display="flex" flexDirection="column" height="100%">
|
<Stack direction="row" alignItems="center" spacing={1} px={2} py={1.5}>
|
<SmartToyOutlinedIcon color="primary" />
|
<Typography variant="h6" flex={1}>
|
{translate("ai.drawer.title")}
|
</Typography>
|
<IconButton size="small" onClick={startNewSession} title={translate("ai.drawer.newSession")} disabled={streaming}>
|
<AddCommentOutlinedIcon fontSize="small" />
|
</IconButton>
|
<IconButton size="small" onClick={onClose} title={translate("ai.common.close")}>
|
<CloseIcon fontSize="small" />
|
</IconButton>
|
</Stack>
|
<Divider />
|
|
<Box flex={1} display="flex" flexDirection={{ xs: "column", md: "row" }} minHeight={0}>
|
<Box
|
width={{ xs: "100%", md: 240 }}
|
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}>
|
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={1}>
|
<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>
|
) : (
|
<List disablePadding sx={{ maxHeight: { xs: 180, md: "calc(100vh - 260px)" }, overflow: "auto" }}>
|
{sessions.map((item) => (
|
<ListItemButton
|
key={item.sessionId}
|
selected={item.sessionId === sessionId}
|
onClick={() => handleSwitchSession(item.sessionId)}
|
disabled={streaming}
|
alignItems="flex-start"
|
>
|
<ListItemText
|
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,
|
fontSize: 12,
|
}}
|
/>
|
<IconButton
|
size="small"
|
edge="end"
|
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={translate("ai.drawer.deleteAction")}
|
>
|
<DeleteOutlineOutlinedIcon fontSize="small" />
|
</IconButton>
|
</ListItemButton>
|
))}
|
</List>
|
)}
|
</Paper>
|
</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={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) => (
|
<Button
|
key={item.path}
|
size="small"
|
variant="outlined"
|
startIcon={item.icon}
|
onClick={() => navigate(item.path)}
|
>
|
{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}>
|
{translate("ai.drawer.loadingRuntime")}
|
</Typography>
|
)}
|
{!!drawerError && (
|
<Alert severity="warning" sx={{ mt: 1.5 }}>
|
{drawerError}
|
</Alert>
|
)}
|
</Box>
|
|
<Divider />
|
|
<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">
|
{translate("ai.drawer.emptyHint")}
|
</Typography>
|
</Paper>
|
)}
|
{messages.map((message, index) => (
|
<Box
|
key={`${message.role}-${index}`}
|
display="flex"
|
justifyContent={message.role === "user" ? "flex-end" : "flex-start"}
|
>
|
<Paper
|
elevation={0}
|
sx={{
|
px: 1.5,
|
py: 1.25,
|
maxWidth: "85%",
|
borderRadius: 2,
|
bgcolor: message.role === "user" ? "primary.main" : "grey.100",
|
color: message.role === "user" ? "primary.contrastText" : "text.primary",
|
whiteSpace: "pre-wrap",
|
wordBreak: "break-word",
|
}}
|
>
|
<Typography variant="caption" display="block" sx={{ opacity: 0.72, mb: 0.5 }}>
|
{message.role === "user" ? translate("ai.drawer.userRole") : translate("ai.drawer.assistantRole")}
|
</Typography>
|
<Typography variant="body2">
|
{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}>
|
{translate("ai.drawer.tokenMetric", {
|
prompt: usage?.promptTokens ?? 0,
|
completion: usage?.completionTokens ?? 0,
|
total: usage?.totalTokens ?? 0,
|
})}
|
</Typography>
|
)}
|
<TextField
|
value={input}
|
onChange={(event) => setInput(event.target.value)}
|
onKeyDown={handleKeyDown}
|
fullWidth
|
multiline
|
minRows={3}
|
maxRows={6}
|
placeholder={translate("ai.drawer.inputPlaceholder")}
|
/>
|
<Stack direction="row" spacing={1} justifyContent="flex-end" mt={1.25}>
|
<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>
|
</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>
|
);
|
};
|
|
export default AiChatDrawer;
|