perf(home): 拆分 ChatComposer 修复输入框打字卡顿
All checks were successful
CI / build (push) Successful in 3m7s

将输入草稿状态从 HomePage 抽离为独立 memo 组件,避免每次按键重渲染消息列表与侧栏;并微调输入区与滚动条样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 17:08:24 +08:00
parent 0095291eb0
commit 894eeb8c4b

View File

@@ -1,6 +1,15 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import {
Atom,
ArrowUp, ArrowUp,
ChevronDown, ChevronDown,
Copy, Copy,
@@ -13,7 +22,7 @@ import {
Pencil, Pencil,
Pin, Pin,
RotateCcw, RotateCcw,
Search, Globe,
Settings, Settings,
Share2, Share2,
Smartphone, Smartphone,
@@ -120,6 +129,113 @@ function replaceSessionUrl(sessionId: string) {
window.history.replaceState(window.history.state, "", nextPath); window.history.replaceState(window.history.state, "", nextPath);
} }
type ChatComposerProps = {
onSend: (text: string, clearDraft: () => void) => Promise<void>;
isSending: boolean;
/** 正在创建首条会话时锁定,避免重复点击 */
creationLocked: boolean;
deepThink: boolean;
onDeepThinkToggle: () => void;
smartSearch: boolean;
onSmartSearchToggle: () => void;
showScrollToBottom: boolean;
onScrollToBottomClick: () => void;
};
/**
* 独立维护输入草稿:按键时只重渲染本区域,避免整页(消息列表、侧栏)随输入重绘导致卡顿。
*/
const ChatComposer = memo(function ChatComposer({
onSend,
isSending,
creationLocked,
deepThink,
onDeepThinkToggle,
smartSearch,
onSmartSearchToggle,
showScrollToBottom,
onScrollToBottomClick,
}: ChatComposerProps) {
const [draft, setDraft] = useState("");
const clearDraft = useCallback(() => setDraft(""), []);
const handleSend = useCallback(async () => {
const trimmed = draft.trim();
if (!trimmed || isSending || creationLocked) return;
await onSend(trimmed, clearDraft);
}, [creationLocked, draft, clearDraft, isSending, onSend]);
return (
<div className="relative">
{showScrollToBottom && (
<button
type="button"
aria-label="滚动到底部"
className="absolute -top-14 right-3 z-10 flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-neutral-200/90 bg-white text-neutral-900 shadow-md transition-colors hover:bg-neutral-50"
onClick={onScrollToBottomClick}
>
<ChevronDown color="#666" size={18} strokeWidth={2} aria-hidden />
</button>
)}
<div className="rounded-4xl border border-[var(--ds-border)] p-3 shadow-xs">
<Input.TextArea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPressEnter={(e) => {
if (e.shiftKey) return;
e.preventDefault();
void handleSend();
}}
placeholder="向 ChatOne 提问"
variant="borderless"
autoSize={{ minRows: 2, maxRows: 8 }}
className="!px-1 !text-[15px] placeholder:text-neutral-400"
/>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2">
<Button
color={deepThink ? "primary" : "default"}
variant={deepThink ? "filled" : undefined}
shape="round"
icon={<Atom size={14} />}
onClick={onDeepThinkToggle}
>
</Button>
<Button
color={smartSearch ? "primary" : "default"}
variant={smartSearch ? "filled" : undefined}
shape="round"
icon={<Globe size={14} />}
onClick={onSmartSearchToggle}
>
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="text"
shape="circle"
icon={<Paperclip size={16} className="text-neutral-400" />}
className="text-neutral-400"
/>
<Button
type="primary"
shape="circle"
icon={<ArrowUp size={18} />}
loading={isSending}
disabled={isSending || creationLocked || !draft.trim()}
onClick={() => {
void handleSend();
}}
/>
</div>
</div>
</div>
</div>
);
});
export default function HomePage() { export default function HomePage() {
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -127,7 +243,6 @@ export default function HomePage() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState<UiMessage[]>(INITIAL_MESSAGES); const [messages, setMessages] = useState<UiMessage[]>(INITIAL_MESSAGES);
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const [deepThink, setDeepThink] = useState(() => { const [deepThink, setDeepThink] = useState(() => {
@@ -164,6 +279,11 @@ export default function HomePage() {
const [sessionActionBusyId, setSessionActionBusyId] = useState<string | null>(null); const [sessionActionBusyId, setSessionActionBusyId] = useState<string | null>(null);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null); const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const activeSessionIdRef = useRef<string | null>(null); const activeSessionIdRef = useRef<string | null>(null);
const messagesRef = useRef<UiMessage[]>(INITIAL_MESSAGES);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
const userJsonSnapshot = useSyncExternalStore( const userJsonSnapshot = useSyncExternalStore(
subscribeUserProfile, subscribeUserProfile,
@@ -287,6 +407,15 @@ export default function HomePage() {
}; };
}, [isMessageListAtBottom]); }, [isMessageListAtBottom]);
const toggleDeepThink = useCallback(() => setDeepThink((v) => !v), []);
const toggleSmartSearch = useCallback(() => setSmartSearch((v) => !v), []);
const handleScrollToBottomButton = useCallback(() => {
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
scrollMessageListToBottom("smooth");
}, [scrollMessageListToBottom]);
useEffect(() => { useEffect(() => {
if (shouldStickToBottomRef.current) { if (shouldStickToBottomRef.current) {
scrollMessageListToBottom("auto"); scrollMessageListToBottom("auto");
@@ -464,142 +593,153 @@ export default function HomePage() {
} }
}, []); }, []);
const sendMessage = async () => { const sendMessage = useCallback(
const trimmed = inputValue.trim(); async (trimmedInput: string, clearDraft: () => void) => {
if (!trimmed || isSending) return; const trimmed = trimmedInput.trim();
let targetSessionId = activeSessionId; if (!trimmed || isSending) return;
if (!targetSessionId) { let targetSessionId = activeSessionId;
if (sessionMutationBusy) return; if (!targetSessionId) {
setSessionMutationBusy(true); if (sessionMutationBusy) return;
try { setSessionMutationBusy(true);
const row = await createChatSession({ try {
// 首条提问作为会话初始标题(后端限制 200 const row = await createChatSession({
title: trimmed.slice(0, 200), // 首条提问作为会话初始标题(后端限制 200
}); title: trimmed.slice(0, 200),
targetSessionId = row.id; });
setActiveSessionId(row.id); targetSessionId = row.id;
replaceSessionUrl(row.id); setActiveSessionId(row.id);
// 侧栏列表后台刷新,不阻塞当前消息发送。 replaceSessionUrl(row.id);
void refreshSessionList(); // 侧栏列表后台刷新,不阻塞当前消息发送。
} catch (e) { void refreshSessionList();
const text = e instanceof Error ? e.message : "新建会话失败"; } catch (e) {
toast.error(text); const text = e instanceof Error ? e.message : "新建会话失败";
return; toast.error(text);
} finally { return;
setSessionMutationBusy(false); } finally {
setSessionMutationBusy(false);
}
} }
}
setInputValue(""); clearDraft();
setIsSending(true); setIsSending(true);
const userMessage: UiMessage = { const userMessage: UiMessage = {
id: `user-${Date.now()}`, id: `user-${Date.now()}`,
role: "user", role: "user",
content: trimmed, content: trimmed,
}; };
const assistantMessageId = `assistant-${Date.now() + 1}`; const assistantMessageId = `assistant-${Date.now() + 1}`;
const assistantPlaceholder: UiMessage = { const assistantPlaceholder: UiMessage = {
id: assistantMessageId, id: assistantMessageId,
role: "assistant", role: "assistant",
content: "", content: "",
thinking: deepThink ? "正在思考并生成答案…" : undefined, thinking: deepThink ? "正在思考并生成答案…" : undefined,
}; };
const nextMessages = [...messages, userMessage, assistantPlaceholder]; const nextMessages = [...messagesRef.current, userMessage, assistantPlaceholder];
setMessages(nextMessages); setMessages(nextMessages);
const payloadMessages: ChatMessagePayload[] = nextMessages.map((item) => ({ const payloadMessages: ChatMessagePayload[] = nextMessages.map((item) => ({
role: item.role, role: item.role,
content: item.content, content: item.content,
})); }));
let assistantText = ""; let assistantText = "";
let lastError: unknown = null; let lastError: unknown = null;
for (let attempt = 0; attempt <= MAX_STREAM_RETRY; attempt += 1) { for (let attempt = 0; attempt <= MAX_STREAM_RETRY; attempt += 1) {
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
let receivedToken = false; let receivedToken = false;
const retryMessages = const retryMessages =
attempt === 0 attempt === 0
? payloadMessages ? payloadMessages
: ([ : ([
...nextMessages ...nextMessages
.slice(-CONTEXT_WINDOW_SIZE) .slice(-CONTEXT_WINDOW_SIZE)
.map((item): ChatMessagePayload => ({ role: item.role, content: item.content })), .map((item): ChatMessagePayload => ({ role: item.role, content: item.content })),
{ {
role: "user" as const, role: "user" as const,
content: `网络抖动导致输出中断。请在不重复已输出内容的前提下继续回答。已输出尾部:${assistantText.slice(-80)}`, content: `网络抖动导致输出中断。请在不重复已输出内容的前提下继续回答。已输出尾部:${assistantText.slice(-80)}`,
}, },
] satisfies ChatMessagePayload[]); ] satisfies ChatMessagePayload[]);
try { try {
await streamQwenChat({ await streamQwenChat({
messages: retryMessages, messages: retryMessages,
model: selectedModel, model: selectedModel,
sessionId: targetSessionId ?? undefined, sessionId: targetSessionId ?? undefined,
enableWebSearch: smartSearch, enableWebSearch: smartSearch,
enableThinking: deepThink, enableThinking: deepThink,
signal: controller.signal, signal: controller.signal,
timeoutMs: STREAM_TIMEOUT_MS, timeoutMs: STREAM_TIMEOUT_MS,
onToken: (token) => { onToken: (token) => {
receivedToken = true; receivedToken = true;
assistantText += token; assistantText += token;
setMessages((prev) => setMessages((prev) =>
prev.map((item) => prev.map((item) =>
item.id === assistantMessageId item.id === assistantMessageId
? { ...item, content: `${item.content}${token}` } ? { ...item, content: `${item.content}${token}` }
: item, : item,
), ),
); );
}, },
}); });
lastError = null;
break;
} catch (error) {
if (error instanceof SessionExpiredError) {
lastError = null; lastError = null;
break; 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;
} }
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) { if (lastError) {
const message = lastError instanceof Error ? lastError.message : "请求失败,请稍后重试"; 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) => setMessages((prev) =>
prev.map((item) => prev.map((item) =>
item.id === assistantMessageId item.id === assistantMessageId ? { ...item, thinking: undefined } : item,
? {
...item,
content: item.content || `接口调用失败:${message}`,
thinking: undefined,
}
: item,
), ),
); );
} },
[
setIsSending(false); activeSessionId,
setMessages((prev) => deepThink,
prev.map((item) => isSending,
item.id === assistantMessageId ? { ...item, thinking: undefined } : item, refreshSessionList,
), selectedModel,
); sessionMutationBusy,
}; smartSearch,
],
);
const sidebarContent = ( const sidebarContent = (
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col bg-[var(--ds-bg-sider)]"> <div className="flex min-h-0 w-full min-w-0 flex-1 flex-col bg-[var(--ds-bg-sider)]">
@@ -618,7 +758,7 @@ export default function HomePage() {
</Button> </Button>
</div> </div>
<div className="min-h-0 flex-1 overflow-auto px-2 py-2"> <div className="min-h-0 flex-1 overflow-auto px-2 py-2 scrollbar-thumb-gray-200 scrollbar-gutter-stable">
{sessionsLoading ? ( {sessionsLoading ? (
<Typography.Text className="block px-2 text-[12px]" type="secondary"> <Typography.Text className="block px-2 text-[12px]" type="secondary">
@@ -808,12 +948,9 @@ export default function HomePage() {
aria-label="选择对话模型" aria-label="选择对话模型"
/> />
</div> </div>
<span className="rounded-full border border-[var(--ds-border)] bg-neutral-50 px-3 py-1 text-xs text-neutral-500">
</span>
</Header> </Header>
<Content className="flex min-h-0 flex-1 flex-col overflow-hidden !bg-[var(--ds-bg-main)]"> <Content className="flex min-h-0 flex-1 flex-col overflow-hidden !bg-[var(--ds-bg-main)] scrollbar-thumb-gray-200 scrollbar-gutter-stable">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> <div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div <div
ref={messageListRef} ref={messageListRef}
@@ -954,86 +1091,17 @@ export default function HomePage() {
<div className="shrink-0 bg-[var(--ds-bg-main)] px-4 pt-0 md:px-8 md:pt-0"> <div className="shrink-0 bg-[var(--ds-bg-main)] px-4 pt-0 md:px-8 md:pt-0">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<div className="relative"> <ChatComposer
{showScrollToBottom && ( onSend={sendMessage}
<button isSending={isSending}
type="button" creationLocked={!activeSessionId && sessionMutationBusy}
aria-label="滚动到底部" deepThink={deepThink}
className="absolute -top-14 right-3 z-10 flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-neutral-200/90 bg-white text-neutral-900 shadow-md transition-colors hover:bg-neutral-50" onDeepThinkToggle={toggleDeepThink}
onClick={() => { smartSearch={smartSearch}
shouldStickToBottomRef.current = true; onSmartSearchToggle={toggleSmartSearch}
setShowScrollToBottom(false); showScrollToBottom={showScrollToBottom}
scrollMessageListToBottom("smooth"); onScrollToBottomClick={handleScrollToBottomButton}
}} />
>
<ChevronDown color="#666" size={18} strokeWidth={2} aria-hidden />
</button>
)}
<div className="rounded-3xl border border-[var(--ds-border)] bg-neutral-50/50 p-3 shadow-sm">
<Input.TextArea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={(e) => {
if (e.shiftKey) return;
e.preventDefault();
void sendMessage();
}}
placeholder="给 ChatOne 发送消息"
variant="borderless"
autoSize={{ minRows: 2, maxRows: 8 }}
className="!px-1 !text-[15px] placeholder:text-neutral-400"
/>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setDeepThink((v) => !v)}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${
deepThink
? "border-sky-300 bg-sky-50 text-sky-700"
: "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300"
}`}
>
<Zap size={14} />
</button>
<button
type="button"
onClick={() => setSmartSearch((v) => !v)}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${
smartSearch
? "border-sky-300 bg-sky-50 text-sky-700"
: "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300"
}`}
>
<Search size={14} />
</button>
</div>
<div className="flex items-center gap-2">
<Button
type="text"
icon={<Paperclip size={16} className="text-neutral-400" />}
className="text-neutral-400"
/>
<Button
type="primary"
shape="circle"
icon={<ArrowUp size={16} />}
loading={isSending}
disabled={isSending || !inputValue.trim()}
onClick={() => {
void sendMessage();
}}
className="!flex !h-9 !w-9 !items-center !justify-center !border-0 !shadow-none"
style={{
background: "var(--ds-send)",
}}
/>
</div>
</div>
</div>
</div>
<p className="m-0 py-2 text-center text-[11px] text-neutral-500"> <p className="m-0 py-2 text-center text-[11px] text-neutral-500">
AI AI
</p> </p>
@@ -1049,7 +1117,7 @@ export default function HomePage() {
closable closable
onClose={() => setMobileSidebarOpen(false)} onClose={() => setMobileSidebarOpen(false)}
open={isMobile && mobileSidebarOpen} open={isMobile && mobileSidebarOpen}
width={280} size={280}
styles={{ styles={{
body: { body: {
padding: 0, padding: 0,