import { useEffect, useMemo, useRef, useState } from "react"; import { ArrowUpOutlined, MenuFoldOutlined, MenuUnfoldOutlined, MessageOutlined, PaperClipOutlined, PlusOutlined, SearchOutlined, SettingOutlined, ThunderboltOutlined, UserOutlined, } from "@ant-design/icons"; import { Button, Collapse, Drawer, Input, Layout, Typography } from "antd"; import { isAbortLikeError, streamQwenChat, type ChatMessagePayload } from "../api/qwenChat"; import StreamMessage from "../components/StreamMessage"; const { Header, Sider, Content } = Layout; const MOBILE_WIDTH = 750; type UiMessage = { id: string; role: "user" | "assistant"; content: string; thinking?: string; }; const INITIAL_MESSAGES: UiMessage[] = [ { id: "init-assistant", role: "assistant", content: "你好,我是 ChatOne 助手,有什么可以帮你?", }, ]; const STREAM_TIMEOUT_MS = 90000; const MAX_STREAM_RETRY = 1; const CONTEXT_WINDOW_SIZE = 8; export default function HomePage() { const [collapsed, setCollapsed] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [viewportWidth, setViewportWidth] = useState(0); const [isMobile, setIsMobile] = useState(false); const [inputValue, setInputValue] = useState(""); const [messages, setMessages] = useState(INITIAL_MESSAGES); const [isSending, setIsSending] = useState(false); const [deepThink, setDeepThink] = useState(false); const [smartSearch, setSmartSearch] = useState(false); const messageListRef = useRef(null); const abortRef = useRef(null); const historyGroups = useMemo( () => [ { title: "30 天内", keys: ["前端 CI 工具对比", "Nginx SPA 配置"] }, { title: "2025-12", keys: ["Gitea Actions 入门"] }, ], [], ); useEffect(() => { const updateIsMobile = () => { const w = Math.min( window.innerWidth, document.documentElement.clientWidth || window.innerWidth, ); setViewportWidth(w); 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); }; }, []); useEffect(() => { const el = messageListRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [messages]); const sendMessage = async () => { const trimmed = inputValue.trim(); if (!trimmed || isSending) return; 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, 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) { 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 = (
{historyGroups.map((group) => (
{group.title} {group.keys.map((key) => ( ))}
))}
{(!collapsed || isMobile) && ( 用户 )}
); return ( {!isMobile && (
{!collapsed && ( ChatOne )}
{sidebarContent}
)}
快速模式
{messages.map((item) => (
{item.role === "assistant" && item.thinking && ( 已思考(用时约 2 秒) ), children: ( {item.thinking} ), }, ]} /> )} {item.role === "assistant" && } {item.role === "user" && item.content}
))}
setInputValue(e.target.value)} onPressEnter={(e) => { if (e.shiftKey) return; e.preventDefault(); void sendMessage(); }} placeholder="给 ChatOne 发送消息" variant="borderless" autoSize={{ minRows: 1, maxRows: 6 }} className="!px-1 !text-[15px] placeholder:text-neutral-400" />
{import.meta.env.DEV && ( W:{viewportWidth}px · {isMobile ? "mobile" : "desktop"} )}
setMobileSidebarOpen(false)} open={isMobile && mobileSidebarOpen} width={280} styles={{ body: { padding: 0, background: "var(--ds-bg-sider)" } }} > {sidebarContent}
); }