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

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
+69 -6
View File
@@ -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;
},
},
+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>
);
+10 -9
View File
@@ -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
View File
@@ -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;
+19
View File
@@ -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
View File
@@ -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;
}
+4
View File
@@ -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";
}
}