perf(frontend): split chat streaming, load Inter, isolate admin CSS
Independent Staging Quality Gate / validate (push) Successful in 10m14s
Independent Staging Quality Gate / publish (push) Successful in 11m5s

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:
Jesse_Chen
2026-08-29 03:39:43 +08:00
parent bf697d71a2
commit 3198fb6b2b
41 changed files with 1528 additions and 307 deletions
@@ -0,0 +1,109 @@
{
"generatedAt": "2026-08-28T19:24:57.446Z",
"replyChars": 916,
"empty": {
"run1": {
"name": "empty-unsplit-1",
"tokens": 46,
"elapsedMs": 1.7442499999999654,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 92,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"run2": {
"name": "empty-unsplit-2",
"tokens": 46,
"elapsedMs": 2.1559169999999312,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 92,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"unsplit": {
"name": "empty-unsplit-1",
"tokens": 46,
"elapsedMs": 1.7442499999999654,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 92,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"split": {
"name": "empty-split",
"tokens": 46,
"elapsedMs": 1.5974159999999529,
"probe": {
"settledListRenders": 1,
"settledRowRenders": 1,
"streamingRowRenders": 46,
"unsplitListRenders": 0
}
}
},
"long": {
"run1": {
"name": "long-unsplit-1",
"tokens": 46,
"elapsedMs": 155.65337499999998,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 1932,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"run2": {
"name": "long-unsplit-2",
"tokens": 46,
"elapsedMs": 147.32045800000003,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 1932,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"unsplit": {
"name": "long-unsplit-1",
"tokens": 46,
"elapsedMs": 155.65337499999998,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 1932,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"split": {
"name": "long-split",
"tokens": 46,
"elapsedMs": 4.582125000000019,
"probe": {
"settledListRenders": 1,
"settledRowRenders": 41,
"streamingRowRenders": 46,
"unsplitListRenders": 0
}
}
},
"variance": {
"emptyElapsed": 0.23601375949547024,
"longElapsed": 0.053535087176875884,
"emptyUnsplitRenders": 0,
"longUnsplitRenders": 0
},
"splitGain": {
"emptyElapsed": 0.18084738422739577,
"longElapsed": 0.9697523383149724,
"longSettledListRenders": 1
},
"usable": true,
"notes": "renderToString always walks the tree, so this measures the split architecture (settled list once + streaming row per token) rather than React.memo itself. Reverse verification is runUnsplit vs runSplitArchitecture."
}
@@ -0,0 +1,205 @@
import { createElement, createRef } from "react";
import { renderToString } from "react-dom/server";
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
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,
type HomeStreamingRenderProbeSnapshot,
} from "../src/lib/home-streaming-render-probe.ts";
const STREAMING_TURN = "事业宫的火星落在第十宫,说明你在对外责任上会反复被推到台前。接下来三个月更适合把已经开过口的项目收束成可验收的结果,而不是再开一条全新战线。感情上第七宫的土星要求把边界说清楚:能一起承担日常安排,才值得把时间押上去。财务上第二宫与第十一宫同时被木星照面,收入机会来自既有圈子的转介,而不是短期投机。健康上注意睡眠被工作节奏切碎,先把就寝时间固定,再谈加码。以上判断来自 D1 与 D10 的宫主联系,D9 尚未用外部引擎闭环,所以婚恋时点只保留为条件性提示。";
const STREAMING_REPLY = STREAMING_TURN.repeat(4);
function chunkText(text: string, size: number): string[] {
const chunks: string[] = [];
for (let index = size; index <= text.length; index += size) {
chunks.push(text.slice(0, index));
}
if (chunks.at(-1) !== text) chunks.push(text);
return chunks;
}
function historyMessages(count: number): ChatMessage[] {
const messages: ChatMessage[] = [];
for (let index = 0; index < count; index += 1) {
messages.push(
index % 2 === 0
? { role: "user", text: `这是第 ${index + 1} 条用户问题,请根据星盘说明接下来三个月的事业安排。` }
: { role: "assistant", text: `这是第 ${index + 1} 条已完成回答。第十宫的工作节奏需要先收束已有项目,再考虑新的对外合作。${"证据层包括 D1 与 D10。".repeat(8)}` },
);
}
if (messages.at(-1)?.role !== "user") {
messages.push({ role: "user", text: "请在刚才的基础上继续说明这个月可以推进的安排。" });
}
return messages;
}
function emptyActions(): ChatTranscriptActions {
return {
onFeedback() {},
onCopy() {},
onRegenerate() {},
onFollowUp() {},
};
}
function baseProps(messages: readonly ChatMessage[]): Omit<ChatTranscriptProps, "loading" | "streamingText"> {
const actionsRef = createRef<ChatTranscriptActions>() as ChatTranscriptProps["actionsRef"];
actionsRef.current = emptyActions();
return {
messages,
sessionId: "session-benchmark",
sessionType: "consultation",
theme: "career",
messageFeedback: {},
copiedMessageKey: null,
cancellationPending: false,
productEntrypointsDisabled: true,
actionsRef,
};
}
export type ScenarioResult = Readonly<{
name: string;
tokens: number;
elapsedMs: number;
probe: HomeStreamingRenderProbeSnapshot;
}>;
function runUnsplit(name: string, messages: readonly ChatMessage[], reply: string): ScenarioResult {
const tokens = chunkText(reply, 20);
const props = baseProps(messages);
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
const started = performance.now();
for (const streamingText of tokens) {
renderToString(createElement(UnsplitChatTranscript, {
...props,
loading: true,
streamingText,
}));
}
const elapsedMs = performance.now() - started;
const probe = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
return { name, tokens: tokens.length, elapsedMs, probe };
}
function runSplitArchitecture(name: string, messages: readonly ChatMessage[], reply: string): ScenarioResult {
const tokens = chunkText(reply, 20);
const props = baseProps(messages);
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
const started = performance.now();
renderToString(createElement(SettledMessageList, {
messages: props.messages,
loading: true,
sessionId: props.sessionId,
sessionType: props.sessionType,
theme: props.theme,
messageFeedback: props.messageFeedback,
copiedMessageKey: props.copiedMessageKey,
cancellationPending: props.cancellationPending,
productEntrypointsDisabled: props.productEntrypointsDisabled,
actionsRef: props.actionsRef,
}));
for (const streamingText of tokens) {
const streamingMessage = streamingChatMessageView(messages, true, streamingText);
if (streamingMessage) {
renderToString(createElement(StreamingMessageEntry, { message: streamingMessage }));
}
}
const elapsedMs = performance.now() - started;
const probe = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
return { name, tokens: tokens.length, elapsedMs, probe };
}
function relativeDelta(first: number, second: number): number {
if (first === 0) return 0;
return Math.abs(first - second) / first;
}
export type BenchmarkReport = Readonly<{
generatedAt: string;
replyChars: number;
empty: { run1: ScenarioResult; run2: ScenarioResult; unsplit: ScenarioResult; split: ScenarioResult };
long: { run1: ScenarioResult; run2: ScenarioResult; unsplit: ScenarioResult; split: ScenarioResult };
variance: { emptyElapsed: number; longElapsed: number; emptyUnsplitRenders: number; longUnsplitRenders: number };
splitGain: { emptyElapsed: number; longElapsed: number; longSettledListRenders: number };
usable: boolean;
notes: string;
}>;
export function runHomeStreamingRenderBenchmark(): BenchmarkReport {
const emptyHistory = [{ role: "user" as const, text: "请根据星盘说明接下来三个月的事业安排。" }];
const longHistory = historyMessages(40);
runUnsplit("empty-warmup", emptyHistory, STREAMING_REPLY);
runUnsplit("long-warmup", longHistory, STREAMING_REPLY);
const emptyUnsplit1 = runUnsplit("empty-unsplit-1", emptyHistory, STREAMING_REPLY);
const emptyUnsplit2 = runUnsplit("empty-unsplit-2", emptyHistory, STREAMING_REPLY);
const longUnsplit1 = runUnsplit("long-unsplit-1", longHistory, STREAMING_REPLY);
const longUnsplit2 = runUnsplit("long-unsplit-2", longHistory, STREAMING_REPLY);
const emptySplit = runSplitArchitecture("empty-split", emptyHistory, STREAMING_REPLY);
const longSplit = runSplitArchitecture("long-split", longHistory, STREAMING_REPLY);
const emptyElapsedVariance = relativeDelta(emptyUnsplit1.elapsedMs, emptyUnsplit2.elapsedMs);
const longElapsedVariance = relativeDelta(longUnsplit1.elapsedMs, longUnsplit2.elapsedMs);
const emptyRenderVariance = relativeDelta(emptyUnsplit1.probe.unsplitListRenders, emptyUnsplit2.probe.unsplitListRenders);
const longRenderVariance = relativeDelta(longUnsplit1.probe.unsplitListRenders, longUnsplit2.probe.unsplitListRenders);
const emptyGain = 1 - emptySplit.elapsedMs / ((emptyUnsplit1.elapsedMs + emptyUnsplit2.elapsedMs) / 2);
const longGain = 1 - longSplit.elapsedMs / ((longUnsplit1.elapsedMs + longUnsplit2.elapsedMs) / 2);
const usable = emptyRenderVariance === 0 && longRenderVariance === 0
&& emptyUnsplit1.probe.streamingRowRenders === emptyUnsplit1.tokens
&& longUnsplit1.probe.streamingRowRenders === longUnsplit1.tokens
&& longSplit.probe.settledListRenders === 1
&& longSplit.probe.streamingRowRenders === longSplit.tokens
&& (emptyElapsedVariance < 0.2 && longElapsedVariance < 0.2
|| longGain > 0.2 && longGain > 2 * Math.max(emptyElapsedVariance, longElapsedVariance));
return {
generatedAt: new Date().toISOString(),
replyChars: STREAMING_REPLY.length,
empty: { run1: emptyUnsplit1, run2: emptyUnsplit2, unsplit: emptyUnsplit1, split: emptySplit },
long: { run1: longUnsplit1, run2: longUnsplit2, unsplit: longUnsplit1, split: longSplit },
variance: {
emptyElapsed: emptyElapsedVariance,
longElapsed: longElapsedVariance,
emptyUnsplitRenders: emptyRenderVariance,
longUnsplitRenders: longRenderVariance,
},
splitGain: {
emptyElapsed: emptyGain,
longElapsed: longGain,
longSettledListRenders: longSplit.probe.settledListRenders,
},
usable,
notes: "renderToString always walks the tree, so this measures the split architecture (settled list once + streaming row per token) rather than React.memo itself. Reverse verification is runUnsplit vs runSplitArchitecture.",
};
}
const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isDirectRun) {
const report = runHomeStreamingRenderBenchmark();
const outputPath = join(dirname(fileURLToPath(import.meta.url)), "home-streaming-render-baseline.json");
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify(report, null, 2));
if (!report.usable) {
console.error("benchmark is not stable enough to detect a 20% change");
process.exitCode = 1;
}
}