| | |
| | | import React, { useEffect, useMemo, useRef, useState } from "react"; |
| | | import { useLocation, useNavigate } from "react-router-dom"; |
| | | import { useNotify } from "react-admin"; |
| | | import { useNotify, useTranslate } from "react-admin"; |
| | | import { |
| | | Alert, |
| | | Box, |
| | |
| | | |
| | | const DEFAULT_PROMPT_CODE = "home.default"; |
| | | |
| | | const quickLinks = [ |
| | | { label: "AI 参数", path: "/aiParam", icon: <SettingsSuggestOutlinedIcon fontSize="small" /> }, |
| | | { label: "Prompt", path: "/aiPrompt", icon: <PsychologyAltOutlinedIcon fontSize="small" /> }, |
| | | { label: "MCP", path: "/aiMcpMount", icon: <CableOutlinedIcon fontSize="small" /> }, |
| | | ]; |
| | | |
| | | const AiChatDrawer = ({ open, onClose }) => { |
| | | const navigate = useNavigate(); |
| | | const location = useLocation(); |
| | | const notify = useNotify(); |
| | | const translate = useTranslate(); |
| | | const abortRef = useRef(null); |
| | | const messagesContainerRef = useRef(null); |
| | | const messagesBottomRef = useRef(null); |
| | | const [runtime, setRuntime] = useState(null); |
| | | const [sessionId, setSessionId] = useState(null); |
| | | const [sessions, setSessions] = useState([]); |
| | |
| | | const [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; |
| | | |
| | |
| | | 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([]); |
| | |
| | | setPersistedMessages(historyMessages); |
| | | setMessages(historyMessages); |
| | | } catch (error) { |
| | | const message = error.message || "获取 AI 运行时失败"; |
| | | const message = error.message || translate("ai.drawer.runtimeFailed"); |
| | | setDrawerError(message); |
| | | } finally { |
| | | setLoadingRuntime(false); |
| | |
| | | const 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); |
| | | } |
| | | }; |
| | |
| | | } |
| | | try { |
| | | await removeAiSession(targetSessionId); |
| | | notify("会话已删除"); |
| | | notify(translate("ai.drawer.sessionDeleted")); |
| | | if (targetSessionId === sessionId) { |
| | | startNewSession(); |
| | | await loadRuntime(null); |
| | | } |
| | | await loadSessions(sessionKeyword); |
| | | } catch (error) { |
| | | const message = error.message || "删除 AI 会话失败"; |
| | | const message = error.message || translate("ai.drawer.deleteSessionFailed"); |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | |
| | | } |
| | | try { |
| | | await pinAiSession(targetSessionId, pinned); |
| | | notify(pinned ? "会话已置顶" : "会话已取消置顶"); |
| | | notify(translate(pinned ? "ai.drawer.pinned" : "ai.drawer.unpinned")); |
| | | await loadSessions(sessionKeyword); |
| | | } catch (error) { |
| | | const message = error.message || "更新会话置顶状态失败"; |
| | | const message = error.message || translate("ai.drawer.pinFailed"); |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | |
| | | } |
| | | try { |
| | | await renameAiSession(renameDialog.sessionId, renameDialog.title); |
| | | notify("会话已重命名"); |
| | | notify(translate("ai.drawer.renamed")); |
| | | closeRenameDialog(); |
| | | await loadSessions(sessionKeyword); |
| | | } catch (error) { |
| | | const message = error.message || "重命名会话失败"; |
| | | const message = error.message || translate("ai.drawer.renameFailed"); |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | |
| | | } |
| | | try { |
| | | await clearAiSessionMemory(sessionId); |
| | | notify("会话记忆已清空"); |
| | | notify(translate("ai.drawer.memoryCleared")); |
| | | await Promise.all([ |
| | | loadRuntime(sessionId), |
| | | loadSessions(sessionKeyword), |
| | | ]); |
| | | } catch (error) { |
| | | const message = error.message || "清空会话记忆失败"; |
| | | const message = error.message || translate("ai.drawer.clearMemoryFailed"); |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | |
| | | } |
| | | try { |
| | | await retainAiSessionLatestRound(sessionId); |
| | | notify("已仅保留当前轮记忆"); |
| | | notify(translate("ai.drawer.retainLatestRoundSuccess")); |
| | | await Promise.all([ |
| | | loadRuntime(sessionId), |
| | | loadSessions(sessionKeyword), |
| | | ]); |
| | | } catch (error) { |
| | | const message = error.message || "保留当前轮记忆失败"; |
| | | const message = error.message || translate("ai.drawer.retainLatestRoundFailed"); |
| | | setDrawerError(message); |
| | | notify(message, { type: "error" }); |
| | | } |
| | |
| | | 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; |
| | | } |
| | | }; |
| | | |
| | |
| | | } |
| | | } |
| | | if (eventName === "error") { |
| | | const message = payload?.message || "AI 对话失败"; |
| | | const message = payload?.message || translate("ai.drawer.chatFailed"); |
| | | const displayMessage = payload?.requestId ? `${message} [${payload.requestId}]` : message; |
| | | setDrawerError(displayMessage); |
| | | notify(displayMessage, { type: "error" }); |
| | |
| | | ); |
| | | } 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" }); |
| | | } |
| | |
| | | <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 |
| | |
| | | onChange={(event) => setSessionKeyword(event.target.value)} |
| | | fullWidth |
| | | size="small" |
| | | placeholder="搜索会话标题" |
| | | placeholder={translate("ai.drawer.searchPlaceholder")} |
| | | InputProps={{ |
| | | startAdornment: <SearchOutlinedIcon fontSize="small" sx={{ mr: 1, color: "text.secondary" }} />, |
| | | }} |
| | |
| | | {!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.lastMessagePreview || item.lastMessageTime || `Session ${item.sessionId}`} |
| | | primary={item.title || translate("ai.drawer.sessionTitle", { id: item.sessionId })} |
| | | secondary={item.lastMessagePreview || item.lastMessageTime || translate("ai.drawer.sessionMetric", { id: item.sessionId })} |
| | | primaryTypographyProps={{ |
| | | noWrap: true, |
| | | fontSize: 14, |
| | |
| | | event.stopPropagation(); |
| | | handlePinSession(item.sessionId, !item.pinned); |
| | | }} |
| | | title={item.pinned ? "取消置顶" : "置顶会话"} |
| | | title={translate(item.pinned ? "ai.drawer.unpinAction" : "ai.drawer.pinAction")} |
| | | > |
| | | {item.pinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />} |
| | | </IconButton> |
| | |
| | | event.stopPropagation(); |
| | | openRenameDialog(item); |
| | | }} |
| | | title="重命名会话" |
| | | title={translate("ai.drawer.renameAction")} |
| | | > |
| | | <EditOutlinedIcon fontSize="small" /> |
| | | </IconButton> |
| | |
| | | event.stopPropagation(); |
| | | handleDeleteSession(item.sessionId); |
| | | }} |
| | | title="删除会话" |
| | | title={translate("ai.drawer.deleteAction")} |
| | | > |
| | | <DeleteOutlineOutlinedIcon fontSize="small" /> |
| | | </IconButton> |
| | |
| | | > |
| | | <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 direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap> |
| | | <Typography variant="body2" fontWeight={700}> |
| | | {item.toolName || "未知工具"} |
| | | {item.toolName || translate("ai.drawer.unknownTool")} |
| | | </Typography> |
| | | <Chip |
| | | size="small" |
| | | color={item.status === "FAILED" ? "error" : item.status === "COMPLETED" ? "success" : "info"} |
| | | label={item.status === "FAILED" ? "失败" : item.status === "COMPLETED" ? "完成" : "执行中"} |
| | | label={translate(item.status === "FAILED" ? "ai.drawer.toolStatusFailed" : item.status === "COMPLETED" ? "ai.drawer.toolStatusCompleted" : "ai.drawer.toolStatusRunning")} |
| | | /> |
| | | {item.durationMs != null && ( |
| | | <Typography variant="caption" color="text.secondary"> |
| | |
| | | : <ExpandMoreOutlinedIcon fontSize="small" />} |
| | | sx={{ ml: "auto", minWidth: "auto", px: 0.5 }} |
| | | > |
| | | {expandedToolIds.includes(item.toolCallId) ? "收起详情" : "查看详情"} |
| | | {expandedToolIds.includes(item.toolCallId) ? translate("ai.drawer.collapseDetail") : translate("ai.drawer.viewDetail")} |
| | | </Button> |
| | | )} |
| | | </Stack> |
| | | <Collapse in={expandedToolIds.includes(item.toolCallId)} timeout="auto" unmountOnExit> |
| | | {!!item.inputSummary && ( |
| | | <Typography variant="caption" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}> |
| | | 入参: {item.inputSummary} |
| | | {translate("ai.drawer.toolInput", { value: item.inputSummary })} |
| | | </Typography> |
| | | )} |
| | | {!!item.outputSummary && ( |
| | | <Typography variant="caption" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}> |
| | | 结果摘要: {item.outputSummary} |
| | | {translate("ai.drawer.toolOutput", { value: item.outputSummary })} |
| | | </Typography> |
| | | )} |
| | | {!!item.errorMessage && ( |
| | | <Typography variant="caption" color="error.main" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}> |
| | | 错误: {item.errorMessage} |
| | | {translate("ai.drawer.toolError", { value: item.errorMessage })} |
| | | </Typography> |
| | | )} |
| | | </Collapse> |
| | |
| | | <Box flex={1} display="flex" flexDirection="column" minHeight={0}> |
| | | <Box px={2} py={1.5}> |
| | | <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> |
| | | <Chip size="small" label={`Req: ${runtimeSummary.requestId}`} /> |
| | | <Chip size="small" label={`Session: ${sessionId || "--"}`} /> |
| | | <Chip size="small" label={`Prompt: ${runtimeSummary.promptName}`} /> |
| | | <Chip size="small" label={`Model: ${runtimeSummary.model}`} /> |
| | | <Chip size="small" label={`MCP: ${runtimeSummary.mountedMcpCount}`} /> |
| | | <Chip size="small" label={`History: ${persistedMessages.length}`} /> |
| | | <Chip size="small" label={`Recent: ${runtimeSummary.recentMessageCount}`} /> |
| | | <Chip size="small" color={runtimeSummary.hasSummary ? "success" : "default"} label={runtimeSummary.hasSummary ? "有摘要" : "无摘要"} /> |
| | | <Chip size="small" color={runtimeSummary.hasFacts ? "info" : "default"} label={runtimeSummary.hasFacts ? "有事实" : "无事实"} /> |
| | | <Chip size="small" label={translate("ai.drawer.requestMetric", { value: runtimeSummary.requestId })} /> |
| | | <Chip size="small" label={translate("ai.drawer.sessionMetric", { id: sessionId || "--" })} /> |
| | | <Chip size="small" label={translate("ai.drawer.promptMetric", { value: runtimeSummary.promptName })} /> |
| | | <Chip size="small" label={translate("ai.drawer.modelMetric", { value: runtimeSummary.model })} /> |
| | | <Chip size="small" label={translate("ai.drawer.mcpMetric", { value: runtimeSummary.mountedMcpCount })} /> |
| | | <Chip size="small" label={translate("ai.drawer.historyMetric", { value: persistedMessages.length })} /> |
| | | <Chip size="small" label={translate("ai.drawer.recentMetric", { value: runtimeSummary.recentMessageCount })} /> |
| | | <Chip size="small" color={runtimeSummary.hasSummary ? "success" : "default"} label={translate(runtimeSummary.hasSummary ? "ai.drawer.hasSummary" : "ai.drawer.noSummary")} /> |
| | | <Chip size="small" color={runtimeSummary.hasFacts ? "info" : "default"} label={translate(runtimeSummary.hasFacts ? "ai.drawer.hasFacts" : "ai.drawer.noFacts")} /> |
| | | </Stack> |
| | | <Stack direction="row" spacing={1} mt={1.5} flexWrap="wrap" useFlexGap> |
| | | {quickLinks.map((item) => ( |
| | |
| | | onClick={handleRetainLatestRound} |
| | | disabled={!sessionId || streaming} |
| | | > |
| | | 仅保留当前轮 |
| | | {translate("ai.drawer.retainLatestRound")} |
| | | </Button> |
| | | <Button |
| | | size="small" |
| | |
| | | onClick={handleClearMemory} |
| | | disabled={!sessionId || streaming} |
| | | > |
| | | 清空记忆 |
| | | {translate("ai.drawer.clearMemory")} |
| | | </Button> |
| | | </Stack> |
| | | {!!runtime?.memorySummary && ( |
| | |
| | | )} |
| | | {loadingRuntime && ( |
| | | <Typography variant="body2" color="text.secondary" mt={1}> |
| | | 正在加载 AI 运行时信息... |
| | | {translate("ai.drawer.loadingRuntime")} |
| | | </Typography> |
| | | )} |
| | | {!!drawerError && ( |
| | |
| | | |
| | | <Divider /> |
| | | |
| | | <Box flex={1} overflow="auto" px={2} py={2} display="flex" flexDirection="column" gap={1.5}> |
| | | <Box |
| | | ref={messagesContainerRef} |
| | | flex={1} |
| | | overflow="auto" |
| | | px={2} |
| | | py={2} |
| | | display="flex" |
| | | flexDirection="column" |
| | | gap={1.5} |
| | | > |
| | | {!messages.length && ( |
| | | <Paper variant="outlined" sx={{ p: 2, bgcolor: "grey.50" }}> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | 这里会通过 SSE 流式返回 AI 回复。你也可以先去上面的快捷入口维护参数、Prompt 和 MCP 挂载。 |
| | | {translate("ai.drawer.emptyHint")} |
| | | </Typography> |
| | | </Paper> |
| | | )} |
| | |
| | | }} |
| | | > |
| | | <Typography variant="caption" display="block" sx={{ opacity: 0.72, mb: 0.5 }}> |
| | | {message.role === "user" ? "你" : "AI"} |
| | | {message.role === "user" ? translate("ai.drawer.userRole") : translate("ai.drawer.assistantRole")} |
| | | </Typography> |
| | | <Typography variant="body2"> |
| | | {message.content || (streaming && index === messages.length - 1 ? "思考中..." : "")} |
| | | {message.content || (streaming && index === messages.length - 1 ? translate("ai.drawer.thinking") : "")} |
| | | </Typography> |
| | | </Paper> |
| | | </Box> |
| | | ))} |
| | | <Box ref={messagesBottomRef} sx={{ height: 1 }} /> |
| | | </Box> |
| | | |
| | | <Divider /> |
| | |
| | | <Box px={2} py={1.5}> |
| | | {usage?.elapsedMs != null && ( |
| | | <Typography variant="caption" color="text.secondary" display="block" mb={0.5}> |
| | | Elapsed: {usage.elapsedMs} ms{usage?.firstTokenLatencyMs != null ? ` / First token: ${usage.firstTokenLatencyMs} ms` : ""} |
| | | {translate("ai.drawer.elapsedMetric", { value: usage.elapsedMs })} |
| | | {usage?.firstTokenLatencyMs != null ? ` / ${translate("ai.drawer.firstTokenMetric", { value: usage.firstTokenLatencyMs })}` : ""} |
| | | </Typography> |
| | | )} |
| | | {usage?.totalTokens != null && ( |
| | | <Typography variant="caption" color="text.secondary" display="block" mb={1}> |
| | | Tokens: prompt {usage?.promptTokens ?? 0} / completion {usage?.completionTokens ?? 0} / total {usage?.totalTokens ?? 0} |
| | | {translate("ai.drawer.tokenMetric", { |
| | | prompt: usage?.promptTokens ?? 0, |
| | | completion: usage?.completionTokens ?? 0, |
| | | total: usage?.totalTokens ?? 0, |
| | | })} |
| | | </Typography> |
| | | )} |
| | | <TextField |
| | |
| | | multiline |
| | | minRows={3} |
| | | maxRows={6} |
| | | placeholder="输入你的问题,按 Enter 发送,Shift + Enter 换行" |
| | | placeholder={translate("ai.drawer.inputPlaceholder")} |
| | | /> |
| | | <Stack direction="row" spacing={1} justifyContent="flex-end" mt={1.25}> |
| | | <Button onClick={() => setInput("")}>清空输入</Button> |
| | | <Button onClick={() => setInput("")}>{translate("ai.drawer.clearInput")}</Button> |
| | | {streaming ? ( |
| | | <Button variant="outlined" color="warning" startIcon={<StopCircleOutlinedIcon />} onClick={() => stopStream(true)}> |
| | | 停止 |
| | | {translate("ai.drawer.stop")} |
| | | </Button> |
| | | ) : ( |
| | | <Button variant="contained" startIcon={<SendRoundedIcon />} onClick={handleSend}> |
| | | 发送 |
| | | {translate("ai.drawer.send")} |
| | | </Button> |
| | | )} |
| | | </Stack> |
| | |
| | | </Box> |
| | | </Box> |
| | | <Dialog open={renameDialog.open} onClose={closeRenameDialog} fullWidth maxWidth="xs"> |
| | | <DialogTitle>重命名会话</DialogTitle> |
| | | <DialogTitle>{translate("ai.drawer.renameDialogTitle")}</DialogTitle> |
| | | <DialogContent> |
| | | <TextField |
| | | value={renameDialog.title} |
| | | onChange={(event) => setRenameDialog((prev) => ({ ...prev, title: event.target.value }))} |
| | | autoFocus |
| | | margin="dense" |
| | | label="会话标题" |
| | | label={translate("ai.drawer.sessionTitleField")} |
| | | fullWidth |
| | | /> |
| | | </DialogContent> |
| | | <DialogActions> |
| | | <Button onClick={closeRenameDialog}>取消</Button> |
| | | <Button onClick={closeRenameDialog}>{translate("ai.common.cancel")}</Button> |
| | | <Button onClick={handleRenameSubmit} variant="contained" disabled={streaming || !renameDialog.title.trim()}> |
| | | 保存 |
| | | {translate("ai.common.save")} |
| | | </Button> |
| | | </DialogActions> |
| | | </Dialog> |