优化聊天框状态持久化,添加 Markdown 样式支持;调整地图组件的层级,避免和聊天框冲突

This commit is contained in:
2026-03-26 11:55:19 +08:00
parent 03a77f7368
commit 8713e5a468
6 changed files with 202 additions and 131 deletions
+108 -127
View File
@@ -3,6 +3,7 @@
import React, { useMemo, useRef, useState, useEffect } from "react"; import React, { useMemo, useRef, useState, useEffect } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
// MUI // MUI
import { import {
@@ -52,6 +53,27 @@ type PersistedChatState = {
conversationId?: string; conversationId?: string;
}; };
const getInitialChatState = (): PersistedChatState => {
if (typeof window === "undefined") {
return { messages: [], conversationId: undefined };
}
try {
const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY);
if (!storedRaw) return { messages: [], conversationId: undefined };
const parsed = JSON.parse(storedRaw) as PersistedChatState;
if (!Array.isArray(parsed.messages)) {
console.error("[GlobalChatbox] Invalid persisted messages format.");
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: undefined };
}
return { messages: parsed.messages, conversationId: parsed.conversationId };
} catch (error) {
console.error("[GlobalChatbox] Failed to read persisted chat state:", error);
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: undefined };
}
};
// --- Components --- // --- Components ---
const TypingIndicator = () => { const TypingIndicator = () => {
@@ -115,14 +137,20 @@ const Blob = ({ color, size, top, left, delay }: { color: string; size: number;
); );
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [messages, setMessages] = useState<Message[]>([]); const initialChatStateRef = useRef<PersistedChatState | null>(null);
if (initialChatStateRef.current === null) {
initialChatStateRef.current = getInitialChatState();
}
const [messages, setMessages] = useState<Message[]>(initialChatStateRef.current.messages);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(undefined); const [conversationId, setConversationId] = useState<string | undefined>(
initialChatStateRef.current.conversationId
);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const hasHydratedRef = useRef(false);
const theme = useTheme(); const theme = useTheme();
const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]); const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]);
@@ -136,36 +164,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
if (!open) return; if (!open) return;
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
inputRef.current?.focus(); inputRef.current?.focus();
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, 0); }, 0);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
try {
const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY);
if (!storedRaw) {
hasHydratedRef.current = true;
return;
}
const parsed = JSON.parse(storedRaw) as PersistedChatState;
if (!Array.isArray(parsed.messages)) {
console.error("[GlobalChatbox] Invalid persisted messages format.");
window.localStorage.removeItem(CHAT_STORAGE_KEY);
hasHydratedRef.current = true;
return;
}
setMessages(parsed.messages);
setConversationId(parsed.conversationId);
} catch (error) {
console.error("[GlobalChatbox] Failed to read persisted chat state:", error);
window.localStorage.removeItem(CHAT_STORAGE_KEY);
} finally {
hasHydratedRef.current = true;
}
}, []);
useEffect(() => {
if (!hasHydratedRef.current) return;
const state: PersistedChatState = { messages, conversationId }; const state: PersistedChatState = { messages, conversationId };
try { try {
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
@@ -227,7 +231,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
}, },
}); });
} catch (error) { } catch (error) {
if (abortRef.current?.signal.aborted) return; if (abortRef.current?.signal.aborted) {
setMessages((prev) =>
prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0))
);
return;
}
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantId m.id === assistantId
@@ -250,8 +259,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
return ( return (
<Drawer <Drawer
anchor="right" anchor="right"
variant="persistent"
open={open} open={open}
onClose={onClose} onClose={onClose}
hideBackdrop
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }} sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 100 }}
PaperProps={{ PaperProps={{
sx: { sx: {
@@ -262,11 +273,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
}, },
}} }}
ModalProps={{
BackdropProps: {
sx: { backdropFilter: "blur(6px)", bgcolor: alpha(theme.palette.background.default, 0.3) },
},
}}
> >
<Box <Box
sx={{ sx={{
@@ -430,101 +436,76 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
: isErrorMessage : isErrorMessage
? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}` ? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}`
: `0 4px 16px -4px ${alpha("#000", 0.05)}`, : `0 4px 16px -4px ${alpha("#000", 0.05)}`,
"--chat-md-text": isUser
// Markdown Styles ? alpha("#fff", 0.96)
"& p": { : isErrorMessage
m: 0, ? theme.palette.error.dark
lineHeight: 1.75, : "#1f2937",
whiteSpace: "pre-wrap", "--chat-md-heading": isUser
color: isUser ? alpha("#fff", 0.96) : isErrorMessage ? theme.palette.error.dark : theme.palette.text.primary, ? "#fff"
}, : isErrorMessage
"& h1, & h2, & h3, & h4, & h5, & h6": { ? theme.palette.error.dark
mt: 0.6, : "#111827",
mb: 0.6, "--chat-md-link": isUser
lineHeight: 1.35, ? "#E3F2FD"
fontWeight: 700, : isErrorMessage
color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : theme.palette.text.primary, ? theme.palette.error.main
}, : "#7C3AED",
"& h1": { fontSize: "1.2rem" }, "--chat-md-link-hover": isUser
"& h2": { fontSize: "1.12rem" }, ? "#fff"
"& h3": { fontSize: "1.04rem" }, : isErrorMessage
"& a": { ? theme.palette.error.dark
color: isUser ? "#E3F2FD" : isErrorMessage ? theme.palette.error.main : theme.palette.primary.dark, : "#6D28D9",
textDecoration: "underline", "--chat-md-inline-code-bg": isUser
wordBreak: "break-all", ? "rgba(255,255,255,0.2)"
textUnderlineOffset: "2px", : isErrorMessage
"&:hover": { ? alpha(theme.palette.error.main, 0.08)
color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : theme.palette.primary.main, : "#EEF2FF",
}, "--chat-md-inline-code-border": isUser
}, ? alpha("#fff", 0.16)
"& code": { : isErrorMessage
fontFamily: "monospace", ? alpha(theme.palette.error.main, 0.25)
bgcolor: isUser : "#CBD5E1",
? "rgba(255,255,255,0.2)" "--chat-md-inline-code-text": isUser
: isErrorMessage ? "#fff"
? alpha(theme.palette.error.main, 0.08) : isErrorMessage
: alpha(theme.palette.grey[100], 0.95), ? theme.palette.error.dark
color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : alpha(theme.palette.text.primary, 0.95), : "#334155",
px: 0.8, "--chat-md-pre-bg": isUser
py: 0.2, ? "rgba(11, 18, 32, 0.56)"
borderRadius: 1, : isErrorMessage
fontSize: "0.85em", ? alpha(theme.palette.error.main, 0.08)
border: isUser : "#111827",
? "none" "--chat-md-pre-border": isUser
: isErrorMessage ? alpha("#fff", 0.12)
? `1px solid ${alpha(theme.palette.error.main, 0.25)}` : isErrorMessage
: `1px solid ${alpha(theme.palette.divider, 0.35)}`, ? alpha(theme.palette.error.main, 0.3)
}, : "#64748B",
"& pre": { "--chat-md-pre-text": isUser
bgcolor: isUser ? "#F8FAFC"
? "rgba(11, 18, 32, 0.56)" : isErrorMessage
: isErrorMessage ? theme.palette.error.dark
? alpha(theme.palette.error.main, 0.08) : "#E5E7EB",
: "#0F172A", "--chat-md-quote-border": isErrorMessage
color: isUser ? alpha(theme.palette.error.main, 0.5)
? "#F8FAFC" : isUser
: isErrorMessage ? alpha("#fff", 0.5)
? theme.palette.error.dark : "#7C3AED",
: "#E2E8F0", "--chat-md-quote-bg": isUser
p: 2, ? alpha("#fff", 0.08)
borderRadius: 3, : isErrorMessage
overflowX: "auto", ? alpha(theme.palette.error.main, 0.06)
my: 1.5, : "#F5F3FF",
fontSize: "0.88em", "--chat-md-quote-text": isUser
border: isErrorMessage ? alpha("#fff", 0.9)
? `1px solid ${alpha(theme.palette.error.main, 0.3)}` : isErrorMessage
: `1px solid ${isUser ? alpha("#fff", 0.12) : alpha("#94A3B8", 0.35)}`, ? theme.palette.error.dark
}, : "#475569",
"& pre code": {
bgcolor: "transparent",
border: "none",
px: 0,
py: 0,
color: "inherit",
},
"& ul, & ol": { pl: 2.5, my: 1 },
"& li": {
my: 0.35,
lineHeight: 1.65,
},
"& blockquote": {
m: 0,
my: 1,
pl: 1.5,
borderLeft: `3px solid ${isErrorMessage ? alpha(theme.palette.error.main, 0.5) : alpha(theme.palette.divider, 0.5)}`,
color: isUser ? alpha("#fff", 0.9) : isErrorMessage ? theme.palette.error.dark : "text.secondary",
bgcolor: isUser
? alpha("#fff", 0.08)
: isErrorMessage
? alpha(theme.palette.error.main, 0.06)
: alpha(theme.palette.grey[100], 0.7),
borderRadius: 1,
py: 0.5,
pr: 1,
},
}} }}
> >
<ReactMarkdown>{message.content || "..."}</ReactMarkdown> <div className={markdownStyles.markdown}>
<ReactMarkdown>{message.content || "..."}</ReactMarkdown>
</div>
</Paper> </Paper>
</motion.div> </motion.div>
); );
@@ -0,0 +1,90 @@
.markdown {
color: var(--chat-md-text);
font-size: 0.95rem;
line-height: 1.75;
word-break: break-word;
}
.markdown p {
margin: 0;
white-space: pre-wrap;
}
.markdown p + p {
margin-top: 0.75rem;
}
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
margin: 0.6rem 0;
line-height: 1.35;
font-weight: 700;
color: var(--chat-md-heading);
}
.markdown h1 { font-size: 1.2rem; }
.markdown h2 { font-size: 1.12rem; }
.markdown h3 { font-size: 1.04rem; }
.markdown a {
color: var(--chat-md-link);
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
}
.markdown a:hover {
color: var(--chat-md-link-hover);
}
.markdown :not(pre) > code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: var(--chat-md-inline-code-bg);
border: 1px solid var(--chat-md-inline-code-border);
color: var(--chat-md-inline-code-text);
border-radius: 6px;
padding: 0.12rem 0.4rem;
font-size: 0.85em;
}
.markdown pre {
background: var(--chat-md-pre-bg);
border: 1px solid var(--chat-md-pre-border);
color: var(--chat-md-pre-text);
border-radius: 10px;
padding: 0.75rem 0.9rem;
overflow-x: auto;
margin: 0.9rem 0;
font-size: 0.88em;
}
.markdown pre code {
border: none;
background: transparent;
color: inherit;
padding: 0;
}
.markdown ul,
.markdown ol {
padding-left: 1.4rem;
margin: 0.5rem 0;
}
.markdown li {
margin: 0.3rem 0;
line-height: 1.65;
}
.markdown blockquote {
margin: 0.8rem 0;
padding: 0.45rem 0.75rem;
border-left: 3px solid var(--chat-md-quote-border);
background: var(--chat-md-quote-bg);
color: var(--chat-md-quote-text);
border-radius: 6px;
}
@@ -180,7 +180,7 @@ const BaseLayers: React.FC = () => {
}; };
return ( return (
<div className="absolute right-17 bottom-11 z-1300"> <div className="absolute right-17 bottom-11 z-20">
<div <div
className="w-20 h-20 bg-white rounded-xl drop-shadow-xl shadow-black" className="w-20 h-20 bg-white rounded-xl drop-shadow-xl shadow-black"
onMouseEnter={handleEnter} onMouseEnter={handleEnter}
@@ -134,7 +134,7 @@ const LayerControl: React.FC = () => {
} }
return ( return (
<div className="absolute left-4 bottom-4 bg-white rounded-md drop-shadow-lg z-1300 opacity-85 hover:opacity-100 transition-opacity max-w-xs"> <div className="absolute left-4 bottom-4 bg-white rounded-md drop-shadow-lg z-20 opacity-85 hover:opacity-100 transition-opacity max-w-xs">
<div className="ml-3 grid grid-cols-3"> <div className="ml-3 grid grid-cols-3">
{layerItems.map((item) => ( {layerItems.map((item) => (
<FormControlLabel <FormControlLabel
@@ -67,7 +67,7 @@ const Scale: React.FC = () => {
} }
`} `}
</style> </style>
<div className="absolute bottom-0 right-0 flex items-center gap-2 px-3 py-1.5 bg-white/90 hover:bg-white rounded-tl-xl shadow-lg backdrop-blur-sm text-xs font-medium text-slate-700 z-1300 transition-all duration-300 pointer-events-auto"> <div className="absolute bottom-0 right-0 flex items-center gap-2 px-3 py-1.5 bg-white/90 hover:bg-white rounded-tl-xl shadow-lg backdrop-blur-sm text-xs font-medium text-slate-700 z-20 transition-all duration-300 pointer-events-auto">
<div <div
ref={scaleLineRef} ref={scaleLineRef}
className="custom-scale-line flex items-center justify-center min-w-[60px]" className="custom-scale-line flex items-center justify-center min-w-[60px]"
+1 -1
View File
@@ -30,7 +30,7 @@ const Zoom: React.FC = () => {
}; };
return ( return (
<div className="absolute right-4 bottom-11 z-1300"> <div className="absolute right-4 bottom-11 z-20">
<div className="w-8 h-26 flex flex-col gap-2 items-center"> <div className="w-8 h-26 flex flex-col gap-2 items-center">
<div className="w-8 h-8 bg-gray-50 flex items-center justify-center rounded-xl drop-shadow-xl shadow-black"> <div className="w-8 h-8 bg-gray-50 flex items-center justify-center rounded-xl drop-shadow-xl shadow-black">
<button <button