import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import {
|
Alert,
|
Avatar,
|
Box,
|
Button,
|
Chip,
|
CircularProgress,
|
Divider,
|
Drawer,
|
Fab,
|
IconButton,
|
List,
|
ListItemButton,
|
ListItemText,
|
MenuItem,
|
Select,
|
Stack,
|
TextField,
|
Tooltip,
|
Typography
|
} from '@mui/material';
|
import AddIcon from '@mui/icons-material/Add';
|
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
|
import CloseIcon from '@mui/icons-material/Close';
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
import SendIcon from '@mui/icons-material/Send';
|
import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined';
|
import { createAiSession, getAiMessages, getAiModels, getAiSessions, removeAiSession, stopAiChat } from '@/api/ai';
|
import { PREFIX_BASE_URL, TOKEN_HEADER_NAME } from '@/config/setting';
|
import { getToken } from '@/utils/token-util';
|
|
const DRAWER_WIDTH = 720;
|
const SESSION_WIDTH = 220;
|
|
const parseSseChunk = (chunk, onEvent) => {
|
const blocks = chunk.split('\n\n');
|
const remain = blocks.pop();
|
blocks.forEach((block) => {
|
if (!block.trim()) {
|
return;
|
}
|
let eventName = 'message';
|
const dataLines = [];
|
block.split('\n').forEach((line) => {
|
if (line.startsWith('event:')) {
|
eventName = line.substring(6).trim();
|
}
|
if (line.startsWith('data:')) {
|
dataLines.push(line.substring(5).trim());
|
}
|
});
|
if (!dataLines.length) {
|
return;
|
}
|
try {
|
onEvent(eventName, JSON.parse(dataLines.join('\n')));
|
} catch (error) {
|
console.error(error);
|
}
|
});
|
return remain || '';
|
};
|
|
const buildAssistantMessage = (sessionId, modelCode) => ({
|
id: `assistant-${Date.now()}`,
|
sessionId,
|
role: 'assistant',
|
content: '',
|
modelCode,
|
createTime: new Date().toISOString()
|
});
|
|
const buildFallbackAnswer = (modelCode, question) => `当前为演示模式,模型[${modelCode}]已收到你的问题:${question}`;
|
|
const shouldUseMockFallback = (models, modelCode) => {
|
const model = (models || []).find((item) => item.code === modelCode);
|
return model ? model.provider === 'mock' : String(modelCode || '').startsWith('mock');
|
};
|
|
const formatTime = (value) => {
|
if (!value) {
|
return '';
|
}
|
const date = new Date(value);
|
if (Number.isNaN(date.getTime())) {
|
return '';
|
}
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
};
|
|
const formatPreview = (value) => {
|
if (!value) {
|
return '开始一个新的对话';
|
}
|
const normalized = String(value).replace(/\s+/g, ' ').trim();
|
return normalized.length > 22 ? `${normalized.slice(0, 22)}...` : normalized;
|
};
|
|
export const AiChatWidget = ({
|
trigger = 'fab',
|
buttonText = 'AI 对话',
|
buttonVariant = 'contained',
|
buttonSx = {}
|
}) => {
|
const [open, setOpen] = useState(false);
|
const [loading, setLoading] = useState(false);
|
const [sending, setSending] = useState(false);
|
const [models, setModels] = useState([]);
|
const [sessions, setSessions] = useState([]);
|
const [activeSessionId, setActiveSessionId] = useState('');
|
const [messagesBySession, setMessagesBySession] = useState({});
|
const [draft, setDraft] = useState('');
|
const [error, setError] = useState('');
|
const streamControllerRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
|
const scrollToBottom = (behavior = 'auto') => {
|
window.setTimeout(() => {
|
window.requestAnimationFrame(() => {
|
messagesEndRef.current?.scrollIntoView({ behavior, block: 'end' });
|
});
|
}, 0);
|
};
|
|
const activeSession = useMemo(
|
() => sessions.find((item) => item.id === activeSessionId) || null,
|
[sessions, activeSessionId]
|
);
|
|
const activeMessages = messagesBySession[activeSessionId] || [];
|
|
useEffect(() => {
|
if (open) {
|
bootstrap();
|
}
|
}, [open]);
|
|
useEffect(() => {
|
if (!open) {
|
return undefined;
|
}
|
const timer = window.setTimeout(() => {
|
scrollToBottom('auto');
|
}, 180);
|
return () => window.clearTimeout(timer);
|
}, [open, activeSessionId]);
|
|
useEffect(() => {
|
if (open && activeSessionId) {
|
loadMessages(activeSessionId);
|
}
|
}, [open, activeSessionId]);
|
|
useEffect(() => {
|
if (open && messagesEndRef.current) {
|
scrollToBottom('smooth');
|
}
|
}, [open, activeMessages]);
|
|
const bootstrap = async () => {
|
setLoading(true);
|
setError('');
|
try {
|
const [modelList, sessionList] = await Promise.all([getAiModels(), getAiSessions()]);
|
setModels(modelList);
|
if (sessionList.length > 0) {
|
setSessions(sessionList);
|
setActiveSessionId((prev) => prev || sessionList[0].id);
|
} else {
|
const created = await createAiSession({ modelCode: modelList[0]?.code });
|
setSessions(created ? [created] : []);
|
setActiveSessionId(created?.id || '');
|
if (created?.id) {
|
setMessagesBySession((prev) => ({ ...prev, [created.id]: [] }));
|
}
|
}
|
} catch (bootstrapError) {
|
setError(bootstrapError.message || 'AI助手初始化失败');
|
} finally {
|
setLoading(false);
|
}
|
};
|
|
const refreshSessions = async (preferSessionId) => {
|
try {
|
const sessionList = await getAiSessions();
|
if (sessionList.length > 0) {
|
setSessions(sessionList);
|
setActiveSessionId(preferSessionId || sessionList[0].id);
|
}
|
} catch (refreshError) {
|
setError(refreshError.message || '刷新会话失败');
|
}
|
};
|
|
const loadMessages = async (sessionId) => {
|
if (!sessionId || messagesBySession[sessionId]) {
|
return;
|
}
|
try {
|
const messageList = await getAiMessages(sessionId);
|
setMessagesBySession((prev) => {
|
const current = prev[sessionId];
|
if (current && current.length > 0) {
|
return prev;
|
}
|
return { ...prev, [sessionId]: messageList };
|
});
|
scrollToBottom('auto');
|
} catch (messageError) {
|
setError(messageError.message || '加载消息失败');
|
}
|
};
|
|
const handleCreateSession = async () => {
|
try {
|
const created = await createAiSession({ modelCode: activeSession?.modelCode || models[0]?.code });
|
setSessions((prev) => [created, ...prev]);
|
setActiveSessionId(created.id);
|
setMessagesBySession((prev) => ({ ...prev, [created.id]: [] }));
|
} catch (createError) {
|
setError(createError.message || '创建会话失败');
|
}
|
};
|
|
const handleDeleteSession = async (sessionId) => {
|
try {
|
await removeAiSession(sessionId);
|
const nextSessions = sessions.filter((item) => item.id !== sessionId);
|
setSessions(nextSessions);
|
setMessagesBySession((prev) => {
|
const next = { ...prev };
|
delete next[sessionId];
|
return next;
|
});
|
if (nextSessions.length > 0) {
|
setActiveSessionId(nextSessions[0].id);
|
} else {
|
const created = await createAiSession({ modelCode: models[0]?.code });
|
if (created?.id) {
|
setSessions([created]);
|
setActiveSessionId(created.id);
|
setMessagesBySession({ [created.id]: [] });
|
}
|
}
|
} catch (deleteError) {
|
setError(deleteError.message || '删除会话失败');
|
}
|
};
|
|
const handleModelChange = (modelCode) => {
|
setSessions((prev) => prev.map((item) => item.id === activeSessionId ? { ...item, modelCode } : item));
|
};
|
|
const handleStop = async () => {
|
if (!activeSessionId) {
|
return;
|
}
|
if (streamControllerRef.current) {
|
streamControllerRef.current.abort();
|
}
|
try {
|
await stopAiChat({ sessionId: activeSessionId });
|
} catch (stopError) {
|
console.error(stopError);
|
} finally {
|
setSending(false);
|
}
|
};
|
|
const handleSend = async () => {
|
if (!draft.trim() || !activeSessionId || sending) {
|
return;
|
}
|
const userContent = draft.trim();
|
const sessionId = activeSessionId;
|
const modelCode = activeSession?.modelCode || models[0]?.code;
|
setDraft('');
|
setError('');
|
setSending(true);
|
setMessagesBySession((prev) => {
|
const current = prev[sessionId] || [];
|
return {
|
...prev,
|
[sessionId]: [
|
...current,
|
{
|
id: `user-${Date.now()}`,
|
sessionId,
|
role: 'user',
|
content: userContent,
|
modelCode,
|
createTime: new Date().toISOString()
|
},
|
buildAssistantMessage(sessionId, modelCode)
|
]
|
};
|
});
|
|
const controller = new AbortController();
|
streamControllerRef.current = controller;
|
let buffer = '';
|
let receivedDelta = false;
|
|
try {
|
const response = await fetch(`${PREFIX_BASE_URL}ai/chat/stream`, {
|
method: 'POST',
|
headers: {
|
'Content-Type': 'application/json',
|
Accept: 'text/event-stream',
|
[TOKEN_HEADER_NAME]: getToken()
|
},
|
body: JSON.stringify({
|
sessionId,
|
message: userContent,
|
modelCode
|
}),
|
signal: controller.signal
|
});
|
|
if (!response.ok || !response.body) {
|
throw new Error('AI流式请求失败');
|
}
|
|
const reader = response.body.getReader();
|
const decoder = new TextDecoder('utf-8');
|
|
while (true) {
|
const { value, done } = await reader.read();
|
if (done) {
|
break;
|
}
|
buffer += decoder.decode(value, { stream: true });
|
buffer = parseSseChunk(buffer, (eventName, payload) => {
|
if (eventName === 'session' && payload.sessionId) {
|
setSessions((prev) => prev.map((item) => item.id === sessionId ? { ...item, modelCode: payload.modelCode || modelCode } : item));
|
}
|
if (eventName === 'delta') {
|
receivedDelta = true;
|
setMessagesBySession((prev) => {
|
const current = [...(prev[sessionId] || [])];
|
if (!current.length) {
|
return prev;
|
}
|
const last = current[current.length - 1];
|
current[current.length - 1] = { ...last, content: `${last.content || ''}${payload.content || ''}` };
|
return { ...prev, [sessionId]: current };
|
});
|
}
|
if (eventName === 'error') {
|
setError(payload.message || 'AI服务异常');
|
}
|
});
|
}
|
|
const latestMessages = await getAiMessages(sessionId);
|
setMessagesBySession((prev) => {
|
const current = prev[sessionId] || [];
|
if (!receivedDelta && current.length > 0 && shouldUseMockFallback(models, modelCode)) {
|
const fallbackAnswer = buildFallbackAnswer(modelCode, userContent);
|
const next = [...current];
|
const last = next[next.length - 1];
|
if (last && last.role === 'assistant' && !last.content) {
|
next[next.length - 1] = { ...last, content: fallbackAnswer };
|
return { ...prev, [sessionId]: next };
|
}
|
}
|
if (latestMessages.length === 0 && current.length > 0) {
|
return prev;
|
}
|
return { ...prev, [sessionId]: latestMessages };
|
});
|
setSessions((prev) => prev.map((item) => {
|
if (item.id !== sessionId) {
|
return item;
|
}
|
const lastMessage = assistantReplyText(latestMessages) || item.lastMessage || userContent;
|
return {
|
...item,
|
lastMessage,
|
lastMessageAt: new Date().toISOString(),
|
updateTime: new Date().toISOString()
|
};
|
}));
|
await refreshSessions(sessionId);
|
} catch (sendError) {
|
if (sendError.name !== 'AbortError') {
|
setError(sendError.message || '发送消息失败');
|
}
|
} finally {
|
setSending(false);
|
streamControllerRef.current = null;
|
}
|
};
|
|
const assistantReplyText = (messageList) => {
|
const assistantMessages = (messageList || []).filter((item) => item.role === 'assistant');
|
if (!assistantMessages.length) {
|
return '';
|
}
|
return assistantMessages[assistantMessages.length - 1].content || '';
|
};
|
|
return (
|
<>
|
{trigger === 'fab' ? (
|
<Fab
|
color="secondary"
|
onClick={() => setOpen(true)}
|
sx={{ position: 'fixed', right: 24, bottom: 24, zIndex: 1301 }}
|
>
|
<ChatBubbleOutlineIcon />
|
</Fab>
|
) : (
|
<Button
|
variant={buttonVariant}
|
startIcon={<ChatBubbleOutlineIcon />}
|
onClick={() => setOpen(true)}
|
sx={buttonSx}
|
>
|
{buttonText}
|
</Button>
|
)}
|
<Drawer
|
anchor="right"
|
open={open}
|
onClose={() => setOpen(false)}
|
PaperProps={{
|
sx: {
|
width: DRAWER_WIDTH,
|
maxWidth: '100vw',
|
backgroundColor: '#f5f7fa'
|
}
|
}}
|
>
|
<Stack sx={{ height: '100%' }}>
|
<Stack
|
direction="row"
|
alignItems="center"
|
spacing={1.5}
|
sx={{
|
px: 2,
|
py: 1.5,
|
backgroundColor: '#fff',
|
color: 'text.primary',
|
borderBottom: '1px solid rgba(0, 0, 0, 0.08)'
|
}}
|
>
|
<Avatar
|
sx={{
|
width: 38,
|
height: 38,
|
bgcolor: 'rgba(25, 118, 210, 0.12)',
|
color: 'primary.main',
|
fontSize: 16,
|
fontWeight: 700
|
}}
|
>
|
AI
|
</Avatar>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
智能对话
|
</Typography>
|
<Typography variant="caption" color="text.secondary">
|
多会话、多模型、流式响应
|
</Typography>
|
</Box>
|
<Chip
|
size="small"
|
label={sending ? '生成中' : '在线'}
|
sx={{
|
bgcolor: sending ? 'rgba(255, 167, 38, 0.14)' : 'rgba(76, 175, 80, 0.12)',
|
color: sending ? '#ad6800' : '#2e7d32',
|
border: '1px solid rgba(0, 0, 0, 0.06)'
|
}}
|
/>
|
<Tooltip title="新建会话">
|
<IconButton color="primary" onClick={handleCreateSession}>
|
<AddIcon />
|
</IconButton>
|
</Tooltip>
|
<IconButton color="default" onClick={() => setOpen(false)}>
|
<CloseIcon />
|
</IconButton>
|
</Stack>
|
<Divider />
|
{error ? <Alert severity="error" sx={{ m: 1.5, borderRadius: 2 }}>{error}</Alert> : null}
|
<Stack direction="row" sx={{ minHeight: 0, flex: 1 }}>
|
<Box sx={{
|
width: SESSION_WIDTH,
|
borderRight: '1px solid',
|
borderColor: 'rgba(0, 0, 0, 0.08)',
|
display: 'flex',
|
flexDirection: 'column',
|
backgroundColor: '#f7f9fc'
|
}}>
|
<Box sx={{ px: 1.5, py: 1.25 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
会话列表
|
</Typography>
|
</Box>
|
<List sx={{ flex: 1, overflowY: 'auto', py: 0, px: 1 }}>
|
{sessions.map((item) => (
|
<ListItemButton
|
key={item.id}
|
selected={item.id === activeSessionId}
|
onClick={() => setActiveSessionId(item.id)}
|
sx={{
|
alignItems: 'flex-start',
|
pr: 1,
|
mb: 0.75,
|
borderRadius: 2,
|
border: '1px solid',
|
borderColor: item.id === activeSessionId ? 'rgba(25, 118, 210, 0.28)' : 'rgba(0, 0, 0, 0.06)',
|
backgroundColor: item.id === activeSessionId ? '#eaf3fe' : '#fff',
|
boxShadow: item.id === activeSessionId ? '0 6px 16px rgba(25, 118, 210, 0.08)' : 'none'
|
}}
|
>
|
<ListItemText
|
primary={item.title || '新对话'}
|
secondary={`${formatPreview(item.lastMessage || item.modelCode)}${formatTime(item.updateTime || item.lastMessageAt) ? ` · ${formatTime(item.updateTime || item.lastMessageAt)}` : ''}`}
|
primaryTypographyProps={{ noWrap: true, fontSize: 13, fontWeight: 700 }}
|
secondaryTypographyProps={{ noWrap: true, fontSize: 11.5, color: 'text.secondary' }}
|
/>
|
<IconButton
|
size="small"
|
edge="end"
|
sx={{ mt: 0.25 }}
|
onClick={(event) => {
|
event.stopPropagation();
|
handleDeleteSession(item.id);
|
}}
|
>
|
<DeleteOutlineIcon fontSize="inherit" />
|
</IconButton>
|
</ListItemButton>
|
))}
|
</List>
|
</Box>
|
<Stack sx={{ flex: 1, minWidth: 0 }}>
|
<Stack
|
direction="row"
|
alignItems="center"
|
spacing={1}
|
sx={{
|
px: 2,
|
py: 1.5,
|
backgroundColor: '#fff'
|
}}
|
>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Typography variant="body2" color="text.secondary">
|
当前模型
|
</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
{activeSession?.title || '未命名会话'}
|
</Typography>
|
</Box>
|
<Select
|
size="small"
|
value={activeSession?.modelCode || models[0]?.code || ''}
|
onChange={(event) => handleModelChange(event.target.value)}
|
sx={{
|
minWidth: 176,
|
borderRadius: 2,
|
backgroundColor: '#fff',
|
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.06)'
|
}}
|
>
|
{models.map((model) => (
|
<MenuItem key={model.code} value={model.code}>
|
{model.name}
|
</MenuItem>
|
))}
|
</Select>
|
</Stack>
|
<Divider />
|
<Box
|
sx={{
|
flex: 1,
|
overflowY: 'auto',
|
px: 2.5,
|
py: 2,
|
backgroundColor: '#f5f7fa'
|
}}
|
>
|
{loading ? (
|
<Stack alignItems="center" justifyContent="center" sx={{ height: '100%' }}>
|
<CircularProgress size={28} />
|
</Stack>
|
) : activeMessages.length === 0 ? (
|
<Stack
|
alignItems="center"
|
justifyContent="center"
|
spacing={1.25}
|
sx={{
|
height: '100%',
|
textAlign: 'center',
|
color: 'text.secondary',
|
px: 3
|
}}
|
>
|
<Avatar sx={{ width: 56, height: 56, bgcolor: 'rgba(25,118,210,0.1)', color: 'primary.main' }}>
|
AI
|
</Avatar>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
开始新的智能对话
|
</Typography>
|
<Typography variant="body2">
|
可以直接提问仓储业务问题,或切换模型开始新的会话。
|
</Typography>
|
</Stack>
|
) : (
|
<Stack spacing={2}>
|
{activeMessages.map((message) => (
|
<Box
|
key={message.id}
|
sx={{
|
alignSelf: message.role === 'user' ? 'flex-end' : 'flex-start',
|
maxWidth: '88%'
|
}}
|
>
|
<Stack
|
direction={message.role === 'user' ? 'row-reverse' : 'row'}
|
spacing={1}
|
alignItems="flex-start"
|
>
|
<Avatar
|
sx={{
|
width: 30,
|
height: 30,
|
mt: 0.25,
|
bgcolor: message.role === 'user' ? '#1976d2' : '#eaf3fe',
|
color: message.role === 'user' ? '#fff' : '#1976d2',
|
fontSize: 12,
|
fontWeight: 700
|
}}
|
>
|
{message.role === 'user' ? '我' : 'AI'}
|
</Avatar>
|
<Box
|
sx={{
|
maxWidth: 'calc(100% - 42px)',
|
display: 'flex',
|
flexDirection: 'column',
|
alignItems: message.role === 'user' ? 'flex-end' : 'flex-start'
|
}}
|
>
|
<Stack
|
direction="row"
|
spacing={0.75}
|
alignItems="center"
|
justifyContent={message.role === 'user' ? 'flex-end' : 'flex-start'}
|
sx={{ px: 0.5, mb: 0.5 }}
|
>
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
{message.role === 'assistant' ? message.modelCode || activeSession?.modelCode : '我'}
|
</Typography>
|
<Typography variant="caption" color="text.disabled">
|
{formatTime(message.createTime)}
|
</Typography>
|
</Stack>
|
<Box
|
sx={{
|
px: 1.8,
|
py: 1.35,
|
minWidth: message.role === 'user' ? 96 : 'auto',
|
maxWidth: message.role === 'user' ? '82%' : '100%',
|
borderRadius: message.role === 'user' ? '20px 20px 8px 20px' : '20px 20px 20px 8px',
|
background: '#fff',
|
color: 'text.primary',
|
boxShadow: '0 8px 18px rgba(15, 23, 42, 0.05)',
|
border: '1px solid rgba(25, 118, 210, 0.08)',
|
display: 'inline-block',
|
whiteSpace: 'pre-wrap',
|
wordBreak: 'normal',
|
overflowWrap: 'anywhere',
|
lineHeight: 1.7,
|
fontSize: 14
|
}}
|
>
|
{message.content || (message.role === 'assistant' && sending ? '正在生成回复...' : '')}
|
</Box>
|
</Box>
|
</Stack>
|
</Box>
|
))}
|
<div ref={messagesEndRef} />
|
</Stack>
|
)}
|
</Box>
|
<Divider />
|
<Box
|
sx={{
|
p: 1.5,
|
backgroundColor: '#fff'
|
}}
|
>
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
<Chip
|
size="small"
|
label={activeSession?.modelCode || '未选择模型'}
|
sx={{ bgcolor: 'rgba(25,118,210,0.08)', color: 'primary.main' }}
|
/>
|
<Typography variant="caption" color="text.secondary">
|
`Enter` 发送,`Shift + Enter` 换行
|
</Typography>
|
</Stack>
|
<TextField
|
fullWidth
|
multiline
|
minRows={3}
|
maxRows={6}
|
value={draft}
|
onChange={(event) => setDraft(event.target.value)}
|
placeholder="输入问题,支持多会话和模型切换"
|
onKeyDown={(event) => {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
event.preventDefault();
|
handleSend();
|
}
|
}}
|
sx={{
|
'& .MuiOutlinedInput-root': {
|
borderRadius: 3,
|
backgroundColor: '#fff'
|
}
|
}}
|
/>
|
<Stack direction="row" spacing={1} justifyContent="flex-end" sx={{ mt: 1.2 }}>
|
<Tooltip title="停止生成">
|
<span>
|
<IconButton color="warning" disabled={!sending} onClick={handleStop}>
|
<StopCircleOutlinedIcon />
|
</IconButton>
|
</span>
|
</Tooltip>
|
<Tooltip title="发送">
|
<span>
|
<IconButton color="primary" disabled={sending || !draft.trim()} onClick={handleSend}>
|
<SendIcon />
|
</IconButton>
|
</span>
|
</Tooltip>
|
</Stack>
|
</Box>
|
</Stack>
|
</Stack>
|
</Stack>
|
</Drawer>
|
</>
|
);
|
};
|