更新依赖,优化认证流程;添加聊天框动画效果,优化消息处理逻辑

This commit is contained in:
2026-03-24 10:56:25 +08:00
parent accf6ad254
commit 045391d036
9 changed files with 879 additions and 326 deletions
+427 -90
View File
@@ -1,23 +1,35 @@
"use client";
import ChatOutlined from "@mui/icons-material/ChatOutlined";
import Close from "@mui/icons-material/Close";
import Send from "@mui/icons-material/Send";
import React, { useMemo, useRef, useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { motion, AnimatePresence } from "framer-motion";
// MUI
import {
Avatar,
Box,
CircularProgress,
Drawer,
IconButton,
List,
ListItem,
ListItemText,
Paper,
Stack,
TextField,
Typography,
useTheme,
alpha,
Tooltip,
} from "@mui/material";
import React, { useMemo, useRef, useState } from "react";
// Icons
import CloseRounded from "@mui/icons-material/CloseRounded";
import SendRounded from "@mui/icons-material/SendRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI
import PersonRounded from "@mui/icons-material/PersonRounded";
// Logic
import { streamCopilotChat } from "@/lib/chatStream";
// Types
type Message = {
id: string;
role: "user" | "assistant";
@@ -29,17 +41,87 @@ type Props = {
onClose: () => void;
};
// Utils
const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// --- Components ---
const TypingIndicator = () => {
return (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}>
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ y: 0 }}
animate={{ y: [-4, 4, -4] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut", // Smooth sine wave
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)", // Warm gradient dots
}}
/>
</motion.div>
))}
</Stack>
);
};
// Animated Background Blob
const Blob = ({ color, size, top, left, delay }: { color: string; size: number; top: string; left: string; delay: number }) => (
<motion.div
initial={{ scale: 0.8, opacity: 0.3, x: 0, y: 0 }}
animate={{
scale: [0.8, 1.2, 0.8],
opacity: [0.3, 0.5, 0.3],
x: [0, 30, 0],
y: [0, -30, 0],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: delay,
}}
style={{
position: "absolute",
top,
left,
width: size,
height: size,
borderRadius: "50%",
background: color,
filter: "blur(60px)",
zIndex: 0,
pointerEvents: "none",
}}
/>
);
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]);
// Auto-scroll
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
const handleSend = async () => {
const prompt = input.trim();
if (!prompt || isStreaming) return;
@@ -65,45 +147,31 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
signal: controller.signal,
onEvent: (event) => {
if (event.type === "token") {
if (!conversationId && event.conversationId) {
setConversationId(event.conversationId);
}
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
setMessages((prev) =>
prev.map((item) =>
item.id === assistantId
? { ...item, content: `${item.content}${event.content}` }
: item,
),
prev.map((m) =>
m.id === assistantId ? { ...m, content: m.content + event.content } : m
)
);
} else if (event.type === "done") {
if (!conversationId && event.conversationId) {
setConversationId(event.conversationId);
}
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
setIsStreaming(false);
} else if (event.type === "error") {
setMessages((prev) =>
prev.map((item) =>
item.id === assistantId
? {
...item,
content:
item.content ||
`Error: ${event.message}${event.detail ? ` (${event.detail})` : ""}`,
}
: item,
),
prev.map((m) =>
m.id === assistantId
? { ...m, content: m.content || `错误:${event.message}` }
: m
)
);
setIsStreaming(false);
}
},
});
} catch (error) {
if (abortRef.current?.signal.aborted) return;
setMessages((prev) =>
prev.map((item) =>
item.id === assistantId
? { ...item, content: `Error: ${String(error)}` }
: item,
),
prev.map((m) => (m.id === assistantId ? { ...m, content: `错误:${String(error)}` } : m))
);
setIsStreaming(false);
} finally {
@@ -118,66 +186,335 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
};
return (
<Drawer anchor="right" open={open} onClose={onClose}>
<Box sx={{ width: { xs: "100vw", sm: 420 }, height: "100%", display: "flex", flexDirection: "column" }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ p: 2, borderBottom: "1px solid", borderColor: "divider" }}>
<Stack direction="row" alignItems="center" spacing={1}>
<ChatOutlined />
<Typography variant="subtitle1" fontWeight={600}>
Copilot Chat
</Typography>
<Drawer
anchor="right"
open={open}
onClose={onClose}
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }}
PaperProps={{
sx: {
width: { xs: "100%", sm: 480 },
background: "transparent",
boxShadow: "none",
overflow: "hidden", // Clip blobs
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
},
}}
ModalProps={{
BackdropProps: {
sx: { backdropFilter: "blur(6px)", bgcolor: alpha(theme.palette.background.default, 0.3) },
},
}}
>
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
bgcolor: alpha("#fff", 0.75), // Light glass base
backdropFilter: "blur(30px)",
position: "relative",
}}
>
{/* Ambient Blobs */}
<Blob color={alpha(theme.palette.primary.main, 0.3)} size={300} top="-10%" left="-20%" delay={0} />
<Blob color={alpha(theme.palette.secondary.main, 0.3)} size={250} top="40%" left="60%" delay={2} />
<Blob color={alpha(theme.palette.success.light, 0.2)} size={200} top="80%" left="-10%" delay={4} />
{/* Header - Transparent & Floating */}
<Box
sx={{
p: 3,
zIndex: 10,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Stack direction="row" alignItems="center" spacing={2}>
<motion.div
whileHover={{ rotate: 10, scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<Box sx={{ position: "relative" }}>
<Avatar
sx={{
background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.primary.main})`,
boxShadow: `0 8px 20px ${alpha(theme.palette.primary.main, 0.4)}`,
width: 48,
height: 48,
}}
>
<AutoAwesome fontSize="medium" sx={{ color: "#fff" }} />
</Avatar>
<Box
sx={{
position: "absolute",
bottom: 2,
right: 2,
width: 12,
height: 12,
bgcolor: "success.main",
borderRadius: "50%",
border: "2px solid #fff",
boxShadow: "0 0 0 2px rgba(255,255,255,0.5)"
}}
/>
</Box>
</motion.div>
<Box>
<Typography variant="h6" fontWeight={800} sx={{ background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, backgroundClip: "text", color: "transparent", letterSpacing: -0.5 }}>
Copilot
</Typography>
<Typography variant="caption" color="text.secondary" fontWeight={500}>
AI
</Typography>
</Box>
</Stack>
<IconButton onClick={onClose} size="small">
<Close />
</IconButton>
</Stack>
<motion.div whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}>
<IconButton onClick={onClose} size="small" sx={{ color: "text.primary", bgcolor: alpha("#fff", 0.5), "&:hover": { bgcolor: "#fff" } }}>
<CloseRounded />
</IconButton>
</motion.div>
</Box>
<List sx={{ flex: 1, overflow: "auto", px: 1.5 }}>
{messages.map((message) => (
<ListItem key={message.id} sx={{ justifyContent: message.role === "user" ? "flex-end" : "flex-start" }}>
<Box
{/* Messages - Bouncy List */}
<Box sx={{ flex: 1, overflowY: "auto", px: 2.5, py: 2, display: "flex", flexDirection: "column", gap: 2.5, zIndex: 5 }}>
<AnimatePresence initial={false}>
{messages.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
style={{ margin: "auto", width: "100%" }}
>
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: 6,
bgcolor: alpha("#fff", 0.6),
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
maxWidth: 320,
mx: "auto",
textAlign: "center",
backdropFilter: "blur(10px)",
}}
>
<motion.div
animate={{ y: [-5, 5, -5] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
>
<AutoAwesome sx={{ fontSize: 56, color: "primary.main", mb: 2, filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))" }} />
</motion.div>
<Typography variant="h6" color="text.primary" fontWeight={700} gutterBottom>
👋
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}>
</Typography>
</Paper>
</motion.div>
)}
{messages.map((message) => {
const isUser = message.role === "user";
return (
<motion.div
key={message.id}
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 350, damping: 25 }}
style={{
alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "85%",
display: "flex",
flexDirection: isUser ? "row-reverse" : "row",
gap: 12,
alignItems: "flex-end",
}}
>
{!isUser && (
<Avatar sx={{ width: 28, height: 28, bgcolor: alpha(theme.palette.secondary.main, 0.1), mb: 0.5 }}>
<AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} />
</Avatar>
)}
<Paper
elevation={isUser ? 8 : 2}
sx={{
p: 2.5,
borderRadius: 4,
borderBottomRightRadius: isUser ? 4 : 24,
borderBottomLeftRadius: !isUser ? 4 : 24,
bgcolor: isUser ? "primary.main" : "#fff",
color: isUser ? "#fff" : "text.primary",
background: isUser ? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})` : undefined,
boxShadow: isUser
? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}`
: `0 4px 16px -4px ${alpha("#000", 0.05)}`,
// Markdown Styles
"& p": { m: 0, lineHeight: 1.6 },
"& code": {
fontFamily: "monospace",
bgcolor: isUser ? "rgba(255,255,255,0.2)" : alpha(theme.palette.grey[100], 0.8),
px: 0.8,
py: 0.2,
borderRadius: 1,
fontSize: "0.85em",
border: isUser ? "none" : `1px solid ${alpha(theme.palette.divider, 0.1)}`,
},
"& pre": {
bgcolor: isUser ? "rgba(0,0,0,0.25)" : "#222",
color: "#f8f8f2",
p: 2,
borderRadius: 3,
overflowX: "auto",
my: 1.5,
fontSize: "0.85em",
border: "1px solid rgba(255,255,255,0.1)",
},
"& ul, & ol": { pl: 2.5, my: 1 },
}}
>
{isUser ? (
<Typography variant="body2" fontSize="0.95rem" sx={{ whiteSpace: "pre-wrap" }}>{message.content}</Typography>
) : (
<ReactMarkdown>{message.content || "..."}</ReactMarkdown>
)}
</Paper>
</motion.div>
);
})}
</AnimatePresence>
{isStreaming && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 300 }}
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 40 }}
>
<Paper
elevation={0}
sx={{
p: 1.5,
borderRadius: 4,
bgcolor: alpha("#fff", 0.8),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`
}}
>
<TypingIndicator />
</Paper>
</motion.div>
)}
<div ref={bottomRef} style={{ height: 1 }} />
</Box>
{/* Input Area - Floating Capsule */}
<Box sx={{ p: 3, zIndex: 10 }}>
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Stack
direction="row"
alignItems="center"
component={Paper}
elevation={12}
sx={{
maxWidth: "86%",
px: 1.5,
py: 1,
borderRadius: 2,
bgcolor: message.role === "user" ? "primary.main" : "grey.100",
color: message.role === "user" ? "primary.contrastText" : "text.primary",
whiteSpace: "pre-wrap",
p: "6px 8px",
borderRadius: 50, // Full capsule
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(10px)",
border: `1px solid ${alpha("#fff", 0.6)}`,
boxShadow: `0 12px 40px -8px ${alpha(theme.palette.primary.main, 0.15)}`,
transition: "all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)",
"&:hover": {
transform: "translateY(-2px)",
boxShadow: `0 16px 48px -8px ${alpha(theme.palette.primary.main, 0.25)}`,
}
}}
>
<ListItemText primaryTypographyProps={{ variant: "body2" }} primary={message.content || "..."} />
</Box>
</ListItem>
))}
</List>
<Stack direction="row" spacing={1} sx={{ p: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
<TextField
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
size="small"
multiline
maxRows={4}
fullWidth
placeholder="输入消息..."
/>
{isStreaming ? (
<IconButton color="warning" onClick={handleAbort}>
<CircularProgress size={20} />
</IconButton>
) : (
<IconButton color="primary" disabled={!canSend} onClick={() => void handleSend()}>
<Send />
</IconButton>
)}
</Stack>
>
<TextField
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
placeholder="输入消息给 Copilot..."
fullWidth
multiline
maxRows={3}
variant="standard"
InputProps={{
disableUnderline: true,
sx: { px: 2.5, py: 1.5, fontSize: "1rem" },
}}
/>
<Box sx={{ pr: 0.5 }}>
<AnimatePresence mode="wait">
{isStreaming ? (
<motion.div
key="stop"
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, rotate: 180 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<IconButton
onClick={handleAbort}
sx={{
bgcolor: alpha(theme.palette.error.main, 0.1),
color: "error.main",
width: 44, height: 44,
"&:hover": { bgcolor: alpha(theme.palette.error.main, 0.2) }
}}
>
<StopRounded />
</IconButton>
</motion.div>
) : (
<motion.div
key="send"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<IconButton
disabled={!canSend}
onClick={() => void handleSend()}
sx={{
bgcolor: canSend ? "primary.main" : "action.disabledBackground",
color: "#fff",
width: 44, height: 44,
transition: "background-color 0.2s",
"&:hover": {
bgcolor: "primary.dark",
boxShadow: `0 4px 12px ${alpha(theme.palette.primary.main, 0.5)}`
}
}}
>
<SendRounded sx={{ ml: 0.5 }} />
</IconButton>
</motion.div>
)}
</AnimatePresence>
</Box>
</Stack>
</motion.div>
</Box>
</Box>
</Drawer>
);