| | |
| | | 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"; |
| | |
| | | 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); |
| | |
| | | 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); |
| | | } |
| | | }; |
| | |
| | | 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) => { |
| | |
| | | } |
| | | 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 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) { |
| | |
| | | setInput(""); |
| | | setUsage(null); |
| | | setDrawerError(""); |
| | | setTraceEvents([]); |
| | | setExpandedTraceIds([]); |
| | | setMessages(ensureAssistantPlaceholder(nextMessages)); |
| | | setStreaming(true); |
| | | |
| | |
| | | |
| | | let completed = false; |
| | | let completedSessionId = sessionId; |
| | | let completedAiParamId = selectedAiParamId; |
| | | |
| | | try { |
| | | await streamAiChat( |
| | | { |
| | | sessionId, |
| | | aiParamId: selectedAiParamId, |
| | | promptCode, |
| | | messages: memoryMessages, |
| | | metadata: { |
| | |
| | | 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); |
| | |
| | | } |
| | | } |
| | | 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" }); |
| | | } |
| | |
| | | setStreaming(false); |
| | | if (completed) { |
| | | await Promise.all([ |
| | | loadRuntime(completedSessionId), |
| | | loadSessions(), |
| | | loadRuntime(completedSessionId, completedAiParamId), |
| | | loadSessions(sessionKeyword), |
| | | ]); |
| | | } |
| | | } |
| | |
| | | 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" }, |
| | | }, |
| | | }} |
| | | > |
| | |
| | | <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.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}> |
| | | 正在加载 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> |
| | | )} |
| | |
| | | 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 |
| | |
| | | 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> |
| | | ); |
| | | }; |