优化聊天框状态持久化,增强错误处理逻辑;优化信息可读性
This commit is contained in:
@@ -25,6 +25,7 @@ import SendRounded from "@mui/icons-material/SendRounded";
|
|||||||
import StopRounded from "@mui/icons-material/StopRounded";
|
import StopRounded from "@mui/icons-material/StopRounded";
|
||||||
import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI
|
import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI
|
||||||
import PersonRounded from "@mui/icons-material/PersonRounded";
|
import PersonRounded from "@mui/icons-material/PersonRounded";
|
||||||
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
||||||
|
|
||||||
// Logic
|
// Logic
|
||||||
import { streamCopilotChat } from "@/lib/chatStream";
|
import { streamCopilotChat } from "@/lib/chatStream";
|
||||||
@@ -34,6 +35,7 @@ type Message = {
|
|||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
|
isError?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -43,6 +45,12 @@ type Props = {
|
|||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1";
|
||||||
|
|
||||||
|
type PersistedChatState = {
|
||||||
|
messages: Message[];
|
||||||
|
conversationId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
// --- Components ---
|
// --- Components ---
|
||||||
|
|
||||||
@@ -114,6 +122,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
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]);
|
||||||
@@ -131,6 +140,40 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to persist chat state:", error);
|
||||||
|
}
|
||||||
|
}, [messages, conversationId]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const prompt = input.trim();
|
const prompt = input.trim();
|
||||||
if (!prompt || isStreaming) return;
|
if (!prompt || isStreaming) return;
|
||||||
@@ -159,7 +202,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
|
if (!conversationId && event.conversationId) setConversationId(event.conversationId);
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantId ? { ...m, content: m.content + event.content } : m
|
m.id === assistantId
|
||||||
|
? { ...m, content: m.content + event.content, isError: false }
|
||||||
|
: m
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else if (event.type === "done") {
|
} else if (event.type === "done") {
|
||||||
@@ -169,7 +214,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantId
|
m.id === assistantId
|
||||||
? { ...m, content: m.content || `错误:${event.message}` }
|
? {
|
||||||
|
...m,
|
||||||
|
content: m.content || `⚠️ **错误:** ${event.message}`,
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
: m
|
: m
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -180,7 +229,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (abortRef.current?.signal.aborted) return;
|
if (abortRef.current?.signal.aborted) return;
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) => (m.id === assistantId ? { ...m, content: `错误:${String(error)}` } : m))
|
prev.map((m) =>
|
||||||
|
m.id === assistantId
|
||||||
|
? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true }
|
||||||
|
: m
|
||||||
|
)
|
||||||
);
|
);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -330,6 +383,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
{messages.map((message) => {
|
{messages.map((message) => {
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
|
const isErrorMessage = Boolean(message.isError);
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
@@ -347,54 +401,130 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!isUser && (
|
{!isUser && (
|
||||||
<Avatar sx={{ width: 28, height: 28, bgcolor: alpha(theme.palette.secondary.main, 0.1), mb: 0.5 }}>
|
<Avatar sx={{ width: 28, height: 28, bgcolor: isErrorMessage ? alpha(theme.palette.error.main, 0.12) : alpha(theme.palette.secondary.main, 0.1), mb: 0.5 }}>
|
||||||
<AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} />
|
{isErrorMessage ? (
|
||||||
|
<ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} />
|
||||||
|
) : (
|
||||||
|
<AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} />
|
||||||
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Paper
|
<Paper
|
||||||
elevation={isUser ? 8 : 2}
|
elevation={isUser ? 8 : isErrorMessage ? 1 : 2}
|
||||||
sx={{
|
sx={{
|
||||||
p: 2.5,
|
p: 2.5,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
borderBottomRightRadius: isUser ? 4 : 24,
|
borderBottomRightRadius: isUser ? 4 : 24,
|
||||||
borderBottomLeftRadius: !isUser ? 4 : 24,
|
borderBottomLeftRadius: !isUser ? 4 : 24,
|
||||||
bgcolor: isUser ? "primary.main" : "#fff",
|
bgcolor: isUser ? "primary.main" : isErrorMessage ? alpha(theme.palette.error.light, 0.18) : "#fff",
|
||||||
color: isUser ? "#fff" : "text.primary",
|
color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary",
|
||||||
background: isUser ? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})` : undefined,
|
background: isUser
|
||||||
|
? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`
|
||||||
|
: isErrorMessage
|
||||||
|
? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})`
|
||||||
|
: undefined,
|
||||||
|
border: isErrorMessage ? `1px solid ${alpha(theme.palette.error.main, 0.35)}` : "none",
|
||||||
boxShadow: isUser
|
boxShadow: isUser
|
||||||
? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}`
|
? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}`
|
||||||
|
: isErrorMessage
|
||||||
|
? `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)}`,
|
||||||
|
|
||||||
// Markdown Styles
|
// Markdown Styles
|
||||||
"& p": { m: 0, lineHeight: 1.6 },
|
"& p": {
|
||||||
|
m: 0,
|
||||||
|
lineHeight: 1.75,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
color: isUser ? alpha("#fff", 0.96) : isErrorMessage ? theme.palette.error.dark : theme.palette.text.primary,
|
||||||
|
},
|
||||||
|
"& h1, & h2, & h3, & h4, & h5, & h6": {
|
||||||
|
mt: 0.6,
|
||||||
|
mb: 0.6,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : theme.palette.text.primary,
|
||||||
|
},
|
||||||
|
"& h1": { fontSize: "1.2rem" },
|
||||||
|
"& h2": { fontSize: "1.12rem" },
|
||||||
|
"& h3": { fontSize: "1.04rem" },
|
||||||
|
"& a": {
|
||||||
|
color: isUser ? "#E3F2FD" : isErrorMessage ? theme.palette.error.main : theme.palette.primary.dark,
|
||||||
|
textDecoration: "underline",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
textUnderlineOffset: "2px",
|
||||||
|
"&:hover": {
|
||||||
|
color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : theme.palette.primary.main,
|
||||||
|
},
|
||||||
|
},
|
||||||
"& code": {
|
"& code": {
|
||||||
fontFamily: "monospace",
|
fontFamily: "monospace",
|
||||||
bgcolor: isUser ? "rgba(255,255,255,0.2)" : alpha(theme.palette.grey[100], 0.8),
|
bgcolor: isUser
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: isErrorMessage
|
||||||
|
? alpha(theme.palette.error.main, 0.08)
|
||||||
|
: alpha(theme.palette.grey[100], 0.95),
|
||||||
|
color: isUser ? "#fff" : isErrorMessage ? theme.palette.error.dark : alpha(theme.palette.text.primary, 0.95),
|
||||||
px: 0.8,
|
px: 0.8,
|
||||||
py: 0.2,
|
py: 0.2,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
fontSize: "0.85em",
|
fontSize: "0.85em",
|
||||||
border: isUser ? "none" : `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
border: isUser
|
||||||
|
? "none"
|
||||||
|
: isErrorMessage
|
||||||
|
? `1px solid ${alpha(theme.palette.error.main, 0.25)}`
|
||||||
|
: `1px solid ${alpha(theme.palette.divider, 0.35)}`,
|
||||||
},
|
},
|
||||||
"& pre": {
|
"& pre": {
|
||||||
bgcolor: isUser ? "rgba(0,0,0,0.25)" : "#222",
|
bgcolor: isUser
|
||||||
color: "#f8f8f2",
|
? "rgba(11, 18, 32, 0.56)"
|
||||||
|
: isErrorMessage
|
||||||
|
? alpha(theme.palette.error.main, 0.08)
|
||||||
|
: "#0F172A",
|
||||||
|
color: isUser
|
||||||
|
? "#F8FAFC"
|
||||||
|
: isErrorMessage
|
||||||
|
? theme.palette.error.dark
|
||||||
|
: "#E2E8F0",
|
||||||
p: 2,
|
p: 2,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
my: 1.5,
|
my: 1.5,
|
||||||
fontSize: "0.85em",
|
fontSize: "0.88em",
|
||||||
border: "1px solid rgba(255,255,255,0.1)",
|
border: isErrorMessage
|
||||||
|
? `1px solid ${alpha(theme.palette.error.main, 0.3)}`
|
||||||
|
: `1px solid ${isUser ? alpha("#fff", 0.12) : alpha("#94A3B8", 0.35)}`,
|
||||||
|
},
|
||||||
|
"& pre code": {
|
||||||
|
bgcolor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
px: 0,
|
||||||
|
py: 0,
|
||||||
|
color: "inherit",
|
||||||
},
|
},
|
||||||
"& ul, & ol": { pl: 2.5, my: 1 },
|
"& 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,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isUser ? (
|
<ReactMarkdown>{message.content || "..."}</ReactMarkdown>
|
||||||
<Typography variant="body2" fontSize="0.95rem" sx={{ whiteSpace: "pre-wrap" }}>{message.content}</Typography>
|
|
||||||
) : (
|
|
||||||
<ReactMarkdown>{message.content || "..."}</ReactMarkdown>
|
|
||||||
)}
|
|
||||||
</Paper>
|
</Paper>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user