perf(chat): isolate the composer, split heavy chunks, fix dead tokens
Second batch from the staging UX audit (BUG-248..253). - css: expose the 32 palette tokens through @theme. Seven utilities including text-ink, text-danger and text-warning compiled to no CSS at all, so 30 call sites had been silently inert (BUG-248) - chat: move the composer into its own component behind a draft store, so a keystroke no longer re-renders a 2723-line component, and persist the draft across reloads (BUG-249) - chat: load gsap, react-markdown and thinking-orbs on demand. First Load JS for / drops 549.5 kB to 476.3 kB gzipped (BUG-250) - chat: route the five in-app destinations through router.push, and keep the five auth redirects and the bootstrap retry as hard loads on purpose (BUG-251) - a11y: announce reply completion, and move the live region out of the aria-busy subtree that was likely suppressing even the start announcement (BUG-252) - docs: give the 27 collided bug ids unique numbers and repair their inbound references; require search rather than a full read of a 3690-line file (BUG-253) Verified: tsc, eslint, next build, and 1592 assertions across the 199 non-database test files. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,6 +14,38 @@
|
||||
--color-border: #d8d6cf;
|
||||
--color-input: #b8b5ad;
|
||||
--color-ring: #85432f;
|
||||
--color-action: var(--color-action);
|
||||
--color-action-soft: var(--color-action-soft);
|
||||
--color-action-hover: var(--color-action-hover);
|
||||
--color-action-on-dark: var(--color-action-on-dark);
|
||||
--color-focus: var(--color-focus);
|
||||
--color-ink: var(--color-ink);
|
||||
--color-ink-strong: var(--color-ink-strong);
|
||||
--color-ink-secondary: var(--color-ink-secondary);
|
||||
--color-ink-tertiary: var(--color-ink-tertiary);
|
||||
--color-canvas: var(--color-canvas);
|
||||
--color-canvas-soft: var(--color-canvas-soft);
|
||||
--color-canvas-muted: var(--color-canvas-muted);
|
||||
--color-canvas-strong: var(--color-canvas-strong);
|
||||
--color-sidebar: var(--color-sidebar);
|
||||
--color-sidebar-solid: var(--color-sidebar-solid);
|
||||
--color-selected: var(--color-selected);
|
||||
--color-surface-dark: var(--color-surface-dark);
|
||||
--color-surface-dark-raised: var(--color-surface-dark-raised);
|
||||
--color-surface-dark-soft: var(--color-surface-dark-soft);
|
||||
--color-on-dark: var(--color-on-dark);
|
||||
--color-border-strong: var(--color-border-strong);
|
||||
--color-danger: var(--color-danger);
|
||||
--color-danger-muted: var(--color-danger-muted);
|
||||
--color-success: var(--color-success);
|
||||
--color-success-muted: var(--color-success-muted);
|
||||
--color-warning: var(--color-warning);
|
||||
--color-scrim: var(--color-scrim);
|
||||
--color-frosted: var(--color-frosted);
|
||||
--color-light-hover: var(--color-light-hover);
|
||||
--color-dark-hover: var(--color-dark-hover);
|
||||
--color-dark-muted: var(--color-dark-muted);
|
||||
--color-dark-border: var(--color-dark-border);
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
+81
-61
@@ -2,7 +2,8 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ArrowDown, ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowDown, ArrowUpRight, Sparkles, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
@@ -31,9 +32,9 @@ import {
|
||||
LocationSearchCombobox,
|
||||
type ResolvedBirthLocation,
|
||||
} from "@/components/location-search-combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChatComposer } from "@/components/chat-composer";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
|
||||
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
|
||||
import { parseAgentReply, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply";
|
||||
import {
|
||||
@@ -70,6 +71,7 @@ import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mo
|
||||
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
|
||||
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
|
||||
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
||||
import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-announcement";
|
||||
import {
|
||||
requestBirthTimeAssessment,
|
||||
type JourneyClientResponse,
|
||||
@@ -189,6 +191,11 @@ type ChatSession = {
|
||||
};
|
||||
|
||||
type RequestError = { sessionId: string; message: string };
|
||||
type ReplyOutcome = {
|
||||
readonly sessionId: string;
|
||||
readonly phase: Extract<ChatReplyPhase, "completed" | "stopped" | "failed">;
|
||||
readonly replyOrdinal: number;
|
||||
};
|
||||
type StreamingReply = { sessionId: string; text: string; activity?: AgentActivityView };
|
||||
type BirthPlace = {
|
||||
label: string;
|
||||
@@ -833,7 +840,7 @@ function OnboardingChatMessage({ role, text, streaming = false, length = text.le
|
||||
streaming ? (
|
||||
<>
|
||||
<div className={`onboarding-stream ${length >= text.length ? "is-complete" : ""}`} aria-hidden="true"><ChatMessageContent text={protectedVisibleText} /></div>
|
||||
<span className="sr-only" aria-live="polite">{length >= text.length ? text : ""}</span>
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{length >= text.length ? text : ""}</span>
|
||||
</>
|
||||
) : <ChatMessageContent text={protectedVisibleText} />
|
||||
) : <p>{protectedVisibleText}</p>}
|
||||
@@ -993,6 +1000,7 @@ async function patchSessionModel(sessionId: string, modelId: string, signal?: Ab
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [profile, setProfile] = useState<Profile>(emptyProfile);
|
||||
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
@@ -1019,14 +1027,14 @@ export default function Home() {
|
||||
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
|
||||
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(null);
|
||||
const [activeSessionId, setActiveSessionId] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
const [draftTheme, setDraftTheme] = useState<Theme | null>(null);
|
||||
const [draftEntrypoint, setDraftEntrypoint] = useState<ConsultationEntrypoint | null>(null);
|
||||
const draftTheme = useRef<Theme | null>(null);
|
||||
const draftEntrypoint = useRef<ConsultationEntrypoint | null>(null);
|
||||
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
|
||||
const [cancellationPending, setCancellationPending] = useState(false);
|
||||
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||||
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
|
||||
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
|
||||
const [replyOutcome, setReplyOutcome] = useState<ReplyOutcome | null>(null);
|
||||
const [requestError, setRequestError] = useState<RequestError | null>(null);
|
||||
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
|
||||
createBirthTimeConsultationConsentState,
|
||||
@@ -1110,6 +1118,11 @@ export default function Home() {
|
||||
const activeStreamingActivity = streamingReply && streamingReply.sessionId === activeSession?.id
|
||||
? streamingReply.activity
|
||||
: undefined;
|
||||
const activeReplyOutcome = replyOutcome && replyOutcome.sessionId === activeSession?.id ? replyOutcome : null;
|
||||
const replyPhase: ChatReplyPhase = isLoading
|
||||
? consultationPhase === "recovering" ? "recovering" : "generating"
|
||||
: activeReplyOutcome?.phase ?? "idle";
|
||||
const replyAnnouncement = chatReplyAnnouncement(replyPhase, activeReplyOutcome?.replyOrdinal ?? 0);
|
||||
const accountId = account?.user.id;
|
||||
const rectificationCardAction = resolveRectificationEntryAction(
|
||||
rectificationEntrySummary ?? {
|
||||
@@ -1270,6 +1283,18 @@ export default function Home() {
|
||||
&& !conversationAnchor.anchored
|
||||
&& Boolean(activeSession?.messages.length);
|
||||
|
||||
function setDraft(value: string) {
|
||||
setComposerDraft(value);
|
||||
}
|
||||
|
||||
function setDraftTheme(theme: Theme | null) {
|
||||
draftTheme.current = theme;
|
||||
}
|
||||
|
||||
function setDraftEntrypoint(entrypoint: ConsultationEntrypoint | null) {
|
||||
draftEntrypoint.current = entrypoint;
|
||||
}
|
||||
|
||||
function restoreConsultationRecovery(session: ChatSession, requestId: string) {
|
||||
if (pendingConsultation.current) return;
|
||||
const lastMessage = session.messages.at(-1);
|
||||
@@ -1597,7 +1622,7 @@ export default function Home() {
|
||||
setConsultationPhase(null);
|
||||
setStreamingReply(null);
|
||||
setRequestError(null);
|
||||
setComposerNotice("回答已恢复。");
|
||||
setComposerNotice("回答已恢复,已显示在对话区末尾。");
|
||||
void refreshAccount();
|
||||
return;
|
||||
}
|
||||
@@ -2208,7 +2233,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
async function saveOnboardingName() {
|
||||
const name = draft.replace(/\s+/g, " ").trim().slice(0, 80);
|
||||
const name = composerDraftSnapshot().replace(/\s+/g, " ").trim().slice(0, 80);
|
||||
if (!name || !account || profileSaving) return;
|
||||
const nextProfile = { ...profileDraft, name };
|
||||
setProfileSaving(true);
|
||||
@@ -2649,6 +2674,7 @@ export default function Home() {
|
||||
const pending = pendingConsultation.current;
|
||||
if (!pending || pending.cancelled) return;
|
||||
|
||||
setReplyOutcome({ sessionId: pending.sessionId, phase: "stopped", replyOrdinal: 0 });
|
||||
const isPreview = process.env.NODE_ENV === "development" && uiPreview.current;
|
||||
if (pending.phase !== "undo" && !isPreview) {
|
||||
stoppedRequestAwaitingSettlement.current = pending.requestId;
|
||||
@@ -2711,7 +2737,7 @@ export default function Home() {
|
||||
setPendingSessionId(null);
|
||||
setConsultationPhase(null);
|
||||
setStreamingReply(null);
|
||||
setComposerNotice("回答已恢复。");
|
||||
setComposerNotice("回答已恢复,已显示在对话区末尾。");
|
||||
void refreshAccount();
|
||||
return;
|
||||
}
|
||||
@@ -2841,7 +2867,7 @@ export default function Home() {
|
||||
: initialConsultationRoute;
|
||||
|
||||
if (account.credits <= 0 && !account.activeSubscription) {
|
||||
window.location.assign(membershipHref("insufficient-credits"));
|
||||
router.push(membershipHref("insufficient-credits"));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2865,6 +2891,7 @@ export default function Home() {
|
||||
const previousOnboardingState = onboardingJustCompleted;
|
||||
cancellationFeedbackRequest.current = null;
|
||||
setRequestError(null);
|
||||
setReplyOutcome(null);
|
||||
setComposerNotice("");
|
||||
consultationStatusMissingCount.current = 0;
|
||||
setPendingSessionId(sessionId);
|
||||
@@ -2997,7 +3024,7 @@ export default function Home() {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() };
|
||||
if (response.status === 401) window.location.assign("/login");
|
||||
if (response.status === 402) window.location.assign(membershipHref("insufficient-credits"));
|
||||
if (response.status === 402) router.push(membershipHref("insufficient-credits"));
|
||||
throw new ConsultationResponseError(
|
||||
response.status,
|
||||
payloadMessage(errorPayload, "服务暂时不可用"),
|
||||
@@ -3092,6 +3119,11 @@ export default function Home() {
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
updateSession(sessionId, () => completedSession);
|
||||
setReplyOutcome({
|
||||
sessionId,
|
||||
phase: "completed",
|
||||
replyOrdinal: completedSession.messages.filter((message) => message.role === "assistant").length,
|
||||
});
|
||||
completeConsultationInterface(requestId);
|
||||
void refreshAccount();
|
||||
return true;
|
||||
@@ -3101,6 +3133,7 @@ export default function Home() {
|
||||
const partialReply = latestPartialReply;
|
||||
if (!cancelled && ownsInterface && caught instanceof ConsultationResponseError) {
|
||||
setRequestError({ sessionId, message: caught.message });
|
||||
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
|
||||
setComposerNotice(caught.message);
|
||||
completeConsultationInterface(requestId);
|
||||
return false;
|
||||
@@ -3144,7 +3177,7 @@ export default function Home() {
|
||||
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
|
||||
return;
|
||||
}
|
||||
void send(draft, draftTheme ?? undefined, draftEntrypoint);
|
||||
void send(composerDraftSnapshot(), draftTheme.current ?? undefined, draftEntrypoint.current);
|
||||
}
|
||||
|
||||
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
@@ -3165,8 +3198,8 @@ export default function Home() {
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<main className="app-loading app-loading-error" aria-live="assertive">
|
||||
<div className="app-loading-content">
|
||||
<main className="app-loading app-loading-error">
|
||||
<div className="app-loading-content" role="alert">
|
||||
<strong>暂时无法进入 Jyotisha</strong>
|
||||
<span>{accountError}</span>
|
||||
<div className="app-loading-actions">
|
||||
@@ -3200,6 +3233,7 @@ export default function Home() {
|
||||
return (
|
||||
<SidebarProvider escapeBlocked={accountMenuOpen || modalOpen}>
|
||||
<main className="chat-app">
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
|
||||
<AppSidebar
|
||||
sessions={sidebarSessions}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
@@ -3235,10 +3269,10 @@ export default function Home() {
|
||||
}}
|
||||
onAccountMenuOpenChange={setAccountMenuOpen}
|
||||
onNewChat={() => void startNewChat()}
|
||||
onOpenReports={() => window.location.assign("/reports")}
|
||||
onOpenReports={() => router.push("/reports")}
|
||||
onSelectSession={selectSession}
|
||||
onOpenProfile={() => openAccountDialog("profile")}
|
||||
onOpenRedeem={() => window.location.assign(membershipHref("account-menu"))}
|
||||
onOpenRedeem={() => router.push(membershipHref("account-menu"))}
|
||||
onOpenLogout={() => openAccountDialog("logout")}
|
||||
/>
|
||||
{pendingSessionDeletion ? (
|
||||
@@ -3264,7 +3298,7 @@ export default function Home() {
|
||||
<strong>{activeSession?.title || "新对话"}</strong>
|
||||
</div>
|
||||
<div className="chat-header-actions">
|
||||
<button className="credit-button" type="button" onClick={() => window.location.assign(membershipHref("credits"))} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
|
||||
<button className="credit-button" type="button" onClick={() => router.push(membershipHref("credits"))} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
|
||||
<Sparkles className="credit-icon" aria-hidden="true" />
|
||||
<span>{account ? account.credits : "—"}</span>
|
||||
</button>
|
||||
@@ -3445,7 +3479,6 @@ export default function Home() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-list" aria-busy={isLoading}>
|
||||
<span className="sr-only" aria-live="polite">{isLoading ? "Jyotisha 正在回答" : ""}</span>
|
||||
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message) => (
|
||||
<ChatMessageRow key={message.renderKey} message={message} />
|
||||
))}
|
||||
@@ -3497,48 +3530,35 @@ export default function Home() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form className="composer" onSubmit={submit}>
|
||||
<Textarea
|
||||
ref={composerInput}
|
||||
aria-label={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
|
||||
placeholder={!account
|
||||
? "正在读取账户…"
|
||||
: !profileComplete
|
||||
? onboardingStep === "name"
|
||||
? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"
|
||||
: "请先完成上方资料"
|
||||
: account.credits === 0 && !account.activeSubscription
|
||||
? "余额不足,可前往会员页充值点数或开通会员"
|
||||
: "例如:未来半年是否适合换工作?"}
|
||||
rows={1}
|
||||
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
|
||||
disabled={isLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
}}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
className="composer-stop"
|
||||
aria-label={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
|
||||
title={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => void stopResponse()}
|
||||
>
|
||||
<Square aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button aria-label={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
<ChatComposer
|
||||
inputRef={composerInput}
|
||||
inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
|
||||
placeholder={!account
|
||||
? "正在读取账户…"
|
||||
: !profileComplete
|
||||
? onboardingStep === "name"
|
||||
? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"
|
||||
: "请先完成上方资料"
|
||||
: account.credits === 0 && !account.activeSubscription
|
||||
? "余额不足,可前往会员页充值点数或开通会员"
|
||||
: "例如:未来半年是否适合换工作?"}
|
||||
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
|
||||
inputDisabled={isLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
|
||||
submitBlocked={Boolean(pendingSessionId) || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
stopVisible={isLoading}
|
||||
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
|
||||
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
|
||||
onSubmit={submit}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
}}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
onStop={() => void stopResponse()}
|
||||
/>
|
||||
<div className="composer-footer">
|
||||
<ModelSelector
|
||||
models={modelCatalog?.models ?? []}
|
||||
|
||||
Reference in New Issue
Block a user