更新依赖,优化认证流程;添加聊天框动画效果,优化消息处理逻辑
This commit is contained in:
@@ -1,14 +1,58 @@
|
||||
import { NextAuthOptions } from "next-auth";
|
||||
import { JWT } from "next-auth/jwt";
|
||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||
import Avatar from "@assets/avatar/avatar-small.jpeg";
|
||||
|
||||
type KeycloakTokenResponse = {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
};
|
||||
|
||||
const keycloakIssuer = process.env.KEYCLOAK_ISSUER!;
|
||||
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID!;
|
||||
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET!;
|
||||
const keycloakTokenEndpoint = `${keycloakIssuer.replace(/\/$/, "")}/protocol/openid-connect/token`;
|
||||
|
||||
const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
||||
if (!token.refreshToken) {
|
||||
return { ...token, error: "RefreshAccessTokenError" };
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: keycloakClientId,
|
||||
client_secret: keycloakClientSecret,
|
||||
refresh_token: token.refreshToken,
|
||||
});
|
||||
|
||||
const response = await fetch(keycloakTokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const refreshed = (await response.json()) as KeycloakTokenResponse;
|
||||
|
||||
if (!response.ok || !refreshed.access_token || typeof refreshed.expires_in !== "number") {
|
||||
return { ...token, error: "RefreshAccessTokenError" };
|
||||
}
|
||||
|
||||
return {
|
||||
...token,
|
||||
accessToken: refreshed.access_token,
|
||||
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
|
||||
refreshToken: refreshed.refresh_token ?? token.refreshToken,
|
||||
error: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const authOptions: NextAuthOptions = {
|
||||
// Configure one or more authentication providers
|
||||
providers: [
|
||||
KeycloakProvider({
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||
issuer: process.env.KEYCLOAK_ISSUER!,
|
||||
clientId: keycloakClientId,
|
||||
clientSecret: keycloakClientSecret,
|
||||
issuer: keycloakIssuer,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
@@ -25,10 +69,26 @@ const authOptions: NextAuthOptions = {
|
||||
if (profile?.sub) {
|
||||
token.sub = profile.sub;
|
||||
}
|
||||
if (account?.access_token) {
|
||||
token.accessToken = account.access_token;
|
||||
|
||||
if (account) {
|
||||
if (account.access_token) {
|
||||
token.accessToken = account.access_token;
|
||||
}
|
||||
if (account.refresh_token) {
|
||||
token.refreshToken = account.refresh_token;
|
||||
}
|
||||
if (typeof account.expires_at === "number") {
|
||||
token.accessTokenExpires = account.expires_at * 1000;
|
||||
}
|
||||
token.error = undefined;
|
||||
return token;
|
||||
}
|
||||
return token;
|
||||
|
||||
if (typeof token.accessTokenExpires === "number" && Date.now() < token.accessTokenExpires - 30_000) {
|
||||
return token;
|
||||
}
|
||||
|
||||
return refreshAccessToken(token);
|
||||
},
|
||||
session: async ({ session, token }) => {
|
||||
if (session.user && token.sub) {
|
||||
@@ -37,6 +97,9 @@ const authOptions: NextAuthOptions = {
|
||||
if (token.accessToken) {
|
||||
session.accessToken = token.accessToken;
|
||||
}
|
||||
if (token.error) {
|
||||
session.error = token.error;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ColorModeContext } from "@contexts/color-mode";
|
||||
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
|
||||
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
|
||||
import { IoChatbubbleEllipsesOutline } from "react-icons/io5";
|
||||
import Logout from "@mui/icons-material/Logout";
|
||||
import SwapHoriz from "@mui/icons-material/SwapHoriz";
|
||||
import ChatOutlined from "@mui/icons-material/ChatOutlined";
|
||||
@@ -94,24 +95,24 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() => setShowChatbox(true)}
|
||||
>
|
||||
<ChatOutlined />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
{/* <IconButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setMode();
|
||||
}}
|
||||
>
|
||||
{mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />}
|
||||
</IconButton>
|
||||
</IconButton> */}
|
||||
|
||||
{(user?.avatar || user?.name) && (
|
||||
<>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() => setShowChatbox(true)}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<IoChatbubbleEllipsesOutline />
|
||||
</IconButton>
|
||||
<ButtonBase
|
||||
onClick={handleMenuOpen}
|
||||
sx={{
|
||||
|
||||
+6
-2
@@ -15,9 +15,13 @@ const resolveUrl = (input: RequestInfo | URL) => {
|
||||
const isMetaProjectsRequest = (input: RequestInfo | URL) =>
|
||||
resolveUrl(input).includes("/api/v1/meta/projects");
|
||||
|
||||
export interface ApiFetchInit extends RequestInit {
|
||||
skipAuthRedirect?: boolean;
|
||||
}
|
||||
|
||||
export const apiFetch = async (
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit = {},
|
||||
init: ApiFetchInit = {},
|
||||
) => {
|
||||
const projectId = useProjectStore.getState().currentProjectId;
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
@@ -31,7 +35,7 @@ export const apiFetch = async (
|
||||
|
||||
const response = await fetch(input, { ...init, headers });
|
||||
|
||||
if (response.status === 401 && typeof window !== "undefined") {
|
||||
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
|
||||
useAuthStore.getState().setAccessToken(null);
|
||||
if (!isSigningOut) {
|
||||
isSigningOut = true;
|
||||
|
||||
@@ -78,4 +78,23 @@ describe("streamCopilotChat", () => {
|
||||
{ type: "error", message: "stream request failed", detail: "bad request" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits re-login message on unauthorized response", async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
body: null,
|
||||
text: async () => "unauthorized",
|
||||
});
|
||||
|
||||
const events: Array<{ type: string; message?: string; detail?: string }> = [];
|
||||
await streamCopilotChat({
|
||||
message: "hi",
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: "error", message: "Login expired. Please sign in again.", detail: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-2
@@ -49,14 +49,23 @@ export const streamCopilotChat = async ({
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
}),
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
const detail = await response.text();
|
||||
let message = "stream request failed";
|
||||
|
||||
if (response.status === 403) {
|
||||
message = "Permission denied. Please contact administrator.";
|
||||
} else if (response.status === 401) {
|
||||
message = "Login expired. Please sign in again.";
|
||||
}
|
||||
|
||||
onEvent({
|
||||
type: "error",
|
||||
message: "stream request failed",
|
||||
detail,
|
||||
message,
|
||||
detail: (response.status === 403 || response.status === 401) ? undefined : detail,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -4,6 +4,7 @@ import "next-auth/jwt";
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
accessToken?: string;
|
||||
error?: "RefreshAccessTokenError";
|
||||
user?: {
|
||||
id?: string;
|
||||
name?: string | null;
|
||||
@@ -21,5 +22,8 @@ declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
sub?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
accessTokenExpires?: number;
|
||||
error?: "RefreshAccessTokenError";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user