From 40905cbd04c2e332cd4bc2b9e0c5b3e1da9cccfa Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期一, 30 三月 2026 08:17:32 +0800
Subject: [PATCH] feat: complete rsf-design phase 1 integration
---
rsf-admin/src/layout/AiChatDrawer.jsx | 881 +++++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 files changed, 803 insertions(+), 78 deletions(-)
diff --git a/rsf-admin/src/layout/AiChatDrawer.jsx b/rsf-admin/src/layout/AiChatDrawer.jsx
index d837069..cbbd584 100644
--- a/rsf-admin/src/layout/AiChatDrawer.jsx
+++ b/rsf-admin/src/layout/AiChatDrawer.jsx
@@ -1,22 +1,32 @@
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 ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
import {
Alert,
Box,
Button,
Chip,
+ Collapse,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
Divider,
Drawer,
IconButton,
List,
ListItemButton,
ListItemText,
+ MenuItem,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
+import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
+import { atomOneLight } from "react-syntax-highlighter/dist/esm/styles/hljs";
import SmartToyOutlinedIcon from "@mui/icons-material/SmartToyOutlined";
import SendRoundedIcon from "@mui/icons-material/SendRounded";
import StopCircleOutlinedIcon from "@mui/icons-material/StopCircleOutlined";
@@ -26,44 +36,252 @@
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 AI_CHAT_DRAWER_Z_INDEX = 1400;
+const AI_CHAT_DIALOG_Z_INDEX = AI_CHAT_DRAWER_Z_INDEX + 20;
-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 normalizeMarkdownContent = (content) => {
+ if (!content) {
+ return "";
+ }
+ return content
+ .replace(/\r\n/g, "\n")
+ .replace(/(\n[-*] .+)\n{2,}(?=[-*] )/g, "$1\n")
+ .replace(/(\n\d+\. .+)\n{2,}(?=\d+\. )/g, "$1\n")
+ .replace(/([^\n])\n{3,}/g, "$1\n\n");
+};
+
+const markdownSx = {
+ width: "100%",
+ fontSize: "0.84rem",
+ "& > *:first-of-type": {
+ mt: 0,
+ },
+ "& > *:last-child": {
+ mb: 0,
+ },
+ "& p": {
+ m: 0,
+ lineHeight: 1.28,
+ },
+ "& p + p": {
+ mt: 0.1,
+ },
+ "& h1, & h2, & h3, & h4, & h5, & h6": {
+ mt: 0.25,
+ mb: 0.04,
+ lineHeight: 1.16,
+ fontWeight: 700,
+ },
+ "& h1": {
+ fontSize: "0.96rem",
+ },
+ "& h2": {
+ fontSize: "0.92rem",
+ },
+ "& h3": {
+ fontSize: "0.89rem",
+ },
+ "& ul, & ol": {
+ my: 0.02,
+ pl: 1.3,
+ },
+ "& ul > li, & ol > li": {
+ lineHeight: 1.2,
+ },
+ "& li + li": {
+ mt: 0,
+ },
+ "& li > p": {
+ display: "inline",
+ m: 0,
+ lineHeight: "inherit",
+ },
+ "& li::marker": {
+ fontSize: "0.78rem",
+ },
+ "& blockquote": {
+ m: 0,
+ mt: 0.18,
+ px: 0.7,
+ py: 0.25,
+ borderLeft: "3px solid rgba(25, 118, 210, 0.35)",
+ bgcolor: "rgba(25, 118, 210, 0.06)",
+ },
+ "& hr": {
+ my: 0.25,
+ border: 0,
+ borderTop: "1px solid rgba(0, 0, 0, 0.12)",
+ },
+ "& table": {
+ width: "100%",
+ borderCollapse: "collapse",
+ mt: 0.18,
+ mb: 0.04,
+ fontSize: "0.78rem",
+ },
+ "& th, & td": {
+ border: "1px solid rgba(0, 0, 0, 0.12)",
+ px: 0.4,
+ py: 0.22,
+ textAlign: "left",
+ verticalAlign: "top",
+ },
+ "& th": {
+ bgcolor: "rgba(0, 0, 0, 0.04)",
+ fontWeight: 700,
+ },
+ "& a": {
+ color: "primary.main",
+ textDecoration: "underline",
+ wordBreak: "break-all",
+ },
+ "& img": {
+ maxWidth: "100%",
+ borderRadius: 1.5,
+ },
+ "& code": {
+ fontFamily: "'Consolas', 'Monaco', monospace",
+ },
+};
+
+const AiMarkdownContent = ({ content }) => (
+ <Box sx={markdownSx}>
+ <ReactMarkdown
+ remarkPlugins={[remarkGfm]}
+ components={{
+ p: ({ children }) => <Typography variant="body2">{children}</Typography>,
+ li: ({ children }) => <Box component="li" sx={{ fontSize: "0.875rem" }}>{children}</Box>,
+ blockquote: ({ children }) => <Box component="blockquote">{children}</Box>,
+ a: ({ href, children }) => (
+ <Box
+ component="a"
+ href={href}
+ target="_blank"
+ rel="noreferrer"
+ >
+ {children}
+ </Box>
+ ),
+ code({ inline, className, children, ...props }) {
+ const match = /language-(\w+)/.exec(className || "");
+ const code = String(children).replace(/\n$/, "");
+ if (!inline) {
+ return (
+ <Box sx={{ mt: 0.7, mb: 0.2, borderRadius: 1.5, overflow: "hidden" }}>
+ <SyntaxHighlighter
+ language={match?.[1]}
+ style={atomOneLight}
+ customStyle={{
+ margin: 0,
+ padding: "6px 8px",
+ borderRadius: 12,
+ fontSize: "0.74rem",
+ }}
+ wrapLongLines
+ PreTag="div"
+ {...props}
+ >
+ {code}
+ </SyntaxHighlighter>
+ </Box>
+ );
+ }
+ return (
+ <Box
+ component="code"
+ sx={{
+ px: 0.45,
+ py: "1px",
+ borderRadius: 0.75,
+ bgcolor: "rgba(0, 0, 0, 0.08)",
+ fontSize: "0.74em",
+ }}
+ {...props}
+ >
+ {children}
+ </Box>
+ );
+ },
+ }}
+ >
+ {normalizeMarkdownContent(content)}
+ </ReactMarkdown>
+ </Box>
+);
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 [selectedAiParamId, setSelectedAiParamId] = useState(null);
const [sessionId, setSessionId] = useState(null);
const [sessions, setSessions] = useState([]);
const [persistedMessages, setPersistedMessages] = useState([]);
const [messages, setMessages] = useState([]);
+ const [traceEvents, setTraceEvents] = useState([]);
+ const [expandedTraceIds, setExpandedTraceIds] = 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 [runtimePanelExpanded, setRuntimePanelExpanded] = useState(false);
+
+ 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 selectableModelOptions = useMemo(() => {
+ if (runtime?.modelOptions?.length) {
+ return runtime.modelOptions;
+ }
+ if (runtime?.model) {
+ return [{
+ aiParamId: runtime?.aiParamId ?? "CURRENT_MODEL",
+ name: runtime.model,
+ model: runtime.model,
+ active: true,
+ }];
+ }
+ return [];
+ }, [runtime]);
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) {
+ setRuntimePanelExpanded(false);
initializeDrawer();
} else {
stopStream(false);
@@ -74,37 +292,52 @@
stopStream(false);
}, []);
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ const timer = window.requestAnimationFrame(() => {
+ scrollMessagesToBottom();
+ });
+ return () => window.cancelAnimationFrame(timer);
+ }, [open, messages, streaming]);
+
const initializeDrawer = async (targetSessionId = null) => {
+ setTraceEvents([]);
+ setExpandedTraceIds([]);
await Promise.all([
loadRuntime(targetSessionId),
- loadSessions(),
+ loadSessions(sessionKeyword),
]);
};
- const loadRuntime = async (targetSessionId = null) => {
+ const loadRuntime = async (targetSessionId = null, targetAiParamId = selectedAiParamId) => {
setLoadingRuntime(true);
setDrawerError("");
try {
- const data = await getAiRuntime(DEFAULT_PROMPT_CODE, targetSessionId);
+ const data = await getAiRuntime(DEFAULT_PROMPT_CODE, targetSessionId, targetAiParamId);
const historyMessages = data?.persistedMessages || [];
setRuntime(data);
+ setSelectedAiParamId(data?.aiParamId ?? null);
setSessionId(data?.sessionId || null);
setPersistedMessages(historyMessages);
setMessages(historyMessages);
+ return data;
} catch (error) {
- const message = error.message || "鑾峰彇 AI 杩愯鏃跺け璐�";
+ const message = error.message || translate("ai.drawer.runtimeFailed");
setDrawerError(message);
+ return null;
} 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);
}
};
@@ -116,16 +349,48 @@
setSessionId(null);
setPersistedMessages([]);
setMessages([]);
+ setTraceEvents([]);
+ setExpandedTraceIds([]);
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);
+ setTraceEvents([]);
+ setExpandedTraceIds([]);
await loadRuntime(targetSessionId);
+ };
+
+ const handleModelChange = async (event) => {
+ if (streaming) {
+ return;
+ }
+ const rawValue = event.target.value;
+ const nextAiParamId = rawValue === "" ? null : Number(rawValue);
+ if (nextAiParamId === selectedAiParamId) {
+ return;
+ }
+ const previousAiParamId = selectedAiParamId;
+ setSelectedAiParamId(nextAiParamId);
+ const data = await loadRuntime(sessionId, nextAiParamId);
+ if (!data) {
+ setSelectedAiParamId(previousAiParamId);
+ notify(translate("ai.drawer.modelSwitchFailed"), { type: "error" });
+ }
};
const handleDeleteSession = async (targetSessionId) => {
@@ -134,14 +399,93 @@
}
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" });
}
@@ -153,8 +497,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;
}
};
@@ -183,6 +537,60 @@
return next;
};
+ const appendTraceEvent = (payload) => {
+ if (!payload?.traceId) {
+ return;
+ }
+ setTraceEvents((prev) => {
+ const index = prev.findIndex((item) => item.traceId === payload.traceId);
+ if (index < 0) {
+ return [...prev, payload].sort((left, right) => (
+ (left?.sequence ?? 0) - (right?.sequence ?? 0)
+ ));
+ }
+ const next = [...prev];
+ next[index] = { ...next[index], ...payload };
+ return next;
+ });
+ };
+
+ const toggleTraceEventExpanded = (traceId) => {
+ if (!traceId) {
+ return;
+ }
+ setExpandedTraceIds((prev) => (
+ prev.includes(traceId)
+ ? prev.filter((item) => item !== traceId)
+ : [...prev, traceId]
+ ));
+ };
+
+ const getThinkingStatusLabel = (status) => {
+ if (status === "COMPLETED") {
+ return translate("ai.drawer.thinkingStatusCompleted");
+ }
+ if (status === "FAILED") {
+ return translate("ai.drawer.thinkingStatusFailed");
+ }
+ if (status === "ABORTED") {
+ return translate("ai.drawer.thinkingStatusAborted");
+ }
+ if (status === "UPDATED") {
+ return translate("ai.drawer.thinkingStatusUpdated");
+ }
+ return translate("ai.drawer.thinkingStatusStarted");
+ };
+
+ const getToolStatusLabel = (status) => {
+ if (status === "FAILED") {
+ return translate("ai.drawer.toolStatusFailed");
+ }
+ if (status === "COMPLETED") {
+ return translate("ai.drawer.toolStatusCompleted");
+ }
+ return translate("ai.drawer.toolStatusRunning");
+ };
+
const handleSend = async () => {
const content = input.trim();
if (!content || streaming) {
@@ -193,6 +601,8 @@
setInput("");
setUsage(null);
setDrawerError("");
+ setTraceEvents([]);
+ setExpandedTraceIds([]);
setMessages(ensureAssistantPlaceholder(nextMessages));
setStreaming(true);
@@ -201,11 +611,13 @@
let completed = false;
let completedSessionId = sessionId;
+ let completedAiParamId = selectedAiParamId;
try {
await streamAiChat(
{
sessionId,
+ aiParamId: selectedAiParamId,
promptCode,
messages: memoryMessages,
metadata: {
@@ -217,13 +629,18 @@
onEvent: (eventName, payload) => {
if (eventName === "start") {
setRuntime(payload);
+ setSelectedAiParamId(payload?.aiParamId ?? null);
if (payload?.sessionId) {
setSessionId(payload.sessionId);
completedSessionId = payload.sessionId;
}
+ completedAiParamId = payload?.aiParamId ?? completedAiParamId;
}
if (eventName === "delta") {
appendAssistantDelta(payload?.content || "");
+ }
+ if (eventName === "trace") {
+ appendTraceEvent(payload);
}
if (eventName === "done") {
setUsage(payload);
@@ -233,16 +650,17 @@
}
}
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" });
}
@@ -251,8 +669,8 @@
setStreaming(false);
if (completed) {
await Promise.all([
- loadRuntime(completedSessionId),
- loadSessions(),
+ loadRuntime(completedSessionId, completedAiParamId),
+ loadSessions(sessionKeyword),
]);
}
}
@@ -272,11 +690,11 @@
onClose={onClose}
ModalProps={{ keepMounted: true }}
sx={{
- zIndex: 1400,
+ zIndex: AI_CHAT_DRAWER_Z_INDEX,
"& .MuiDrawer-paper": {
top: 0,
height: "100vh",
- width: { xs: "100vw", md: "50vw" },
+ width: { xs: "100vw", md: "70vw" },
},
}}
>
@@ -284,12 +702,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>
@@ -306,16 +724,27 @@
>
<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>
) : (
@@ -329,11 +758,12 @@
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,
@@ -346,9 +776,33 @@
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>
@@ -360,31 +814,203 @@
</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.activityTrace")}
+ </Typography>
+ <Paper variant="outlined" sx={{ flex: 1, minHeight: { xs: 140, md: 0 }, overflow: "hidden", bgcolor: "grey.50" }}>
+ {!traceEvents.length ? (
+ <Box px={1.5} py={1.25}>
+ <Typography variant="body2" color="text.secondary">
+ {translate("ai.drawer.noActivityTrace")}
+ </Typography>
+ </Box>
+ ) : (
+ <Stack spacing={1} sx={{ p: 1.25, maxHeight: { xs: 220, md: "calc(100vh - 180px)" }, overflow: "auto" }}>
+ {traceEvents.map((item) => (
+ <Paper
+ key={item.traceId}
+ variant="outlined"
+ sx={{
+ p: 1.25,
+ bgcolor: item.status === "FAILED"
+ ? "error.lighter"
+ : item.traceType === "thinking"
+ ? "info.lighter"
+ : "common.white",
+ borderColor: item.status === "FAILED"
+ ? "error.light"
+ : item.traceType === "thinking"
+ ? "info.light"
+ : "divider",
+ }}
+ >
+ <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
+ <Chip
+ size="small"
+ variant="outlined"
+ color={item.traceType === "thinking" ? "info" : "primary"}
+ label={translate(item.traceType === "thinking" ? "ai.drawer.traceTypeThinking" : "ai.drawer.traceTypeTool")}
+ />
+ <Typography variant="body2" fontWeight={700}>
+ {item.traceType === "thinking"
+ ? (item.title || translate("ai.drawer.thinkingProcess"))
+ : (item.toolName || item.title || translate("ai.drawer.unknownTool"))}
+ </Typography>
+ <Chip
+ size="small"
+ color={item.status === "FAILED"
+ ? "error"
+ : item.status === "COMPLETED"
+ ? "success"
+ : item.status === "ABORTED"
+ ? "warning"
+ : "info"}
+ label={item.traceType === "thinking"
+ ? getThinkingStatusLabel(item.status)
+ : getToolStatusLabel(item.status)}
+ />
+ {item.durationMs != null && (
+ <Typography variant="caption" color="text.secondary">
+ {item.durationMs} ms
+ </Typography>
+ )}
+ {item.traceType === "tool" && (item.inputSummary || item.outputSummary || item.errorMessage) && (
+ <Button
+ size="small"
+ onClick={() => toggleTraceEventExpanded(item.traceId)}
+ endIcon={expandedTraceIds.includes(item.traceId)
+ ? <ExpandLessOutlinedIcon fontSize="small" />
+ : <ExpandMoreOutlinedIcon fontSize="small" />}
+ sx={{ ml: "auto", minWidth: "auto", px: 0.5 }}
+ >
+ {expandedTraceIds.includes(item.traceId) ? translate("ai.drawer.collapseDetail") : translate("ai.drawer.viewDetail")}
+ </Button>
+ )}
+ </Stack>
+ {item.traceType === "thinking" ? (
+ <Typography variant="caption" display="block" color="text.secondary" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
+ {item.content || translate("ai.drawer.thinkingEmpty")}
+ </Typography>
+ ) : (
+ <Collapse in={expandedTraceIds.includes(item.traceId)} timeout="auto" unmountOnExit>
+ {!!item.title && (
+ <Typography variant="caption" display="block" color="text.secondary" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
+ {item.title}
+ </Typography>
+ )}
+ {!!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}`} />
+ <Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
+ <Typography variant="subtitle2">
+ {translate("ai.drawer.runtimeOverview")}
+ </Typography>
+ <Button
+ size="small"
+ onClick={() => setRuntimePanelExpanded((prev) => !prev)}
+ endIcon={runtimePanelExpanded
+ ? <ExpandLessOutlinedIcon fontSize="small" />
+ : <ExpandMoreOutlinedIcon fontSize="small" />}
+ sx={{ minWidth: "auto", px: 1 }}
+ >
+ {translate(runtimePanelExpanded ? "ai.drawer.runtimeCollapse" : "ai.drawer.runtimeExpand")}
+ </Button>
</Stack>
- <Stack direction="row" spacing={1} mt={1.5} flexWrap="wrap" useFlexGap>
- {quickLinks.map((item) => (
+ <Collapse in={runtimePanelExpanded} timeout="auto" unmountOnExit>
+ <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mt: 1.5 }}>
+ <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
- key={item.path}
size="small"
variant="outlined"
- startIcon={item.icon}
- onClick={() => navigate(item.path)}
+ startIcon={<HistoryOutlinedIcon />}
+ onClick={handleRetainLatestRound}
+ disabled={!sessionId || streaming}
>
- {item.label}
+ {translate("ai.drawer.retainLatestRound")}
</Button>
- ))}
- </Stack>
+ <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>
+ )}
+ </Collapse>
{loadingRuntime && (
<Typography variant="body2" color="text.secondary" mt={1}>
- 姝e湪鍔犺浇 AI 杩愯鏃朵俊鎭�...
+ {translate("ai.drawer.loadingRuntime")}
</Typography>
)}
{!!drawerError && (
@@ -396,11 +1022,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 鍥炲銆備綘涔熷彲浠ュ厛鍘讳笂闈㈢殑蹇嵎鍏ュ彛缁存姢鍙傛暟銆丳rompt 鍜� MCP 鎸傝浇銆�
+ {translate("ai.drawer.emptyHint")}
</Typography>
</Paper>
)}
@@ -410,36 +1045,58 @@
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" ? "浣�" : "AI"}
- </Typography>
- <Typography variant="body2">
- {message.content || (streaming && index === messages.length - 1 ? "鎬濊�冧腑..." : "")}
- </Typography>
- </Paper>
+ <Stack spacing={1} sx={{ maxWidth: "85%", width: "100%" }} alignItems={message.role === "user" ? "flex-end" : "flex-start"}>
+ <Paper
+ elevation={0}
+ sx={{
+ px: 1.5,
+ py: 1.25,
+ width: "fit-content",
+ maxWidth: "100%",
+ 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>
+ {message.role === "assistant" ? (
+ <AiMarkdownContent
+ content={message.content || (streaming && index === messages.length - 1
+ ? translate("ai.drawer.thinking")
+ : "")}
+ />
+ ) : (
+ <Typography variant="body2" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
+ {message.content || ""}
+ </Typography>
+ )}
+ </Paper>
+ </Stack>
</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
@@ -450,24 +1107,92 @@
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>
+ <Stack
+ direction={{ xs: "column", sm: "row" }}
+ spacing={1}
+ justifyContent="space-between"
+ alignItems={{ xs: "stretch", sm: "center" }}
+ mt={1.25}
+ >
+ {!!selectableModelOptions.length && (
+ <TextField
+ select
+ size="small"
+ label={translate("ai.drawer.modelSelectorLabel")}
+ value={selectedAiParamId ?? runtime?.aiParamId ?? selectableModelOptions[0]?.aiParamId ?? ""}
+ onChange={handleModelChange}
+ disabled={streaming || loadingRuntime || selectableModelOptions.length <= 1}
+ SelectProps={{
+ MenuProps: {
+ disableScrollLock: true,
+ sx: {
+ zIndex: 1605,
+ },
+ PaperProps: {
+ sx: {
+ zIndex: 1606,
+ },
+ },
+ },
+ }}
+ sx={{
+ minWidth: { xs: "100%", sm: 260 },
+ maxWidth: { xs: "100%", sm: 320 },
+ }}
+ >
+ {selectableModelOptions.map((item) => (
+ <MenuItem key={String(item.aiParamId)} value={item.aiParamId}>
+ {`${item.name || item.model || "--"}${item.model && item.name !== item.model ? ` / ${item.model}` : ""}${item.active ? ` ${translate("ai.drawer.defaultModelSuffix")}` : ""}`}
+ </MenuItem>
+ ))}
+ </TextField>
+ )}
+ <Stack direction="row" spacing={1} justifyContent="flex-end">
+ <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>
</Stack>
</Box>
</Box>
</Box>
</Box>
+ <Dialog
+ open={renameDialog.open}
+ onClose={closeRenameDialog}
+ fullWidth
+ maxWidth="xs"
+ sx={{
+ zIndex: AI_CHAT_DIALOG_Z_INDEX,
+ }}
+ >
+ <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>
);
};
--
Gitblit v1.9.1