perf(frontend): split chat streaming, load Inter, isolate admin CSS
Settled messages no longer rebuild on every token, Inter is actually requested, and admin routes drop the 33 KB chat stylesheet. Root force-dynamic is gone so public shells can prerender without changing the no-store Cache-Control contract. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createElement, createRef } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
AccountDialogOverlay,
|
||||
type AccountOverlayModel,
|
||||
} from "../src/components/account-dialog-overlay.tsx";
|
||||
|
||||
test("a closed account overlay does not render profile or logout content", () => {
|
||||
const overlayRef = createRef<HTMLElement | null>() as AccountOverlayModel["overlayRef"];
|
||||
const closeButtonRef = createRef<HTMLButtonElement | null>() as AccountOverlayModel["closeButtonRef"];
|
||||
const modelRef = createRef<AccountOverlayModel | null>() as { current: AccountOverlayModel | null };
|
||||
let profileRenders = 0;
|
||||
let logoutRenders = 0;
|
||||
modelRef.current = {
|
||||
title: "个人资料",
|
||||
dialogClass: "profile-modal",
|
||||
signingOut: false,
|
||||
close() {},
|
||||
takeOpenLibraryRequest() { return false; },
|
||||
overlayRef,
|
||||
closeButtonRef,
|
||||
renderProfile() {
|
||||
profileRenders += 1;
|
||||
return null;
|
||||
},
|
||||
renderLogout() {
|
||||
logoutRenders += 1;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const html = renderToString(createElement(AccountDialogOverlay, {
|
||||
open: false,
|
||||
dialog: null,
|
||||
openEpoch: 0,
|
||||
modelRef,
|
||||
}));
|
||||
|
||||
assert.equal(html, "");
|
||||
assert.equal(profileRenders, 0);
|
||||
assert.equal(logoutRenders, 0);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
chartLibraryFromCloudOthers,
|
||||
chartLibrarySessionBranch,
|
||||
keepLocalChartLibraryOnCloudFailure,
|
||||
} from "../src/lib/chart-library-session.ts";
|
||||
|
||||
type RecordShape = { id: string; role: "self" | "other" };
|
||||
|
||||
function upsertSelf(library: RecordShape[], profile: unknown): RecordShape[] {
|
||||
const others = library.filter((record) => record.role !== "self");
|
||||
if (!profile) return others;
|
||||
return [{ id: "self", role: "self" }, ...others];
|
||||
}
|
||||
|
||||
test("clearing the account empties the chart library session", () => {
|
||||
assert.equal(chartLibrarySessionBranch(null, "acct-1"), "clear");
|
||||
assert.equal(chartLibrarySessionBranch("", "acct-1"), "clear");
|
||||
});
|
||||
|
||||
test("switching accounts hydrates local then cloud instead of writing the previous library", () => {
|
||||
assert.equal(chartLibrarySessionBranch("acct-2", "acct-1"), "hydrate-then-persist");
|
||||
});
|
||||
|
||||
test("a later profile change on the same account still upserts self", () => {
|
||||
assert.equal(chartLibrarySessionBranch("acct-1", "acct-1"), "persist-self");
|
||||
const current: RecordShape[] = [{ id: "other-1", role: "other" }];
|
||||
assert.deepEqual(upsertSelf(current, { name: "self" }), [
|
||||
{ id: "self", role: "self" },
|
||||
{ id: "other-1", role: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a successful cloud read keeps only non-self records before upserting self", () => {
|
||||
const cloud: RecordShape[] = [
|
||||
{ id: "stale-self", role: "self" },
|
||||
{ id: "other-1", role: "other" },
|
||||
];
|
||||
assert.deepEqual(chartLibraryFromCloudOthers(cloud, { name: "self" }, upsertSelf), [
|
||||
{ id: "self", role: "self" },
|
||||
{ id: "other-1", role: "other" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a failed cloud read leaves the local library in place", () => {
|
||||
const local: RecordShape[] = [{ id: "self", role: "self" }, { id: "other-1", role: "other" }];
|
||||
assert.equal(keepLocalChartLibraryOnCloudFailure(local), local);
|
||||
});
|
||||
@@ -97,7 +97,7 @@ test("the streaming reply announces state transitions, never streamed deltas", (
|
||||
|
||||
// And: the old start-only region that lived inside the busy message list is gone.
|
||||
assert.doesNotMatch(pageSource, /<span className="sr-only" aria-live="polite">\{isLoading \? "Jyotisha 正在回答" : ""\}<\/span>/);
|
||||
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>\n\s*\{chatMessageViews\(/);
|
||||
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>\n\s*<ChatTranscript/);
|
||||
});
|
||||
|
||||
test("the reply phase covers start, completion and every terminal state", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import test from "node:test";
|
||||
import { chatMessageViews } from "../src/lib/chat-message-view.ts";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const transcriptSource = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
|
||||
const activitySource = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
|
||||
@@ -182,7 +183,7 @@ test("ordinary consultation replies reuse the shared Agent action bar", () => {
|
||||
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
|
||||
assert.match(actionsSource, new RegExp(`aria-label="${label}"`));
|
||||
}
|
||||
assert.match(pageSource, /<ChatMessageActions/);
|
||||
assert.match(transcriptSource, /<ChatMessageActions/);
|
||||
assert.match(pageSource, /toggleChatMessageFeedback/);
|
||||
assert.match(pageSource, /function regenerateLatestAnswer\(renderKey: string\)/);
|
||||
assert.match(pageSource, /messages: session\.messages\.slice\(0, -1\)/);
|
||||
|
||||
@@ -233,14 +233,15 @@ test("homepage creation and sidebar selection resolve through distinct server in
|
||||
|
||||
test("an answered conversation offers no suggested follow-up questions", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const transcript = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
|
||||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
// Given: the chips above the composer stay gone. Grounded continuations sit under
|
||||
// the latest answer and send immediately instead of filling the composer.
|
||||
assert.doesNotMatch(source, /composer-suggestions|activeSuggestions|chooseConversationSuggestion/);
|
||||
assert.doesNotMatch(styles, /composer-suggestions/);
|
||||
assert.match(source, /ConversationFollowUps/);
|
||||
assert.match(source, /deriveConsultationFollowUps/);
|
||||
assert.match(transcript, /ConversationFollowUps/);
|
||||
assert.match(transcript, /deriveConsultationFollowUps/);
|
||||
assert.match(styles, /\.conversation-follow-ups/);
|
||||
|
||||
// Then: nothing in the conversation carries or stores a suggestion list any more.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createElement, createRef } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SettledMessageList,
|
||||
StreamingMessageEntry,
|
||||
UnsplitChatTranscript,
|
||||
type ChatTranscriptActions,
|
||||
type ChatTranscriptProps,
|
||||
} from "../src/components/chat-transcript.tsx";
|
||||
import type { ChatMessage } from "../src/lib/chat-message-view.ts";
|
||||
import { streamingChatMessageView } from "../src/lib/chat-message-view.ts";
|
||||
import {
|
||||
disableHomeStreamingRenderProbe,
|
||||
enableHomeStreamingRenderProbe,
|
||||
homeStreamingRenderProbeSnapshot,
|
||||
resetHomeStreamingRenderProbe,
|
||||
} from "../src/lib/home-streaming-render-probe.ts";
|
||||
|
||||
function propsFor(messages: readonly ChatMessage[]): Omit<ChatTranscriptProps, "loading" | "streamingText"> {
|
||||
const actionsRef = createRef<ChatTranscriptActions>() as ChatTranscriptProps["actionsRef"];
|
||||
actionsRef.current = {
|
||||
onFeedback() {},
|
||||
onCopy() {},
|
||||
onRegenerate() {},
|
||||
onFollowUp() {},
|
||||
};
|
||||
return {
|
||||
messages,
|
||||
sessionId: "session-test",
|
||||
sessionType: "consultation",
|
||||
theme: "career",
|
||||
messageFeedback: {},
|
||||
copiedMessageKey: null,
|
||||
cancellationPending: false,
|
||||
productEntrypointsDisabled: true,
|
||||
actionsRef,
|
||||
};
|
||||
}
|
||||
|
||||
test("the split architecture renders settled history once while streaming tokens", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "user", text: "问题一" },
|
||||
{ role: "assistant", text: "回答一,需要足够长才能生成追问卡片。" },
|
||||
{ role: "user", text: "问题二" },
|
||||
{ role: "assistant", text: "回答二,需要足够长才能生成追问卡片。" },
|
||||
{ role: "user", text: "请继续说明这个月的安排。" },
|
||||
];
|
||||
const tokens = ["甲", "甲乙", "甲乙丙", "甲乙丙丁", "甲乙丙丁戊"];
|
||||
const base = propsFor(messages);
|
||||
|
||||
resetHomeStreamingRenderProbe();
|
||||
enableHomeStreamingRenderProbe();
|
||||
renderToString(createElement(SettledMessageList, { ...base, loading: true }));
|
||||
for (const streamingText of tokens) {
|
||||
const streamingMessage = streamingChatMessageView(messages, true, streamingText);
|
||||
assert.ok(streamingMessage);
|
||||
renderToString(createElement(StreamingMessageEntry, { message: streamingMessage }));
|
||||
}
|
||||
const split = homeStreamingRenderProbeSnapshot();
|
||||
disableHomeStreamingRenderProbe();
|
||||
|
||||
resetHomeStreamingRenderProbe();
|
||||
enableHomeStreamingRenderProbe();
|
||||
for (const streamingText of tokens) {
|
||||
renderToString(createElement(UnsplitChatTranscript, { ...base, loading: true, streamingText }));
|
||||
}
|
||||
const unsplit = homeStreamingRenderProbeSnapshot();
|
||||
disableHomeStreamingRenderProbe();
|
||||
|
||||
assert.equal(split.settledListRenders, 1);
|
||||
assert.equal(split.streamingRowRenders, tokens.length);
|
||||
assert.equal(unsplit.unsplitListRenders, tokens.length);
|
||||
assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
|
||||
});
|
||||
@@ -63,9 +63,9 @@ const RELOCATION_2015_PROBE = {
|
||||
question: "2015 年 5 月前后有没有搬家或长期住到外地?",
|
||||
candidate_ids: ["05:00", "05:10"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "no" as const, supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 0.87,
|
||||
source: "dasha_boundary",
|
||||
@@ -80,9 +80,9 @@ const EDUCATION_2014_PROBE = {
|
||||
question: "2014 年前后有没有升学、转学或换学习环境?",
|
||||
candidate_ids: ["05:00", "05:10"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "no" as const, supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 0.64,
|
||||
source: "dasha_activation",
|
||||
@@ -118,8 +118,8 @@ const YEARLESS_D24_PROBE = {
|
||||
question: "引擎给出的区分机会绑定 D24。",
|
||||
candidate_ids: ["05:00", "05:10"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
|
||||
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:10"] },
|
||||
{ answer_class: "no" as const, supports: ["05:10"], conflicts: ["05:00"] },
|
||||
],
|
||||
information_gain: 2.5,
|
||||
source: "varga_contrast",
|
||||
@@ -558,7 +558,7 @@ test("clicking A applies the choice without invoking a language model", async ()
|
||||
test("answering a discriminator persists the next dated card so GET still has a tap target", async () => {
|
||||
const closed = parseV9CaseDossier(twoProbeDossier());
|
||||
assert.ok(closed);
|
||||
closed.conversationSummary.activeFocus = null;
|
||||
Object.assign(closed.conversationSummary, { activeFocus: null });
|
||||
assert.equal(choiceCardFromCaseDossier(closed), null);
|
||||
|
||||
const accounting = persistChoiceAccounting(twoProbeDossier(), {
|
||||
@@ -608,7 +608,7 @@ test("answering a discriminator persists the next dated card so GET still has a
|
||||
const refreshed = parseV9CaseDossier(twoProbeDossier());
|
||||
assert.ok(refreshed);
|
||||
const answered = twoProbeInference();
|
||||
refreshed.latestResult = {
|
||||
Object.assign(refreshed, { latestResult: {
|
||||
...refreshed.latestResult!,
|
||||
decisionReceipt: {
|
||||
...(refreshed.latestResult?.decisionReceipt ?? {}),
|
||||
@@ -623,8 +623,8 @@ test("answering a discriminator persists the next dated card so GET still has a
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
refreshed.conversationSummary.activeFocus = {
|
||||
} });
|
||||
Object.assign(refreshed.conversationSummary, { activeFocus: {
|
||||
id: NEXT_FOCUS_ID,
|
||||
caseId: CASE_ID,
|
||||
questionId: String(setFocus.args.p_question_id),
|
||||
@@ -636,7 +636,7 @@ test("answering a discriminator persists the next dated card so GET still has a
|
||||
status: "active",
|
||||
askedAt: "2026-08-28T07:37:50.000Z",
|
||||
resolvedAt: null,
|
||||
};
|
||||
} });
|
||||
const card = choiceCardFromCaseDossier(refreshed);
|
||||
assert.ok(card);
|
||||
assert.equal(card.focus_id, NEXT_FOCUS_ID);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-n
|
||||
import { selectDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
|
||||
import { contrastPacketFromDossier, decideFromDossier, overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import { buildMethodFollowupPlan, conversationalSessionOutcome } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
|
||||
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
import { parseV9CaseDossier, parseV9ComputeProjection } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { safeCaseProjection } from "../src/mastra/rectification-v9-tools.ts";
|
||||
@@ -398,24 +399,24 @@ test("career and relationship training still discriminates before family or occu
|
||||
const familyProbe = {
|
||||
year: 2013,
|
||||
year_label: "2013 年 3 月前后",
|
||||
domain: "family",
|
||||
domain: "family" as const,
|
||||
event_family: "家人结婚、添丁或住院",
|
||||
source: "dasha_boundary",
|
||||
tracks: ["vimshottari", "narayana"],
|
||||
source: "dasha_boundary" as const,
|
||||
tracks: ["vimshottari", "narayana"] as const,
|
||||
tracks_agree: true,
|
||||
unique_minute_claim: false,
|
||||
unique_minute_claim: false as const,
|
||||
user_meaning: "时间范围锁定 2013 年 3 月前后。",
|
||||
role: "distinguish",
|
||||
phase: "candidate_discriminator",
|
||||
role: "distinguish" as const,
|
||||
phase: "candidate_discriminator" as const,
|
||||
information_gain: 1.2,
|
||||
semantic_key: "family.2013.03.dasha_boundary",
|
||||
candidate_split_hash: "family.2013.03",
|
||||
candidate_ids: ["05:00", "05:07", "05:12"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07", "05:12"] },
|
||||
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00", "05:12"] },
|
||||
{ answer_class: "no", supports: ["05:12"], conflicts: ["05:00", "05:07"] },
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:07", "05:12"] },
|
||||
{ answer_class: "weak_yes" as const, supports: ["05:07"], conflicts: ["05:00", "05:12"] },
|
||||
{ answer_class: "no" as const, supports: ["05:12"], conflicts: ["05:00", "05:07"] },
|
||||
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
|
||||
],
|
||||
style_options: [
|
||||
{ label: "明确发生且时间吻合", answer_class: "yes" },
|
||||
@@ -423,7 +424,7 @@ test("career and relationship training still discriminates before family or occu
|
||||
{ label: "明确没有发生", answer_class: "no" },
|
||||
{ label: "这段记不清楚", answer_class: "unsure" },
|
||||
],
|
||||
};
|
||||
} satisfies DiscriminatingEventProbe;
|
||||
const dossier = {
|
||||
evidence,
|
||||
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
|
||||
const siteStyles = readFileSync(new URL("../src/app/site-styles.ts", import.meta.url), "utf8");
|
||||
const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8");
|
||||
const adminCss = readFileSync(new URL("../src/app/admin/admin.css", import.meta.url), "utf8");
|
||||
const homePage = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("Inter is loaded through next/font and applied as a CSS variable", () => {
|
||||
assert.match(layout, /import \{ Inter \} from "next\/font\/google"/);
|
||||
assert.match(layout, /display: "swap"/);
|
||||
assert.match(layout, /variable: "--font-inter"/);
|
||||
assert.match(layout, /className=\{inter\.variable\}/);
|
||||
assert.match(readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"), /var\(--font-inter, Inter\)/);
|
||||
assert.match(
|
||||
readFileSync(new URL("../src/components/admin/admin-app.tsx", import.meta.url), "utf8"),
|
||||
/var\(--font-inter, Inter\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("site chrome owns globals.css; admin does not import it", () => {
|
||||
assert.match(siteStyles, /import "\.\/globals\.css"/);
|
||||
assert.match(siteStyles, /import "\.\/birth-time-choice\.css"/);
|
||||
assert.doesNotMatch(layout, /globals\.css|birth-time-choice\.css|site-styles/);
|
||||
assert.match(homePage, /import "@\/app\/site-styles"/);
|
||||
assert.match(loginPage, /import "\.\.\/site-styles"/);
|
||||
assert.doesNotMatch(adminLayout, /globals\.css|site-styles/);
|
||||
assert.doesNotMatch(
|
||||
readFileSync(new URL("../src/app/error.tsx", import.meta.url), "utf8"),
|
||||
/site-styles|globals\.css/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
readFileSync(new URL("../src/app/not-found.tsx", import.meta.url), "utf8"),
|
||||
/site-styles|globals\.css/,
|
||||
);
|
||||
assert.match(adminCss, /--font-body: StyreneB, var\(--font-inter, Inter\)/);
|
||||
});
|
||||
@@ -67,7 +67,16 @@ test("the web build stamps the Git SHA as Next deploymentId and does not cache t
|
||||
assert.match(nextConfig, /source: "\/"/);
|
||||
assert.match(nextConfig, /source: "\/login"/);
|
||||
assert.match(nextConfig, /Cache-Control["'],\s*value: "private, no-store, must-revalidate"/);
|
||||
assert.match(layoutSource, /export const dynamic = "force-dynamic"/);
|
||||
// Root force-dynamic was locking every route into ƒ Dynamic, including shells
|
||||
// that never read cookies(). Chat HTML stays uncached via the headers above;
|
||||
// routes that actually need a request stay dynamic on their own modules.
|
||||
assert.doesNotMatch(layoutSource, /export const dynamic = "force-dynamic"/);
|
||||
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
|
||||
const reportsPage = readFileSync(new URL("../src/app/reports/page.tsx", import.meta.url), "utf8");
|
||||
const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8");
|
||||
assert.match(loginPage, /export const dynamic = "force-dynamic"/);
|
||||
assert.match(reportsPage, /export const dynamic = "force-dynamic"/);
|
||||
assert.match(adminLayout, /export const dynamic = "force-dynamic"/);
|
||||
assert.match(layoutSource, /StaleClientRecovery/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user