import React, { useEffect, useMemo, useRef, useState } from "react";
|
import { useLocation, useNavigate } from "react-router-dom";
|
import { useNotify } from "react-admin";
|
import {
|
Alert,
|
Box,
|
Button,
|
Chip,
|
Divider,
|
Drawer,
|
IconButton,
|
List,
|
ListItemButton,
|
ListItemText,
|
Paper,
|
Stack,
|
TextField,
|
Typography,
|
} from "@mui/material";
|
import SmartToyOutlinedIcon from "@mui/icons-material/SmartToyOutlined";
|
import SendRoundedIcon from "@mui/icons-material/SendRounded";
|
import StopCircleOutlinedIcon from "@mui/icons-material/StopCircleOutlined";
|
import SettingsSuggestOutlinedIcon from "@mui/icons-material/SettingsSuggestOutlined";
|
import PsychologyAltOutlinedIcon from "@mui/icons-material/PsychologyAltOutlined";
|
import CableOutlinedIcon from "@mui/icons-material/CableOutlined";
|
import CloseIcon from "@mui/icons-material/Close";
|
import AddCommentOutlinedIcon from "@mui/icons-material/AddCommentOutlined";
|
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
|
import { getAiRuntime, getAiSessions, removeAiSession, streamAiChat } from "@/api/ai/chat";
|
|
const DEFAULT_PROMPT_CODE = "home.default";
|
|
const quickLinks = [
|
{ label: "AI 参数", path: "/aiParam", icon: <SettingsSuggestOutlinedIcon fontSize="small" /> },
|
{ label: "Prompt", path: "/aiPrompt", icon: <PsychologyAltOutlinedIcon fontSize="small" /> },
|
{ label: "MCP", path: "/aiMcpMount", icon: <CableOutlinedIcon fontSize="small" /> },
|
];
|
|
const AiChatDrawer = ({ open, onClose }) => {
|
const navigate = useNavigate();
|
const location = useLocation();
|
const notify = useNotify();
|
const abortRef = useRef(null);
|
const [runtime, setRuntime] = useState(null);
|
const [sessionId, setSessionId] = useState(null);
|
const [sessions, setSessions] = useState([]);
|
const [persistedMessages, setPersistedMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
const [input, setInput] = useState("");
|
const [loadingRuntime, setLoadingRuntime] = useState(false);
|
const [streaming, setStreaming] = useState(false);
|
const [usage, setUsage] = useState(null);
|
const [drawerError, setDrawerError] = useState("");
|
|
const promptCode = runtime?.promptCode || DEFAULT_PROMPT_CODE;
|
|
const runtimeSummary = useMemo(() => {
|
return {
|
promptName: runtime?.promptName || "--",
|
model: runtime?.model || "--",
|
mountedMcpCount: runtime?.mountedMcpCount ?? 0,
|
};
|
}, [runtime]);
|
|
useEffect(() => {
|
if (open) {
|
initializeDrawer();
|
} else {
|
stopStream(false);
|
}
|
}, [open]);
|
|
useEffect(() => () => {
|
stopStream(false);
|
}, []);
|
|
const initializeDrawer = async (targetSessionId = null) => {
|
await Promise.all([
|
loadRuntime(targetSessionId),
|
loadSessions(),
|
]);
|
};
|
|
const loadRuntime = async (targetSessionId = null) => {
|
setLoadingRuntime(true);
|
setDrawerError("");
|
try {
|
const data = await getAiRuntime(DEFAULT_PROMPT_CODE, targetSessionId);
|
const historyMessages = data?.persistedMessages || [];
|
setRuntime(data);
|
setSessionId(data?.sessionId || null);
|
setPersistedMessages(historyMessages);
|
setMessages(historyMessages);
|
} catch (error) {
|
const message = error.message || "获取 AI 运行时失败";
|
setDrawerError(message);
|
} finally {
|
setLoadingRuntime(false);
|
}
|
};
|
|
const loadSessions = async () => {
|
try {
|
const data = await getAiSessions(DEFAULT_PROMPT_CODE);
|
setSessions(data);
|
} catch (error) {
|
const message = error.message || "获取 AI 会话列表失败";
|
setDrawerError(message);
|
}
|
};
|
|
const startNewSession = () => {
|
if (streaming) {
|
return;
|
}
|
setSessionId(null);
|
setPersistedMessages([]);
|
setMessages([]);
|
setUsage(null);
|
setDrawerError("");
|
};
|
|
const handleSwitchSession = async (targetSessionId) => {
|
if (streaming || targetSessionId === sessionId) {
|
return;
|
}
|
setUsage(null);
|
await loadRuntime(targetSessionId);
|
};
|
|
const handleDeleteSession = async (targetSessionId) => {
|
if (streaming || !targetSessionId) {
|
return;
|
}
|
try {
|
await removeAiSession(targetSessionId);
|
notify("会话已删除");
|
if (targetSessionId === sessionId) {
|
startNewSession();
|
await loadRuntime(null);
|
}
|
await loadSessions();
|
} catch (error) {
|
const message = error.message || "删除 AI 会话失败";
|
setDrawerError(message);
|
notify(message, { type: "error" });
|
}
|
};
|
|
const stopStream = (showTip = true) => {
|
if (abortRef.current) {
|
abortRef.current.abort();
|
abortRef.current = null;
|
setStreaming(false);
|
if (showTip) {
|
notify("已停止当前对话输出");
|
}
|
}
|
};
|
|
const appendAssistantDelta = (delta) => {
|
setMessages((prev) => {
|
const next = [...prev];
|
const last = next[next.length - 1];
|
if (last && last.role === "assistant") {
|
next[next.length - 1] = {
|
...last,
|
content: `${last.content || ""}${delta}`,
|
};
|
return next;
|
}
|
next.push({ role: "assistant", content: delta });
|
return next;
|
});
|
};
|
|
const ensureAssistantPlaceholder = (seedMessages) => {
|
const next = [...seedMessages];
|
const last = next[next.length - 1];
|
if (!last || last.role !== "assistant") {
|
next.push({ role: "assistant", content: "" });
|
}
|
return next;
|
};
|
|
const handleSend = async () => {
|
const content = input.trim();
|
if (!content || streaming) {
|
return;
|
}
|
const memoryMessages = [{ role: "user", content }];
|
const nextMessages = [...messages, ...memoryMessages];
|
setInput("");
|
setUsage(null);
|
setDrawerError("");
|
setMessages(ensureAssistantPlaceholder(nextMessages));
|
setStreaming(true);
|
|
const controller = new AbortController();
|
abortRef.current = controller;
|
|
let completed = false;
|
let completedSessionId = sessionId;
|
|
try {
|
await streamAiChat(
|
{
|
sessionId,
|
promptCode,
|
messages: memoryMessages,
|
metadata: {
|
path: location.pathname,
|
},
|
},
|
{
|
signal: controller.signal,
|
onEvent: (eventName, payload) => {
|
if (eventName === "start") {
|
setRuntime(payload);
|
if (payload?.sessionId) {
|
setSessionId(payload.sessionId);
|
completedSessionId = payload.sessionId;
|
}
|
}
|
if (eventName === "delta") {
|
appendAssistantDelta(payload?.content || "");
|
}
|
if (eventName === "done") {
|
setUsage(payload);
|
completed = true;
|
if (payload?.sessionId) {
|
completedSessionId = payload.sessionId;
|
}
|
}
|
if (eventName === "error") {
|
const message = payload?.message || "AI 对话失败";
|
setDrawerError(message);
|
notify(message, { type: "error" });
|
}
|
},
|
}
|
);
|
} catch (error) {
|
if (error?.name !== "AbortError") {
|
const message = error.message || "AI 对话失败";
|
setDrawerError(message);
|
notify(message, { type: "error" });
|
}
|
} finally {
|
abortRef.current = null;
|
setStreaming(false);
|
if (completed) {
|
await Promise.all([
|
loadRuntime(completedSessionId),
|
loadSessions(),
|
]);
|
}
|
}
|
};
|
|
const handleKeyDown = (event) => {
|
if (event.key === "Enter" && !event.shiftKey) {
|
event.preventDefault();
|
handleSend();
|
}
|
};
|
|
return (
|
<Drawer
|
anchor="right"
|
open={open}
|
onClose={onClose}
|
ModalProps={{ keepMounted: true }}
|
sx={{
|
zIndex: 1400,
|
"& .MuiDrawer-paper": {
|
top: 0,
|
height: "100vh",
|
width: { xs: "100vw", md: "50vw" },
|
},
|
}}
|
>
|
<Box display="flex" flexDirection="column" height="100%">
|
<Stack direction="row" alignItems="center" spacing={1} px={2} py={1.5}>
|
<SmartToyOutlinedIcon color="primary" />
|
<Typography variant="h6" flex={1}>
|
AI 对话
|
</Typography>
|
<IconButton size="small" onClick={startNewSession} title="新建会话" disabled={streaming}>
|
<AddCommentOutlinedIcon fontSize="small" />
|
</IconButton>
|
<IconButton size="small" onClick={onClose} title="关闭">
|
<CloseIcon fontSize="small" />
|
</IconButton>
|
</Stack>
|
<Divider />
|
|
<Box flex={1} display="flex" flexDirection={{ xs: "column", md: "row" }} minHeight={0}>
|
<Box
|
width={{ xs: "100%", md: 240 }}
|
borderRight={{ xs: "none", md: "1px solid rgba(224, 224, 224, 1)" }}
|
borderBottom={{ xs: "1px solid rgba(224, 224, 224, 1)", md: "none" }}
|
display="flex"
|
flexDirection="column"
|
minHeight={0}
|
>
|
<Box px={2} py={1.5}>
|
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={1}>
|
<Typography variant="subtitle2">会话列表</Typography>
|
<Button size="small" onClick={startNewSession} disabled={streaming}>
|
新建会话
|
</Button>
|
</Stack>
|
<Paper variant="outlined" sx={{ overflow: "hidden" }}>
|
{!sessions.length ? (
|
<Box px={1.5} py={1.25}>
|
<Typography variant="body2" color="text.secondary">
|
暂无历史会话
|
</Typography>
|
</Box>
|
) : (
|
<List disablePadding sx={{ maxHeight: { xs: 180, md: "calc(100vh - 260px)" }, overflow: "auto" }}>
|
{sessions.map((item) => (
|
<ListItemButton
|
key={item.sessionId}
|
selected={item.sessionId === sessionId}
|
onClick={() => handleSwitchSession(item.sessionId)}
|
disabled={streaming}
|
alignItems="flex-start"
|
>
|
<ListItemText
|
primary={item.title || `会话 ${item.sessionId}`}
|
secondary={item.lastMessageTime || `Session ${item.sessionId}`}
|
primaryTypographyProps={{
|
noWrap: true,
|
fontSize: 14,
|
}}
|
secondaryTypographyProps={{
|
noWrap: true,
|
fontSize: 12,
|
}}
|
/>
|
<IconButton
|
size="small"
|
edge="end"
|
disabled={streaming}
|
onClick={(event) => {
|
event.stopPropagation();
|
handleDeleteSession(item.sessionId);
|
}}
|
title="删除会话"
|
>
|
<DeleteOutlineOutlinedIcon fontSize="small" />
|
</IconButton>
|
</ListItemButton>
|
))}
|
</List>
|
)}
|
</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>
|
<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>
|
))}
|
</Stack>
|
{loadingRuntime && (
|
<Typography variant="body2" color="text.secondary" mt={1}>
|
正在加载 AI 运行时信息...
|
</Typography>
|
)}
|
{!!drawerError && (
|
<Alert severity="warning" sx={{ mt: 1.5 }}>
|
{drawerError}
|
</Alert>
|
)}
|
</Box>
|
|
<Divider />
|
|
<Box 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 挂载。
|
</Typography>
|
</Paper>
|
)}
|
{messages.map((message, index) => (
|
<Box
|
key={`${message.role}-${index}`}
|
display="flex"
|
justifyContent={message.role === "user" ? "flex-end" : "flex-start"}
|
>
|
<Paper
|
elevation={0}
|
sx={{
|
px: 1.5,
|
py: 1.25,
|
maxWidth: "85%",
|
borderRadius: 2,
|
bgcolor: message.role === "user" ? "primary.main" : "grey.100",
|
color: message.role === "user" ? "primary.contrastText" : "text.primary",
|
whiteSpace: "pre-wrap",
|
wordBreak: "break-word",
|
}}
|
>
|
<Typography variant="caption" display="block" sx={{ opacity: 0.72, mb: 0.5 }}>
|
{message.role === "user" ? "你" : "AI"}
|
</Typography>
|
<Typography variant="body2">
|
{message.content || (streaming && index === messages.length - 1 ? "思考中..." : "")}
|
</Typography>
|
</Paper>
|
</Box>
|
))}
|
</Box>
|
|
<Divider />
|
|
<Box px={2} py={1.5}>
|
{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}
|
</Typography>
|
)}
|
<TextField
|
value={input}
|
onChange={(event) => setInput(event.target.value)}
|
onKeyDown={handleKeyDown}
|
fullWidth
|
multiline
|
minRows={3}
|
maxRows={6}
|
placeholder="输入你的问题,按 Enter 发送,Shift + Enter 换行"
|
/>
|
<Stack direction="row" spacing={1} justifyContent="flex-end" mt={1.25}>
|
<Button onClick={() => setInput("")}>清空输入</Button>
|
{streaming ? (
|
<Button variant="outlined" color="warning" startIcon={<StopCircleOutlinedIcon />} onClick={() => stopStream(true)}>
|
停止
|
</Button>
|
) : (
|
<Button variant="contained" startIcon={<SendRoundedIcon />} onClick={handleSend}>
|
发送
|
</Button>
|
)}
|
</Stack>
|
</Box>
|
</Box>
|
</Box>
|
</Box>
|
</Drawer>
|
);
|
};
|
|
export default AiChatDrawer;
|