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 4be83913..6b382045 100644 Binary files a/frontend/public/jyotish-logo.png and b/frontend/public/jyotish-logo.png differ 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/); });