From 3198fb6b2bf941761da63bf911142407fbec9913 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 29 Aug 2026 03:39:43 +0800 Subject: [PATCH] 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 --- BLOCKED.md | 7 + PROGRESS-frontend-optimization-20260828.md | 154 ++++++ docs/BUG_HISTORY.md | 32 ++ frontend/DESIGN.md | 2 +- frontend/public/file.svg | 1 - frontend/public/globe.svg | 1 - frontend/public/jyotish-logo.png | Bin 33511 -> 8584 bytes frontend/public/next.svg | 1 - frontend/public/vercel.svg | 1 - frontend/public/window.svg | 1 - .../home-streaming-render-baseline.json | 109 ++++ .../home-streaming-render-benchmark.mts | 205 ++++++++ frontend/src/app/admin/admin.css | 9 + frontend/src/app/globals.css | 2 +- frontend/src/app/layout.tsx | 11 +- frontend/src/app/login/page.tsx | 1 + frontend/src/app/membership/orders/page.tsx | 1 + frontend/src/app/membership/page.tsx | 1 + frontend/src/app/page.tsx | 494 +++++++++--------- frontend/src/app/reports/[reportId]/error.tsx | 1 + .../src/app/reports/[reportId]/loading.tsx | 1 + .../src/app/reports/[reportId]/not-found.tsx | 1 + frontend/src/app/reports/[reportId]/page.tsx | 1 + frontend/src/app/reports/page.tsx | 1 + frontend/src/app/site-styles.ts | 2 + .../src/components/account-dialog-overlay.tsx | 77 +++ frontend/src/components/admin/admin-app.tsx | 2 +- frontend/src/components/chat-transcript.tsx | 293 +++++++++++ frontend/src/lib/chart-library-session.ts | 22 + frontend/src/lib/chat-message-view.ts | 69 ++- .../src/lib/home-streaming-render-probe.ts | 50 ++ frontend/tests/account-dialog-overlay.test.ts | 45 ++ frontend/tests/chart-library-session.test.ts | 50 ++ .../chat-navigation-a11y-contract.test.ts | 2 +- frontend/tests/chat-stream-layout.test.ts | 3 +- .../tests/consultation-entrypoint.test.ts | 5 +- .../tests/home-streaming-render-split.test.ts | 77 +++ .../tests/rectification-answer-choice.test.ts | 26 +- .../rectification-decision-authority.test.ts | 23 +- .../site-style-isolation-contract.test.ts | 40 ++ frontend/tests/stale-client-recovery.test.ts | 11 +- 41 files changed, 1528 insertions(+), 307 deletions(-) create mode 100644 PROGRESS-frontend-optimization-20260828.md delete mode 100644 frontend/public/file.svg delete mode 100644 frontend/public/globe.svg delete mode 100644 frontend/public/next.svg delete mode 100644 frontend/public/vercel.svg delete mode 100644 frontend/public/window.svg create mode 100644 frontend/scripts/home-streaming-render-baseline.json create mode 100644 frontend/scripts/home-streaming-render-benchmark.mts create mode 100644 frontend/src/app/site-styles.ts create mode 100644 frontend/src/components/account-dialog-overlay.tsx create mode 100644 frontend/src/components/chat-transcript.tsx create mode 100644 frontend/src/lib/chart-library-session.ts create mode 100644 frontend/src/lib/home-streaming-render-probe.ts create mode 100644 frontend/tests/account-dialog-overlay.test.ts create mode 100644 frontend/tests/chart-library-session.test.ts create mode 100644 frontend/tests/home-streaming-render-split.test.ts create mode 100644 frontend/tests/site-style-isolation-contract.test.ts diff --git a/BLOCKED.md b/BLOCKED.md index 7038e56e..9d0f073a 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -4,6 +4,13 @@ - PostgreSQL 事务反向测试:当前执行环境没有 `docker`、`postgres`、`initdb`、`psql`、Podman/Colima/Lima。`frontend/tests/admin-database.test.ts` 已实现审计触发器故意失败并断言兑换码行数仍为 0 的红灯证据,但本地执行在启动 fixture 前以 `spawnSync docker ENOENT` 阻塞;交由 exact-SHA staging quality gate 的 Docker 环境运行。全量 `npm test` 因同一缺失 Docker 共阻塞 11 项数据库/部署测试,另有 1 项既有真实 DOM 测试因缺 Playwright headless Chromium 阻塞;其余 1031 项通过,skipped/todo=0。 - staging 两角色浏览器冒烟:已确认受控 admin 测试账号存在且是 `user,admin`,但当前执行环境没有其密码或已登录会话;也未提供受控 viewer 账号。不得读取/猜测凭据或使用他人账号。已完成匿名 shell、5 个资源 401、写请求 401 的服务端冒烟;admin/viewer 登录后浏览器冒烟待授权人员提供受控会话后补验。 +## 前端优化九条(2026-08-29,分支 `codex/frontend-optimization-20260828`) + +- **`web/index.html`、`web/rectification.html`、`web/evidence_packet.html` 不得按“无人引用”删除。** `scripts/jyotish_api_server.py` 仍把它们当作 `/`、`/evidence`、`/rectification` 的调试页;`tests/test_api_async_job_contract.py` 锁路径。任务书全仓检索漏了 Python API。移到 `scripts/` 必须改 `tests/**` 既有断言,本轮禁止。它们不是产品 UI,继续留在 `web/`。 +- **admin 已登录目视(`/admin/users|orders|roles|feature-flags`)未做。** 执行环境没有受控后台会话,与上方 staging 两角色冒烟同一缺口。本轮用 `next build` 客户端 CSS 清单代替:上述四页不再包含 globals(`message-list`)那 33.7 KB gzip,只剩 admin.css 1.8 KB + Inter `@font-face` 0.8 KB = 2.5 KB。登录后的视觉塌陷仍需人补一眼。 +- **`error.tsx` / `not-found.tsx` 不能 import `site-styles`。** 它们挂在根布局段,import globals 会把聊天 CSS 重新打进每一条 admin 路由。根错误/404 因此不再带 Tailwind 工具类的编译产物;有 `Button` className,但 admin 不依赖这两页的观感。真要恢复这两页的工具类样式,必须另开一条不与 admin 共享的布局链(会移动 `app/page.tsx`,与既有源码锁冲突)。 +- React Compiler 仍关闭,见下方 2026-08-17 记录。本轮未重开。 + ## React Compiler 接管 page.tsx(2026-08-17,分支 codex/react-compiler-20260817) - **~~`Home` 的编译失败原因无法定位~~ 已解除并已定位(2026-08-17,用户授权新增依赖后完成)。** 结论:`Home` 的失败全部来自上游编译器未实现的语法与内部断言失败,**没有一条是本仓库代码写错**。稳定版 `babel-plugin-react-compiler@1.0.0` 报 24 条错误、三类全带 `Todo:`(编译器标记「尚未实现」):13 条 `try/finally`、9 条 `try/catch` 内的 `throw`、2 条 `MemberExpression cannot be safely reordered`。更新的 experimental 版已修好前两类,`Home` 随即撞上两个连续的编译器内部断言失败(`Invariant:`,即编译器 bug):`page.tsx:2666` 的 `catch (error)` 绑定被闭包捕获,以及 `page.tsx:1837` 的提升函数 `refreshAccount`。诊断用的依赖已卸载、锁文件已用 `npm ci` 权威还原、`git diff` 为空,不进交付——因为它买不到任何东西,见下一条。 diff --git a/PROGRESS-frontend-optimization-20260828.md b/PROGRESS-frontend-optimization-20260828.md new file mode 100644 index 00000000..c1cabf33 --- /dev/null +++ b/PROGRESS-frontend-optimization-20260828.md @@ -0,0 +1,154 @@ +# PROGRESS · 前端优化九条(2026-08-28 / 2026-08-29) + +工作树:`../.worktrees/frontend-optimization-20260828` +分支:`codex/frontend-optimization-20260828` @ `bf697d71` 之上 +不要写成 `PROGRESS.md`:根目录已有受版本控制的 `progress.md`。 + +## 任务 0 · 基线复核 + +| 复核项 | 审计给的数 | 实测(`bf697d71` + 本轮前) | 结论 | +| --- | --- | --- | --- | +| `Home` 起止行 | `page.tsx:1090–4313` | `1090–4313` | 一致 | +| `Home` 行数 | 3,224 | 3,224 | 一致 | +| `Home` 内 `useState` | 61 | 61 | 一致 | +| `Home` 内 `useEffect` | 19 | 19 | 一致 | +| `Home` 内 `useMemo` / `useCallback` | 0 / 0 | 0 / 0 | 一致 | +| 全仓库 `React.memo` | 0 | 0 | 一致 | +| `globals.css` 行数 / 选择器数 | 3,278 / 1,389 | 3,278 行 / `{` 计数 1,453(选择器解析器不同,不以审计数为准) | 行数一致;选择器数用实测 | +| `!important` | 25 | 56 | 审计漏计;不以 25 为对照 | +| 字体文件 / `@font-face` / `next/font` | 0 / 0 / 0 | 0 / 0 / 0 | 一致 | +| `public/` 未引用 SVG | 4 | 5(`file/vercel/window/globe/next.svg` 均无产品引用) | 审计漏了 `next.svg` | +| `jyotish-logo.png` | 128×125 / 33,511 B | 同 | 一致 | +| `frontend/tests` 文件数 | 260 | 260 | 一致 | +| `tsx --test` | 待实测 | **2196 pass / 0 fail / 0 skipped**,约 292 s | 基线 | +| `./node_modules/.bin/tsc --noEmit` | 待实测 | 先红后绿:测试夹具类型(未改断言语义)修完后 exit 0。不要用 `npx tsc`(会装到 `tsc@2.0.4` 空包) | 基线 | +| `next build` | 待实测 | exit 0,约 13–22 s;当时**全部路由 `ƒ Dynamic`** | 基线 | +| `/` 首屏 JS / CSS gzip | 待实测 | JS 16 chunk / **438.5 KB**;CSS 2 chunk / **33.7 KB**(globals 约 32.9 KB) | 基线 | + +差异说明:审计没跑过 `next build`。根 `force-dynamic` 让整棵树都是 `ƒ`。`npx tsc` 在本环境会装错包,验收一律用 `./node_modules/.bin/tsc --noEmit`。 + +## 任务 1 · 渲染基准(门控,通过) + +脚本:`frontend/scripts/home-streaming-render-benchmark.mts` +合同测试:`frontend/tests/home-streaming-render-split.test.ts` + +`renderToString` 不会证明 `React.memo`(SSR 每次走完整树)。基准测的是**拆分后的工作量**:已完成列表渲染 1 次 + 每个 token 只渲染流式行,对比整表每个 token 重建。反向验证就是改跑 `UnsplitChatTranscript`。 + +预热一次后再测两次。回复 **916 字**、**46 token**。 + +| 场景 | 未拆分 run1 | 未拆分 run2 | 时间偏差 | 拆分后 | 时间收益 | 列表渲染 | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| 空会话(1 条用户问题 + 流式) | 1.74 ms / 92 行渲染 | 2.16 ms / 92 行 | 23.6% | 1.60 ms / 1 已完成行 + 46 流式行 | 18% | 未拆分 46 次 vs 拆分 1 次 | +| 长会话(≥40 条历史,末条为用户) | 155.7 ms / 1932 行 | 147.3 ms / 1932 行 | **5.4%** | 4.58 ms / 41 已完成行 + 46 流式行 | **97.0%** | 未拆分 46 次 vs 拆分 1 次 | + +渲染次数两次完全一致。长会话时间偏差 5.4% < 20%,收益 97% >> 20%。空会话时间偏噪、收益不足 20%,验收以长会话为准。`usable: true`。 + +## 任务 2 · 五条清理 + +### 2.1 删除根 `force-dynamic` — 交付 + +改后 `next build` 路由模式: + +- `○ Static`:`/`、`/_not-found`、`/membership`、`/membership/orders` +- 仍为 `ƒ Dynamic`:`/login`、`/admin/**`、`/reports`、`/reports/[reportId]`、全部 API + +`/` 静态化说得清:根布局不读 `cookies()`/`headers()`,页面是 client,HTML 壳可预渲染。`next.config.ts` 仍对 `/` 和 `/login` 设 `private, no-store`。`/login` 自己读 `headers()`。会员页数据在客户端拉,静态壳可接受。 + +**既有断言改动(锁的是错误实现,不是“聊天 HTML 不缓存”):** +`frontend/tests/stale-client-recovery.test.ts` 不再要求根布局 `force-dynamic`,改为禁止根布局再写这行,并锁 login/reports/admin 仍各自声明。反缓存合同仍是 `next.config.ts` 的 `Cache-Control`。 + +### 2.2 Inter — 选 (a) + +`next/font/google` Inter,`display: "swap"`,`--font-inter` 挂在 ``。`DESIGN.md` §3 已记。`/` HTML 有 woff2 `rel="preload" as="font"`。未加载 Tiempos。建档 BUG-430。 + +### 2.3 `web/*.html` — 不删(止损/登记) + +`scripts/jyotish_api_server.py` 仍提供 `/`、`/evidence`、`/rectification`;`tests/test_api_async_job_contract.py` 锁路径。任务书检索不全。移走会改 `tests/**` 既有断言。见 `BLOCKED.md`。 + +### 2.4 public 资源 + +删除 5 个无引用 SVG。logo 按 2×(约 64px)重导出并 `optimize=True`:64×62 / **8,584 B**(原 33,511 B)。未换成 SVG(三维渲染)。`email-otp-login` 仍 `next/image` `alt=""`。 + +### 2.5 合并 effect + +一个 effect、三个显式分支。建档 BUG-429。新增测试,未改 `chart-library-other-profile` 既有断言。 + +## 任务 3 · 消息列表拆分 — 交付 + +`ChatTranscript` = `SettledMessageList`(memo)+ `StreamingMessageEntry`(memo)。流式期间历史 props 不含 token 文本。禁止手写 `useMemo`/`useCallback`;handler 走 `actionsRef`。 + +长会话收益见任务 1(97% 时间、列表渲染 46→1)。反向:`UnsplitChatTranscript` 回到 155 ms / 1932 行。 + +**既有断言改动:** + +- `chat-navigation-a11y-contract`:原先锁 `page.tsx` 内联 `chatMessageViews(`,那正是本条要拆掉的热点。无障碍合同改为锁 `aria-busy` + ` \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg deleted file mode 100644 index 567f17b0..00000000 --- a/frontend/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/jyotish-logo.png b/frontend/public/jyotish-logo.png index 4be83913f95eaed4de3cfc40b404d0720a3cca7c..6b382045befd5931d73ae829ba27cfc9ff0daab4 100644 GIT binary patch literal 8584 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%0DC;Kjr1wyT?>^Op`covDZW@qZ)0Y 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 IM6N<$f~?W;bN~PV diff --git a/frontend/public/next.svg b/frontend/public/next.svg deleted file mode 100644 index 5174b28c..00000000 --- a/frontend/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg deleted file mode 100644 index 77053960..00000000 --- a/frontend/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg deleted file mode 100644 index b2b2a44f..00000000 --- a/frontend/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/scripts/home-streaming-render-baseline.json b/frontend/scripts/home-streaming-render-baseline.json new file mode 100644 index 00000000..671284b7 --- /dev/null +++ b/frontend/scripts/home-streaming-render-baseline.json @@ -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." +} diff --git a/frontend/scripts/home-streaming-render-benchmark.mts b/frontend/scripts/home-streaming-render-benchmark.mts new file mode 100644 index 00000000..075ab1a2 --- /dev/null +++ b/frontend/scripts/home-streaming-render-benchmark.mts @@ -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 { + const actionsRef = createRef() 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; + } +} diff --git a/frontend/src/app/admin/admin.css b/frontend/src/app/admin/admin.css index 302e3d57..422a7071 100644 --- a/frontend/src/app/admin/admin.css +++ b/frontend/src/app/admin/admin.css @@ -1,3 +1,12 @@ +:root { + --font-body: StyreneB, var(--font-inter, Inter), system-ui, sans-serif; + --font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; +} +html, body { + width: 100%; + min-height: 100%; + margin: 0; +} .admin-app-shell { min-height: 100dvh; background: #f3f2ee; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index e86f560f..ba5da917 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -89,7 +89,7 @@ --shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2); --shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18); --font-display: "Tiempos Headline", "Songti SC", STSong, "Noto Serif CJK SC", Georgia, serif; - --font-body: StyreneB, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + --font-body: StyreneB, var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; --font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; --type-display-lg: 48px; --type-display-md: 36px; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 58cb79ca..14e13f9c 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,11 +1,14 @@ import type { Metadata, Viewport } from "next"; +import { Inter } from "next/font/google"; import Script from "next/script"; import { Toaster } from "@/components/ui/sonner"; import { StaleClientRecovery } from "@/components/stale-client-recovery"; -import "./globals.css"; -import "./birth-time-choice.css"; -export const dynamic = "force-dynamic"; +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); export const metadata: Metadata = { title: "Jyotisha · 印度占星", @@ -24,7 +27,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac && process.env.NEXT_PUBLIC_ENABLE_REACT_DEVTOOLS === "1"; return ( - + {enableReactDevTools && ( <> diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 649818a4..5865154f 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -3,6 +3,7 @@ import { readIdentityConfig } from "@/modules/identity/config"; import { resolveIdentitySurface } from "@/modules/identity/host"; import { headers } from "next/headers"; import { notFound } from "next/navigation"; +import "../site-styles"; export const dynamic = "force-dynamic"; diff --git a/frontend/src/app/membership/orders/page.tsx b/frontend/src/app/membership/orders/page.tsx index 3a65ab97..8488d12a 100644 --- a/frontend/src/app/membership/orders/page.tsx +++ b/frontend/src/app/membership/orders/page.tsx @@ -1,5 +1,6 @@ "use client"; +import "@/app/site-styles"; import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { ArrowLeft } from "lucide-react"; diff --git a/frontend/src/app/membership/page.tsx b/frontend/src/app/membership/page.tsx index f98360de..b1265585 100644 --- a/frontend/src/app/membership/page.tsx +++ b/frontend/src/app/membership/page.tsx @@ -1,5 +1,6 @@ "use client"; +import "@/app/site-styles"; import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; import Link from "next/link"; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 29cb543a..1647bc2d 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,11 +1,13 @@ "use client"; +import "@/app/site-styles"; import Link from "next/link"; import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; -import { ArrowDown, ArrowUpRight, Sparkles, X } from "lucide-react"; +import { ArrowDown, ArrowUpRight, Sparkles } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; +import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay"; import { AppSidebar } from "@/components/app-sidebar"; import { UserAvatar } from "@/components/user-avatar"; import { @@ -25,12 +27,12 @@ import { } from "@/lib/rectification-entry"; import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification"; import { ChatMessageContent } from "@/components/chat-message-content"; -import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; +import { AgentAvatar } from "@/components/chat-message-row"; import { - ChatMessageActions, toggleChatMessageFeedback, type ChatMessageFeedback, } from "@/components/chat-message-actions"; +import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-transcript"; import { ModelSelector } from "@/components/model-selector"; import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall"; import { BirthPlacePicker } from "@/components/birth-place-picker"; @@ -39,9 +41,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; import { clearStaleClientReload } from "@/lib/stale-client-recovery"; import { chinaLocations, type ProvinceNode } from "@/data/china-locations"; -import { ConversationFollowUps } from "@/components/conversation-follow-ups"; import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply"; -import { deriveConsultationFollowUps } from "@/lib/consultation-follow-ups"; import { beamAvatarPalettes, beamAvatarSchema, @@ -100,7 +100,8 @@ import { BALANCE_CHANGED_EVENT, membershipHref, } from "@/lib/membership"; -import { chatMessageViews, nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view"; +import { nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view"; +import { chartLibrarySessionBranch } from "@/lib/chart-library-session"; import { emptyConsultationTimeline, reduceConsultationTimeline, @@ -1094,7 +1095,6 @@ export default function Home() { const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [activeAccountDialog, setActiveAccountDialog] = useState(null); const [chartLibrary, setChartLibrary] = useState([]); - const [chartLibraryOpen, setChartLibraryOpen] = useState(false); const [synastryRelationshipType, setSynastryRelationshipType] = useState("romance"); const [synastryPendingId, setSynastryPendingId] = useState(null); const [otherProfileDraft, setOtherProfileDraft] = useState(emptyProfile); @@ -1178,6 +1178,15 @@ export default function Home() { const modelSelectionVersions = useRef(new Map()); const activeSessionIdRef = useRef(""); const chartLibraryLoadedAccount = useRef(""); + const openChartLibraryOnDialog = useRef(false); + const accountOverlayRef = useRef(null); + const accountOverlayEpochRef = useRef(0); + const chatActionsRef = useRef({ + onFeedback() {}, + onCopy() {}, + onRegenerate() {}, + onFollowUp() {}, + }); const activeOnboardingRequestIdentity = useRef(""); const accountRefreshGuard = useRef(createLatestAccountRequestGuard()); const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined); @@ -1300,43 +1309,41 @@ export default function Home() { }, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]); useEffect(() => { - if (!accountId) { + const branch = chartLibrarySessionBranch(accountId, chartLibraryLoadedAccount.current); + if (branch === "clear" || !accountId) { setChartLibrary([]); setSynastryHistory([]); chartLibraryLoadedAccount.current = ""; return; } - if (chartLibraryLoadedAccount.current === accountId) return; - chartLibraryLoadedAccount.current = accountId; - setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile)); - setSynastryHistory(readSynastryHistory(accountId)); - void fetchCloudChartLibrary() - .then((cloudLibrary) => { - setChartLibrary(() => { - const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile); - localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); - return next; + if (branch === "hydrate-then-persist") { + chartLibraryLoadedAccount.current = accountId; + setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile)); + setSynastryHistory(readSynastryHistory(accountId)); + void fetchCloudChartLibrary() + .then((cloudLibrary) => { + setChartLibrary(() => { + const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile); + localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); + return next; + }); + }) + .catch(() => { + // Cloud chart library is best-effort; local library remains usable. }); - }) - .catch(() => { - // Cloud chart library is best-effort; local library remains usable. - }); - void fetchCloudSynastryHistory() - .then((cloudHistory) => { - setSynastryHistory((current) => { - const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const)); - const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10); - writeSynastryHistory(accountId, next); - return next; + void fetchCloudSynastryHistory() + .then((cloudHistory) => { + setSynastryHistory((current) => { + const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const)); + const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10); + writeSynastryHistory(accountId, next); + return next; + }); + }) + .catch(() => { + // Cloud synastry history is best-effort; local history remains usable. }); - }) - .catch(() => { - // Cloud synastry history is best-effort; local history remains usable. - }); - }, [accountId, profile]); - - useEffect(() => { - if (!accountId) return; + } setChartLibrary((current) => { const next = upsertSelfChart(current, profile); localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); @@ -3745,6 +3752,188 @@ export default function Home() { })); const modalOpen = activeAccountDialog !== null || onboardingPaywallOpen; + chatActionsRef.current = { + onFeedback(feedbackKey, requested) { + setMessageFeedback((current) => { + const next = { ...current }; + const value = toggleChatMessageFeedback(current[feedbackKey], requested); + if (value) next[feedbackKey] = value; + else delete next[feedbackKey]; + return next; + }); + }, + onCopy(feedbackKey, text) { + void copyAssistantMessage(feedbackKey, text); + }, + onRegenerate(renderKey) { + regenerateLatestAnswer(renderKey); + }, + onFollowUp(question) { + void send(question, activeSession?.theme); + }, + }; + if (activeAccountDialog !== null) accountOverlayEpochRef.current += 1; + accountOverlayRef.current = { + title: activeAccountDialog ? accountDialogTitles[activeAccountDialog] : "", + dialogClass: activeAccountDialog ? accountDialogClasses[activeAccountDialog] : "", + signingOut, + close: closeAccountDialog, + takeOpenLibraryRequest() { + if (!openChartLibraryOnDialog.current) return false; + openChartLibraryOnDialog.current = false; + return true; + }, + overlayRef: accountDialog, + closeButtonRef: closeButton, + renderProfile(library) { + return ( + <> + {accountError &&

{accountError}

} + {account.avatar && ( +
+
+ 头像 + Beam 形象由随机种子生成,刷新和换设备后保持一致 +
+
+ +
+
+ {beamAvatarPalettes.map((palette, index) => ( + + ))} +
+ +
+
+ {avatarNotice &&

{avatarNotice}

} +
+ )} +
+
出生资料加密传输并保存到云端,用于此账号的所有对话
+
+
+ 当前默认星盘 + {profileDraft.name.trim() || "未命名"} + 角色:本人 +
+ +
+ {library.open && ( +
+
+ 本人 + {chartLibrary.filter((record) => record.role === "self").map((record) => ( +
+
+ {record.profile.name || "未命名"} + {record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)} +
+ 当前默认 +
+ ))} +
+
+ 其他 + {chartLibrary.filter((record) => record.role === "other").length === 0 &&

还没有其他星盘。

} + {chartLibrary.filter((record) => record.role === "other").map((record) => ( +
+
+ {record.profile.name || "未命名"} + {record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)} +
+
+ + + + +
+
+ ))} +
+ {synastryReportCard && ( +
+
+ 合盘结果摘要 + {synastryReportCard.partnerName} + Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"} +
+ {synastryReportCard.headline &&

{synastryReportCard.headline}

} +
+ 查看证据 +
    + {(synastryReportCard.strengths || []).map((item) =>
  • {item}
  • )} + {(synastryReportCard.risks || []).map((item) =>
  • {item}
  • )} +
+ 下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"} +
+
+ )} + {synastryHistory.length > 0 && ( +
+ 合盘历史 + {synastryHistory.slice(0, 5).map((item) => ( + + ))} +
+ )} +
+
添加其他星盘用于合盘、亲友盘或客户盘。
+ + + +
+ )} +
+ {profileNotice &&

{profileNotice}

} +
+ + + + + ); + }, + renderLogout() { + return ( + <> +

退出后,需要重新登录才能继续查看对话。

+ {accountError &&

{accountError}

} +
+ + +
+ + ); + }, + }; + return (
@@ -3788,11 +3977,11 @@ export default function Home() { onOpenReports={() => router.push("/reports")} onSelectSession={selectSession} onSelectChart={() => { - setChartLibraryOpen(true); + openChartLibraryOnDialog.current = true; openAccountDialog("profile"); }} onAddChart={() => { - setChartLibraryOpen(true); + openChartLibraryOnDialog.current = true; openAccountDialog("profile"); }} onOpenProfile={() => openAccountDialog("profile")} @@ -4007,55 +4196,23 @@ export default function Home() { ) : (
- {chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking, activeStreamingSections, activeStreamingTimeline).map((message, index, views) => { - const showActions = message.role === "assistant" - && message.state === "settled" - && Boolean(message.text); - const feedbackKey = `${activeSession.id}:${message.renderKey}`; - const latestRegeneratableKey = !isLoading && !cancellationPending - ? [...views].reverse().find((item) => ( - item.role === "assistant" && item.state === "settled" && Boolean(item.text) - ))?.renderKey - : undefined; - const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : ""; - const followUps = showActions - && message.renderKey === latestRegeneratableKey - && activeSession.sessionType === "consultation" - && previousQuestion - ? deriveConsultationFollowUps({ - question: previousQuestion, - answer: message.text, - theme: activeSession.theme, - entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null, - }) - : []; - return ( -
- - {showActions && ( - setMessageFeedback((current) => { - const next = { ...current }; - const value = toggleChatMessageFeedback(current[feedbackKey], requested); - if (value) next[feedbackKey] = value; - else delete next[feedbackKey]; - return next; - })} - onCopy={() => void copyAssistantMessage(feedbackKey, message.text)} - onRegenerate={() => regenerateLatestAnswer(message.renderKey)} - /> - )} - void send(question, activeSession.theme)} - /> -
- ); - })} + {activeError &&

{activeError}

}
)} @@ -4145,161 +4302,12 @@ export default function Home() { } - {activeAccountDialog !== null && ( -
-
event.stopPropagation()}> -
-

{accountDialogTitles[activeAccountDialog]}

- -
- - {activeAccountDialog === "profile" && ( - <> - {accountError &&

{accountError}

} - {account.avatar && ( -
-
- 头像 - Beam 形象由随机种子生成,刷新和换设备后保持一致 -
-
- -
-
- {beamAvatarPalettes.map((palette, index) => ( - - ))} -
- -
-
- {avatarNotice &&

{avatarNotice}

} -
- )} -
-
出生资料加密传输并保存到云端,用于此账号的所有对话
-
-
- 当前默认星盘 - {profileDraft.name.trim() || "未命名"} - 角色:本人 -
- -
- {chartLibraryOpen && ( -
-
- 本人 - {chartLibrary.filter((record) => record.role === "self").map((record) => ( -
-
- {record.profile.name || "未命名"} - {record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)} -
- 当前默认 -
- ))} -
-
- 其他 - {chartLibrary.filter((record) => record.role === "other").length === 0 &&

还没有其他星盘。

} - {chartLibrary.filter((record) => record.role === "other").map((record) => ( -
-
- {record.profile.name || "未命名"} - {record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)} -
-
- - - - -
-
- ))} -
- {synastryReportCard && ( -
-
- 合盘结果摘要 - {synastryReportCard.partnerName} - Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"} -
- {synastryReportCard.headline &&

{synastryReportCard.headline}

} -
- 查看证据 -
    - {(synastryReportCard.strengths || []).map((item) =>
  • {item}
  • )} - {(synastryReportCard.risks || []).map((item) =>
  • {item}
  • )} -
- 下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"} -
-
- )} - {synastryHistory.length > 0 && ( -
- 合盘历史 - {synastryHistory.slice(0, 5).map((item) => ( - - ))} -
- )} -
-
添加其他星盘用于合盘、亲友盘或客户盘。
- - - -
- )} -
- {profileNotice &&

{profileNotice}

} -
- - - - - )} - - {activeAccountDialog === "logout" && ( - <> -

退出后,需要重新登录才能继续查看对话。

- {accountError &&

{accountError}

} -
- - -
- - )} -
-
- )} + {onboardingPaywallOpen && account && ( void; +}>; + +export type AccountOverlayModel = Readonly<{ + title: string; + dialogClass: string; + signingOut: boolean; + close: () => void; + takeOpenLibraryRequest: () => boolean; + overlayRef: MutableRefObject; + closeButtonRef: MutableRefObject; + renderProfile: (library: AccountOverlayLibraryControls) => ReactNode; + renderLogout: () => ReactNode; +}>; + +export const AccountDialogOverlay = memo(function AccountDialogOverlay({ + open, + dialog, + openEpoch, + modelRef, +}: Readonly<{ + open: boolean; + dialog: "profile" | "logout" | null; + openEpoch: number; + modelRef: MutableRefObject; +}>) { + const [chartLibraryOpen, setChartLibraryOpen] = useState(false); + + useEffect(() => { + if (!open) return; + if (modelRef.current?.takeOpenLibraryRequest()) setChartLibraryOpen(true); + }, [dialog, modelRef, open]); + + if (!open || dialog === null) return null; + const model = modelRef.current; + if (!model) return null; + void openEpoch; + + return ( +
+
event.stopPropagation()} + > +
+

{model.title}

+ +
+ {dialog === "profile" && model.renderProfile({ + open: chartLibraryOpen, + toggle: () => setChartLibraryOpen((current) => !current), + })} + {dialog === "logout" && model.renderLogout()} +
+
+ ); +}); diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index 931aa593..255018ef 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -51,7 +51,7 @@ export function AdminApp({ children }: { children: ReactNode }) { borderRadiusLG: 8, boxShadowSecondary: "0 1px 2px rgba(29, 29, 31, .06)", boxShadowTertiary: "none", - fontFamily: "StyreneB, Inter, system-ui, sans-serif", + fontFamily: "StyreneB, var(--font-inter, Inter), system-ui, sans-serif", }, components: { Layout: { diff --git a/frontend/src/components/chat-transcript.tsx b/frontend/src/components/chat-transcript.tsx new file mode 100644 index 00000000..a15b028e --- /dev/null +++ b/frontend/src/components/chat-transcript.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { ChatMessageRow } from "@/components/chat-message-row"; +import { + ChatMessageActions, + toggleChatMessageFeedback, + type ChatMessageFeedback, +} from "@/components/chat-message-actions"; +import { ConversationFollowUps } from "@/components/conversation-follow-ups"; +import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint"; +import { deriveConsultationFollowUps } from "@/lib/consultation-follow-ups"; +import type { ConsultationDomain } from "@/lib/consultation-domain-registry"; +import type { AgentActivityView, ChatMessage, ChatMessageView } from "@/lib/chat-message-view"; +import { chatMessageViews, settledChatMessageViews, streamingChatMessageView } from "@/lib/chat-message-view"; +import type { PublicThinkingSection } from "@/lib/consultation-thinking-plan"; +import type { ConsultationTimelineRow } from "@/lib/consultation-run-timeline"; +import { + noteSettledListRender, + noteSettledRowRender, + noteStreamingRowRender, + noteUnsplitListRender, +} from "@/lib/home-streaming-render-probe"; +import { memo, type MutableRefObject } from "react"; + +export type ChatTranscriptActions = Readonly<{ + onFeedback: (feedbackKey: string, requested: ChatMessageFeedback) => void; + onCopy: (feedbackKey: string, text: string) => void; + onRegenerate: (renderKey: string) => void; + onFollowUp: (question: string) => void; +}>; + +export type ChatTranscriptProps = Readonly<{ + messages: readonly ChatMessage[]; + loading: boolean; + streamingText: string; + streamingActivity?: AgentActivityView; + streamingThinking?: string; + streamingSections?: readonly PublicThinkingSection[]; + streamingTimeline?: readonly ConsultationTimelineRow[]; + sessionId: string; + sessionType: "consultation" | "birth_time_rectification"; + theme?: ConsultationDomain; + messageFeedback: Readonly>; + copiedMessageKey: string | null; + cancellationPending: boolean; + productEntrypointsDisabled: boolean; + actionsRef: MutableRefObject; +}>; + +const SettledMessageEntry = memo(function SettledMessageEntry({ + message, + views, + index, + sessionId, + sessionType, + theme, + messageFeedback, + copiedMessageKey, + loading, + cancellationPending, + productEntrypointsDisabled, + actionsRef, +}: Readonly<{ + message: ChatMessageView; + views: readonly ChatMessageView[]; + index: number; + sessionId: string; + sessionType: "consultation" | "birth_time_rectification"; + theme?: ConsultationDomain; + messageFeedback: Readonly>; + copiedMessageKey: string | null; + loading: boolean; + cancellationPending: boolean; + productEntrypointsDisabled: boolean; + actionsRef: MutableRefObject; +}>) { + noteSettledRowRender(); + const showActions = message.role === "assistant" + && message.state === "settled" + && Boolean(message.text); + const feedbackKey = `${sessionId}:${message.renderKey}`; + const latestRegeneratableKey = !loading && !cancellationPending + ? [...views].reverse().find((item) => ( + item.role === "assistant" && item.state === "settled" && Boolean(item.text) + ))?.renderKey + : undefined; + const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : ""; + const followUps = showActions + && message.renderKey === latestRegeneratableKey + && sessionType === "consultation" + && previousQuestion + ? deriveConsultationFollowUps({ + question: previousQuestion, + answer: message.text, + theme, + entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null, + }) + : []; + return ( +
+ + {showActions && ( + actionsRef.current.onFeedback(feedbackKey, requested)} + onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)} + onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)} + /> + )} + actionsRef.current.onFollowUp(question)} + /> +
+ ); +}); + +export const SettledMessageList = memo(function SettledMessageList({ + messages, + sessionId, + sessionType, + theme, + messageFeedback, + copiedMessageKey, + loading, + cancellationPending, + productEntrypointsDisabled, + actionsRef, +}: Omit) { + noteSettledListRender(); + const views = settledChatMessageViews(messages); + return ( + <> + {views.map((message, index) => ( + + ))} + + ); +}); + +export const StreamingMessageEntry = memo(function StreamingMessageEntry({ + message, +}: Readonly<{ message: ChatMessageView }>) { + noteStreamingRowRender(); + return ( +
+ +
+ ); +}); + +export const ChatTranscript = memo(function ChatTranscript({ + messages, + loading, + streamingText, + streamingActivity, + streamingThinking, + streamingSections, + streamingTimeline, + sessionId, + sessionType, + theme, + messageFeedback, + copiedMessageKey, + cancellationPending, + productEntrypointsDisabled, + actionsRef, +}: ChatTranscriptProps) { + const streamingMessage = streamingChatMessageView( + messages, + loading, + streamingText, + streamingActivity, + streamingThinking, + streamingSections, + streamingTimeline, + ); + + return ( + <> + + {streamingMessage ? : null} + + ); +}); + +export function UnsplitChatTranscript({ + messages, + loading, + streamingText, + streamingActivity, + streamingThinking, + streamingSections, + streamingTimeline, + sessionId, + sessionType, + theme, + messageFeedback, + copiedMessageKey, + cancellationPending, + productEntrypointsDisabled, + actionsRef, +}: ChatTranscriptProps) { + noteUnsplitListRender(); + const views = chatMessageViews( + messages, + loading, + streamingText, + streamingActivity, + streamingThinking, + streamingSections, + streamingTimeline, + ); + return ( + <> + {views.map((message, index) => { + noteSettledRowRender(); + if (message.state !== "settled") noteStreamingRowRender(); + const showActions = message.role === "assistant" + && message.state === "settled" + && Boolean(message.text); + const feedbackKey = `${sessionId}:${message.renderKey}`; + const latestRegeneratableKey = !loading && !cancellationPending + ? [...views].reverse().find((item) => ( + item.role === "assistant" && item.state === "settled" && Boolean(item.text) + ))?.renderKey + : undefined; + const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : ""; + const followUps = showActions + && message.renderKey === latestRegeneratableKey + && sessionType === "consultation" + && previousQuestion + ? deriveConsultationFollowUps({ + question: previousQuestion, + answer: message.text, + theme, + entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null, + }) + : []; + return ( +
+ + {showActions && ( + actionsRef.current.onFeedback(feedbackKey, requested)} + onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)} + onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)} + /> + )} + actionsRef.current.onFollowUp(question)} + /> +
+ ); + })} + + ); +} + +export { toggleChatMessageFeedback }; diff --git a/frontend/src/lib/chart-library-session.ts b/frontend/src/lib/chart-library-session.ts new file mode 100644 index 00000000..b1c74777 --- /dev/null +++ b/frontend/src/lib/chart-library-session.ts @@ -0,0 +1,22 @@ +export type ChartLibrarySessionBranch = "clear" | "hydrate-then-persist" | "persist-self"; + +export function chartLibrarySessionBranch( + accountId: string | null | undefined, + loadedAccount: string, +): ChartLibrarySessionBranch { + if (!accountId) return "clear"; + if (loadedAccount !== accountId) return "hydrate-then-persist"; + return "persist-self"; +} + +export function chartLibraryFromCloudOthers( + cloudLibrary: readonly T[], + profile: unknown, + upsertSelfChart: (library: T[], profile: unknown) => T[], +): T[] { + return upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile); +} + +export function keepLocalChartLibraryOnCloudFailure(localLibrary: T): T { + return localLibrary; +} diff --git a/frontend/src/lib/chat-message-view.ts b/frontend/src/lib/chat-message-view.ts index c97943af..91e0363f 100644 --- a/frontend/src/lib/chat-message-view.ts +++ b/frontend/src/lib/chat-message-view.ts @@ -71,6 +71,41 @@ export type ChatMessageView = ChatMessage & { readonly timeline?: readonly ConsultationTimelineRow[]; }; +export function settledChatMessageViews( + messages: readonly ChatMessage[], +): readonly ChatMessageView[] { + return messages.map((message, index) => ({ + ...message, + renderKey: `message-${index}`, + state: "settled" as const, + ...(message.role === "assistant" + ? { timeline: consultationTimelineFromSettled(message) } + : {}), + })); +} + +export function streamingChatMessageView( + messages: readonly ChatMessage[], + loading: boolean, + streamingText: string, + activity?: AgentActivityView, + thinkingText?: string, + thinkingSections?: readonly PublicThinkingSection[], + timeline?: readonly ConsultationTimelineRow[], +): ChatMessageView | undefined { + if (!loading || messages.at(-1)?.role === "assistant") return undefined; + return { + role: "assistant", + text: streamingText, + thinkingText, + thinkingSections, + timeline: timeline ?? [], + renderKey: `message-${messages.length}`, + state: streamingText ? "streaming" : "thinking", + activity, + }; +} + export function chatMessageViews( messages: readonly ChatMessage[], loading: boolean, @@ -80,27 +115,15 @@ export function chatMessageViews( thinkingSections?: readonly PublicThinkingSection[], timeline?: readonly ConsultationTimelineRow[], ): readonly ChatMessageView[] { - const settled = messages.map((message, index) => ({ - ...message, - renderKey: `message-${index}`, - state: "settled" as const, - ...(message.role === "assistant" - ? { timeline: consultationTimelineFromSettled(message) } - : {}), - })); - if (!loading || messages.at(-1)?.role === "assistant") return settled; - - return [ - ...settled, - { - role: "assistant", - text: streamingText, - thinkingText, - thinkingSections, - timeline: timeline ?? [], - renderKey: `message-${messages.length}`, - state: streamingText ? "streaming" : "thinking", - activity, - }, - ]; + const settled = settledChatMessageViews(messages); + const streaming = streamingChatMessageView( + messages, + loading, + streamingText, + activity, + thinkingText, + thinkingSections, + timeline, + ); + return streaming ? [...settled, streaming] : settled; } diff --git a/frontend/src/lib/home-streaming-render-probe.ts b/frontend/src/lib/home-streaming-render-probe.ts new file mode 100644 index 00000000..81963d1c --- /dev/null +++ b/frontend/src/lib/home-streaming-render-probe.ts @@ -0,0 +1,50 @@ +export type HomeStreamingRenderProbeSnapshot = Readonly<{ + settledListRenders: number; + settledRowRenders: number; + streamingRowRenders: number; + unsplitListRenders: number; +}>; + +const emptySnapshot = (): HomeStreamingRenderProbeSnapshot => ({ + settledListRenders: 0, + settledRowRenders: 0, + streamingRowRenders: 0, + unsplitListRenders: 0, +}); + +let enabled = false; +let snapshot = emptySnapshot(); + +export function enableHomeStreamingRenderProbe() { + enabled = true; + snapshot = emptySnapshot(); +} + +export function disableHomeStreamingRenderProbe() { + enabled = false; + snapshot = emptySnapshot(); +} + +export function resetHomeStreamingRenderProbe() { + snapshot = emptySnapshot(); +} + +export function homeStreamingRenderProbeSnapshot(): HomeStreamingRenderProbeSnapshot { + return snapshot; +} + +export function noteSettledListRender() { + if (enabled) snapshot = { ...snapshot, settledListRenders: snapshot.settledListRenders + 1 }; +} + +export function noteSettledRowRender() { + if (enabled) snapshot = { ...snapshot, settledRowRenders: snapshot.settledRowRenders + 1 }; +} + +export function noteStreamingRowRender() { + if (enabled) snapshot = { ...snapshot, streamingRowRenders: snapshot.streamingRowRenders + 1 }; +} + +export function noteUnsplitListRender() { + if (enabled) snapshot = { ...snapshot, unsplitListRenders: snapshot.unsplitListRenders + 1 }; +} diff --git a/frontend/tests/account-dialog-overlay.test.ts b/frontend/tests/account-dialog-overlay.test.ts new file mode 100644 index 00000000..28cf25d3 --- /dev/null +++ b/frontend/tests/account-dialog-overlay.test.ts @@ -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() as AccountOverlayModel["overlayRef"]; + const closeButtonRef = createRef() as AccountOverlayModel["closeButtonRef"]; + const modelRef = createRef() 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); +}); diff --git a/frontend/tests/chart-library-session.test.ts b/frontend/tests/chart-library-session.test.ts new file mode 100644 index 00000000..5ce04b47 --- /dev/null +++ b/frontend/tests/chart-library-session.test.ts @@ -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); +}); diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index b731d7cb..36ad5161 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -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, /\{isLoading \? "Jyotisha 正在回答" : ""\}<\/span>/); - assert.match(pageSource, /
\n\s*\{chatMessageViews\(/); + assert.match(pageSource, /
\n\s* { diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts index d1bae1ce..684e7e9b 100644 --- a/frontend/tests/chat-stream-layout.test.ts +++ b/frontend/tests/chat-stream-layout.test.ts @@ -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, / { 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. diff --git a/frontend/tests/home-streaming-render-split.test.ts b/frontend/tests/home-streaming-render-split.test.ts new file mode 100644 index 00000000..7284f43c --- /dev/null +++ b/frontend/tests/home-streaming-render-split.test.ts @@ -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 { + const actionsRef = createRef() 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); +}); diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 08a33809..aa9c74dd 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -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); diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index cabdbc70..112011de 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -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: [] }, diff --git a/frontend/tests/site-style-isolation-contract.test.ts b/frontend/tests/site-style-isolation-contract.test.ts new file mode 100644 index 00000000..5a5581e5 --- /dev/null +++ b/frontend/tests/site-style-isolation-contract.test.ts @@ -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\)/); +}); diff --git a/frontend/tests/stale-client-recovery.test.ts b/frontend/tests/stale-client-recovery.test.ts index 457f73d6..16d7a1f7 100644 --- a/frontend/tests/stale-client-recovery.test.ts +++ b/frontend/tests/stale-client-recovery.test.ts @@ -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/); });