优化聊天框状态持久化,添加 Markdown 样式支持;调整地图组件的层级,避免和聊天框冲突
This commit is contained in:
@@ -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]"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user