import type { CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { ArrowUp, ChevronDown, Copy, Ellipsis, LogOut, MessageCirclePlus, Paperclip, PanelLeftClose, PanelLeftOpen, Pencil, Pin, RotateCcw, Search, Settings, Share2, Smartphone, Trash2, User, Zap, CircleHelp, } from "lucide-react"; import { Avatar, Button, Collapse, Drawer, Dropdown, Input, Layout, Modal, Select, Tooltip, Typography, message, } from "antd"; import type { MenuProps } from "antd"; import { useNavigate, useParams } from "react-router-dom"; import { ACCESS_TOKEN_KEY, SessionExpiredError, USER_KEY, logout } from "../api/auth"; import { createChatSession, deleteChatSession, listChatSessionMessages, listChatSessions, normalizeSessionMessages, sessionDisplayTitle, sessionStableId, updateChatSessionTitle, type ChatSession, type ChatTurnForUi, } from "../api/chatSessions"; import { isAbortLikeError, streamQwenChat, type ChatMessagePayload } from "../api/qwenChat"; import StreamMessage from "../components/StreamMessage"; const { Header, Sider, Content } = Layout; const MOBILE_WIDTH = 750; type UiMessage = ChatTurnForUi; type StoredUserProfile = { nickname: string; avatarUrl: string; }; function parseStoredUserProfileJson(raw: string | null): { displayName: string; avatar?: string } { try { if (!raw) return { displayName: "用户" }; const user = JSON.parse(raw) as StoredUserProfile; const displayName = user.nickname.trim() || "用户"; const avatar = user.avatarUrl || undefined; return { displayName, avatar }; } catch { return { displayName: "用户" }; } } /** 与 `persistUser` 内触发的 `chatone-user-changed` 及跨标签 `storage` 对齐,用于刷新侧栏昵称 */ function subscribeUserProfile(onChange: () => void) { const onStorage = (e: StorageEvent) => { if (e.key === USER_KEY || e.key === null) onChange(); }; window.addEventListener("chatone-user-changed", onChange); window.addEventListener("storage", onStorage); return () => { window.removeEventListener("chatone-user-changed", onChange); window.removeEventListener("storage", onStorage); }; } const INITIAL_MESSAGES: UiMessage[] = []; const STREAM_TIMEOUT_MS = 90000; const MAX_STREAM_RETRY = 1; const CONTEXT_WINDOW_SIZE = 8; const DEEP_THINK_STORAGE_KEY = "chatone-deep-think-enabled"; const SMART_SEARCH_STORAGE_KEY = "chatone-smart-search-enabled"; const SELECTED_MODEL_STORAGE_KEY = "chatone-selected-model"; /** 展示名与请求 model 字段;需与后端实际支持的模型 id 一致 */ const DEFAULT_QWEN_MODEL = "qwen3.6-plus"; const QWEN_MODEL_OPTIONS: { value: string; label: string }[] = [ { value: "qwen3-max", label: "Qwen3-Max" }, { value: DEFAULT_QWEN_MODEL, label: "Qwen3.6-Plus" }, { value: "qwen3.5-flash", label: "Qwen3.5-Flash" }, { value: "qwen-plus", label: "Qwen-Plus" }, { value: "deepseek-r1", label: "DeepSeek-R1" }, { value: "kimi-k2.6", label: "Kimi-K2.6" }, ]; function replaceSessionUrl(sessionId: string) { const base = import.meta.env.BASE_URL ?? "/"; const prefix = base === "/" ? "" : base.replace(/\/$/, ""); const nextPath = `${prefix}/s/${encodeURIComponent(sessionId)}`; window.history.replaceState(window.history.state, "", nextPath); } export default function HomePage() { const [modal, modalContextHolder] = Modal.useModal(); const navigate = useNavigate(); const { sessionId: routeSessionId } = useParams<{ sessionId?: string }>(); const [collapsed, setCollapsed] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [isMobile, setIsMobile] = useState(false); const [inputValue, setInputValue] = useState(""); const [messages, setMessages] = useState(INITIAL_MESSAGES); const [isSending, setIsSending] = useState(false); const [deepThink, setDeepThink] = useState(() => { try { return window.localStorage.getItem(DEEP_THINK_STORAGE_KEY) === "1"; } catch { return false; } }); const [smartSearch, setSmartSearch] = useState(() => { try { return window.localStorage.getItem(SMART_SEARCH_STORAGE_KEY) === "1"; } catch { return false; } }); const [selectedModel, setSelectedModel] = useState(() => { try { const raw = window.localStorage.getItem(SELECTED_MODEL_STORAGE_KEY); const supported = QWEN_MODEL_OPTIONS.some((item) => item.value === raw); return supported && raw ? raw : DEFAULT_QWEN_MODEL; } catch { return DEFAULT_QWEN_MODEL; } }); const messageListRef = useRef(null); const shouldStickToBottomRef = useRef(true); const abortRef = useRef(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionMutationBusy, setSessionMutationBusy] = useState(false); const [sessionActionBusyId, setSessionActionBusyId] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); const activeSessionIdRef = useRef(null); const userJsonSnapshot = useSyncExternalStore( subscribeUserProfile, () => window.localStorage.getItem(USER_KEY) ?? "", () => "", ); const userProfile = useMemo( () => parseStoredUserProfileJson(userJsonSnapshot || null), [userJsonSnapshot], ); useEffect(() => { activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); useEffect(() => { window.localStorage.setItem(DEEP_THINK_STORAGE_KEY, deepThink ? "1" : "0"); }, [deepThink]); useEffect(() => { window.localStorage.setItem(SMART_SEARCH_STORAGE_KEY, smartSearch ? "1" : "0"); }, [smartSearch]); useEffect(() => { window.localStorage.setItem(SELECTED_MODEL_STORAGE_KEY, selectedModel); }, [selectedModel]); /** 用户信息下拉:略增大行高与项间距 */ const userMenuItemStyle: CSSProperties = { height: "auto", lineHeight: 1.45, paddingBlock: 10, marginBlock: 3, }; const userMenuDividerStyle: CSSProperties = { margin: "10px 0" }; const userMenuItems: MenuProps["items"] = [ { key: "app", icon: , label: "下载手机应用", style: userMenuItemStyle }, { key: "settings", icon: , label: "系统设置", style: userMenuItemStyle }, { key: "help", icon: , label: "帮助与反馈", style: userMenuItemStyle, }, { type: "divider", style: userMenuDividerStyle }, { key: "logout", icon: , label: "退出登录", danger: true, style: userMenuItemStyle, }, ]; const onUserMenuClick: MenuProps["onClick"] = async ({ key }) => { if (key === "logout") { abortRef.current?.abort(); setIsSending(false); await logout(); message.success("已退出登录"); navigate("/login", { replace: true }); return; } if (key === "app" || key === "settings" || key === "help") { message.info("功能开发中"); } }; useEffect(() => { const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY); if (!accessToken) { navigate("/login", { replace: true }); } }, [navigate]); useEffect(() => { const updateIsMobile = () => { const w = Math.min( window.innerWidth, document.documentElement.clientWidth || window.innerWidth, ); const mobile = w < MOBILE_WIDTH; setIsMobile(mobile); if (!mobile) setMobileSidebarOpen(false); }; updateIsMobile(); window.addEventListener("resize", updateIsMobile); window.addEventListener("orientationchange", updateIsMobile); return () => { window.removeEventListener("resize", updateIsMobile); window.removeEventListener("orientationchange", updateIsMobile); }; }, []); const isMessageListAtBottom = useCallback(() => { const el = messageListRef.current; if (!el) return true; // 允许少量像素误差,避免小数像素造成闪烁。 return el.scrollHeight - el.scrollTop - el.clientHeight <= 24; }, []); const scrollMessageListToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { const el = messageListRef.current; if (!el) return; el.scrollTo({ top: el.scrollHeight, behavior }); }, []); useEffect(() => { const el = messageListRef.current; if (!el) return; const onScroll = () => { const atBottom = isMessageListAtBottom(); shouldStickToBottomRef.current = atBottom; setShowScrollToBottom(!atBottom); }; onScroll(); el.addEventListener("scroll", onScroll, { passive: true }); return () => { el.removeEventListener("scroll", onScroll); }; }, [isMessageListAtBottom]); useEffect(() => { if (shouldStickToBottomRef.current) { scrollMessageListToBottom("auto"); setShowScrollToBottom(false); } else { setShowScrollToBottom(true); } }, [messages, scrollMessageListToBottom]); const refreshSessionList = useCallback(async (): Promise => { if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return []; setSessionsLoading(true); try { const list = await listChatSessions({ limit: 50 }); setSessions(list); return list; } catch { message.error("会话列表加载失败"); return [] as ChatSession[]; } finally { setSessionsLoading(false); } }, []); useEffect(() => { if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return; void refreshSessionList(); }, [refreshSessionList]); /** * 打开指定会话并加载历史消息。 * 默认会把当前会话 id 同步到路由;当由路由参数驱动时可关闭 URL 更新,避免重复跳转。 */ const openSession = useCallback( async (sessionId: string, options?: { updateUrl?: boolean }) => { if (options?.updateUrl ?? true) { navigate(`/s/${encodeURIComponent(sessionId)}`); } setActiveSessionId(sessionId); try { const rows = await listChatSessionMessages(sessionId, { limit: 100 }); const normalized = normalizeSessionMessages(rows); setMessages(normalized.length > 0 ? normalized : INITIAL_MESSAGES.map((m) => ({ ...m }))); } catch { message.error("加载会话消息失败"); } }, [navigate], ); useEffect(() => { if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return; if (!routeSessionId) { setActiveSessionId(null); setMessages(INITIAL_MESSAGES.map((m) => ({ ...m }))); return; } // 路由已与当前会话一致时,不重复加载,避免覆盖流式中的本地消息状态。 if (routeSessionId === activeSessionIdRef.current) return; void openSession(routeSessionId, { updateUrl: false }); }, [openSession, routeSessionId]); const handleNewSession = async () => { setActiveSessionId(null); setMessages(INITIAL_MESSAGES.map((m) => ({ ...m }))); navigate("/"); }; const handleRenameSession = useCallback( (session: ChatSession) => { const sid = sessionStableId(session); if (!sid || sessionActionBusyId) return; let nextTitle = session.title; modal.confirm({ centered: true, title: "重命名会话", content: ( { nextTitle = event.target.value; }} onPressEnter={(event) => { event.preventDefault(); }} /> ), okText: "保存", cancelText: "取消", async onOk() { if (nextTitle === session.title) return; setSessionActionBusyId(sid); try { await updateChatSessionTitle(sid, { title: nextTitle }); await refreshSessionList(); message.success("会话已重命名"); } catch (error) { const text = error instanceof Error ? error.message : "重命名会话失败"; message.error(text); throw error; } finally { setSessionActionBusyId(null); } }, }); }, [modal, refreshSessionList, sessionActionBusyId], ); const handleDeleteSession = useCallback( (session: ChatSession) => { const sid = sessionStableId(session); if (!sid || sessionActionBusyId) return; modal.confirm({ centered: true, title: "删除会话", content: "确认删除该会话?删除后不可恢复。", okText: "删除", okType: "danger", cancelText: "取消", async onOk() { setSessionActionBusyId(sid); try { await deleteChatSession(sid); if (activeSessionId === sid) { abortRef.current?.abort(); setIsSending(false); setActiveSessionId(null); setMessages(INITIAL_MESSAGES.map((m) => ({ ...m }))); navigate("/"); } await refreshSessionList(); message.success("会话已删除"); } catch (error) { const text = error instanceof Error ? error.message : "删除会话失败"; message.error(text); throw error; } finally { setSessionActionBusyId(null); } }, }); }, [activeSessionId, modal, navigate, refreshSessionList, sessionActionBusyId], ); const handleCopyUserMessage = useCallback(async (content: string) => { try { await navigator.clipboard.writeText(content); message.success("已复制"); } catch { message.error("复制失败,请检查浏览器权限"); } }, []); const handleCopyAssistantMessage = useCallback(async (content: string) => { try { await navigator.clipboard.writeText(content); message.success("已复制"); } catch { message.error("复制失败,请检查浏览器权限"); } }, []); const sendMessage = async () => { const trimmed = inputValue.trim(); if (!trimmed || isSending) return; let targetSessionId = activeSessionId; if (!targetSessionId) { if (sessionMutationBusy) return; setSessionMutationBusy(true); try { const row = await createChatSession({ // 首条提问作为会话初始标题(后端限制 200)。 title: trimmed.slice(0, 200), }); targetSessionId = row.id; setActiveSessionId(row.id); replaceSessionUrl(row.id); // 侧栏列表后台刷新,不阻塞当前消息发送。 void refreshSessionList(); } catch (e) { const text = e instanceof Error ? e.message : "新建会话失败"; message.error(text); return; } finally { setSessionMutationBusy(false); } } setInputValue(""); setIsSending(true); const userMessage: UiMessage = { id: `user-${Date.now()}`, role: "user", content: trimmed, }; const assistantMessageId = `assistant-${Date.now() + 1}`; const assistantPlaceholder: UiMessage = { id: assistantMessageId, role: "assistant", content: "", thinking: deepThink ? "正在思考并生成答案…" : undefined, }; const nextMessages = [...messages, userMessage, assistantPlaceholder]; setMessages(nextMessages); const payloadMessages: ChatMessagePayload[] = nextMessages.map((item) => ({ role: item.role, content: item.content, })); let assistantText = ""; let lastError: unknown = null; for (let attempt = 0; attempt <= MAX_STREAM_RETRY; attempt += 1) { const controller = new AbortController(); abortRef.current = controller; let receivedToken = false; const retryMessages = attempt === 0 ? payloadMessages : ([ ...nextMessages .slice(-CONTEXT_WINDOW_SIZE) .map((item): ChatMessagePayload => ({ role: item.role, content: item.content })), { role: "user" as const, content: `网络抖动导致输出中断。请在不重复已输出内容的前提下继续回答。已输出尾部:${assistantText.slice(-80)}`, }, ] satisfies ChatMessagePayload[]); try { await streamQwenChat({ messages: retryMessages, model: selectedModel, sessionId: targetSessionId ?? undefined, enableWebSearch: smartSearch, enableThinking: deepThink, signal: controller.signal, timeoutMs: STREAM_TIMEOUT_MS, onToken: (token) => { receivedToken = true; assistantText += token; setMessages((prev) => prev.map((item) => item.id === assistantMessageId ? { ...item, content: `${item.content}${token}` } : item, ), ); }, }); lastError = null; break; } catch (error) { if (error instanceof SessionExpiredError) { lastError = null; break; } lastError = error; const isAbortLike = isAbortLikeError(error); const canRetry = attempt < MAX_STREAM_RETRY; if (!canRetry) break; // 网络抖动或超时时自动重连;若已收到部分 token 也保留已输出文本。 if (isAbortLike || !receivedToken) { await new Promise((resolve) => window.setTimeout(resolve, 300)); continue; } break; } finally { abortRef.current = null; } } if (lastError) { const message = lastError instanceof Error ? lastError.message : "请求失败,请稍后重试"; setMessages((prev) => prev.map((item) => item.id === assistantMessageId ? { ...item, content: item.content || `接口调用失败:${message}`, thinking: undefined, } : item, ), ); } setIsSending(false); setMessages((prev) => prev.map((item) => item.id === assistantMessageId ? { ...item, thinking: undefined } : item, ), ); }; const sidebarContent = (
{sessionsLoading ? ( 加载会话… ) : sessions.length === 0 ? ( 暂无会话,点击「新建会话」开始 ) : (
会话 {sessions.map((s) => { const sid = sessionStableId(s); if (!sid) return null; const title = sessionDisplayTitle(s); const active = sid === activeSessionId; const itemMenu: MenuProps = { items: [ { key: "rename", icon: , label: "重命名" }, { key: "pin", icon: , label: "置顶" }, { key: "delete", icon: , danger: true, label: "删除" }, ], onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); if (key === "rename") void handleRenameSession(s); if (key === "pin") message.info("置顶功能开发中"); if (key === "delete") void handleDeleteSession(s); }, }; return (
{(!collapsed || isMobile) && (
)}
); })}
)}
); return ( {modalContextHolder} {!isMobile && (
{!collapsed && ( ChatOne )}
{sidebarContent}
)}