Files
Jyotisha/frontend/scripts/home-streaming-render-benchmark.mts
T
Jesse_ChenandClaude Opus 5.5 6ad0bea320 feat(rectification): start from an offer card in the consultation, come back to it (BUG-1247~1251)
TASK-rectification-in-chat-step1-20261006, direct execution.

- The homepage 生时校正 pill and the text trigger (isRectificationHandoffQuestion)
  are gone. The consult agent decides (RECTIFICATION_OFFER_RULE, one
  definition) and calls offer-birth-time-rectification; the server decides
  whether a card may show (self, product on, no case in progress,
  feature_pricing price). The offer is stored with the answer and travels on
  run.completed; the card hangs under that answer.
- Opening from the card binds the offer to the case in the consultation's own
  messages (no table change); GET/POST /api/rectification/cases/[caseId]/source
  find the source and go back: one divider (time changed or not), earlier
  answers captioned 按校正前的时间, and a 按新时间重新看 button that sends only
  when clicked. Auto-return after the post-adopt check or a terminal case, only
  for work done in this view.
- Model history skips the divider and notes answers written with the old time.

Full suite 4987 / fail 24, identical to 6ec8efd5 (4973 / 24), no test name
lost; tsc 0; lint 0 errors; build keeps / Static, first-load gzip +0.20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-07 12:58:58 +08:00

213 lines
9.3 KiB
TypeScript

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 {
LatestAssistantEntry,
SettledMessageList,
UnsplitChatTranscript,
type ChatTranscriptActions,
type ChatTranscriptProps,
} from "../src/components/chat-transcript.tsx";
import type { ChatMessage } from "../src/lib/chat-message-view.ts";
import { settledChatMessageViews, 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() {},
onStartRectification() {},
};
}
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(LatestAssistantEntry, {
...props,
loading: true,
message: streamingMessage,
views: [...settledChatMessageViews(messages), streamingMessage],
index: messages.length,
}));
}
}
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;
}
}