perf(frontend): split chat streaming, load Inter, isolate admin CSS
Settled messages no longer rebuild on every token, Inter is actually requested, and admin routes drop the 33 KB chat stylesheet. Root force-dynamic is gone so public shells can prerender without changing the no-store Cache-Control contract. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -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` 为空,不进交付——因为它买不到任何东西,见下一条。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)不得用同一套办法偷偷补上。
|
||||
- 相关记录:无
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
|
||||
@@ -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 +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 +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 |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 8.4 KiB |
@@ -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 +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 +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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||