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

Settled messages no longer rebuild on every token, Inter is actually
requested, and admin routes drop the 33 KB chat stylesheet. Root
force-dynamic is gone so public shells can prerender without changing
the no-store Cache-Control contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-29 03:39:43 +08:00
co-authored by Cursor
parent bf697d71a2
commit 3198fb6b2b
41 changed files with 1528 additions and 307 deletions
+7
View File
@@ -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` 为空,不进交付——因为它买不到任何东西,见下一条。
+154
View File
@@ -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` 挂在 `<html>`。`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` + `<ChatTranscript`;列表切片仍不得出现 `aria-live`/`role="status"`(`role="alert"` 的错误行仍在 `page.tsx`)。
- `chat-stream-layout`:`<ChatMessageActions` 改锁 `chat-transcript.tsx`。合同是“普通咨询复用共享操作条”,不是 JSX 必须写在 `page.tsx`。
- `consultation-entrypoint`「已回答对话不再给输入框上方建议」:`ConversationFollowUps` / `deriveConsultationFollowUps` 改锁 transcript。合同是追问在答案下方而不是 composer chips。
未达 20% 才会还原;未触发。
## 任务 4 · Home 第一刀(账户弹层)— 交付
`AccountDialogOverlay`:`open === false` 时 props 稳定(`openEpoch` 固定为 0),memo 跳过。打开时从 `modelRef.current` 读最新模型。`chartLibraryOpen` 下沉到 overlay。
- `Home` `useState`:**61 → 60**
- `Home` `useEffect`:19 → 18(含任务 2.5)
- 未碰生时校正外壳
- DESIGN.md 弹层契约:关闭按钮 `aria-label="关闭"`、dialog `aria-modal`、错误/成功 live region、44px 触达类名未改
弹层关闭时的流式收益无法用 SSR 量化(关闭路径是 `return null`)。反向:`frontend/tests/account-dialog-overlay.test.ts` 证明 `open=false` 不调用 `renderProfile`/`renderLogout`。未触发 400 行硬停(overlay 组件约 80 行;资料 JSX 仍在 `page.tsx` 的 render 函数里以保住源码锁)。
## 任务 5 · admin 与 globals.css 隔离 — 交付
根布局不再 import `globals.css`。站点路由 import `site-styles.ts`。`error.tsx` / `not-found.tsx` **不能** import(它们与 admin 共享根段,会把 33 KB globals 打进 admin)。
`/admin/users|orders|roles|feature-flags` 客户端 CSS 清单:
| | 基线(任务 0,全树 ƒ) | 本轮 |
| --- | --- | --- |
| `/` CSS gzip | 33.7 KB | 34.5 KB(含 Inter `@font-face`) |
| `/admin/*` CSS gzip | globals 32.9 KB + admin 1.7 KB ≈ **34.6 KB** | admin 1.8 KB + Inter 0.8 KB = **2.5 KB** |
约 **−32 KB gzip**。未登录,未能目视后台四页;用构建清单代替。见 `BLOCKED.md`。
`/` 现为 `○ Static`,HTML 可预渲染。`/` JS gzip 458.2 KB(基线 438.5 KB,+Inter +transcript)。
## 本轮后 `Home` 数字
| | 任务 0 | 现在 |
| --- | ---: | ---: |
| 行数 | 3,224 | 3,231 |
| `useState` | 61 | 60 |
| `useEffect` | 19 | 18 |
| `useMemo`/`useCallback` | 0/0 | 0/0 |
| `React.memo`(`frontend/src`) | 0 | transcript + overlay |
## 既有测试断言改动(仅上述三处)
理由见 2.1 与任务 3。其余 `frontend/tests/**` 与 `tests/**` 未改语义。类型夹具 `as const` / `Object.assign` 只为 `tsc`(BUG-409),不断言行为。
## 止损
- 2.3:不删 `web/*.html`
- 3/4:未触发 <20% 还原
- 5:源码隔离后若 `error`/`not-found` 仍 import globals,admin 会拿回 33 KB;已从这两个边界拿掉
- 后台登录目视:阻塞
## 第 09 条 · 深色主题(待决策,人拍板)
`globals.css` 是 `color-scheme: light`,0 处 `prefers-color-scheme`。DESIGN.md 把纸感暖白写成产品身份,但 23,000 字文档没写“永远不做深色”。**这是产品决策,不是本轮代码任务。** 需要人决定:维持单一浅色,或另开一轮做深色主题。
## 最终测试数
基线 **2196** → 本轮 **2205 pass / 0 fail / 0 skipped**(+9:chart-library-session 5、home-streaming-render-split 1、site-style-isolation 2、account-dialog-overlay 1)。
浏览器(`next start` PORT 3010,无后端):`/login` 可见压缩 logo 与 Inter 变量 class、Link 头 `woff2` preload;`/membership` 站点样式完整;`/admin/users` 仍 403。未登录 `/` 因本机 Supabase 未配置走恢复页,不是本轮回归。后台已登录四页目视仍见 `BLOCKED.md`。
## 验收命令(本机)
```bash
./node_modules/.bin/tsc --noEmit
./node_modules/.bin/eslint # 0 error
./node_modules/.bin/tsx --test tests/*.test.ts
./node_modules/.bin/next build
```
+32
View File
@@ -6557,3 +6557,35 @@
- 复发自:无
- 修复版本:待发布
## BUG-429 | 同一账号资料变化时星盘库被两个 effect 各写一遍 localStorage
- 状态:resolved
- 首次发现:2026-08-29
- 最近更新:2026-08-29
- 影响面:`/` 主对话页星盘库 hydration 与本人记录 upsert
- 用户现象:进入对话或改一次出生资料时,主线程同步 `JSON.stringify` 并写 `localStorage` 两次;账号切换时两条路径靠 ref 提前 return 分工,没有显式分支。
- 触发条件:`accountId` 或 `profile` 引用变化。
- 根因:两个 `useEffect` 依赖数组都是 `[accountId, profile]`,一个负责首次本地+云端,一个无条件 upsert 本人。行为能跑通,但每次资料变化都重复序列化。
- 修复:合并为一个 effect,按 `clear` / `hydrate-then-persist` / `persist-self` 显式分支。云端失败仍保留本地库;本人记录仍 upsert;切账号仍清空。
- 验证:`frontend/tests/chart-library-session.test.ts` 覆盖清空、切账号 hydration、同账号 persist-self、云端只保留 other、云端失败保留本地。既有 `chart-library-other-profile` 源码锁未改。
- 防复发:同一份资料不得用两个同依赖 effect 靠 ref 提前 return 分工;分支名必须写在代码里。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
## BUG-430 | 字体栈声明了 Inter 却从未加载,Windows/Linux 静默落到系统黑体
- 状态:resolved
- 首次发现:2026-08-29
- 最近更新:2026-08-29
- 影响面:全站 `--font-body`、后台 antd `fontFamily`
- 用户现象:设计稿写了 Inter,实际请求里没有 woff2;非苹果系统落到 Segoe UI / 微软雅黑。
- 触发条件:打开任意带根布局的页面。
- 根因:仓库 0 个字体文件、0 处 `@font-face`、0 处 `next/font`,CSS 与 admin token 却把 Inter 写在 StyreneB 之后。
- 修复:根布局用 `next/font/google` 加载 Inter(`display: "swap"`、`--font-inter`)。`--font-body` 与 admin `fontFamily` 改为 `var(--font-inter, Inter)`。Tiempos / 宋体栈未改。构建产物与 `/` HTML 可见 woff2 preload。
- 验证:`frontend/tests/site-style-isolation-contract.test.ts`;`next build` 的 `/` HTML 含 Inter woff2 preload。
- 防复发:字体栈里出现的西文家族必须有 `next/font` 或 `@font-face`;缺授权的展示字体(Tiempos)不得用同一套办法偷偷补上。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
+1 -1
View File
@@ -41,7 +41,7 @@ Rules: neutrals stay warm; the action color is scarce; roughly ninety percent of
### Font stacks
- Display: `"Tiempos Headline", "Songti SC", "STSong", "Noto Serif CJK SC", Georgia, serif`. The licensed Copernicus/Tiempos files are unavailable; the Chinese Song serif stack is the declared production substitute.
- Body/UI: `StyreneB, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`.
- Body/UI: `StyreneB, var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`. Inter is loaded with `next/font/google` (`display: "swap"`, CSS variable `--font-inter`) so Windows/Linux no longer silently fall back past a never-requested Inter. Tiempos Headline remains unloaded; the Songti stack is still the declared production substitute.
- Code/data: `"JetBrains Mono", "SFMono-Regular", Consolas, monospace`.
### Scale
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,109 @@
{
"generatedAt": "2026-08-28T19:24:57.446Z",
"replyChars": 916,
"empty": {
"run1": {
"name": "empty-unsplit-1",
"tokens": 46,
"elapsedMs": 1.7442499999999654,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 92,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"run2": {
"name": "empty-unsplit-2",
"tokens": 46,
"elapsedMs": 2.1559169999999312,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 92,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"unsplit": {
"name": "empty-unsplit-1",
"tokens": 46,
"elapsedMs": 1.7442499999999654,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 92,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"split": {
"name": "empty-split",
"tokens": 46,
"elapsedMs": 1.5974159999999529,
"probe": {
"settledListRenders": 1,
"settledRowRenders": 1,
"streamingRowRenders": 46,
"unsplitListRenders": 0
}
}
},
"long": {
"run1": {
"name": "long-unsplit-1",
"tokens": 46,
"elapsedMs": 155.65337499999998,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 1932,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"run2": {
"name": "long-unsplit-2",
"tokens": 46,
"elapsedMs": 147.32045800000003,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 1932,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"unsplit": {
"name": "long-unsplit-1",
"tokens": 46,
"elapsedMs": 155.65337499999998,
"probe": {
"settledListRenders": 0,
"settledRowRenders": 1932,
"streamingRowRenders": 46,
"unsplitListRenders": 46
}
},
"split": {
"name": "long-split",
"tokens": 46,
"elapsedMs": 4.582125000000019,
"probe": {
"settledListRenders": 1,
"settledRowRenders": 41,
"streamingRowRenders": 46,
"unsplitListRenders": 0
}
}
},
"variance": {
"emptyElapsed": 0.23601375949547024,
"longElapsed": 0.053535087176875884,
"emptyUnsplitRenders": 0,
"longUnsplitRenders": 0
},
"splitGain": {
"emptyElapsed": 0.18084738422739577,
"longElapsed": 0.9697523383149724,
"longSettledListRenders": 1
},
"usable": true,
"notes": "renderToString always walks the tree, so this measures the split architecture (settled list once + streaming row per token) rather than React.memo itself. Reverse verification is runUnsplit vs runSplitArchitecture."
}
@@ -0,0 +1,205 @@
import { createElement, createRef } from "react";
import { renderToString } from "react-dom/server";
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
SettledMessageList,
StreamingMessageEntry,
UnsplitChatTranscript,
type ChatTranscriptActions,
type ChatTranscriptProps,
} from "../src/components/chat-transcript.tsx";
import type { ChatMessage } from "../src/lib/chat-message-view.ts";
import { streamingChatMessageView } from "../src/lib/chat-message-view.ts";
import {
disableHomeStreamingRenderProbe,
enableHomeStreamingRenderProbe,
homeStreamingRenderProbeSnapshot,
resetHomeStreamingRenderProbe,
type HomeStreamingRenderProbeSnapshot,
} from "../src/lib/home-streaming-render-probe.ts";
const STREAMING_TURN = "事业宫的火星落在第十宫,说明你在对外责任上会反复被推到台前。接下来三个月更适合把已经开过口的项目收束成可验收的结果,而不是再开一条全新战线。感情上第七宫的土星要求把边界说清楚:能一起承担日常安排,才值得把时间押上去。财务上第二宫与第十一宫同时被木星照面,收入机会来自既有圈子的转介,而不是短期投机。健康上注意睡眠被工作节奏切碎,先把就寝时间固定,再谈加码。以上判断来自 D1 与 D10 的宫主联系,D9 尚未用外部引擎闭环,所以婚恋时点只保留为条件性提示。";
const STREAMING_REPLY = STREAMING_TURN.repeat(4);
function chunkText(text: string, size: number): string[] {
const chunks: string[] = [];
for (let index = size; index <= text.length; index += size) {
chunks.push(text.slice(0, index));
}
if (chunks.at(-1) !== text) chunks.push(text);
return chunks;
}
function historyMessages(count: number): ChatMessage[] {
const messages: ChatMessage[] = [];
for (let index = 0; index < count; index += 1) {
messages.push(
index % 2 === 0
? { role: "user", text: `这是第 ${index + 1} 条用户问题,请根据星盘说明接下来三个月的事业安排。` }
: { role: "assistant", text: `这是第 ${index + 1} 条已完成回答。第十宫的工作节奏需要先收束已有项目,再考虑新的对外合作。${"证据层包括 D1 与 D10。".repeat(8)}` },
);
}
if (messages.at(-1)?.role !== "user") {
messages.push({ role: "user", text: "请在刚才的基础上继续说明这个月可以推进的安排。" });
}
return messages;
}
function emptyActions(): ChatTranscriptActions {
return {
onFeedback() {},
onCopy() {},
onRegenerate() {},
onFollowUp() {},
};
}
function baseProps(messages: readonly ChatMessage[]): Omit<ChatTranscriptProps, "loading" | "streamingText"> {
const actionsRef = createRef<ChatTranscriptActions>() as ChatTranscriptProps["actionsRef"];
actionsRef.current = emptyActions();
return {
messages,
sessionId: "session-benchmark",
sessionType: "consultation",
theme: "career",
messageFeedback: {},
copiedMessageKey: null,
cancellationPending: false,
productEntrypointsDisabled: true,
actionsRef,
};
}
export type ScenarioResult = Readonly<{
name: string;
tokens: number;
elapsedMs: number;
probe: HomeStreamingRenderProbeSnapshot;
}>;
function runUnsplit(name: string, messages: readonly ChatMessage[], reply: string): ScenarioResult {
const tokens = chunkText(reply, 20);
const props = baseProps(messages);
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
const started = performance.now();
for (const streamingText of tokens) {
renderToString(createElement(UnsplitChatTranscript, {
...props,
loading: true,
streamingText,
}));
}
const elapsedMs = performance.now() - started;
const probe = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
return { name, tokens: tokens.length, elapsedMs, probe };
}
function runSplitArchitecture(name: string, messages: readonly ChatMessage[], reply: string): ScenarioResult {
const tokens = chunkText(reply, 20);
const props = baseProps(messages);
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
const started = performance.now();
renderToString(createElement(SettledMessageList, {
messages: props.messages,
loading: true,
sessionId: props.sessionId,
sessionType: props.sessionType,
theme: props.theme,
messageFeedback: props.messageFeedback,
copiedMessageKey: props.copiedMessageKey,
cancellationPending: props.cancellationPending,
productEntrypointsDisabled: props.productEntrypointsDisabled,
actionsRef: props.actionsRef,
}));
for (const streamingText of tokens) {
const streamingMessage = streamingChatMessageView(messages, true, streamingText);
if (streamingMessage) {
renderToString(createElement(StreamingMessageEntry, { message: streamingMessage }));
}
}
const elapsedMs = performance.now() - started;
const probe = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
return { name, tokens: tokens.length, elapsedMs, probe };
}
function relativeDelta(first: number, second: number): number {
if (first === 0) return 0;
return Math.abs(first - second) / first;
}
export type BenchmarkReport = Readonly<{
generatedAt: string;
replyChars: number;
empty: { run1: ScenarioResult; run2: ScenarioResult; unsplit: ScenarioResult; split: ScenarioResult };
long: { run1: ScenarioResult; run2: ScenarioResult; unsplit: ScenarioResult; split: ScenarioResult };
variance: { emptyElapsed: number; longElapsed: number; emptyUnsplitRenders: number; longUnsplitRenders: number };
splitGain: { emptyElapsed: number; longElapsed: number; longSettledListRenders: number };
usable: boolean;
notes: string;
}>;
export function runHomeStreamingRenderBenchmark(): BenchmarkReport {
const emptyHistory = [{ role: "user" as const, text: "请根据星盘说明接下来三个月的事业安排。" }];
const longHistory = historyMessages(40);
runUnsplit("empty-warmup", emptyHistory, STREAMING_REPLY);
runUnsplit("long-warmup", longHistory, STREAMING_REPLY);
const emptyUnsplit1 = runUnsplit("empty-unsplit-1", emptyHistory, STREAMING_REPLY);
const emptyUnsplit2 = runUnsplit("empty-unsplit-2", emptyHistory, STREAMING_REPLY);
const longUnsplit1 = runUnsplit("long-unsplit-1", longHistory, STREAMING_REPLY);
const longUnsplit2 = runUnsplit("long-unsplit-2", longHistory, STREAMING_REPLY);
const emptySplit = runSplitArchitecture("empty-split", emptyHistory, STREAMING_REPLY);
const longSplit = runSplitArchitecture("long-split", longHistory, STREAMING_REPLY);
const emptyElapsedVariance = relativeDelta(emptyUnsplit1.elapsedMs, emptyUnsplit2.elapsedMs);
const longElapsedVariance = relativeDelta(longUnsplit1.elapsedMs, longUnsplit2.elapsedMs);
const emptyRenderVariance = relativeDelta(emptyUnsplit1.probe.unsplitListRenders, emptyUnsplit2.probe.unsplitListRenders);
const longRenderVariance = relativeDelta(longUnsplit1.probe.unsplitListRenders, longUnsplit2.probe.unsplitListRenders);
const emptyGain = 1 - emptySplit.elapsedMs / ((emptyUnsplit1.elapsedMs + emptyUnsplit2.elapsedMs) / 2);
const longGain = 1 - longSplit.elapsedMs / ((longUnsplit1.elapsedMs + longUnsplit2.elapsedMs) / 2);
const usable = emptyRenderVariance === 0 && longRenderVariance === 0
&& emptyUnsplit1.probe.streamingRowRenders === emptyUnsplit1.tokens
&& longUnsplit1.probe.streamingRowRenders === longUnsplit1.tokens
&& longSplit.probe.settledListRenders === 1
&& longSplit.probe.streamingRowRenders === longSplit.tokens
&& (emptyElapsedVariance < 0.2 && longElapsedVariance < 0.2
|| longGain > 0.2 && longGain > 2 * Math.max(emptyElapsedVariance, longElapsedVariance));
return {
generatedAt: new Date().toISOString(),
replyChars: STREAMING_REPLY.length,
empty: { run1: emptyUnsplit1, run2: emptyUnsplit2, unsplit: emptyUnsplit1, split: emptySplit },
long: { run1: longUnsplit1, run2: longUnsplit2, unsplit: longUnsplit1, split: longSplit },
variance: {
emptyElapsed: emptyElapsedVariance,
longElapsed: longElapsedVariance,
emptyUnsplitRenders: emptyRenderVariance,
longUnsplitRenders: longRenderVariance,
},
splitGain: {
emptyElapsed: emptyGain,
longElapsed: longGain,
longSettledListRenders: longSplit.probe.settledListRenders,
},
usable,
notes: "renderToString always walks the tree, so this measures the split architecture (settled list once + streaming row per token) rather than React.memo itself. Reverse verification is runUnsplit vs runSplitArchitecture.",
};
}
const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isDirectRun) {
const report = runHomeStreamingRenderBenchmark();
const outputPath = join(dirname(fileURLToPath(import.meta.url)), "home-streaming-render-baseline.json");
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify(report, null, 2));
if (!report.usable) {
console.error("benchmark is not stable enough to detect a 20% change");
process.exitCode = 1;
}
}
+9
View File
@@ -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;
+1 -1
View File
@@ -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;
+7 -4
View File
@@ -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 (
<html lang="zh-CN" suppressHydrationWarning>
<html lang="zh-CN" className={inter.variable} suppressHydrationWarning>
<head>
{enableReactDevTools && (
<>
+1
View File
@@ -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";
@@ -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";
+1
View File
@@ -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";
+251 -243
View File
@@ -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<AccountDialog | null>(null);
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
const [chartLibraryOpen, setChartLibraryOpen] = useState(false);
const [synastryRelationshipType, setSynastryRelationshipType] = useState<SynastryRelationshipType>("romance");
const [synastryPendingId, setSynastryPendingId] = useState<string | null>(null);
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
@@ -1178,6 +1178,15 @@ export default function Home() {
const modelSelectionVersions = useRef(new Map<string, number>());
const activeSessionIdRef = useRef("");
const chartLibraryLoadedAccount = useRef("");
const openChartLibraryOnDialog = useRef(false);
const accountOverlayRef = useRef<AccountOverlayModel | null>(null);
const accountOverlayEpochRef = useRef(0);
const chatActionsRef = useRef<ChatTranscriptActions>({
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 && <p className="form-error" role="alert">{accountError}</p>}
{account.avatar && (
<section className="sheet-section avatar-section" aria-labelledby="avatar-section-title">
<div className="section-heading">
<b id="avatar-section-title">头像</b>
<small>Beam 形象由随机种子生成,刷新和换设备后保持一致</small>
</div>
<div className="avatar-editor">
<UserAvatar avatar={account.avatar} size={96} label="当前头像预览" />
<div className="avatar-editor-controls">
<div className="avatar-palette-list" role="radiogroup" aria-label="头像配色">
{beamAvatarPalettes.map((palette, index) => (
<button
key={palette.name}
className="avatar-palette"
type="button"
role="radio"
aria-checked={account.avatar?.palette === index}
aria-label={palette.name}
title={palette.name}
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "set_palette", palette: index })}
>
{palette.colors.map((color) => <span key={color} style={{ backgroundColor: color }} />)}
</button>
))}
</div>
<button
className="button-secondary avatar-randomize"
type="button"
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "randomize" })}
>
{avatarSaving ? "保存中" : "换一个形象"}
</button>
</div>
</div>
{avatarNotice && <p className="form-success avatar-notice" role="status">{avatarNotice}</p>}
</section>
)}
<section className="sheet-section birth-section">
<div className="section-heading"><b>出生资料</b><small>加密传输并保存到云端,用于此账号的所有对话</small></div>
<div className="default-chart-card" aria-label="当前默认星盘">
<div>
<span>当前默认星盘</span>
<strong>{profileDraft.name.trim() || "未命名"}</strong>
<small>角色:本人</small>
</div>
<button className="button-secondary" type="button" onClick={library.toggle}>管理星盘库</button>
</div>
{library.open && (
<div className="chart-library-panel" aria-label="星盘库">
<div className="chart-library-group">
<b>本人</b>
{chartLibrary.filter((record) => record.role === "self").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<span>当前默认</span>
</article>
))}
</div>
<div className="chart-library-group">
<b>其他</b>
{chartLibrary.filter((record) => record.role === "other").length === 0 && <p className="empty-library-copy">还没有其他星盘。</p>}
{chartLibrary.filter((record) => record.role === "other").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<div className="chart-library-actions">
<select aria-label="关系类型" value={synastryRelationshipType} onChange={(event) => setSynastryRelationshipType(event.target.value as SynastryRelationshipType)} disabled={synastryPendingId !== null}>
<option value="romance">婚恋</option>
<option value="business">商业合作</option>
<option value="family">亲友/家庭</option>
<option value="general">其他关系</option>
</select>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === record.id ? "正在计算合盘..." : "用于合盘"}</button>
<button className="button-secondary" type="button" onClick={() => void makeDefaultChart(record)} disabled={profileSaving}>设为默认</button>
<button className="button-secondary danger-button" type="button" onClick={() => deleteOtherChart(record.id)}>删除</button>
</div>
</article>
))}
</div>
{synastryReportCard && (
<article className="synastry-report-card" aria-label="合盘结果摘要">
<div>
<span>合盘结果摘要</span>
<strong>{synastryReportCard.partnerName}</strong>
<small>Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"}</small>
</div>
{synastryReportCard.headline && <p>{synastryReportCard.headline}</p>}
<details>
<summary>查看证据</summary>
<ul>
{(synastryReportCard.strengths || []).map((item) => <li key={item}>{item}</li>)}
{(synastryReportCard.risks || []).map((item) => <li key={item}>{item}</li>)}
</ul>
<small>下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"}</small>
</details>
</article>
)}
{synastryHistory.length > 0 && (
<div className="synastry-history-list" aria-label="合盘历史">
<b>合盘历史</b>
{synastryHistory.slice(0, 5).map((item) => (
<button key={item.id} type="button" className="synastry-history-item" onClick={() => setSynastryReportCard(item)}>
<span>{item.partnerName}</span>
<small>Ashtakoot {item.score ?? "?"}/{item.maxScore ?? "?"} · {item.assessment || item.scoreBand || "待解释"}</small>
</button>
))}
</div>
)}
<form className="profile-form chart-library-form" onSubmit={saveOtherChart}>
<div className="section-heading"><b>添加其他星盘</b><small>用于合盘、亲友盘或客户盘。</small></div>
<ProfileFields value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" />
<button className="button-primary save-profile" type="submit" disabled={!account}>添加到星盘库</button>
</form>
</div>
)}
</section>
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
<form className="profile-form" onSubmit={saveProfile}>
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="profile-name" />
<button className="button-primary save-profile" type="submit" disabled={profileSaving || !account}>{profileSaving ? "保存中" : "保存出生资料"}</button>
</form>
</>
);
},
renderLogout() {
return (
<>
<p className="logout-copy">退出后,需要重新登录才能继续查看对话。</p>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={closeAccountDialog} disabled={signingOut}>取消</button>
<button className="button-primary danger-primary" type="button" onClick={() => void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "确认退出"}</button>
</div>
</>
);
},
};
return (
<SidebarProvider escapeBlocked={accountMenuOpen || modalOpen}>
<main className="chat-app">
@@ -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() {
</div>
) : (
<div className="message-list" aria-busy={isLoading}>
{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 (
<div className="message-entry" key={message.renderKey}>
<ChatMessageRow message={message} />
{showActions && (
<ChatMessageActions
feedback={messageFeedback[feedbackKey]}
copied={copiedMessageKey === feedbackKey}
canRegenerate={message.renderKey === latestRegeneratableKey}
onFeedback={(requested) => 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)}
/>
)}
<ConversationFollowUps
questions={followUps}
disabled={productEntrypointsDisabled}
onSelect={(question) => void send(question, activeSession.theme)}
/>
</div>
);
})}
<ChatTranscript
messages={activeSession.messages}
loading={isLoading}
streamingText={activeStreamingText}
streamingActivity={activeStreamingActivity}
streamingThinking={activeStreamingThinking}
streamingSections={activeStreamingSections}
streamingTimeline={activeStreamingTimeline}
sessionId={activeSession.id}
sessionType={activeSession.sessionType}
theme={activeSession.theme}
messageFeedback={messageFeedback}
copiedMessageKey={copiedMessageKey}
cancellationPending={cancellationPending}
productEntrypointsDisabled={productEntrypointsDisabled}
actionsRef={chatActionsRef}
/>
{activeError && <p className="error-message" role="alert">{activeError}</p>}
</div>
)}
@@ -4145,161 +4302,12 @@ export default function Home() {
</div>}
</SidebarInset>
{activeAccountDialog !== null && (
<div className="account-modal-overlay" onMouseDown={closeAccountDialog}>
<section className={`account-modal ${accountDialogClasses[activeAccountDialog]}`} ref={accountDialog} role="dialog" aria-modal="true" aria-labelledby="account-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
<header className="account-modal-header">
<h2 id="account-dialog-title">{accountDialogTitles[activeAccountDialog]}</h2>
<button className="dialog-close" ref={closeButton} aria-label="关闭" type="button" onClick={closeAccountDialog} disabled={signingOut}><X aria-hidden="true" /></button>
</header>
{activeAccountDialog === "profile" && (
<>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
{account.avatar && (
<section className="sheet-section avatar-section" aria-labelledby="avatar-section-title">
<div className="section-heading">
<b id="avatar-section-title">头像</b>
<small>Beam 形象由随机种子生成,刷新和换设备后保持一致</small>
</div>
<div className="avatar-editor">
<UserAvatar avatar={account.avatar} size={96} label="当前头像预览" />
<div className="avatar-editor-controls">
<div className="avatar-palette-list" role="radiogroup" aria-label="头像配色">
{beamAvatarPalettes.map((palette, index) => (
<button
key={palette.name}
className="avatar-palette"
type="button"
role="radio"
aria-checked={account.avatar?.palette === index}
aria-label={palette.name}
title={palette.name}
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "set_palette", palette: index })}
>
{palette.colors.map((color) => <span key={color} style={{ backgroundColor: color }} />)}
</button>
))}
</div>
<button
className="button-secondary avatar-randomize"
type="button"
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "randomize" })}
>
{avatarSaving ? "保存中" : "换一个形象"}
</button>
</div>
</div>
{avatarNotice && <p className="form-success avatar-notice" role="status">{avatarNotice}</p>}
</section>
)}
<section className="sheet-section birth-section">
<div className="section-heading"><b>出生资料</b><small>加密传输并保存到云端,用于此账号的所有对话</small></div>
<div className="default-chart-card" aria-label="当前默认星盘">
<div>
<span>当前默认星盘</span>
<strong>{profileDraft.name.trim() || "未命名"}</strong>
<small>角色:本人</small>
</div>
<button className="button-secondary" type="button" onClick={() => setChartLibraryOpen((current) => !current)}>管理星盘库</button>
</div>
{chartLibraryOpen && (
<div className="chart-library-panel" aria-label="星盘库">
<div className="chart-library-group">
<b>本人</b>
{chartLibrary.filter((record) => record.role === "self").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<span>当前默认</span>
</article>
))}
</div>
<div className="chart-library-group">
<b>其他</b>
{chartLibrary.filter((record) => record.role === "other").length === 0 && <p className="empty-library-copy">还没有其他星盘。</p>}
{chartLibrary.filter((record) => record.role === "other").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<div className="chart-library-actions">
<select aria-label="关系类型" value={synastryRelationshipType} onChange={(event) => setSynastryRelationshipType(event.target.value as SynastryRelationshipType)} disabled={synastryPendingId !== null}>
<option value="romance">婚恋</option>
<option value="business">商业合作</option>
<option value="family">亲友/家庭</option>
<option value="general">其他关系</option>
</select>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === record.id ? "正在计算合盘..." : "用于合盘"}</button>
<button className="button-secondary" type="button" onClick={() => void makeDefaultChart(record)} disabled={profileSaving}>设为默认</button>
<button className="button-secondary danger-button" type="button" onClick={() => deleteOtherChart(record.id)}>删除</button>
</div>
</article>
))}
</div>
{synastryReportCard && (
<article className="synastry-report-card" aria-label="合盘结果摘要">
<div>
<span>合盘结果摘要</span>
<strong>{synastryReportCard.partnerName}</strong>
<small>Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"}</small>
</div>
{synastryReportCard.headline && <p>{synastryReportCard.headline}</p>}
<details>
<summary>查看证据</summary>
<ul>
{(synastryReportCard.strengths || []).map((item) => <li key={item}>{item}</li>)}
{(synastryReportCard.risks || []).map((item) => <li key={item}>{item}</li>)}
</ul>
<small>下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"}</small>
</details>
</article>
)}
{synastryHistory.length > 0 && (
<div className="synastry-history-list" aria-label="合盘历史">
<b>合盘历史</b>
{synastryHistory.slice(0, 5).map((item) => (
<button key={item.id} type="button" className="synastry-history-item" onClick={() => setSynastryReportCard(item)}>
<span>{item.partnerName}</span>
<small>Ashtakoot {item.score ?? "?"}/{item.maxScore ?? "?"} · {item.assessment || item.scoreBand || "待解释"}</small>
</button>
))}
</div>
)}
<form className="profile-form chart-library-form" onSubmit={saveOtherChart}>
<div className="section-heading"><b>添加其他星盘</b><small>用于合盘、亲友盘或客户盘。</small></div>
<ProfileFields value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" />
<button className="button-primary save-profile" type="submit" disabled={!account}>添加到星盘库</button>
</form>
</div>
)}
</section>
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
<form className="profile-form" onSubmit={saveProfile}>
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="profile-name" />
<button className="button-primary save-profile" type="submit" disabled={profileSaving || !account}>{profileSaving ? "保存中" : "保存出生资料"}</button>
</form>
</>
)}
{activeAccountDialog === "logout" && (
<>
<p className="logout-copy">退出后,需要重新登录才能继续查看对话。</p>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={closeAccountDialog} disabled={signingOut}>取消</button>
<button className="button-primary danger-primary" type="button" onClick={() => void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "确认退出"}</button>
</div>
</>
)}
</section>
</div>
)}
<AccountDialogOverlay
open={activeAccountDialog !== null}
dialog={activeAccountDialog}
openEpoch={activeAccountDialog !== null ? accountOverlayEpochRef.current : 0}
modelRef={accountOverlayRef}
/>
{onboardingPaywallOpen && account && (
<OnboardingRedeemPaywall
credits={account.credits}
@@ -5,6 +5,7 @@ import { useEffect } from "react";
import { TriangleAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import "../../site-styles";
export default function ReportError({
error,
@@ -1,4 +1,5 @@
import { LoaderCircle } from "lucide-react";
import "../../site-styles";
export default function ReportLoading() {
return (
@@ -3,6 +3,7 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import "../../site-styles";
export default function ReportNotFound() {
return (
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { PersonalReportPage } from "@/components/personal-report/personal-report-page";
import "../../site-styles";
export const dynamic = "force-dynamic";
+1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { PersonalReportCenter } from "@/components/personal-report/personal-report-center";
import "../site-styles";
export const dynamic = "force-dynamic";
+2
View File
@@ -0,0 +1,2 @@
import "./globals.css";
import "./birth-time-choice.css";
@@ -0,0 +1,77 @@
"use client";
import { X } from "lucide-react";
import { memo, useEffect, useState, type MutableRefObject, type ReactNode } from "react";
export type AccountOverlayLibraryControls = Readonly<{
open: boolean;
toggle: () => void;
}>;
export type AccountOverlayModel = Readonly<{
title: string;
dialogClass: string;
signingOut: boolean;
close: () => void;
takeOpenLibraryRequest: () => boolean;
overlayRef: MutableRefObject<HTMLElement | null>;
closeButtonRef: MutableRefObject<HTMLButtonElement | null>;
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<AccountOverlayModel | null>;
}>) {
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 (
<div className="account-modal-overlay" onMouseDown={model.close}>
<section
className={`account-modal ${model.dialogClass}`}
ref={model.overlayRef}
role="dialog"
aria-modal="true"
aria-labelledby="account-dialog-title"
onMouseDown={(event) => event.stopPropagation()}
>
<header className="account-modal-header">
<h2 id="account-dialog-title">{model.title}</h2>
<button
className="dialog-close"
ref={model.closeButtonRef}
aria-label="关闭"
type="button"
onClick={model.close}
disabled={model.signingOut}
>
<X aria-hidden="true" />
</button>
</header>
{dialog === "profile" && model.renderProfile({
open: chartLibraryOpen,
toggle: () => setChartLibraryOpen((current) => !current),
})}
{dialog === "logout" && model.renderLogout()}
</section>
</div>
);
});
+1 -1
View File
@@ -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: {
+293
View File
@@ -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<Record<string, ChatMessageFeedback>>;
copiedMessageKey: string | null;
cancellationPending: boolean;
productEntrypointsDisabled: boolean;
actionsRef: MutableRefObject<ChatTranscriptActions>;
}>;
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<Record<string, ChatMessageFeedback>>;
copiedMessageKey: string | null;
loading: boolean;
cancellationPending: boolean;
productEntrypointsDisabled: boolean;
actionsRef: MutableRefObject<ChatTranscriptActions>;
}>) {
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 (
<div className="message-entry">
<ChatMessageRow message={message} />
{showActions && (
<ChatMessageActions
feedback={messageFeedback[feedbackKey]}
copied={copiedMessageKey === feedbackKey}
canRegenerate={message.renderKey === latestRegeneratableKey}
onFeedback={(requested) => actionsRef.current.onFeedback(feedbackKey, requested)}
onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)}
onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)}
/>
)}
<ConversationFollowUps
questions={followUps}
disabled={productEntrypointsDisabled}
onSelect={(question) => actionsRef.current.onFollowUp(question)}
/>
</div>
);
});
export const SettledMessageList = memo(function SettledMessageList({
messages,
sessionId,
sessionType,
theme,
messageFeedback,
copiedMessageKey,
loading,
cancellationPending,
productEntrypointsDisabled,
actionsRef,
}: Omit<ChatTranscriptProps, "streamingText" | "streamingActivity" | "streamingThinking" | "streamingSections" | "streamingTimeline">) {
noteSettledListRender();
const views = settledChatMessageViews(messages);
return (
<>
{views.map((message, index) => (
<SettledMessageEntry
key={message.renderKey}
message={message}
views={views}
index={index}
sessionId={sessionId}
sessionType={sessionType}
theme={theme}
messageFeedback={messageFeedback}
copiedMessageKey={copiedMessageKey}
loading={loading}
cancellationPending={cancellationPending}
productEntrypointsDisabled={productEntrypointsDisabled}
actionsRef={actionsRef}
/>
))}
</>
);
});
export const StreamingMessageEntry = memo(function StreamingMessageEntry({
message,
}: Readonly<{ message: ChatMessageView }>) {
noteStreamingRowRender();
return (
<div className="message-entry">
<ChatMessageRow message={message} />
</div>
);
});
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 (
<>
<SettledMessageList
messages={messages}
loading={loading}
sessionId={sessionId}
sessionType={sessionType}
theme={theme}
messageFeedback={messageFeedback}
copiedMessageKey={copiedMessageKey}
cancellationPending={cancellationPending}
productEntrypointsDisabled={productEntrypointsDisabled}
actionsRef={actionsRef}
/>
{streamingMessage ? <StreamingMessageEntry message={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 (
<div className="message-entry" key={message.renderKey}>
<ChatMessageRow message={message} />
{showActions && (
<ChatMessageActions
feedback={messageFeedback[feedbackKey]}
copied={copiedMessageKey === feedbackKey}
canRegenerate={message.renderKey === latestRegeneratableKey}
onFeedback={(requested) => actionsRef.current.onFeedback(feedbackKey, requested)}
onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)}
onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)}
/>
)}
<ConversationFollowUps
questions={followUps}
disabled={productEntrypointsDisabled}
onSelect={(question) => actionsRef.current.onFollowUp(question)}
/>
</div>
);
})}
</>
);
}
export { toggleChatMessageFeedback };
+22
View File
@@ -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<T extends { role: "self" | "other" }>(
cloudLibrary: readonly T[],
profile: unknown,
upsertSelfChart: (library: T[], profile: unknown) => T[],
): T[] {
return upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile);
}
export function keepLocalChartLibraryOnCloudFailure<T>(localLibrary: T): T {
return localLibrary;
}
+46 -23
View File
@@ -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;
}
@@ -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 };
}
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { createElement, createRef } from "react";
import { renderToString } from "react-dom/server";
import test from "node:test";
import {
AccountDialogOverlay,
type AccountOverlayModel,
} from "../src/components/account-dialog-overlay.tsx";
test("a closed account overlay does not render profile or logout content", () => {
const overlayRef = createRef<HTMLElement | null>() as AccountOverlayModel["overlayRef"];
const closeButtonRef = createRef<HTMLButtonElement | null>() as AccountOverlayModel["closeButtonRef"];
const modelRef = createRef<AccountOverlayModel | null>() as { current: AccountOverlayModel | null };
let profileRenders = 0;
let logoutRenders = 0;
modelRef.current = {
title: "个人资料",
dialogClass: "profile-modal",
signingOut: false,
close() {},
takeOpenLibraryRequest() { return false; },
overlayRef,
closeButtonRef,
renderProfile() {
profileRenders += 1;
return null;
},
renderLogout() {
logoutRenders += 1;
return null;
},
};
const html = renderToString(createElement(AccountDialogOverlay, {
open: false,
dialog: null,
openEpoch: 0,
modelRef,
}));
assert.equal(html, "");
assert.equal(profileRenders, 0);
assert.equal(logoutRenders, 0);
});
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
chartLibraryFromCloudOthers,
chartLibrarySessionBranch,
keepLocalChartLibraryOnCloudFailure,
} from "../src/lib/chart-library-session.ts";
type RecordShape = { id: string; role: "self" | "other" };
function upsertSelf(library: RecordShape[], profile: unknown): RecordShape[] {
const others = library.filter((record) => record.role !== "self");
if (!profile) return others;
return [{ id: "self", role: "self" }, ...others];
}
test("clearing the account empties the chart library session", () => {
assert.equal(chartLibrarySessionBranch(null, "acct-1"), "clear");
assert.equal(chartLibrarySessionBranch("", "acct-1"), "clear");
});
test("switching accounts hydrates local then cloud instead of writing the previous library", () => {
assert.equal(chartLibrarySessionBranch("acct-2", "acct-1"), "hydrate-then-persist");
});
test("a later profile change on the same account still upserts self", () => {
assert.equal(chartLibrarySessionBranch("acct-1", "acct-1"), "persist-self");
const current: RecordShape[] = [{ id: "other-1", role: "other" }];
assert.deepEqual(upsertSelf(current, { name: "self" }), [
{ id: "self", role: "self" },
{ id: "other-1", role: "other" },
]);
});
test("a successful cloud read keeps only non-self records before upserting self", () => {
const cloud: RecordShape[] = [
{ id: "stale-self", role: "self" },
{ id: "other-1", role: "other" },
];
assert.deepEqual(chartLibraryFromCloudOthers(cloud, { name: "self" }, upsertSelf), [
{ id: "self", role: "self" },
{ id: "other-1", role: "other" },
]);
});
test("a failed cloud read leaves the local library in place", () => {
const local: RecordShape[] = [{ id: "self", role: "self" }, { id: "other-1", role: "other" }];
assert.equal(keepLocalChartLibraryOnCloudFailure(local), local);
});
@@ -97,7 +97,7 @@ test("the streaming reply announces state transitions, never streamed deltas", (
// And: the old start-only region that lived inside the busy message list is gone.
assert.doesNotMatch(pageSource, /<span className="sr-only" aria-live="polite">\{isLoading \? "Jyotisha 正在回答" : ""\}<\/span>/);
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>\n\s*\{chatMessageViews\(/);
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>\n\s*<ChatTranscript/);
});
test("the reply phase covers start, completion and every terminal state", () => {
+2 -1
View File
@@ -5,6 +5,7 @@ import test from "node:test";
import { chatMessageViews } from "../src/lib/chat-message-view.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const transcriptSource = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const activitySource = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
@@ -182,7 +183,7 @@ test("ordinary consultation replies reuse the shared Agent action bar", () => {
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
assert.match(actionsSource, new RegExp(`aria-label="${label}"`));
}
assert.match(pageSource, /<ChatMessageActions/);
assert.match(transcriptSource, /<ChatMessageActions/);
assert.match(pageSource, /toggleChatMessageFeedback/);
assert.match(pageSource, /function regenerateLatestAnswer\(renderKey: string\)/);
assert.match(pageSource, /messages: session\.messages\.slice\(0, -1\)/);
@@ -233,14 +233,15 @@ test("homepage creation and sidebar selection resolve through distinct server in
test("an answered conversation offers no suggested follow-up questions", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const transcript = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
// Given: the chips above the composer stay gone. Grounded continuations sit under
// the latest answer and send immediately instead of filling the composer.
assert.doesNotMatch(source, /composer-suggestions|activeSuggestions|chooseConversationSuggestion/);
assert.doesNotMatch(styles, /composer-suggestions/);
assert.match(source, /ConversationFollowUps/);
assert.match(source, /deriveConsultationFollowUps/);
assert.match(transcript, /ConversationFollowUps/);
assert.match(transcript, /deriveConsultationFollowUps/);
assert.match(styles, /\.conversation-follow-ups/);
// Then: nothing in the conversation carries or stores a suggestion list any more.
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { createElement, createRef } from "react";
import { renderToString } from "react-dom/server";
import test from "node:test";
import {
SettledMessageList,
StreamingMessageEntry,
UnsplitChatTranscript,
type ChatTranscriptActions,
type ChatTranscriptProps,
} from "../src/components/chat-transcript.tsx";
import type { ChatMessage } from "../src/lib/chat-message-view.ts";
import { streamingChatMessageView } from "../src/lib/chat-message-view.ts";
import {
disableHomeStreamingRenderProbe,
enableHomeStreamingRenderProbe,
homeStreamingRenderProbeSnapshot,
resetHomeStreamingRenderProbe,
} from "../src/lib/home-streaming-render-probe.ts";
function propsFor(messages: readonly ChatMessage[]): Omit<ChatTranscriptProps, "loading" | "streamingText"> {
const actionsRef = createRef<ChatTranscriptActions>() as ChatTranscriptProps["actionsRef"];
actionsRef.current = {
onFeedback() {},
onCopy() {},
onRegenerate() {},
onFollowUp() {},
};
return {
messages,
sessionId: "session-test",
sessionType: "consultation",
theme: "career",
messageFeedback: {},
copiedMessageKey: null,
cancellationPending: false,
productEntrypointsDisabled: true,
actionsRef,
};
}
test("the split architecture renders settled history once while streaming tokens", () => {
const messages: ChatMessage[] = [
{ role: "user", text: "问题一" },
{ role: "assistant", text: "回答一,需要足够长才能生成追问卡片。" },
{ role: "user", text: "问题二" },
{ role: "assistant", text: "回答二,需要足够长才能生成追问卡片。" },
{ role: "user", text: "请继续说明这个月的安排。" },
];
const tokens = ["甲", "甲乙", "甲乙丙", "甲乙丙丁", "甲乙丙丁戊"];
const base = propsFor(messages);
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
renderToString(createElement(SettledMessageList, { ...base, loading: true }));
for (const streamingText of tokens) {
const streamingMessage = streamingChatMessageView(messages, true, streamingText);
assert.ok(streamingMessage);
renderToString(createElement(StreamingMessageEntry, { message: streamingMessage }));
}
const split = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
for (const streamingText of tokens) {
renderToString(createElement(UnsplitChatTranscript, { ...base, loading: true, streamingText }));
}
const unsplit = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
assert.equal(split.settledListRenders, 1);
assert.equal(split.streamingRowRenders, tokens.length);
assert.equal(unsplit.unsplitListRenders, tokens.length);
assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
});
@@ -63,9 +63,9 @@ const RELOCATION_2015_PROBE = {
question: "2015 年 5 月前后有没有搬家或长期住到外地?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no" as const, supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
],
information_gain: 0.87,
source: "dasha_boundary",
@@ -80,9 +80,9 @@ const EDUCATION_2014_PROBE = {
question: "2014 年前后有没有升学、转学或换学习环境?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no" as const, supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
],
information_gain: 0.64,
source: "dasha_activation",
@@ -118,8 +118,8 @@ const YEARLESS_D24_PROBE = {
question: "引擎给出的区分机会绑定 D24。",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no" as const, supports: ["05:10"], conflicts: ["05:00"] },
],
information_gain: 2.5,
source: "varga_contrast",
@@ -558,7 +558,7 @@ test("clicking A applies the choice without invoking a language model", async ()
test("answering a discriminator persists the next dated card so GET still has a tap target", async () => {
const closed = parseV9CaseDossier(twoProbeDossier());
assert.ok(closed);
closed.conversationSummary.activeFocus = null;
Object.assign(closed.conversationSummary, { activeFocus: null });
assert.equal(choiceCardFromCaseDossier(closed), null);
const accounting = persistChoiceAccounting(twoProbeDossier(), {
@@ -608,7 +608,7 @@ test("answering a discriminator persists the next dated card so GET still has a
const refreshed = parseV9CaseDossier(twoProbeDossier());
assert.ok(refreshed);
const answered = twoProbeInference();
refreshed.latestResult = {
Object.assign(refreshed, { latestResult: {
...refreshed.latestResult!,
decisionReceipt: {
...(refreshed.latestResult?.decisionReceipt ?? {}),
@@ -623,8 +623,8 @@ test("answering a discriminator persists the next dated card so GET still has a
}],
},
},
};
refreshed.conversationSummary.activeFocus = {
} });
Object.assign(refreshed.conversationSummary, { activeFocus: {
id: NEXT_FOCUS_ID,
caseId: CASE_ID,
questionId: String(setFocus.args.p_question_id),
@@ -636,7 +636,7 @@ test("answering a discriminator persists the next dated card so GET still has a
status: "active",
askedAt: "2026-08-28T07:37:50.000Z",
resolvedAt: null,
};
} });
const card = choiceCardFromCaseDossier(refreshed);
assert.ok(card);
assert.equal(card.focus_id, NEXT_FOCUS_ID);
@@ -10,6 +10,7 @@ import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-n
import { selectDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { contrastPacketFromDossier, decideFromDossier, overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { buildMethodFollowupPlan, conversationalSessionOutcome } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { parseV9CaseDossier, parseV9ComputeProjection } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { safeCaseProjection } from "../src/mastra/rectification-v9-tools.ts";
@@ -398,24 +399,24 @@ test("career and relationship training still discriminates before family or occu
const familyProbe = {
year: 2013,
year_label: "2013 年 3 月前后",
domain: "family",
domain: "family" as const,
event_family: "家人结婚、添丁或住院",
source: "dasha_boundary",
tracks: ["vimshottari", "narayana"],
source: "dasha_boundary" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false,
unique_minute_claim: false as const,
user_meaning: "时间范围锁定 2013 年 3 月前后。",
role: "distinguish",
phase: "candidate_discriminator",
role: "distinguish" as const,
phase: "candidate_discriminator" as const,
information_gain: 1.2,
semantic_key: "family.2013.03.dasha_boundary",
candidate_split_hash: "family.2013.03",
candidate_ids: ["05:00", "05:07", "05:12"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07", "05:12"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00", "05:12"] },
{ answer_class: "no", supports: ["05:12"], conflicts: ["05:00", "05:07"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:07", "05:12"] },
{ answer_class: "weak_yes" as const, supports: ["05:07"], conflicts: ["05:00", "05:12"] },
{ answer_class: "no" as const, supports: ["05:12"], conflicts: ["05:00", "05:07"] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
],
style_options: [
{ label: "明确发生且时间吻合", answer_class: "yes" },
@@ -423,7 +424,7 @@ test("career and relationship training still discriminates before family or occu
{ label: "明确没有发生", answer_class: "no" },
{ label: "这段记不清楚", answer_class: "unsure" },
],
};
} satisfies DiscriminatingEventProbe;
const dossier = {
evidence,
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
const siteStyles = readFileSync(new URL("../src/app/site-styles.ts", import.meta.url), "utf8");
const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8");
const adminCss = readFileSync(new URL("../src/app/admin/admin.css", import.meta.url), "utf8");
const homePage = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
test("Inter is loaded through next/font and applied as a CSS variable", () => {
assert.match(layout, /import \{ Inter \} from "next\/font\/google"/);
assert.match(layout, /display: "swap"/);
assert.match(layout, /variable: "--font-inter"/);
assert.match(layout, /className=\{inter\.variable\}/);
assert.match(readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"), /var\(--font-inter, Inter\)/);
assert.match(
readFileSync(new URL("../src/components/admin/admin-app.tsx", import.meta.url), "utf8"),
/var\(--font-inter, Inter\)/,
);
});
test("site chrome owns globals.css; admin does not import it", () => {
assert.match(siteStyles, /import "\.\/globals\.css"/);
assert.match(siteStyles, /import "\.\/birth-time-choice\.css"/);
assert.doesNotMatch(layout, /globals\.css|birth-time-choice\.css|site-styles/);
assert.match(homePage, /import "@\/app\/site-styles"/);
assert.match(loginPage, /import "\.\.\/site-styles"/);
assert.doesNotMatch(adminLayout, /globals\.css|site-styles/);
assert.doesNotMatch(
readFileSync(new URL("../src/app/error.tsx", import.meta.url), "utf8"),
/site-styles|globals\.css/,
);
assert.doesNotMatch(
readFileSync(new URL("../src/app/not-found.tsx", import.meta.url), "utf8"),
/site-styles|globals\.css/,
);
assert.match(adminCss, /--font-body: StyreneB, var\(--font-inter, Inter\)/);
});
+10 -1
View File
@@ -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/);
});