Merge codex/rectification-ux-20260903: reveal the rectification surface once, one live row across choice and adoption, gaps as live rows, empty-case start, board first state
BUG-505..509. Verified: tsc 0, focused suites 186/186, full suite 2602/2568 with the 24 known no-Docker failures, lint 0 errors, next build 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
# BLOCKED
|
||||
|
||||
## 生时校正会话面:消除空白假死与交互摩擦(2026-09-03,分支 `codex/rectification-ux-20260903`,重做)
|
||||
|
||||
- **浏览器级手工验收未做:执行环境无登录态、无 Chrome。** 任务书任务 0 的手工项与任务 1 的整轮录屏(开场 → 3 道选择题 → 候选 → 采用)都做不了;已按任务书把它们写进 `docs/testing/staging-manual-walkthrough-20260901.md` 第 8 节(含深链/刷新与缺口重试两条),交给有真实会话的人。本轮的替代证据:`tests/rectification-surface-state.test.ts`(纯函数会话态/缺口态、hydration 超时/失败、turn 解析)与 `tests/rectification-surface-contract.test.ts`(源码锁:一次揭幕、prepare 阶段 hydration、无重挂、续接同一行、空态、板首态)。
|
||||
- **1.3「若快照 API 提供填报时间宫位表则直接渲染」未做:API 不提供。** `frontend/src/app/api/rectification/cases/[caseId]/route.ts` 的 dossier 响应只带 `accepted_time` / `confirmed_time` / `candidate_range`,无 natal/declared 宫位表;按任务书只做文案与收窄,不造数据。
|
||||
- **书面偏差:hydration 不是 `Promise.allSettled([turns, snapshot])`,而是一次请求套 4 秒 `Promise.race`。** turns 与快照来自同一个 `GET /api/rectification/cases/:id`,两个并行请求会重复;语义(拉完再切换、超时仍揭幕)不变。上限常量直接等于 `BOOTSTRAP_PREPARE_TIMEOUT_MS`,只有一个。
|
||||
- **书面偏差:turns 后到的填充与重试计数复位不用 `useEffect`。** `npm run lint` 的 `react-hooks` 规则拦截 effect 内同步 setState;改为 React 文档的"渲染中按上一 prop 调整 state"模式与事件处理器内复位,行为等价。
|
||||
- **书面偏差:首页卡片不带 `aria-busy`。** `tests/home-bootstrap-reveal.test.ts` 锁死 `starter-home.tsx` 不得出现 `aria-busy={`(unified-loading 裁决);卡片只用 `data-opening` + 静态文案 + `cursor: progress`,侧栏行仍带 `aria-busy`。
|
||||
|
||||
## Agent 聊天流式体验与双会话面统一(2026-09-01,分支 `codex/streaming-ux-20260901`)
|
||||
|
||||
- **~~任务 3 未做,等待第三批拆页合入~~ 已解除并完成(2026-09-02)。** `origin/staging` 合入 `bf6989ec`(第三批)、`124d3990`、`058e5db9` 后,本分支 rebase 到其上,任务 3 按任务书原文完成:`page.tsx` 的滚动 effect 与内联「跳到最新」按钮删除,跟随并入 `useConversationScrollAnchor`,校正面接入同一 hook 与 `JumpToLatestButton`,复用 `ChatComposer`(`value` 受控、500 字上限),`rectification-sticky-scroll.ts` 删除,720px 覆写删除。对应 DESIGN.md 四条与 BUG-477/476 一并落地。
|
||||
|
||||
@@ -7777,3 +7777,82 @@
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-505 | 生时校正面进入时先闪普通对话、再空白、再整面重挂;问题槽用「等待服务端更新」裸文案
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-03
|
||||
- 影响面:`use-rectification-surface`、`use-session-management`、`page.tsx`(面板 key、prepare 阶段)、`rectification-agentic-chat`、`starter-home`、`sidebar-session-row`
|
||||
- 用户现象:点首页卡片没有任何反馈;从侧栏进已有校正会话先闪一帧普通对话、再是一块空白面板、记录到了再整面重挂;新建校正第一轮结束时画面闪一下、滚动归零;恢复会话后快照没回来前问题区空着;答完题后 composer 上方是「当前没有可回答的问题,正在等待服务端更新。」之类没有动作、没有时限的文案。
|
||||
- 触发条件:任何进入校正面的路径(卡片、侧栏、深链/刷新);任何一轮结束到下一题到达之间。
|
||||
- 根因:`selectSession` 先 `setActiveSessionId` 再 open,`rectificationSurfaceOpen` 在 open 返回前为 false;面板 key 带 `rectificationTurns.length > 0 ? "ready" : "loading"`,turns 到达即重挂;面板挂载后自拉快照;问题缺口只有裸文案。
|
||||
- 修复:`/cases/open` 后用 `hydrateRectificationCase` 一次读取 turns + 快照(与 `BOOTSTRAP_PREPARE_TIMEOUT_MS` 同一 4 秒上限)再切会话、写 URL;`selectSession` 对未打开的校正会话不先切;启动时选中的校正会话在 prepare 阶段完成 hydration 再揭幕;key 只剩 session/Case 绑定,后到 turns 经渲染期 prop 调整只填空 transcript;卸载中止流与快照读取。入口卡片脚注静态「正在打开…」、侧栏行「打开中」,不转圈。缺口改为 `rectificationQuestionGapState`:`preparing`(时间线 live 行「正在准备下一个问题…」+ 2s 定时重拉 ≤2 次)→ `unavailable`(「没有拿到下一个问题。」+ 44px「重新加载」);hydration 超时也走同一缺口。
|
||||
- 验证:`rectification-surface-contract`(一次揭幕、无重挂、prepare 阶段 hydration、静态入口反馈、缺口四态、无「等待服务端更新」);`rectification-surface-state`(hydration 超时/失败、缺口纯函数、turn 解析);`rectification-agentic-entry` / `rectification-question-in-message` / `rectification-spoken-collect` 的旧锁按红线改注。
|
||||
- 防复发:面板一个会话只挂载一次,turns 与快照都是 prop/state 更新;任何等待必须是时间线 live 行或带动作的状态,不得出现「等待服务端更新」类文案;揭幕后不得出现非生成中的 spinner。
|
||||
- 相关记录:BUG-479(首页两阶段揭幕)、BUG-473(校正面接入共享时间线)
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-506 | 选择题点击后有空帧并闪下一题卡片;采用候选只有按钮变灰
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-03
|
||||
- 影响面:`rectification-agentic-chat`(`send`、`submitStructuredChoice`、`acceptCandidate`)
|
||||
- 用户现象:点完选项,「正在记录本次选择…」那行消失一帧,下一题卡片闪现又消失,再出现「正在处理…」;点「采用此时间」只看到按钮变灰,页面其它部分静止好几秒。
|
||||
- 触发条件:选择题 `willContinue`;采用候选。
|
||||
- 根因:`willContinue` 分支删掉 thinking 行、置 `choiceContinuationPending`、`finally setPending(false)`,下一次 effect 才 `send("read_only")` 再追加新行;中间那一帧 `busy=false` 且快照刚装进新 `choiceCard`。采用流程不追加任何 live 行。
|
||||
- 修复:`send(action, text, continuation)`,continuation 复用已有 live 行并跳过 busy 守卫;选择与采用在同一个 async 链里 `await send("read_only", …, { reuseAssistantRenderKey })`;`busy` 全程为 true;采用点击即追加「正在采用 HH:MM…」行;`choiceContinuationPending` 与 effect 删除。
|
||||
- 验证:`rectification-surface-contract`「一条 live 行贯穿」;`rectification-agentic-entry` / `rectification-answer-choice` 的旧锁按红线改注。
|
||||
- 防复发:后续轮不得经 effect 中转;一条链里 `busy` 不得掉回 false。
|
||||
- 相关记录:BUG-505
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-507 | 记录为空的已有校正会话进入后永远空白
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-03
|
||||
- 影响面:`rectification-agentic-chat`、`rectification-surface-state`
|
||||
- 用户现象:某个开场当时失败/未持久化的校正会话,从侧栏再进来什么都不显示,composer 可用但不知道先说什么。
|
||||
- 触发条件:`initialTurns=[]` 且服务端对 resumed case 一律 `should_start_opening=false`。
|
||||
- 根因:面板只在 `shouldStartOpening` 时自动开场,空 turns 无任何 CTA。
|
||||
- 修复:`rectificationConversationState` 判 `empty` → 「这段校正还没有开始。」+「开始提问」(`send("opening")`,`openingStarted` 守卫;服务端幂等抑制重复 opening,不改服务端)。
|
||||
- 验证:`rectification-surface-state` 会话态纯函数;`rectification-surface-contract` 空态源码锁。
|
||||
- 防复发:无 turns 且不自动开场时必须给起点。
|
||||
- 相关记录:BUG-505
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-508 | 选择题选中无确认感、记录中只有沙漏光标
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-03
|
||||
- 影响面:`rectification-choice-card`
|
||||
- 用户现象:点了选项只看到底色变化,卡里没有进度,不知道有没有点上。
|
||||
- 触发条件:任何选择题点击。
|
||||
- 根因:`is-pending` 只改 `cursor: wait`;选中项无对勾。
|
||||
- 修复:选中项加 `Check` + 「已选择」;pending 时卡顶一行 `InlineSpinner` + shimmer「正在记录…」(与时间线 live 行同形)。`35688015` 已把选中态收进按钮、`d9404976` 已内嵌进消息,本轮只补差额;用户选择的回显由内嵌卡的 `answer_option` 承担(刷新前后一致),不再另加用户气泡。
|
||||
- 验证:`rectification-surface-contract`。
|
||||
- 防复发:卡内等待只能是时间线 live 行同形。
|
||||
- 相关记录:BUG-506
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-509 | 校正盘面首态是一整块空面板
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-03
|
||||
- 影响面:`rectification-board`、`rectification-board-model`、`page.tsx`、`globals.css`
|
||||
- 用户现象:桌面端右侧 18–22.5rem 的面板第一阶段只有一句「补充经历后…」,移动端 peek 同样;用户明明填过出生时间。
|
||||
- 触发条件:`candidateResult` 为空。
|
||||
- 根因:首态不用 profile 的填报时间。
|
||||
- 修复:`declaredTime` 由 `page.tsx` 从 profile 派生(`reportedTime || time`,须为 `H:MM`)传入;头部时钟位显示填报时间,正文「填报出生时间 HH:MM」+「回答几个问题后…」;peek「填报 HH:MM」;`is-board-empty` 收窄到 `minmax(16rem, 18rem)`。快照 API 不提供填报时间的宫位表,不造数据。
|
||||
- 验证:`rectification-surface-state` 板文案纯函数;`rectification-surface-contract`。
|
||||
- 防复发:首态必须显示已知的填报时间;不得为填报时间伪造宫位表。
|
||||
- 相关记录:BUG-505
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# PROGRESS · 生时校正会话面:消除空白假死与交互摩擦(重做,2026-09-03)
|
||||
|
||||
工作树:`.worktrees/rectification-ux-20260903`
|
||||
分支:`codex/rectification-ux-20260903`
|
||||
基线:`origin/staging` @ `af411f59`(任务书重启说明所在),收尾时 rebase 到 `0cefaea6`(两次均为文档提交:任务书搬家、任务状态板;无代码变化)。
|
||||
任务书:`docs/tasks/TASK-rectification-ux-20260902.md`,以其「2026-09-03 重启说明」为准。第一次执行的分支 `codex/rectification-ux-20260902` @ `fec000f7` 只作参考,未 cherry-pick。
|
||||
|
||||
未改服务端与 SQL。未改 `.gitea/workflows/**`。未合并 staging,未碰 main。未手写 `useCallback` / `useMemo`(`rectification-agentic-chat.tsx` 既有的原样保留;新增的 `refetchQuestion` / `reloadQuestion` / `startOpeningManually` 是普通函数)。BUG 编号:开工确认远端最大号 504,本轮 505–509。未复活问题槽、未复活 consult handoff。
|
||||
|
||||
| 任务 | 状态 | commit | BUG |
|
||||
| --- | --- | --- | --- |
|
||||
| 0.2 一次揭幕、面板不重挂、深链/popstate 并入 prepare 阶段 | 完成 | `53017d41` | BUG-505 |
|
||||
| 0.1 入口有反馈不转圈 | 完成(同 commit) | `53017d41` | BUG-505 |
|
||||
| 0.3 问题槽只有生成中态、在对话流内 | 前半由 staging `d9404976` 完成(题目内嵌消息,槽已删);后半"没有等待快照这一态"随 0.2 成立 | — | — |
|
||||
| 0.4 「等待服务端更新」→ live 行 + 重试 + 重新加载 | 完成 | `360989ca` | BUG-505 |
|
||||
| 0.6 空 turns 会话的起点 | 完成 | `360989ca` | BUG-507 |
|
||||
| 0.7 停止后的文案 | 完成(现状是静默返回,已补通知) | `360989ca` | — |
|
||||
| 1.4 开场 live 文案 | 完成 | `360989ca` | — |
|
||||
| 1.5 402 跳转前提示 | 完成 | `360989ca` | — |
|
||||
| 0.5 选择题/采用不留缝不闪卡 | 完成(接 `beginLiveRun` / `rememberLiveActivity`) | `1e50007c` | BUG-506 |
|
||||
| 1.1 选择卡确认感 | 补差额完成(选中态进按钮由 `35688015`、内嵌变体由 `d9404976` 已做;本轮补对勾「已选择」与卡顶「正在记录…」行) | `3c33e598` | BUG-508 |
|
||||
| 1.2 用户选择回显 | **跳过**:`d9404976` 已把题目内嵌进 assistant 消息,答过的卡以 `question.answer_option` 标出所选项,持久化 turn 的 `question` 也带 `answer_option`,刷新前后都能看到自己选了什么;再加一条用户气泡会重复 | — | — |
|
||||
| 1.3 盘面首态 | 完成(文案 + 收窄 + peek;宫位表 API 不提供,未造数据) | `3c33e598` | BUG-509 |
|
||||
| 2 DESIGN.md | 完成 | 本 docs commit | — |
|
||||
|
||||
## 六段空白与死角:各自的消除证据
|
||||
|
||||
| 空白 | 消除方式 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 1 首页卡片无反馈 | `rectificationCardLabel` 在 `rectificationLoading` 时为「正在打开…」;卡片 `data-opening` + `cursor: progress`;侧栏目标行「打开中」+ `aria-busy` | `rectification-surface-contract` → "entry feedback is static…" |
|
||||
| 2 侧栏进入:普通对话 → 空白 → 重挂 | `selectSession` 对未打开的校正会话不先 `setActiveSessionId`/写 URL;hook 在 `/cases/open` 后 `hydrateRectificationCase`(一次 Case 读取 turns + 快照,`BOOTSTRAP_PREPARE_TIMEOUT_MS` 上限)再一次性切换;面板 key 去掉 `-ready/-loading` | 同上 → "…revealed once…";`rectification-surface-state` → hydration 两条 |
|
||||
| 2′ 深链 / 刷新 / popstate | page 的 resume effect 从"揭幕后"改为"prepare 阶段起";`bootstrapPrepareSettled` 新增 `rectificationApplicable/Settled`,校正会话 hydration 完成(或 open 报错 / 资料不全)才揭幕;popstate 走 `selectSession`,同样延后切换 | `rectification-surface-contract` → "…hydrated before the reveal, and popstate defers…";`home-bootstrap-reveal` 既有用例不变(新字段可选) |
|
||||
| 3 第一轮结束整面重挂 | key 只剩 `${sessionId}-${caseId}`;后到的 turns 经渲染期按上一 prop 调整只填空 transcript;卸载 `abort()` | 同上(`doesNotMatch(page, /"ready" : "loading"/)`、`seededTurns` 锁) |
|
||||
| 4 快照回来前问题区空 | 快照随揭幕到位(`initialSnapshot` 初始化 state,`caseSnapshotLoaded` 初值 `initialSnapshot !== null`);面板不再挂载自拉;hydration 超时时走 0.4 的缺口重试 | 同上(`useState(initialSnapshot !== null)`、无挂载 fetch);`rectification-surface-state` → "a snapshot that never arrived is a gap…" |
|
||||
| 5 选择题两段缝 + 闪卡 | `send(action, text, continuation)` 续接同一条 live 行并跳过 busy 守卫;`willContinue` 在同一 async 链里 `await send("read_only", …)`;`choiceContinuationPending` 与 effect 删除;`busy` 全程 true | 同上 → "…keeps one live row through the follow-up turn" |
|
||||
| 6 采用只有按钮变灰 | 点击即追加「正在采用 HH:MM…」行,POST → 快照 → 复用该行续接 read_only;失败移除该行 | 同上;`rectification-surface-state` → `rectificationAdoptingLabel` |
|
||||
| 死角 空 turns 会话 | `rectificationConversationState` 判 `empty` → 「这段校正还没有开始。」+「开始提问」(`send("opening")`,`openingStarted` 守卫) | `rectification-surface-state` → "conversation state…";契约 → "an empty Case…" |
|
||||
| 文案 「等待服务端更新」 | `rectificationQuestionGapState`:`preparing`(transcript 末尾一条时间线 live 行「正在准备下一个问题…」+ 2s 定时重拉 ≤2 次)→ `unavailable`(「没有拿到下一个问题。」+ 44px「重新加载」);`question_source: "unavailable"` 直接 `unavailable`;候选卡待采用时不算缺口 | `rectification-surface-state` → 缺口三条;契约 → "…never tells the reader to wait for the server" |
|
||||
|
||||
## 改动文件
|
||||
|
||||
- 新建:`frontend/src/lib/rectification-surface-state.ts`(常量、hydration、turn 解析含 `question`/`offer_result_id`/`tool_activities`、会话态与缺口态纯函数、板文案、填报时间派生)、`frontend/tests/rectification-surface-state.test.ts`(10 用例)、`frontend/tests/rectification-surface-contract.test.ts`(8 用例)。
|
||||
- 改动:`use-rectification-surface.ts`、`use-session-management.ts`、`home-bootstrap.ts`(可选字段)、`page.tsx`(2 个 state、hook 参数、卡片文案、resume effect 门、prepare 判定、面板 key/props、侧栏 prop)、`app-sidebar.tsx`、`sidebar-session-row.tsx`、`starter-home.tsx`、`conversational-birth-time-rectification.tsx`(props 与 turn 类型)、`rectification-agentic-chat.tsx`、`consultation-run-timeline.tsx`(导出 `ConsultationTimelineLiveRow`)、`rectification-choice-card.tsx`、`rectification-board.tsx`、`rectification-board-model.ts`(`rectificationBoardPeekCopy` 第二参可选)、`globals.css`、`DESIGN.md`、`docs/BUG_HISTORY.md`、`docs/testing/staging-manual-walkthrough-20260901.md`(第 8 节)、`docs/tasks/README.md`(索引)、`BLOCKED.md`。
|
||||
|
||||
## 与任务书的书面偏差(均登记 BLOCKED.md)
|
||||
|
||||
1. hydration 是一次 `GET /api/rectification/cases/:id` 套 4 秒 `Promise.race`,不是 `Promise.allSettled([turns, snapshot])`(同一端点,两次请求会重复);上限常量直接等于 `BOOTSTRAP_PREPARE_TIMEOUT_MS`,只有一个。
|
||||
2. turns 后到的填充与重试计数复位不用 `useEffect`(`react-hooks/set-state-in-effect`):前者渲染期按上一 prop 调整,后者在 `applyCaseSnapshot`(有问题到达)与 `setPending(true)` 里复位。
|
||||
3. 首页卡片不带 `aria-busy`:`tests/home-bootstrap-reveal.test.ts` 锁死 `starter-home.tsx` 不得出现 `aria-busy={`。卡片只用 `data-opening` + 静态文案 + `cursor: progress`;侧栏行仍带 `aria-busy`。
|
||||
4. 缺口的 live 行放在 transcript 末尾(`.rectification-message-wrap.rectification-question-gap`),不是 staging 原来的 composer 上方状态行;`.rectification-composer-status` 样式删除。
|
||||
|
||||
## 1.3 核实
|
||||
|
||||
`frontend/src/app/api/rectification/cases/[caseId]/route.ts` 的 dossier 响应只带 `accepted_time` / `confirmed_time` / `candidate_range`,无 natal/declared 宫位表;按任务书只做文案与收窄,`declaredTime` 由 `page.tsx` 从 profile(`reportedTime || time`,须为 `H:MM`)派生。
|
||||
|
||||
## 被触碰的既有断言(红线 4,每处源码上方均注明原值与错因)
|
||||
|
||||
| 文件 | 原断言 | 现在 | 理由 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tests/rectification-agentic-entry.test.ts` :85、:194 附近 | `key={…-${rectificationTurns.length > 0 ? "ready" : "loading"}}` | key 只含 session/Case;`doesNotMatch /"ready" : "loading"/` | 锁的正是重挂缺陷(BUG-505) |
|
||||
| 同上 :87 | hook 内联 `methods: Array.isArray((turn.receipt as { methods?: unknown }).methods)` | `parsePersistedRectificationTurns(payload?.turns)` + lib 里 `methods: stringList(receipt.methods)` | 解析器搬到 lib 供 hydration 与刷新共用(BUG-505) |
|
||||
| 同上 :233–:236 | `await loadCaseSnapshot(); choiceContinuationPending.current = true;`、effect 内 `void send("read_only", "")` | `await loadCaseSnapshot(); … await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel })`;`doesNotMatch /choiceContinuationPending/`;原 `doesNotMatch` 裸调用锁保留 | 锁的是 effect 中转(BUG-506) |
|
||||
| 同上 workspace class | 无 `is-board-empty` 的类模板 | 含 `${candidateResult ? "" : " is-board-empty"}` | 板收窄(BUG-509) |
|
||||
| `tests/rectification-question-in-message.test.ts` :136–137 | `/当前没有可回答的问题,正在等待服务端更新/`、`/题目加载失败,请刷新/` | `doesNotMatch` 两句 + 锁 `RECTIFICATION_QUESTION_PREPARING_LABEL` / `_UNAVAILABLE_COPY` | 锁的是裸等待文案(BUG-505) |
|
||||
| `tests/rectification-spoken-collect.test.ts` "missing current_question…" | `showMissingQuestion = Boolean(…)` 与同两句文案 | `rectificationQuestionGapState({ … questionMissing: currentQuestion === null … resumableCase })`;`doesNotMatch` 两句 | 同上;仍锁 resumable 门 |
|
||||
| `tests/rectification-answer-choice.test.ts` :1201 | `/send\("read_only", ""\)/` | `send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel })` | 后续轮带续接行(BUG-506) |
|
||||
|
||||
新增测试:`tests/rectification-surface-state.test.ts`(10)、`tests/rectification-surface-contract.test.ts`(8)。
|
||||
|
||||
## 命令输出
|
||||
|
||||
`./node_modules/.bin/tsc --noEmit` → **0**(每个 commit 前各一次,rebase 后再一次)。
|
||||
|
||||
`npm run lint` → **0 error,73 warning**;触及文件里唯一的 warning 是 `rectification-agentic-chat.tsx` `submitStructuredChoice` 的既有 `exhaustive-deps`(staging 同在,依赖表原本就缺 `busy` / `actionIdForChoice`,本轮未动);新建的三个文件 0 warning。
|
||||
|
||||
`./node_modules/.bin/tsx --test tests/*.test.ts`(基线在干净 `af411f59` 上跑):
|
||||
|
||||
| | 基线 `af411f59` | 现在 |
|
||||
| --- | ---: | ---: |
|
||||
| tests | 2584 | 2602 |
|
||||
| pass | 2550 | 2568 |
|
||||
| fail | 24 | 24 |
|
||||
| skipped | 10 | 10 |
|
||||
|
||||
24 条失败与基线逐条比对**完全同一集合**(`comm -3` 差集为空):全部是本机无 Docker / PostgreSQL / 部署夹具的既有缺口。10 条 skipped 同基线。rebase 到 `8db71aaf` / `0cefaea6` 只改了 md 文件,src/tests 逐字节相同,全量数字沿用;rebase 后复跑 tsc 与校正相关聚焦套件(见下)。
|
||||
|
||||
聚焦套件(rebase 后,15 个文件):`rectification-surface-state`、`rectification-surface-contract`、`rectification-agentic-entry`、`rectification-question-in-message`、`rectification-spoken-collect`、`rectification-answer-choice`、`rectification-board-model`、`rectification-varga-style-copy`、`home-bootstrap-reveal`、`chat-session-url`、`chat-notice-and-scroll-contract`、`starter-questions`、`class-name-definition-contract`、`touch-target-contract`、`dark-theme-contract` → **173 / 173**。
|
||||
|
||||
`./node_modules/.bin/next build` → **0**。`✓ Compiled successfully in 17.8s`、`Finished TypeScript in 41s`、`Generating static pages (62/62)`,`/` 仍是 `○ (Static)`。
|
||||
|
||||
## 未做与原因
|
||||
|
||||
见 `BLOCKED.md` 顶部:浏览器级手工验收(六条手工项 + 深链/刷新 + 缺口重试 + 整轮录屏)无登录态无 Chrome,已写进 `docs/testing/staging-manual-walkthrough-20260901.md` 第 8 节;1.3 的宫位表 API 不提供;四条书面偏差。
|
||||
@@ -44,7 +44,7 @@
|
||||
| `TASK-rectification-adopt-flow-20260902.md` | — | 采用流程整治 | 已验收 | `35e5781e`(BUG-497~500) |
|
||||
| `TASK-rectification-adopt-flow-fix-20260903.md` | — | 采用流程修复单(含删「用这个时间看盘」) | 已验收 | `e8c98c37`(BUG-501/502) |
|
||||
| `TASK-rectification-uncertainty-stop-20260903.md` | — | 不确定度停止规则加样本下限 | 已验收 | `0c0df426`(BUG-503) |
|
||||
| `TASK-rectification-ux-20260902.md` | — | 会话面空白假死与交互摩擦 | 执行中 | 分支 `codex/rectification-ux-20260903`(09-03 在新基线重做,BUG-505 起) |
|
||||
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 待合并 | 分支 `codex/rectification-ux-20260903`(09-03 在新基线重做,BUG-505–509,已推送待验收) |
|
||||
|
||||
### 聊天主链路与首页
|
||||
|
||||
|
||||
@@ -53,6 +53,29 @@
|
||||
- 找一条消息很多的老对话点开:侧栏应立即出现,消息稍后加载(有加载态),不卡整页。
|
||||
- 星盘库增删改、发起合盘、点每日星语、进出生时校正:应与改造前一致——这四块代码整体搬过家,重点看有没有点了没反应或样式塌掉。
|
||||
|
||||
## 8. 生时校正面:一次揭幕与无缝续接(BUG-505–509)
|
||||
|
||||
1. 首页点「生时校正」卡片。
|
||||
- ✅ 预期:卡片脚注立即变成「正在打开…」(不转圈),随后**一次性**出现完整面板:历史消息、当前问题、右侧盘面同时就位;不会先出现一个空面板再填内容。
|
||||
2. 在普通对话里,从侧栏点一个已有的校正会话。
|
||||
- ✅ 预期:旧画面保持不动,侧栏该行短暂显示「打开中」,然后直接切到校正面板;**不闪**一帧普通对话,**不出现**空白面板。
|
||||
3. 直接用带 `?c=` 的校正会话链接打开页面,或在校正会话里刷新。
|
||||
- ✅ 预期:加载屏结束后直接是校正面板,不会先出现普通对话再换成校正面板。
|
||||
4. 新建一段校正,等第一条引导说完。
|
||||
- ✅ 预期:进行中行先写「正在读取你的出生资料,准备第一个问题…」;第一轮结束的瞬间画面不闪、滚动不归零、时间线开合状态保留。
|
||||
5. 连续答两道选择题。
|
||||
- ✅ 预期:点完选项立刻在卡上看到对勾「已选择」、卡顶一行「正在记录…」,下面一条「正在记录本次选择…」的进行中行**一直在**,直到下一题出现在新的回复里;中间没有空帧,下一题卡片不会闪现又消失。
|
||||
6. 出现候选时间后点「采用此时间」。
|
||||
- ✅ 预期:transcript 末尾出现「正在采用 HH:MM…」进行中行,同一条行一直到后续回复出现。
|
||||
7. 找一个当初开场失败的旧校正会话(记录为空)进入。
|
||||
- ✅ 预期:看到「这段校正还没有开始。」和「开始提问」按钮,点后正常开场。
|
||||
8. 右侧盘面在没有候选前。
|
||||
- ✅ 预期:头部时钟位显示你填报的出生时间,正文是「填报出生时间 HH:MM」两行;面板比有结果时窄一些。
|
||||
9. 流式回答中点停止。
|
||||
- ✅ 预期:已生成内容保留,提示「已停止,已生成的内容保留;本次不会扣点。」,不是「暂时不可用」。
|
||||
10. 一轮结束后下一题迟迟不来(可用 DevTools 把 `/api/rectification/cases/*` 节流或断网)。
|
||||
- ✅ 预期:末尾是一条「正在准备下一个问题…」的进行中行,约 4 秒后变成「没有拿到下一个问题。」和「重新加载」按钮;任何时候都不出现「等待服务端更新」。
|
||||
|
||||
---
|
||||
|
||||
四轮改造:BUG-464(消息权威化)、BUG-465(会话 URL)、BUG-466(云端唯一真相)、拆页两批。自动化侧每轮均已验收(tsc / 2400+ 条测试 / 构建 / SQL 幂等),此清单覆盖的是自动化够不着的真浏览器行为。
|
||||
|
||||
@@ -200,6 +200,31 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
|
||||
- **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows.
|
||||
- **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative.
|
||||
|
||||
### Rectification surface
|
||||
|
||||
The birth-time rectification session is the consultation transcript plus a house board; questions live inside the assistant message that asked them. It shares every waiting vocabulary with the consultation surface; nothing here spins or says "loading" after the reveal.
|
||||
|
||||
- **Reveal:** the surface mounts once per session/Case binding. Opening a Case (homepage card, sidebar row, deep link or refresh) reads the turns and the snapshot in one Case request before the switch, under the same 4-second budget as the home reveal (`RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS` is `BOOTSTRAP_PREPARE_TIMEOUT_MS`); a session selected at bootstrap is hydrated during the prepare phase. The entry shows a static note meanwhile (card footer “正在打开…”, sidebar row “打开中”, `cursor: progress`) and the previous view stays put. Turns and snapshot are initial state; anything that arrives later is a prop or state update, never a remount. A late or failed read still reveals, with the composer notice “校正记录没有完全加载,可以继续”.
|
||||
- **States** — what the transcript's trailing entry and the composer show:
|
||||
|
||||
| State | Trailing entry | Composer |
|
||||
|---|---|---|
|
||||
| `opening` | live row “正在读取你的出生资料,准备第一个问题…”, then tool labels | disabled, stop visible |
|
||||
| `empty` | “这段校正还没有开始。” and one primary action “开始提问” | enabled |
|
||||
| `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card |
|
||||
| `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled |
|
||||
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “重新加载” | enabled |
|
||||
| `choice-pending` | the answered card (`Check` + “已选择”, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | disabled, stop visible |
|
||||
| `candidates` | the offer cards under the message that offered them | enabled |
|
||||
| `adopting` | “正在采用 HH:MM…” through the follow-up turn | disabled during the chain |
|
||||
| `confirmed` | “已确认校正时间:HH:MM” | enabled |
|
||||
| `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled |
|
||||
| stopped | the row settles with what streamed; notice “已停止,已生成的内容保留;本次不会扣点。” | enabled |
|
||||
|
||||
- **Rules:** a follow-up turn continues on the live row already in place; `busy` never drops in the middle of a chain. No copy may ask the reader to wait for the server; a gap is a live row with retries, then a reload button. A hydration that timed out is the same gap. A 402 shows “校正点数不足,正在前往兑换…” for 600ms before the page leaves.
|
||||
- **Board:** before any candidate exists the header clock shows the declared birth minute and the body reads “填报出生时间 HH:MM” / “回答几个问题后,这里会显示宫位随时间的变化。”; the column narrows to `minmax(16rem, 18rem)` (`is-board-empty`) and widens once a result arrives. No house table is invented for the declared time; the snapshot API does not provide one.
|
||||
- **Accessibility:** the sidebar opening note sets `aria-busy`; the live row is the timeline row (`role="status"` shimmer label); the reload and start actions are real 44px buttons.
|
||||
|
||||
### Birth time intake
|
||||
|
||||
- **Structure:** birth date, five radio choice rows for time knowledge, then only the time, uncertainty, period, or clue field required by the selected source.
|
||||
@@ -407,6 +432,8 @@ or user IDs.
|
||||
|
||||
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
|
||||
|
||||
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。
|
||||
|
||||
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
|
||||
|
||||
Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
@@ -757,6 +757,14 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: 0; width: 2px; background: var(--sidebar-ring); }
|
||||
.session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-caption); font-weight: 500; }
|
||||
.session-title > svg { width: 14px; height: 14px; flex: 0 0 auto; color: currentColor; }
|
||||
/* A rectification session whose Case is being opened: a static note, no spinner after the reveal. */
|
||||
.session-opening-note {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 500;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.session-main small { color: var(--color-ink-tertiary); line-height: 1.3; font-size: var(--type-overline); }
|
||||
.session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; justify-self: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out; }
|
||||
.session-menu-trigger > svg { width: 18px; height: 18px; }
|
||||
@@ -995,6 +1003,9 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
|
||||
.product-entrypoint-hitarea { position: absolute; z-index: 2; inset: 0; width: 100%; min-height: 0; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; }
|
||||
.product-entrypoint-card > :not(.product-entrypoint-hitarea) { position: relative; z-index: 1; pointer-events: none; }
|
||||
/* The card is opening a Case: static copy and a progress cursor, no spinner (unified-loading ruling). */
|
||||
.product-entrypoint-card[data-opening="true"],
|
||||
.product-entrypoint-card[data-opening="true"] .product-entrypoint-hitarea { cursor: progress; }
|
||||
.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); transform: translateY(0); }
|
||||
.product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; }
|
||||
@media (hover: hover) {
|
||||
@@ -2624,6 +2635,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
/* No candidate yet: the board only carries the declared time, so it takes less
|
||||
width until there is a house table to show. */
|
||||
.rectification-workspace.is-board-empty {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(16rem, 18rem);
|
||||
}
|
||||
.rectification-workspace.is-compact {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
@@ -2920,11 +2936,60 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-weight: 600;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.rectification-composer-status {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
/* The gap between a settled turn and its next question, as the last entry of the
|
||||
transcript: a live timeline row while refetching, then copy and a reload. */
|
||||
.rectification-question-gap {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
justify-items: start;
|
||||
margin: var(--space-2) 0 var(--space-3);
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
}
|
||||
.rectification-question-gap__copy {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
/* The standalone live row has no summary above it. */
|
||||
.consultation-run-timeline__list.is-standalone { margin-top: 0; }
|
||||
/* A Case with nothing to show and no automatic opening: one line and one way to start. */
|
||||
.rectification-empty-state {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
justify-items: start;
|
||||
margin: var(--space-4) 0;
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.rectification-empty-state p { margin: 0; }
|
||||
/* The tap is being recorded: same shape as a timeline live row. */
|
||||
.rectification-choice-card__pending {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 24px;
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rectification-choice-card__selected {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline-start: var(--space-2);
|
||||
color: var(--color-action);
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.rectification-choice-card__selected svg { width: 14px; height: 14px; }
|
||||
.rectification-board__clock.is-declared { color: var(--color-ink-tertiary); }
|
||||
.rectification-board__empty { display: grid; gap: var(--space-1); }
|
||||
.rectification-board__empty p { margin: 0; }
|
||||
.rectification-snapshot {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
type RectificationEntrySummary,
|
||||
} from "@/lib/rectification-entry";
|
||||
import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
|
||||
import {
|
||||
declaredBirthTime,
|
||||
RECTIFICATION_OPENING_LABEL,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
import {
|
||||
toggleChatMessageFeedback,
|
||||
type ChatMessageFeedback,
|
||||
@@ -295,6 +300,8 @@ export default function Home() {
|
||||
const [rectificationReadonly, setRectificationReadonly] = useState(false);
|
||||
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
|
||||
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
|
||||
const [rectificationSnapshot, setRectificationSnapshot] = useState<RectificationCaseSnapshotPayload | null>(null);
|
||||
const [rectificationOpeningSessionId, setRectificationOpeningSessionId] = useState<string | null>(null);
|
||||
const [rectificationEntrySummary, setRectificationEntrySummary] = useState<RectificationEntrySummary | null>(null);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [bootstrapPhase, setBootstrapPhase] = useState<BootstrapPhase>("account");
|
||||
@@ -415,7 +422,10 @@ export default function Home() {
|
||||
latestTerminal: null,
|
||||
},
|
||||
);
|
||||
const rectificationCardLabel = rectificationEntryLabels[rectificationCardAction];
|
||||
const rectificationCardLabel = rectificationLoading
|
||||
? RECTIFICATION_OPENING_LABEL
|
||||
: rectificationEntryLabels[rectificationCardAction];
|
||||
const rectificationDeclaredTime = declaredBirthTime(profile);
|
||||
const rectificationErrorMessage = rectificationError === "profile_incomplete"
|
||||
? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。"
|
||||
: rectificationError;
|
||||
@@ -482,7 +492,8 @@ export default function Home() {
|
||||
setDraftTheme, setOnboardingStep, setProfileNotice, setRectificationCaseId,
|
||||
setRectificationEntrySummary, setRectificationError, setRectificationLoading,
|
||||
setRectificationPendingQuestion, setRectificationReadonly, setRectificationSessionId,
|
||||
setRectificationShouldStartOpening, setRectificationTurns, setSessions, uiPreview,
|
||||
setRectificationShouldStartOpening, setRectificationSnapshot, setRectificationOpeningSessionId,
|
||||
setRectificationTurns, setSessions, uiPreview,
|
||||
updateSession, openAccountDialog, refreshAccount, rectificationSessionOpenerRef,
|
||||
});
|
||||
|
||||
@@ -502,8 +513,13 @@ export default function Home() {
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, [hydrated]);
|
||||
|
||||
// A rectification session selected at bootstrap (deep link, refresh, the
|
||||
// most recent session) is opened and hydrated during the prepare phase, so
|
||||
// the reveal shows the surface itself rather than a plain transcript that is
|
||||
// swapped out a moment later. After the reveal the same effect serves
|
||||
// history navigation.
|
||||
useEffect(() => {
|
||||
if (!hydrated
|
||||
if ((!hydrated && bootstrapPhase === "account")
|
||||
|| !account
|
||||
|| !modelCatalog
|
||||
|| activeSession?.sessionType !== "birth_time_rectification"
|
||||
@@ -516,6 +532,7 @@ export default function Home() {
|
||||
}, [
|
||||
account,
|
||||
activeSession,
|
||||
bootstrapPhase,
|
||||
creatingSession,
|
||||
hydrated,
|
||||
modelCatalog,
|
||||
@@ -647,6 +664,10 @@ export default function Home() {
|
||||
dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable,
|
||||
dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending",
|
||||
entrySummarySettled: rectificationEntrySummarySettled,
|
||||
rectificationApplicable: activeSession?.sessionType === "birth_time_rectification",
|
||||
rectificationSettled: activeSession?.id === rectificationSessionId
|
||||
|| rectificationError !== ""
|
||||
|| !profileComplete,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1718,6 +1739,7 @@ export default function Home() {
|
||||
sessions={sidebarSessions}
|
||||
charts={sidebarCharts}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
openingSessionId={rectificationOpeningSessionId}
|
||||
account={sidebarAccount}
|
||||
accountMenuOpen={accountMenuOpen}
|
||||
accountTriggerRef={accountTrigger}
|
||||
@@ -1928,12 +1950,14 @@ export default function Home() {
|
||||
|
||||
{rectificationSurfaceOpen && rectificationCaseId && (
|
||||
<ConversationalBirthTimeRectification
|
||||
key={`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`}
|
||||
key={`${rectificationSessionId}-${rectificationCaseId}`}
|
||||
caseId={rectificationCaseId}
|
||||
sessionId={rectificationSessionId ?? ""}
|
||||
readonly={rectificationReadonly}
|
||||
shouldStartOpening={rectificationShouldStartOpening}
|
||||
initialTurns={rectificationTurns}
|
||||
initialSnapshot={rectificationSnapshot}
|
||||
declaredTime={rectificationDeclaredTime}
|
||||
models={modelCatalog?.models ?? []}
|
||||
selectedModelId={activeSession?.modelId ?? ""}
|
||||
onSelectModel={(modelId) => void selectSessionModel(modelId)}
|
||||
|
||||
@@ -59,6 +59,8 @@ export type AppSidebarProps = {
|
||||
sessions: readonly SidebarSession[];
|
||||
charts: readonly SidebarChart[];
|
||||
activeSessionId: string | null;
|
||||
/** A rectification session whose Case is being opened and hydrated; its row says so, statically. */
|
||||
openingSessionId?: string | null;
|
||||
account: SidebarAccount;
|
||||
accountMenuOpen: boolean;
|
||||
accountTriggerRef: Ref<HTMLButtonElement>;
|
||||
@@ -82,6 +84,7 @@ export function AppSidebar({
|
||||
sessions,
|
||||
charts,
|
||||
activeSessionId,
|
||||
openingSessionId = null,
|
||||
account,
|
||||
accountMenuOpen,
|
||||
accountTriggerRef,
|
||||
@@ -141,6 +144,7 @@ export function AppSidebar({
|
||||
ref={index === 0 ? firstSessionRef : undefined}
|
||||
session={session}
|
||||
active={session.id === activeSessionId}
|
||||
opening={session.id === openingSessionId}
|
||||
disabled={sessionControls.disabled}
|
||||
menuOpen={sessionControls.menuSessionId === session.id}
|
||||
onMenuOpenChange={(open) => sessionControls.onMenuSessionChange(open ? session.id : null)}
|
||||
|
||||
@@ -83,6 +83,20 @@ export function ConsultationRunTimeline({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One live step outside a reply's timeline — the rectification surface uses it
|
||||
* for the gap between a settled turn and its next question. It is the same
|
||||
* row as inside the timeline (marker, spinner, shimmer label), not a second
|
||||
* waiting vocabulary.
|
||||
*/
|
||||
export function ConsultationTimelineLiveRow({ label, id = "live" }: Readonly<{ label: string; id?: string }>) {
|
||||
return (
|
||||
<ol className="agent-thinking-timeline consultation-run-timeline__list is-standalone" aria-label="进行中">
|
||||
<TimelineRow row={{ id, kind: "write", status: "live", label }} />
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
|
||||
const KindIcon = KIND_ICONS[row.kind];
|
||||
const expandable = Boolean(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { PublicLanguageModel } from "../lib/public-models.ts";
|
||||
import type { ChatMessage } from "../lib/chat-message-view.ts";
|
||||
import type { RectificationCaseSnapshotPayload } from "../lib/rectification-surface-state.ts";
|
||||
import { RectificationAgenticChat } from "./rectification-agentic-chat.tsx";
|
||||
|
||||
export type PersistedRectificationTurn = Readonly<{
|
||||
@@ -10,9 +11,18 @@ export type PersistedRectificationTurn = Readonly<{
|
||||
text: string | null;
|
||||
status: string;
|
||||
question?: unknown;
|
||||
offer_result_id?: string | null;
|
||||
receipt?: Readonly<{
|
||||
status: string;
|
||||
phases: readonly string[];
|
||||
tool_activities?: readonly Readonly<{
|
||||
tool: string;
|
||||
status: string;
|
||||
methods?: readonly string[];
|
||||
started_at?: string | null;
|
||||
elapsed_ms?: number | null;
|
||||
detail?: Readonly<Record<string, unknown>> | null;
|
||||
}>[];
|
||||
tools: readonly string[];
|
||||
methods?: readonly string[];
|
||||
skill_name?: string;
|
||||
@@ -26,6 +36,10 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
|
||||
readonly: boolean;
|
||||
shouldStartOpening: boolean;
|
||||
initialTurns: readonly PersistedRectificationTurn[];
|
||||
/** The Case snapshot read together with the turns before the surface mounted; null when hydration failed. */
|
||||
initialSnapshot: RectificationCaseSnapshotPayload | null;
|
||||
/** The declared birth minute from the profile, shown on the board before any candidate exists. */
|
||||
declaredTime: string | null;
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
|
||||
@@ -43,6 +43,23 @@ import {
|
||||
} from "@/lib/rectification-board-model";
|
||||
import { membershipHref } from "@/lib/membership";
|
||||
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
|
||||
import {
|
||||
rectificationAdoptingLabel,
|
||||
RECTIFICATION_EMPTY_ACTION_LABEL,
|
||||
RECTIFICATION_EMPTY_COPY,
|
||||
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
|
||||
RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS,
|
||||
RECTIFICATION_QUESTION_PREPARING_LABEL,
|
||||
RECTIFICATION_QUESTION_RELOAD_LABEL,
|
||||
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_STOPPED_NOTICE,
|
||||
rectificationConversationState,
|
||||
rectificationInitialLiveLabel,
|
||||
rectificationQuestionGapState,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
|
||||
import {
|
||||
isPublicRectificationActivity,
|
||||
@@ -62,9 +79,11 @@ import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseSta
|
||||
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
|
||||
import type { PublicLanguageModel } from "@/lib/public-models";
|
||||
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
import { CharacterRemaining } from "./character-remaining";
|
||||
import { ChatComposer } from "./chat-composer";
|
||||
import { ChatMessageRow } from "./chat-message-row";
|
||||
import { ConsultationTimelineLiveRow } from "./consultation-run-timeline";
|
||||
import { JumpToLatestButton } from "./jump-to-latest-button";
|
||||
import {
|
||||
ChatMessageActions,
|
||||
@@ -196,6 +215,10 @@ type RectificationAgenticChatProps = Readonly<{
|
||||
readonly: boolean;
|
||||
shouldStartOpening: boolean;
|
||||
initialTurns: readonly PersistedTurn[];
|
||||
/** The Case snapshot read together with the turns before mount; null when hydration failed or timed out. */
|
||||
initialSnapshot: RectificationCaseSnapshotPayload | null;
|
||||
/** The declared birth minute from the profile, for the board before any candidate exists. */
|
||||
declaredTime: string | null;
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
@@ -386,6 +409,32 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
|
||||
});
|
||||
}
|
||||
|
||||
type CaseSnapshotState = Readonly<{
|
||||
candidate: CandidateResult;
|
||||
question: CurrentQuestionModel | null;
|
||||
questionSource: "focus" | "unavailable" | null;
|
||||
choice: ChoiceCardModel | null;
|
||||
caseStatus: RectificationCaseStatus | null;
|
||||
savedTime: string | null;
|
||||
savedStatus: "accepted" | "confirmed" | null;
|
||||
}>;
|
||||
|
||||
/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */
|
||||
function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null {
|
||||
if (!payload) return null;
|
||||
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
|
||||
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
|
||||
return {
|
||||
candidate: parseRectificationCandidateResult(payload.latest_result),
|
||||
question: currentQuestionFromSnapshot(payload.current_question),
|
||||
questionSource: questionSourceFromSnapshot(payload.question_source),
|
||||
choice: parseRectificationChoiceCard(payload.choice_card),
|
||||
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
|
||||
savedTime: confirmedTime ?? acceptedTime,
|
||||
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const {
|
||||
caseId,
|
||||
@@ -393,6 +442,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
readonly,
|
||||
shouldStartOpening,
|
||||
initialTurns,
|
||||
initialSnapshot,
|
||||
declaredTime,
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
@@ -411,20 +462,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [savedTime, setSavedTime] = useState<string | null>(null);
|
||||
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
|
||||
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
|
||||
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
|
||||
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(null);
|
||||
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(null);
|
||||
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(null);
|
||||
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false);
|
||||
const [savedTime, setSavedTime] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null);
|
||||
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null);
|
||||
const [candidateResult, setCandidateResult] = useState<CandidateResult>(() => caseSnapshotState(initialSnapshot)?.candidate ?? null);
|
||||
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(() => caseSnapshotState(initialSnapshot)?.choice ?? null);
|
||||
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
|
||||
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
|
||||
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
|
||||
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
|
||||
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
|
||||
const [openingRequested, setOpeningRequested] = useState(false);
|
||||
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
|
||||
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
|
||||
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
|
||||
const [choiceNonce, setChoiceNonce] = useState(0);
|
||||
const choiceContinuationPending = useRef(false);
|
||||
const conversation = useRef<HTMLElement>(null);
|
||||
const workspace = useRef<HTMLDivElement>(null);
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -432,6 +484,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const openingStarted = useRef(false);
|
||||
const previousBoardResult = useRef<CandidateResult>(null);
|
||||
const runAbort = useRef<AbortController | null>(null);
|
||||
const snapshotAbort = useRef<AbortController | null>(null);
|
||||
const choiceActionIds = useRef(new Map<string, string>());
|
||||
const currentQuestionRef = useRef(currentQuestion);
|
||||
const offerSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -492,6 +545,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
|
||||
const setPending = useCallback((value: boolean) => {
|
||||
setBusy(value);
|
||||
// A turn starting is a fresh chance for the next question to arrive.
|
||||
if (value) setQuestionRetryAttempts(0);
|
||||
onPendingChange?.(value);
|
||||
}, [onPendingChange]);
|
||||
|
||||
@@ -589,6 +644,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
|
||||
setCandidateResult(nextCandidate);
|
||||
setCurrentQuestion(nextQuestion);
|
||||
if (nextQuestion !== null) setQuestionRetryAttempts(0);
|
||||
setQuestionSource(questionSourceFromSnapshot(payload.question_source));
|
||||
setChoiceCard(nextChoice);
|
||||
setCaseStatus(nextCaseStatus);
|
||||
@@ -606,13 +662,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
question: CurrentQuestionModel | null;
|
||||
turns: readonly unknown[];
|
||||
} | null | undefined> => {
|
||||
snapshotAbort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
snapshotAbort.current = controller;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store" },
|
||||
{ cache: "no-store", signal: controller.signal },
|
||||
);
|
||||
if (!response.ok) return undefined;
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (controller.signal.aborted) return undefined;
|
||||
applyCaseSnapshot(payload);
|
||||
return {
|
||||
question: currentQuestionFromSnapshot(payload?.current_question),
|
||||
@@ -621,53 +681,74 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
} catch {
|
||||
// Snapshot refresh is best-effort; the durable Case remains on the server.
|
||||
return undefined;
|
||||
} finally {
|
||||
if (snapshotAbort.current === controller) snapshotAbort.current = null;
|
||||
}
|
||||
}, [applyCaseSnapshot, caseId, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetch(
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store", signal: controller.signal },
|
||||
)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload) => {
|
||||
if (controller.signal.aborted) return;
|
||||
applyCaseSnapshot(payload);
|
||||
})
|
||||
.catch(() => {
|
||||
// Snapshot refresh is best-effort; the durable Case remains on the server.
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [applyCaseSnapshot, caseId, sessionId]);
|
||||
// Turns that arrive after mount (the parent refreshes the Case after each
|
||||
// completed turn) only ever fill an empty transcript; a live or finished
|
||||
// conversation is never overwritten or remounted. Adjusted during render
|
||||
// from the previous prop, the way React documents it, not in an effect.
|
||||
const [seededTurns, setSeededTurns] = useState(initialTurns);
|
||||
if (seededTurns !== initialTurns) {
|
||||
setSeededTurns(initialTurns);
|
||||
if (messages.length === 0 && initialTurns.length > 0) setMessages(messagesFromTurns(initialTurns));
|
||||
}
|
||||
|
||||
const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
|
||||
// Leaving the surface mid-turn ends the stream and any snapshot read
|
||||
// instead of letting them write into an unmounted component.
|
||||
useEffect(() => () => {
|
||||
runAbort.current?.abort();
|
||||
snapshotAbort.current?.abort();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Run one agent turn. A `continuation` reuses the live row an earlier step
|
||||
* (a tapped choice, an adopted candidate) already put at the end of the
|
||||
* transcript, so the reader sees one uninterrupted working row from the tap
|
||||
* to the next question instead of a gap and a re-appended row. The caller
|
||||
* of a continuation already holds `busy`.
|
||||
*/
|
||||
const send = useCallback(async (
|
||||
action: "opening" | "message" | "read_only",
|
||||
messageText: string,
|
||||
continuation?: Readonly<{ reuseAssistantRenderKey: string; label: string }>,
|
||||
) => {
|
||||
const trimmed = action === "message" ? messageText.trim() : "";
|
||||
if ((action === "message" && !trimmed) || busy || readonly) return;
|
||||
if ((action === "message" && !trimmed) || readonly) return;
|
||||
if (!continuation && busy) return;
|
||||
setError("");
|
||||
setPending(true);
|
||||
beginLiveRun("正在处理…");
|
||||
const initialLabel = rectificationInitialLiveLabel(action, continuation?.label);
|
||||
beginLiveRun(initialLabel);
|
||||
|
||||
keyCounter.current += 1;
|
||||
const requestId = globalThis.crypto.randomUUID();
|
||||
const turnKey = keyCounter.current;
|
||||
const userRenderKey = `v9-user-${turnKey}`;
|
||||
const assistantRenderKey = `v9-assistant-${turnKey}`;
|
||||
const assistantRenderKey = continuation?.reuseAssistantRenderKey ?? `v9-assistant-${turnKey}`;
|
||||
|
||||
const pendingFocusId = currentQuestionRef.current?.focus_id;
|
||||
setMessages((current) => [
|
||||
...(action === "message" && pendingFocusId
|
||||
? markQuestionAnswered(current, pendingFocusId, "typed")
|
||||
: current),
|
||||
...(action === "message"
|
||||
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
|
||||
: []),
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: {
|
||||
phase: "evidence-validation",
|
||||
label: "正在处理…",
|
||||
startedAt: Date.now(),
|
||||
} },
|
||||
]);
|
||||
const liveRow: RenderMessage = {
|
||||
role: "assistant",
|
||||
text: "",
|
||||
renderKey: assistantRenderKey,
|
||||
state: "thinking",
|
||||
activityTrace: emptyActivityTrace(),
|
||||
activity: { phase: "evidence-validation", label: initialLabel, startedAt: Date.now() },
|
||||
};
|
||||
setMessages((current) => (continuation
|
||||
? current.map((message) => (message.renderKey === assistantRenderKey ? { ...liveRow, activity: message.activity ?? liveRow.activity } : message))
|
||||
: [
|
||||
...(action === "message" && pendingFocusId
|
||||
? markQuestionAnswered(current, pendingFocusId, "typed")
|
||||
: current),
|
||||
...(action === "message"
|
||||
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
|
||||
: []),
|
||||
liveRow,
|
||||
]));
|
||||
setDraft("");
|
||||
|
||||
let raw = "";
|
||||
@@ -677,7 +758,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
let completedTurnId: string | undefined;
|
||||
let currentActivity: AgentActivityView | undefined = {
|
||||
phase: "evidence-validation",
|
||||
label: "正在处理…",
|
||||
label: initialLabel,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
// Every stream event lands in `frames`; it commits at most once per animation
|
||||
@@ -726,7 +807,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return;
|
||||
}
|
||||
if (response.status === 402) {
|
||||
window.location.assign(membershipHref("rectification"));
|
||||
// Say why before the page leaves, instead of vanishing mid-chat.
|
||||
setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE);
|
||||
window.setTimeout(() => {
|
||||
window.location.assign(membershipHref("rectification"));
|
||||
}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS);
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) setError("请先登录。");
|
||||
@@ -931,6 +1016,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
return [];
|
||||
}));
|
||||
// The reader pressed stop: what streamed stays, and the notice says so
|
||||
// rather than blaming the service.
|
||||
if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE);
|
||||
return;
|
||||
}
|
||||
setError("生时校正暂时不可用,请稍后再试。");
|
||||
@@ -987,7 +1075,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
keyCounter.current += 1;
|
||||
const turnKey = keyCounter.current;
|
||||
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
|
||||
beginLiveRun(RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer);
|
||||
const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer;
|
||||
beginLiveRun(recordingLabel);
|
||||
setMessages((current) => [
|
||||
...markQuestionAnswered(current, focusId, optionId),
|
||||
{
|
||||
@@ -998,7 +1087,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
activityTrace: emptyActivityTrace(),
|
||||
activity: {
|
||||
phase: "evidence-validation",
|
||||
label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer,
|
||||
label: recordingLabel,
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
@@ -1039,11 +1128,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const snapshot = await loadCaseSnapshot();
|
||||
const turns = snapshot?.turns ?? [];
|
||||
if (willContinue) {
|
||||
setMessages((current) => mergeTurnQuestions(
|
||||
current.filter((message) => message.renderKey !== assistantRenderKey),
|
||||
turns,
|
||||
));
|
||||
choiceContinuationPending.current = true;
|
||||
// The follow-up turn continues on the row already in place: no removed
|
||||
// row, no effect hop, and `busy` never drops in between (so the next
|
||||
// card cannot flash before the turn hides it).
|
||||
setMessages((current) => mergeTurnQuestions(current, turns));
|
||||
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
|
||||
} else {
|
||||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||||
? payload.narration.trim()
|
||||
@@ -1086,16 +1175,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onMessagesChange,
|
||||
onProfileIncomplete,
|
||||
readonly,
|
||||
send,
|
||||
sessionId,
|
||||
setPending,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!choiceContinuationPending.current || busy || readonly) return;
|
||||
choiceContinuationPending.current = false;
|
||||
void send("read_only", "");
|
||||
}, [busy, readonly, send]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTurns.length > 0) {
|
||||
if (shouldStartOpening) onOpeningConsumed?.();
|
||||
@@ -1112,6 +1196,24 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
setError("");
|
||||
setAcceptingCandidateId(candidateId);
|
||||
setPending(true);
|
||||
// Adoption is a server round trip followed by an agent turn; the reader sees
|
||||
// one live row for the whole of it instead of a greyed button.
|
||||
const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? "";
|
||||
const adoptingLabel = rectificationAdoptingLabel(candidateTime);
|
||||
keyCounter.current += 1;
|
||||
const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`;
|
||||
beginLiveRun(adoptingLabel);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
role: "assistant",
|
||||
text: "",
|
||||
renderKey: assistantRenderKey,
|
||||
state: "thinking",
|
||||
activityTrace: emptyActivityTrace(),
|
||||
activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() },
|
||||
},
|
||||
]);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
|
||||
@@ -1159,14 +1261,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onSaved?.(payload.saved_time, "accepted");
|
||||
onCompleted?.();
|
||||
await loadCaseSnapshot();
|
||||
choiceContinuationPending.current = true;
|
||||
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel });
|
||||
} catch (caught) {
|
||||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
|
||||
} finally {
|
||||
setAcceptingCandidateId(null);
|
||||
setPending(false);
|
||||
}
|
||||
}, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, sessionId, setPending]);
|
||||
}, [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
|
||||
|
||||
async function copyMessage(message: RenderMessage) {
|
||||
try {
|
||||
@@ -1289,33 +1392,63 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
&& message.question.focus_id === currentQuestion.focus_id
|
||||
&& !questionIsAnswered(message.question)
|
||||
));
|
||||
const questionLoadFailed = questionSource === "unavailable";
|
||||
const showMissingQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion === null
|
||||
&& !questionLoadFailed,
|
||||
);
|
||||
const showUnavailableQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion !== null
|
||||
&& !liveQuestionOnMessages
|
||||
&& !questionLoadFailed,
|
||||
);
|
||||
const showQuestionLoadFailed = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& questionLoadFailed,
|
||||
);
|
||||
// The gap between a settled turn and its next question has two visible
|
||||
// states: `preparing` (one live timeline row, timed refetches) and
|
||||
// `unavailable` (copy and a reload button) — never bare copy telling the
|
||||
// reader to wait for the server.
|
||||
const questionGap = rectificationQuestionGapState({
|
||||
liveQuestionVisible: liveQuestionOnMessages,
|
||||
questionMissing: currentQuestion === null,
|
||||
questionLoadFailed: questionSource === "unavailable",
|
||||
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
|
||||
busy,
|
||||
readonly,
|
||||
regenerating: regeneratingMessageKey !== null,
|
||||
snapshotLoaded: caseSnapshotLoaded,
|
||||
resumableCase,
|
||||
retryAttempts: questionRetryAttempts,
|
||||
retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
});
|
||||
const conversationState = rectificationConversationState({
|
||||
messageCount: messages.length,
|
||||
busy,
|
||||
readonly,
|
||||
shouldStartOpening,
|
||||
openingStarted: openingRequested,
|
||||
});
|
||||
const canSend = !busy && !readonly && !regeneratingMessageKey;
|
||||
|
||||
// Question recovery: the turn already refetched once on completion; while
|
||||
// the gap is `preparing`, refetch on a timer up to the retry limit, then
|
||||
// hand the reader a reload button. Attempts reset once a question arrives
|
||||
// or another turn starts (both in handlers, never in an effect).
|
||||
useVisibilityAwarePoll({
|
||||
enabled: questionGap === "preparing",
|
||||
intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
|
||||
refreshOnVisible: false,
|
||||
onPoll: () => {
|
||||
setQuestionRetryAttempts((current) => current + 1);
|
||||
void refetchQuestion();
|
||||
},
|
||||
});
|
||||
async function refetchQuestion() {
|
||||
await loadCaseSnapshot();
|
||||
}
|
||||
function reloadQuestion() {
|
||||
setQuestionRetryAttempts(0);
|
||||
void refetchQuestion();
|
||||
}
|
||||
|
||||
// A Case with no turns and no automatic first turn (the server never starts
|
||||
// an opening for a resumed Case) needs a way to begin; the server suppresses
|
||||
// a duplicate opening, so this is safe to press twice.
|
||||
function startOpeningManually() {
|
||||
if (readonly || busy || openingStarted.current) return;
|
||||
openingStarted.current = true;
|
||||
setOpeningRequested(true);
|
||||
void send("opening", "");
|
||||
}
|
||||
|
||||
function submitChoice(key: ChoiceKey) {
|
||||
if (!choiceCard) return;
|
||||
void submitStructuredChoice(CHOICE_ACTION, key);
|
||||
@@ -1339,6 +1472,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const boardPeek = compactBoard && !boardOpen ? (
|
||||
<RectificationBoardPeek
|
||||
result={candidateResult}
|
||||
declaredTime={declaredTime}
|
||||
expanded={boardOpen}
|
||||
boardId={boardId}
|
||||
onOpen={toggleBoard}
|
||||
@@ -1348,7 +1482,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return (
|
||||
<div
|
||||
ref={workspace}
|
||||
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
|
||||
className={`rectification-workspace${candidateResult ? "" : " is-board-empty"}${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
|
||||
>
|
||||
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
|
||||
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
|
||||
@@ -1364,6 +1498,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}”
|
||||
</p>
|
||||
)}
|
||||
{conversationState === "empty" && (
|
||||
<div className="rectification-empty-state">
|
||||
<p>{RECTIFICATION_EMPTY_COPY}</p>
|
||||
<Button type="button" onClick={startOpeningManually}>{RECTIFICATION_EMPTY_ACTION_LABEL}</Button>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message) => {
|
||||
const showActions = message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
@@ -1488,6 +1628,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{questionGap === "preparing" && (
|
||||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap">
|
||||
<ConsultationTimelineLiveRow id="question-preparing" label={RECTIFICATION_QUESTION_PREPARING_LABEL} />
|
||||
</div>
|
||||
)}
|
||||
{questionGap === "unavailable" && (
|
||||
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
|
||||
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
|
||||
<Button type="button" variant="outline" onClick={reloadQuestion}>
|
||||
{RECTIFICATION_QUESTION_RELOAD_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{savedTime && savedStatus === "confirmed" && (
|
||||
<p className="rectification-saved" role="status">
|
||||
已确认校正时间:{savedTime}
|
||||
@@ -1525,15 +1678,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
<span className="rectification-adopt-status__hint">之后新建对话即按此时间排盘。</span>
|
||||
</div>
|
||||
)}
|
||||
{(showMissingQuestion || showUnavailableQuestion || showQuestionLoadFailed) && (
|
||||
<p className="rectification-composer-status" role="status">
|
||||
{showQuestionLoadFailed
|
||||
? "题目加载失败,请刷新。"
|
||||
: showMissingQuestion
|
||||
? "当前没有可回答的问题,正在等待服务端更新。"
|
||||
: "当前问题暂时无法显示,请等待服务端更新。"}
|
||||
</p>
|
||||
)}
|
||||
<ChatComposer
|
||||
inputRef={composer}
|
||||
value={draft}
|
||||
@@ -1591,6 +1735,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</div>
|
||||
<RectificationBoard
|
||||
result={candidateResult}
|
||||
declaredTime={declaredTime}
|
||||
savedStatus={savedStatus}
|
||||
diff={boardDiff}
|
||||
compact={compactBoard}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
windowScanLayerLabel,
|
||||
type WindowScanLayer,
|
||||
} from "@/lib/rectification-agentic/v9/refinement-packet";
|
||||
import { rectificationBoardEmptyCopy } from "@/lib/rectification-surface-state";
|
||||
import { RectificationHouseTableView } from "./rectification-house-table";
|
||||
import { ConfirmationGateDisclosure, TechniqueAuditDisclosure } from "./technique-audit-disclosure";
|
||||
|
||||
@@ -35,6 +36,7 @@ function LayerChips({ layers }: Readonly<{ layers: readonly WindowScanLayer[] }>
|
||||
|
||||
function RectificationBoardBody({
|
||||
result,
|
||||
declaredTime,
|
||||
savedStatus,
|
||||
diff,
|
||||
compact,
|
||||
@@ -42,6 +44,7 @@ function RectificationBoardBody({
|
||||
onClose,
|
||||
}: Readonly<{
|
||||
result: RectificationCandidateResult | null;
|
||||
declaredTime: string | null;
|
||||
savedStatus: "accepted" | "confirmed" | null;
|
||||
diff: RectificationBoardDiff;
|
||||
compact: boolean;
|
||||
@@ -50,6 +53,7 @@ function RectificationBoardBody({
|
||||
}>) {
|
||||
const table = result ? workingRectificationHouseTable(result) : null;
|
||||
const workingTime = workingRectificationTime(result);
|
||||
const emptyCopy = rectificationBoardEmptyCopy(declaredTime);
|
||||
const grouped = result ? groupWindowTransitions(result.windowTransitions) : [];
|
||||
const scoringMinutes = grouped.filter((row) => row.scoringLayers.length > 0);
|
||||
const displayMinutes = grouped.filter((row) => row.displayLayers.length > 0);
|
||||
@@ -69,7 +73,11 @@ function RectificationBoardBody({
|
||||
<header className="rectification-board__header">
|
||||
<div className="rectification-board__title-row">
|
||||
<h2 id={titleId}>当前盘面</h2>
|
||||
{workingTime ? <span className="rectification-board__clock">{workingTime}</span> : null}
|
||||
{workingTime
|
||||
? <span className="rectification-board__clock">{workingTime}</span>
|
||||
: emptyCopy.clock
|
||||
? <span className="rectification-board__clock is-declared">{emptyCopy.clock}</span>
|
||||
: null}
|
||||
{compact && (
|
||||
<button
|
||||
className="rectification-board__close"
|
||||
@@ -86,9 +94,9 @@ function RectificationBoardBody({
|
||||
<div className="rectification-board__body">
|
||||
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">{diff.liveMessage}</p>
|
||||
{!table && (
|
||||
<p className="rectification-board__empty">
|
||||
补充经历后,这里会显示当前本命宫位和换升时刻。
|
||||
</p>
|
||||
<div className="rectification-board__empty">
|
||||
{emptyCopy.lines.map((line) => <p key={line}>{line}</p>)}
|
||||
</div>
|
||||
)}
|
||||
{table && (
|
||||
<div className="rectification-snapshot">
|
||||
@@ -194,6 +202,7 @@ function RectificationBoardBody({
|
||||
|
||||
export function RectificationBoard({
|
||||
result,
|
||||
declaredTime,
|
||||
savedStatus,
|
||||
diff,
|
||||
compact,
|
||||
@@ -203,6 +212,7 @@ export function RectificationBoard({
|
||||
onClose,
|
||||
}: Readonly<{
|
||||
result: RectificationCandidateResult | null;
|
||||
declaredTime: string | null;
|
||||
savedStatus: "accepted" | "confirmed" | null;
|
||||
diff: RectificationBoardDiff;
|
||||
compact: boolean;
|
||||
@@ -237,6 +247,7 @@ export function RectificationBoard({
|
||||
>
|
||||
<RectificationBoardBody
|
||||
result={result}
|
||||
declaredTime={declaredTime}
|
||||
savedStatus={savedStatus}
|
||||
diff={diff}
|
||||
compact={compact}
|
||||
@@ -258,16 +269,18 @@ export function RectificationBoard({
|
||||
|
||||
export function RectificationBoardPeek({
|
||||
result,
|
||||
declaredTime = null,
|
||||
expanded,
|
||||
boardId,
|
||||
onOpen,
|
||||
}: Readonly<{
|
||||
result: RectificationCandidateResult | null;
|
||||
declaredTime?: string | null;
|
||||
expanded: boolean;
|
||||
boardId: string;
|
||||
onOpen: () => void;
|
||||
}>) {
|
||||
const peek = rectificationBoardPeekCopy(result);
|
||||
const peek = rectificationBoardPeekCopy(result, declaredTime);
|
||||
return (
|
||||
<button
|
||||
className="rectification-board-peek"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import type {
|
||||
ChoiceKey,
|
||||
RectificationChoiceCard as ChoiceCard,
|
||||
@@ -42,6 +44,14 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
||||
className={`rectification-choice-card${embedded ? " is-embedded" : ""}${props.pending ? " is-pending" : ""}${answered ? " is-answered" : ""}`}
|
||||
aria-label={props.card.scoring ? "校正判断" : "盘外核对"}
|
||||
>
|
||||
{props.pending && answered ? (
|
||||
// The tap is being recorded: the same live-row shape as a timeline step,
|
||||
// so the card itself says something is happening.
|
||||
<p className="rectification-choice-card__pending" role="status">
|
||||
<InlineSpinner size={12} />
|
||||
<span className="agent-activity-status__text">正在记录…</span>
|
||||
</p>
|
||||
) : null}
|
||||
<fieldset
|
||||
className="birth-time-choice-question"
|
||||
disabled={props.pending || props.disabled || answered}
|
||||
@@ -61,6 +71,12 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
||||
onClick={() => select(option.key)}
|
||||
>
|
||||
<strong>{option.key}.</strong> {option.label}
|
||||
{selectedKey === option.key ? (
|
||||
<span className="rectification-choice-card__selected">
|
||||
<Check aria-hidden="true" />
|
||||
已选择
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
@@ -70,6 +86,12 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
||||
onClick={stop}
|
||||
>
|
||||
{props.card.stop_label}
|
||||
{selectedKey === "stop" ? (
|
||||
<span className="rectification-choice-card__selected">
|
||||
<Check aria-hidden="true" />
|
||||
已选择
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { forwardRef } from "react";
|
||||
import { RECTIFICATION_SIDEBAR_OPENING_NOTE } from "@/lib/rectification-surface-state";
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence";
|
||||
|
||||
@@ -39,6 +40,8 @@ export type SidebarSessionControls = {
|
||||
type SidebarSessionRowProps = {
|
||||
readonly session: SidebarSession;
|
||||
readonly active: boolean;
|
||||
/** Its Case is being opened: a static note, no spinner. */
|
||||
readonly opening?: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly menuOpen: boolean;
|
||||
readonly onMenuOpenChange: (open: boolean) => void;
|
||||
@@ -53,6 +56,7 @@ type SidebarSessionRowProps = {
|
||||
export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRowProps>(function SidebarSessionRow({
|
||||
session,
|
||||
active,
|
||||
opening = false,
|
||||
disabled,
|
||||
menuOpen,
|
||||
onMenuOpenChange,
|
||||
@@ -83,12 +87,14 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
|
||||
type="button"
|
||||
isActive={active}
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-busy={opening ? true : undefined}
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="session-title">
|
||||
{session.pinned ? <Star aria-label="已收藏" /> : null}
|
||||
<span className="truncate">{session.title}</span>
|
||||
{opening ? <span className="session-opening-note">{RECTIFICATION_SIDEBAR_OPENING_NOTE}</span> : null}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
<Menu.Trigger
|
||||
|
||||
@@ -93,7 +93,11 @@ export function StarterHome({
|
||||
<span className="product-entrypoint-action" aria-hidden="true">{natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
|
||||
</div>
|
||||
</article>
|
||||
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
|
||||
<article
|
||||
className="birth-rectification-card product-entrypoint-card"
|
||||
aria-labelledby="birth-rectification-title"
|
||||
data-opening={rectificationLoading ? "true" : undefined}
|
||||
>
|
||||
<button
|
||||
className="product-entrypoint-hitarea"
|
||||
type="button"
|
||||
|
||||
@@ -27,6 +27,14 @@ import {
|
||||
} from "@/lib/rectification-entry";
|
||||
import type { PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
|
||||
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
|
||||
import {
|
||||
hydrateRectificationCase,
|
||||
parsePersistedRectificationTurns,
|
||||
RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE,
|
||||
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
rectificationCaseHref,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
|
||||
export type RectificationSurfaceParams = {
|
||||
account: Account | null;
|
||||
@@ -58,6 +66,8 @@ export type RectificationSurfaceParams = {
|
||||
setRectificationReadonly: Dispatch<SetStateAction<boolean>>;
|
||||
setRectificationSessionId: Dispatch<SetStateAction<string | null>>;
|
||||
setRectificationShouldStartOpening: Dispatch<SetStateAction<boolean>>;
|
||||
setRectificationSnapshot: Dispatch<SetStateAction<RectificationCaseSnapshotPayload | null>>;
|
||||
setRectificationOpeningSessionId: Dispatch<SetStateAction<string | null>>;
|
||||
setRectificationTurns: Dispatch<SetStateAction<PersistedRectificationTurn[]>>;
|
||||
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
|
||||
uiPreview: MutableRefObject<boolean>;
|
||||
@@ -98,6 +108,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
setRectificationReadonly,
|
||||
setRectificationSessionId,
|
||||
setRectificationShouldStartOpening,
|
||||
setRectificationSnapshot,
|
||||
setRectificationOpeningSessionId,
|
||||
setRectificationTurns,
|
||||
setSessions,
|
||||
uiPreview,
|
||||
@@ -121,28 +133,10 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
|
||||
async function refreshRectificationCase(caseId: string, sessionId: string) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
const response = await fetch(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json().catch(() => null);
|
||||
const turns = Array.isArray(payload?.turns) ? payload.turns : [];
|
||||
setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown; question?: unknown }) => ({
|
||||
id: String(turn?.id ?? ""),
|
||||
role: turn?.role === "user" ? "user" as const : "assistant" as const,
|
||||
text: typeof turn?.text === "string" ? turn.text : null,
|
||||
status: String(turn?.status ?? "completed"),
|
||||
question: turn?.question ?? null,
|
||||
receipt: turn?.receipt && typeof turn.receipt === "object" ? {
|
||||
status: String((turn.receipt as { status?: unknown }).status ?? ""),
|
||||
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
|
||||
tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [],
|
||||
methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [],
|
||||
skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined,
|
||||
skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined,
|
||||
} : null,
|
||||
})));
|
||||
setRectificationTurns(parsePersistedRectificationTurns(payload?.turns));
|
||||
} catch {
|
||||
// History refresh is best-effort; the stream restores live turns.
|
||||
}
|
||||
@@ -172,6 +166,10 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
rectificationOpenInFlight.current = true;
|
||||
setRectificationLoading(true);
|
||||
setRectificationError("");
|
||||
// Read the selection source now: `selectSession` resets it synchronously
|
||||
// right after handing off, and the URL write below happens after awaits.
|
||||
const selectionSource = sessionSelectionSource.current;
|
||||
setRectificationOpeningSessionId(exactSessionId);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/rectification/cases/open", {
|
||||
@@ -225,6 +223,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]);
|
||||
void persistSession(merged).catch(() => {});
|
||||
|
||||
// One reveal: turns and snapshot are read before the surface mounts, so
|
||||
// the reader never sees an empty transcript or an empty question area
|
||||
// while they load. Past the deadline the surface opens with what arrived
|
||||
// and its own retry path fills in the rest.
|
||||
const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, {
|
||||
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
setRectificationPendingQuestion(pendingConsultationQuestion);
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
@@ -235,13 +241,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
setRectificationReadonly(
|
||||
opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status),
|
||||
);
|
||||
setRectificationTurns([]);
|
||||
setRectificationTurns(hydration.turns);
|
||||
setRectificationSnapshot(hydration.snapshot);
|
||||
activeSessionIdRef.current = opened.sessionId;
|
||||
setActiveSessionId(opened.sessionId);
|
||||
if (!uiPreview.current && sessionSelectionSource.current === "user") {
|
||||
if (!uiPreview.current && selectionSource === "user") {
|
||||
writeSessionUrl(opened.sessionId, "push");
|
||||
}
|
||||
void refreshRectificationCase(opened.caseId, opened.sessionId);
|
||||
if (!hydration.complete) setComposerNotice(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE);
|
||||
void refreshRectificationEntrySummary();
|
||||
return opened;
|
||||
} catch {
|
||||
@@ -250,6 +257,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
} finally {
|
||||
sessionSelectionSource.current = "user";
|
||||
rectificationOpenInFlight.current = false;
|
||||
setRectificationOpeningSessionId(null);
|
||||
setRectificationLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,14 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
|
||||
function selectSession(sessionId: string) {
|
||||
const nextSession = sessions.find((session) => session.id === sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
// A rectification session that is not the open one is not switched to
|
||||
// here: the surface hook opens the Case, hydrates turns and snapshot, and
|
||||
// only then makes it active and writes the URL, so the current view stays
|
||||
// put instead of flashing a plain transcript and then an empty panel.
|
||||
// It reads the selection source before we reset it below.
|
||||
const deferredRectificationSwitch = nextSession?.sessionType === "birth_time_rectification"
|
||||
&& nextSession.id !== rectificationSessionId;
|
||||
if (!deferredRectificationSwitch) setActiveSessionId(sessionId);
|
||||
setDraft("");
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
@@ -327,7 +334,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
}
|
||||
if (nextSession?.sessionType === "birth_time_rectification") {
|
||||
setRectificationError("");
|
||||
if (nextSession.id !== rectificationSessionId) {
|
||||
if (deferredRectificationSwitch) {
|
||||
// The exact sessionId is passed to the server; the server resolves
|
||||
// the exact Case and never switches to another rectification record.
|
||||
void openRectificationSession(nextSession.id);
|
||||
@@ -335,7 +342,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
} else {
|
||||
void ensureSessionMessages(sessionId);
|
||||
}
|
||||
if (!uiPreview.current && sessionSelectionSource.current === "user") {
|
||||
if (!deferredRectificationSwitch && !uiPreview.current && sessionSelectionSource.current === "user") {
|
||||
writeSessionUrl(sessionId, "push");
|
||||
}
|
||||
sessionSelectionSource.current = "user";
|
||||
|
||||
@@ -19,10 +19,14 @@ export type BootstrapPrepareState = Readonly<{
|
||||
dailyStarlanguageApplicable: boolean;
|
||||
dailyStarlanguageSettled: boolean;
|
||||
entrySummarySettled: boolean;
|
||||
/** The selected session is a rectification session: its Case must be open and hydrated before the reveal. */
|
||||
rectificationApplicable?: boolean;
|
||||
rectificationSettled?: boolean;
|
||||
}>;
|
||||
|
||||
export function bootstrapPrepareSettled(state: BootstrapPrepareState): boolean {
|
||||
if (!state.entrySummarySettled) return false;
|
||||
if (state.rectificationApplicable && !state.rectificationSettled) return false;
|
||||
if (state.profileComplete && !state.onboardingSettled) return false;
|
||||
if (state.dailyStarlanguageApplicable && !state.dailyStarlanguageSettled) return false;
|
||||
return true;
|
||||
|
||||
@@ -160,13 +160,18 @@ export function diffRectificationBoard(
|
||||
};
|
||||
}
|
||||
|
||||
export function rectificationBoardPeekCopy(result: RectificationCandidateResult | null): Readonly<{
|
||||
export function rectificationBoardPeekCopy(
|
||||
result: RectificationCandidateResult | null,
|
||||
declaredTime: string | null = null,
|
||||
): Readonly<{
|
||||
title: string;
|
||||
detail: string | null;
|
||||
}> {
|
||||
const table = result ? workingRectificationHouseTable(result) : null;
|
||||
if (!table) {
|
||||
return { title: "当前盘面", detail: "补充经历后会在这里更新" };
|
||||
// Before any candidate exists the board still has a time to show: the one
|
||||
// the reader declared. Without one, say what fills it later.
|
||||
return { title: "当前盘面", detail: declaredTime ? `填报 ${declaredTime}` : "补充经历后会在这里更新" };
|
||||
}
|
||||
return { title: "当前盘面", detail: `${table.time} · 上升${table.lagna}` };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Pure state for the rectification chat surface: what the entry, the
|
||||
* transcript and the composer status show in each phase, and how a Case is
|
||||
* hydrated (turns + snapshot) before the surface is revealed.
|
||||
*
|
||||
* Nothing here touches React. The chat component and the surface hook call
|
||||
* these so the "is there anything to wait for, and what does the reader see
|
||||
* while waiting" decisions are testable without a DOM.
|
||||
*/
|
||||
|
||||
import type { PersistedRectificationTurn } from "../components/conversational-birth-time-rectification.tsx";
|
||||
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "./home-bootstrap.ts";
|
||||
|
||||
/**
|
||||
* Upper bound on hydrating a Case (turns + snapshot) after `/cases/open`
|
||||
* returns and before the surface is revealed. Past it the surface opens with
|
||||
* whatever arrived and the composer status's retry path fills the rest. It is
|
||||
* the home reveal budget: one constant, not two.
|
||||
*/
|
||||
export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS;
|
||||
|
||||
/** Static entry copy while `/cases/open` and hydration are in flight. No spinner. */
|
||||
export const RECTIFICATION_OPENING_LABEL = "正在打开…";
|
||||
export const RECTIFICATION_SIDEBAR_OPENING_NOTE = "打开中";
|
||||
|
||||
/** Question recovery: one immediate refetch after a turn, then this many timed retries. */
|
||||
export const RECTIFICATION_QUESTION_RETRY_LIMIT = 2;
|
||||
export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000;
|
||||
|
||||
export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…";
|
||||
export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。";
|
||||
export const RECTIFICATION_QUESTION_RELOAD_LABEL = "重新加载";
|
||||
export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
|
||||
export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
|
||||
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
|
||||
export const RECTIFICATION_STOPPED_NOTICE = "已停止,已生成的内容保留;本次不会扣点。";
|
||||
export const RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE = "校正点数不足,正在前往兑换…";
|
||||
export const RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS = 600;
|
||||
|
||||
/** Live-row labels by the action that started the turn (task 1.4). */
|
||||
export const RECTIFICATION_OPENING_LIVE_LABEL = "正在读取你的出生资料,准备第一个问题…";
|
||||
export const RECTIFICATION_MESSAGE_LIVE_LABEL = "正在处理…";
|
||||
|
||||
export function rectificationInitialLiveLabel(
|
||||
action: "opening" | "message" | "read_only",
|
||||
continuationLabel?: string,
|
||||
): string {
|
||||
if (action === "opening") return RECTIFICATION_OPENING_LIVE_LABEL;
|
||||
if (action === "read_only" && continuationLabel) return continuationLabel;
|
||||
return RECTIFICATION_MESSAGE_LIVE_LABEL;
|
||||
}
|
||||
|
||||
export type RectificationCaseSnapshotPayload = Readonly<{
|
||||
latest_result?: unknown;
|
||||
current_question?: unknown;
|
||||
choice_card?: unknown;
|
||||
question_source?: unknown;
|
||||
case?: Readonly<{
|
||||
status?: unknown;
|
||||
accepted_time?: unknown;
|
||||
confirmed_time?: unknown;
|
||||
}>;
|
||||
turns?: unknown;
|
||||
}>;
|
||||
|
||||
export function isRectificationCaseSnapshotPayload(value: unknown): value is RectificationCaseSnapshotPayload {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
type RawTurn = {
|
||||
id?: unknown;
|
||||
role?: unknown;
|
||||
text?: unknown;
|
||||
status?: unknown;
|
||||
question?: unknown;
|
||||
offer_result_id?: unknown;
|
||||
receipt?: unknown;
|
||||
};
|
||||
|
||||
type RawToolActivity = {
|
||||
tool?: unknown;
|
||||
status?: unknown;
|
||||
methods?: unknown;
|
||||
started_at?: unknown;
|
||||
elapsed_ms?: unknown;
|
||||
detail?: unknown;
|
||||
};
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map(String) : [];
|
||||
}
|
||||
|
||||
function parseToolActivities(value: unknown): NonNullable<NonNullable<PersistedRectificationTurn["receipt"]>["tool_activities"]> | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.flatMap((item: RawToolActivity) => {
|
||||
if (!item || typeof item !== "object" || typeof item.tool !== "string" || typeof item.status !== "string") return [];
|
||||
return [{
|
||||
tool: item.tool,
|
||||
status: item.status,
|
||||
methods: Array.isArray(item.methods) ? item.methods.map(String) : undefined,
|
||||
started_at: typeof item.started_at === "string" ? item.started_at : null,
|
||||
elapsed_ms: typeof item.elapsed_ms === "number" ? item.elapsed_ms : null,
|
||||
detail: item.detail && typeof item.detail === "object" ? item.detail as Record<string, unknown> : null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
/** The Case API's `turns` rows, normalised to what the chat renders. */
|
||||
export function parsePersistedRectificationTurns(value: unknown): PersistedRectificationTurn[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((turn: RawTurn) => {
|
||||
const receipt = turn?.receipt && typeof turn.receipt === "object"
|
||||
? turn.receipt as { status?: unknown; phases?: unknown; tools?: unknown; methods?: unknown; skill_name?: unknown; skill_version?: unknown; tool_activities?: unknown }
|
||||
: null;
|
||||
const toolActivities = parseToolActivities(receipt?.tool_activities);
|
||||
return {
|
||||
id: String(turn?.id ?? ""),
|
||||
role: turn?.role === "user" ? "user" as const : "assistant" as const,
|
||||
text: typeof turn?.text === "string" ? turn.text : null,
|
||||
status: String(turn?.status ?? "completed"),
|
||||
question: turn?.question ?? null,
|
||||
offer_result_id: typeof turn?.offer_result_id === "string" ? turn.offer_result_id : null,
|
||||
receipt: receipt ? {
|
||||
status: String(receipt.status ?? ""),
|
||||
phases: stringList(receipt.phases),
|
||||
tools: stringList(receipt.tools),
|
||||
methods: stringList(receipt.methods),
|
||||
skill_name: typeof receipt.skill_name === "string" ? receipt.skill_name : undefined,
|
||||
skill_version: typeof receipt.skill_version === "string" ? receipt.skill_version : undefined,
|
||||
...(toolActivities ? { tool_activities: toolActivities } : {}),
|
||||
} : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type RectificationCaseHydration = Readonly<{
|
||||
turns: PersistedRectificationTurn[];
|
||||
snapshot: RectificationCaseSnapshotPayload | null;
|
||||
/** False when the request failed or the deadline passed first. */
|
||||
complete: boolean;
|
||||
}>;
|
||||
|
||||
export function rectificationCaseHref(caseId: string, sessionId: string): string {
|
||||
return `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns and snapshot come from one Case read, so hydration is a single request
|
||||
* raced against the reveal deadline. A late or failed read still resolves, with
|
||||
* empty turns and no snapshot, so the caller can reveal and let the surface's
|
||||
* own retry path fill in the rest.
|
||||
*/
|
||||
export async function hydrateRectificationCase(
|
||||
caseId: string,
|
||||
sessionId: string,
|
||||
options: Readonly<{
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
setTimeoutImpl?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearTimeoutImpl?: (handle: unknown) => void;
|
||||
}> = {},
|
||||
): Promise<RectificationCaseHydration> {
|
||||
const timeoutMs = options.timeoutMs ?? RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const schedule = options.setTimeoutImpl ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
const cancel = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
||||
const incomplete: RectificationCaseHydration = { turns: [], snapshot: null, complete: false };
|
||||
|
||||
let deadline: unknown;
|
||||
const timeout = new Promise<RectificationCaseHydration>((resolve) => {
|
||||
deadline = schedule(() => resolve(incomplete), timeoutMs);
|
||||
});
|
||||
const read = (async (): Promise<RectificationCaseHydration> => {
|
||||
try {
|
||||
const response = await fetchImpl(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
|
||||
if (!response.ok) return incomplete;
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!isRectificationCaseSnapshotPayload(payload)) return incomplete;
|
||||
return {
|
||||
turns: parsePersistedRectificationTurns(payload.turns),
|
||||
snapshot: payload,
|
||||
complete: true,
|
||||
};
|
||||
} catch {
|
||||
return incomplete;
|
||||
}
|
||||
})();
|
||||
try {
|
||||
return await Promise.race([read, timeout]);
|
||||
} finally {
|
||||
cancel(deadline);
|
||||
}
|
||||
}
|
||||
|
||||
export type RectificationConversationState =
|
||||
| "readonly"
|
||||
| "busy"
|
||||
| "opening"
|
||||
| "empty"
|
||||
| "conversation";
|
||||
|
||||
/**
|
||||
* What the transcript area is doing when there are no rendered turns. `opening`
|
||||
* means the first turn is about to be requested (server said so); `empty` means
|
||||
* the Case has nothing to show and no automatic first turn — the reader needs a
|
||||
* way to start.
|
||||
*/
|
||||
export function rectificationConversationState(input: Readonly<{
|
||||
messageCount: number;
|
||||
busy: boolean;
|
||||
readonly: boolean;
|
||||
shouldStartOpening: boolean;
|
||||
openingStarted: boolean;
|
||||
}>): RectificationConversationState {
|
||||
if (input.readonly) return "readonly";
|
||||
if (input.busy) return "busy";
|
||||
if (input.messageCount > 0) return "conversation";
|
||||
if (input.shouldStartOpening || input.openingStarted) return "opening";
|
||||
return "empty";
|
||||
}
|
||||
|
||||
export type RectificationQuestionGapState = "idle" | "preparing" | "unavailable";
|
||||
|
||||
export type RectificationQuestionGapInput = Readonly<{
|
||||
/** The current question is rendered live inside an assistant message. */
|
||||
liveQuestionVisible: boolean;
|
||||
/** The snapshot names no current question at all. */
|
||||
questionMissing: boolean;
|
||||
/** The server said the question could not be loaded (`question_source: "unavailable"`). */
|
||||
questionLoadFailed: boolean;
|
||||
/** Candidate cards are offered and not yet adopted: the reader, not the server, holds the next move. */
|
||||
offerAwaitingReader?: boolean;
|
||||
busy: boolean;
|
||||
readonly: boolean;
|
||||
regenerating: boolean;
|
||||
snapshotLoaded: boolean;
|
||||
resumableCase: boolean;
|
||||
retryAttempts: number;
|
||||
retryLimit?: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The gap between a settled turn and its next question. While retries remain
|
||||
* it is `preparing` (one live row, timed refetches); once they run out, or the
|
||||
* server itself reports the question unavailable, it is `unavailable` with a
|
||||
* manual reload. A snapshot that never arrived (hydration timed out) is a gap
|
||||
* too: the same retries fill it. Questions themselves live inside assistant
|
||||
* messages, so a visible live question means there is no gap.
|
||||
*/
|
||||
export function rectificationQuestionGapState(input: RectificationQuestionGapInput): RectificationQuestionGapState {
|
||||
const limit = input.retryLimit ?? RECTIFICATION_QUESTION_RETRY_LIMIT;
|
||||
if (input.readonly || input.busy || input.regenerating) return "idle";
|
||||
const retryGate = input.retryAttempts < limit ? "preparing" : "unavailable";
|
||||
if (!input.snapshotLoaded) return retryGate;
|
||||
if (!input.resumableCase) return "idle";
|
||||
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
|
||||
if (input.questionLoadFailed) return "unavailable";
|
||||
// Either the snapshot names no question, or it names one that no settled
|
||||
// message carries live yet: both are a gap the next read may close.
|
||||
return retryGate;
|
||||
}
|
||||
|
||||
/** Whether the gap should run another timed snapshot refetch. */
|
||||
export function rectificationQuestionRetryActive(state: RectificationQuestionGapState): boolean {
|
||||
return state === "preparing";
|
||||
}
|
||||
|
||||
/** Adoption live-row copy: the minute is shown so the reader knows what is being applied. */
|
||||
export function rectificationAdoptingLabel(time: string): string {
|
||||
return `正在采用 ${time}…`;
|
||||
}
|
||||
|
||||
/** Board copy when no candidate result exists yet (task 1.3). */
|
||||
export function rectificationBoardEmptyCopy(declaredTime: string | null): Readonly<{
|
||||
clock: string | null;
|
||||
lines: readonly string[];
|
||||
}> {
|
||||
if (!declaredTime) {
|
||||
return { clock: null, lines: ["补充经历后,这里会显示当前本命宫位和换升时刻。"] };
|
||||
}
|
||||
return {
|
||||
clock: declaredTime,
|
||||
lines: [`填报出生时间 ${declaredTime}`, "回答几个问题后,这里会显示宫位随时间的变化。"],
|
||||
};
|
||||
}
|
||||
|
||||
/** The declared birth minute from the profile, or null when only a period was given. */
|
||||
export function declaredBirthTime(profile: Readonly<{ time?: string; reportedTime?: string }>): string | null {
|
||||
const candidate = (profile.reportedTime || profile.time || "").trim();
|
||||
return /^\d{1,2}:\d{2}$/.test(candidate) ? candidate : null;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const component = readFileSync(
|
||||
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const surfaceState = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
|
||||
const chat = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -82,9 +83,18 @@ test("persisted rectification turns hydrate after the async Case refresh", () =>
|
||||
assert.doesNotMatch(chat, /spoken-answer/);
|
||||
assert.doesNotMatch(chat, /splitRectificationSpokenAndThinking|settleRectificationSpokenAndThinking|finalizeRectificationSpokenAndThinking/);
|
||||
assert.match(chat, /text: raw,/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
|
||||
// Was: key=...-${rectificationTurns.length > 0 ? "ready" : "loading"}. That suffix
|
||||
// remounted the whole surface when the first turns arrived (an empty panel, then
|
||||
// a flash), and again would have dropped a live stream; turns now hydrate before
|
||||
// the switch and later arrive as a prop update (BUG-505).
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.doesNotMatch(page, /"ready" : "loading"/);
|
||||
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
|
||||
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
|
||||
// Was: an inline `methods: Array.isArray((turn.receipt as { methods?: unknown }).methods)`
|
||||
// parser in the surface hook; the parser moved to rectification-surface-state.ts so
|
||||
// hydration and refresh share it (BUG-505).
|
||||
assert.match(page, /setRectificationTurns\(parsePersistedRectificationTurns\(payload\?\.turns\)\)/);
|
||||
assert.match(surfaceState, /methods: stringList\(receipt\.methods\)/);
|
||||
assert.match(component, /methods\?: readonly string\[\]/);
|
||||
});
|
||||
|
||||
@@ -191,7 +201,9 @@ test("persisted turns survive remounts; duplicate openings are suppressed by the
|
||||
assert.match(chat, /initialTurns/);
|
||||
assert.match(chat, /const openingStarted = useRef\(false\)/);
|
||||
assert.match(chat, /if \(initialTurns\.length > 0\) \{/);
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
|
||||
// Was: the "ready"/"loading" key suffix (see above). Persisted turns now survive
|
||||
// because nothing remounts: the key is the session/Case binding only (BUG-505).
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.match(page, /initialTurns=\{rectificationTurns\}/);
|
||||
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
|
||||
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
|
||||
@@ -218,9 +230,16 @@ test("candidate acceptance is non-billable, mutually exclusive, and continues th
|
||||
assert.match(chat, /resultId: candidateResult\.resultId/);
|
||||
assert.match(chat, /if \(!candidateResult \|\| acceptingCandidateId \|\| busy \|\| readonly\) return;/);
|
||||
assert.match(chat, /setAcceptingCandidateId\(candidateId\);[\s\S]*setPending\(true\);/);
|
||||
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*choiceContinuationPending\.current = true;/);
|
||||
// Was: `await loadCaseSnapshot(); choiceContinuationPending.current = true;` and an
|
||||
// effect `if (!choiceContinuationPending.current || busy || readonly) return; void
|
||||
// send("read_only", "")`. That hop dropped `busy` for a frame and removed the live
|
||||
// row, so the next card flashed and the transcript went blank between the tap and
|
||||
// the follow-up turn. Continuation now runs in the same async chain on the same
|
||||
// row (BUG-506). The old `doesNotMatch` guarded against a *bare* `send("read_only",
|
||||
// "")` after the snapshot — a continuation carries the row to reuse, and is kept.
|
||||
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/);
|
||||
assert.match(chat, /finally \{[\s\S]*setAcceptingCandidateId\(null\);[\s\S]*setPending\(false\);/);
|
||||
assert.match(chat, /if \(!choiceContinuationPending\.current \|\| busy \|\| readonly\) return;[\s\S]*void send\("read_only", ""\);/);
|
||||
assert.doesNotMatch(chat, /choiceContinuationPending/);
|
||||
assert.doesNotMatch(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", ""\);/);
|
||||
assert.doesNotMatch(chat, /action: "accept_candidate"/);
|
||||
const acceptRoute = readFileSync(
|
||||
@@ -503,7 +522,10 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
|
||||
assert.match(styles, /\.chat-header-rectification \.rectification-board-peek \{[\s\S]*width: auto/);
|
||||
assert.doesNotMatch(chat, /rectification-workspace__board-trigger/);
|
||||
assert.doesNotMatch(chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("<form className=\"composer\"")), /RectificationBoardPeek/);
|
||||
assert.match(chat, /className=\{`rectification-workspace\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}`\}/);
|
||||
// Was: the class template without `is-board-empty`. The board narrows while no
|
||||
// candidate exists (BUG-509); compact/open modifiers are unchanged and still
|
||||
// follow the empty modifier so the single-column layout wins.
|
||||
assert.match(chat, /className=\{`rectification-workspace\$\{candidateResult \? "" : " is-board-empty"\}\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}`\}/);
|
||||
});
|
||||
|
||||
test("rectification composer can stop a live agent run", () => {
|
||||
|
||||
@@ -1198,7 +1198,10 @@ test("the public agent route treats structured choice as a non-model command", (
|
||||
assert.match(chat, /isPersistedFocusId\(focusId\)/);
|
||||
assert.match(chat, /focusId,/);
|
||||
assert.match(chat, /shouldContinueAfterStructuredChoice\(payload\?\.nextAction, payload\)/);
|
||||
assert.match(chat, /send\("read_only", ""\)/);
|
||||
// Was: /send\("read_only", ""\)/ — the bare follow-up call from an effect hop. The
|
||||
// follow-up now continues on the live row already in place, so the call carries
|
||||
// that row (BUG-506); the route contract (read_only after a structured choice) holds.
|
||||
assert.match(chat, /send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel \}\)/);
|
||||
assert.match(chat, /willContinue/);
|
||||
assert.match(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)/);
|
||||
// Former locks: `回到最新` and `followTailRef.current` — the rectification-only chip and follow
|
||||
|
||||
@@ -133,8 +133,12 @@ test("one assistant article owns the stem and options; the slot class is gone",
|
||||
assert.doesNotMatch(chat, /rectification-question-slot/);
|
||||
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
|
||||
assert.match(chat, /role="status"/);
|
||||
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
|
||||
assert.match(chat, /题目加载失败,请刷新/);
|
||||
// Was: /当前没有可回答的问题,正在等待服务端更新/ and /题目加载失败,请刷新/ — bare copy
|
||||
// that told the reader to wait for the server with nothing happening. The gap is
|
||||
// now a live timeline row with timed refetches, then copy and a reload (BUG-505).
|
||||
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_PREPARING_LABEL/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
|
||||
assert.match(chat, /question_source/);
|
||||
});
|
||||
|
||||
|
||||
@@ -144,12 +144,17 @@ test("collect_spoken stem lives on turn.question inside the same assistant artic
|
||||
|
||||
test("missing current_question is explicit only for resumable cases", () => {
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
assert.match(chat, /const showMissingQuestion = Boolean\([\s\S]*caseSnapshotLoaded[\s\S]*resumableCase[\s\S]*currentQuestion === null/);
|
||||
// Was: `const showMissingQuestion = Boolean(... caseSnapshotLoaded ... resumableCase ...
|
||||
// currentQuestion === null` plus the copy /当前没有可回答的问题,正在等待服务端更新/ and
|
||||
// /题目加载失败,请刷新/. Those were bare waiting sentences; the same inputs now feed
|
||||
// `rectificationQuestionGapState`, which yields a live row with retries and then a
|
||||
// reload button, and is still gated on a resumable case (BUG-505).
|
||||
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{[\s\S]*questionMissing: currentQuestion === null,[\s\S]*snapshotLoaded: caseSnapshotLoaded,[\s\S]*resumableCase,/);
|
||||
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
|
||||
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
|
||||
assert.match(chat, /题目加载失败,请刷新/);
|
||||
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
|
||||
assert.match(chat, /readonly && \(/);
|
||||
assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/);
|
||||
assert.doesNotMatch(chat, /questionGap[\s\S]*caseStatus.*TERMINAL/);
|
||||
});
|
||||
|
||||
test("choice options render inside the same assistant message, not a sibling slot", () => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { homeSurface as page } from "./home-surface.ts";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
const chat = read("../src/components/rectification-agentic-chat.tsx");
|
||||
const hook = read("../src/hooks/use-rectification-surface.ts");
|
||||
const sessions = read("../src/hooks/use-session-management.ts");
|
||||
const bootstrap = read("../src/lib/home-bootstrap.ts");
|
||||
const starter = read("../src/components/starter-home.tsx");
|
||||
const sidebarRow = read("../src/components/sidebar-session-row.tsx");
|
||||
const board = read("../src/components/rectification-board.tsx");
|
||||
const choiceCard = read("../src/components/rectification-choice-card.tsx");
|
||||
const wrapper = read("../src/components/conversational-birth-time-rectification.tsx");
|
||||
const timeline = read("../src/components/consultation-run-timeline.tsx");
|
||||
const styles = read("../src/app/globals.css");
|
||||
|
||||
test("the rectification surface is revealed once: Case hydration precedes the switch and the key never remounts", () => {
|
||||
// BUG-505: open → hydrate (turns + snapshot, one Case read, deadline) → switch, in that order.
|
||||
assert.match(hook, /const hydration = await hydrateRectificationCase\(opened\.caseId, opened\.sessionId, \{\s*timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,/);
|
||||
const openBody = hook.slice(hook.indexOf("const hydration = await hydrateRectificationCase"), hook.indexOf("void refreshRectificationEntrySummary();"));
|
||||
assert.match(openBody, /setRectificationTurns\(hydration\.turns\);[\s\S]*setRectificationSnapshot\(hydration\.snapshot\);[\s\S]*setActiveSessionId\(opened\.sessionId\);/);
|
||||
assert.match(openBody, /if \(!hydration\.complete\) setComposerNotice\(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE\);/);
|
||||
assert.doesNotMatch(hook, /void refreshRectificationCase\(opened\.caseId/);
|
||||
// The selection source is read before any await; selectSession resets it synchronously.
|
||||
assert.match(hook, /const selectionSource = sessionSelectionSource\.current;[\s\S]*await fetch\("\/api\/rectification\/cases\/open"/);
|
||||
assert.match(hook, /selectionSource === "user"\) \{\s*writeSessionUrl\(opened\.sessionId, "push"\)/);
|
||||
// selectSession does not switch to a rectification session before its Case is hydrated.
|
||||
assert.match(sessions, /const deferredRectificationSwitch = nextSession\?\.sessionType === "birth_time_rectification"\s*&& nextSession\.id !== rectificationSessionId;/);
|
||||
assert.match(sessions, /if \(!deferredRectificationSwitch\) setActiveSessionId\(sessionId\);/);
|
||||
assert.match(sessions, /if \(!deferredRectificationSwitch && !uiPreview\.current && sessionSelectionSource\.current === "user"\) \{\s*writeSessionUrl\(sessionId, "push"\);/);
|
||||
// Panel: one key per binding, snapshot and turns as initial state, later turns as a prop update.
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.doesNotMatch(page, /"ready" : "loading"/);
|
||||
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
|
||||
assert.match(page, /declaredTime=\{rectificationDeclaredTime\}/);
|
||||
assert.match(wrapper, /initialSnapshot: RectificationCaseSnapshotPayload \| null;/);
|
||||
assert.match(chat, /useState\(initialSnapshot !== null\)/);
|
||||
// Later turns fill an empty transcript by render-time adjustment from the previous prop, not an effect.
|
||||
assert.match(chat, /const \[seededTurns, setSeededTurns\] = useState\(initialTurns\);\s*if \(seededTurns !== initialTurns\) \{\s*setSeededTurns\(initialTurns\);\s*if \(messages\.length === 0 && initialTurns\.length > 0\) setMessages\(messagesFromTurns\(initialTurns\)\);/);
|
||||
// No self-fetch on mount; unmount ends the stream and any snapshot read.
|
||||
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*const controller = new AbortController\(\);\s*void fetch\(/);
|
||||
assert.match(chat, /useEffect\(\(\) => \(\) => \{\s*runAbort\.current\?\.abort\(\);\s*snapshotAbort\.current\?\.abort\(\);\s*\}, \[\]\);/);
|
||||
});
|
||||
|
||||
test("a rectification session selected at bootstrap is hydrated before the reveal, and popstate defers the same way", () => {
|
||||
// The resume effect runs during the prepare phase, not only after the reveal.
|
||||
assert.match(page, /if \(\(!hydrated && bootstrapPhase === "account"\)\s*\|\| !account/);
|
||||
assert.match(bootstrap, /if \(state\.rectificationApplicable && !state\.rectificationSettled\) return false;/);
|
||||
assert.match(page, /rectificationApplicable: activeSession\?\.sessionType === "birth_time_rectification",\s*rectificationSettled: activeSession\?\.id === rectificationSessionId\s*\|\| rectificationError !== ""\s*\|\| !profileComplete,/);
|
||||
// One deadline constant for hydration and the home reveal.
|
||||
assert.match(read("../src/lib/rectification-surface-state.ts"), /export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS;/);
|
||||
// History navigation goes through selectSession, which defers the switch (locked above).
|
||||
assert.match(sessions, /sessionSelectionSource\.current = "history";\s*selectSession\(requestedId\);/);
|
||||
});
|
||||
|
||||
test("entry feedback is static: card and sidebar row say 正在打开, and nothing spins after the reveal", () => {
|
||||
assert.match(page, /const rectificationCardLabel = rectificationLoading\s*\? RECTIFICATION_OPENING_LABEL/);
|
||||
assert.match(starter, /data-opening=\{rectificationLoading \? "true" : undefined\}/);
|
||||
assert.doesNotMatch(starter, /InlineSpinner/);
|
||||
assert.match(sidebarRow, /aria-busy=\{opening \? true : undefined\}/);
|
||||
assert.match(sidebarRow, /RECTIFICATION_SIDEBAR_OPENING_NOTE/);
|
||||
assert.match(page, /openingSessionId=\{rectificationOpeningSessionId\}/);
|
||||
assert.match(styles, /\.product-entrypoint-card\[data-opening="true"\][^{]*\{ cursor: progress; \}/);
|
||||
assert.match(styles, /\.session-opening-note \{/);
|
||||
// The only spinner inside the chat is a timeline live row (generation), never a loading state.
|
||||
assert.equal((chat.match(/InlineSpinner/g) ?? []).length, 0);
|
||||
assert.match(timeline, /export function ConsultationTimelineLiveRow/);
|
||||
assert.match(chat, /<ConsultationTimelineLiveRow id="question-preparing" label=\{RECTIFICATION_QUESTION_PREPARING_LABEL\} \/>/);
|
||||
});
|
||||
|
||||
test("the question gap is a live row with retries, then a reload; it never tells the reader to wait for the server", () => {
|
||||
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新|rectification-composer-status/);
|
||||
assert.doesNotMatch(chat, /showMissingQuestion|showUnavailableQuestion|showQuestionLoadFailed/);
|
||||
assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{/);
|
||||
assert.match(chat, /questionGap === "preparing" && \(/);
|
||||
assert.match(chat, /questionGap === "unavailable" && \(/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
|
||||
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionGap === "preparing",\s*intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,/);
|
||||
// Attempts reset in handlers (a question arriving, a turn starting), never in an effect.
|
||||
assert.match(chat, /if \(nextQuestion !== null\) setQuestionRetryAttempts\(0\);/);
|
||||
assert.match(chat, /if \(value\) setQuestionRetryAttempts\(0\);/);
|
||||
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(currentQuestion !== null/);
|
||||
// In flow: the gap is the last entry of the transcript, after the message loop, before the saved-time line.
|
||||
const gapIndex = chat.indexOf('{questionGap === "preparing" && (');
|
||||
assert.ok(gapIndex > chat.indexOf("{messages.map((message) => {"));
|
||||
assert.ok(gapIndex < chat.indexOf("{savedTime && savedStatus === \"confirmed\""));
|
||||
assert.match(styles, /\.rectification-question-gap \{/);
|
||||
});
|
||||
|
||||
test("a tapped choice or an adopted candidate keeps one live row through the follow-up turn", () => {
|
||||
// BUG-506: no effect hop, no removed row, busy held across the chain.
|
||||
assert.doesNotMatch(chat, /choiceContinuationPending/);
|
||||
assert.match(chat, /const send = useCallback\(async \(\s*action: "opening" \| "message" \| "read_only",\s*messageText: string,\s*continuation\?: Readonly<\{ reuseAssistantRenderKey: string; label: string \}>,/);
|
||||
assert.match(chat, /if \(!continuation && busy\) return;/);
|
||||
assert.match(chat, /const initialLabel = rectificationInitialLiveLabel\(action, continuation\?\.label\);/);
|
||||
assert.match(chat, /beginLiveRun\(initialLabel\);/);
|
||||
assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel \}\);/);
|
||||
assert.match(chat, /await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/);
|
||||
assert.match(chat, /rectificationAdoptingLabel\(candidateTime\)/);
|
||||
assert.doesNotMatch(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\),\s*turns,\s*\)\);/);
|
||||
});
|
||||
|
||||
test("an empty Case with no automatic opening offers a way to start", () => {
|
||||
// BUG-507
|
||||
assert.match(chat, /conversationState === "empty" && \(/);
|
||||
assert.match(chat, /RECTIFICATION_EMPTY_COPY/);
|
||||
assert.match(chat, /onClick=\{startOpeningManually\}>\{RECTIFICATION_EMPTY_ACTION_LABEL\}/);
|
||||
assert.match(chat, /function startOpeningManually\(\) \{\s*if \(readonly \|\| busy \|\| openingStarted\.current\) return;\s*openingStarted\.current = true;/);
|
||||
assert.match(styles, /\.rectification-empty-state \{/);
|
||||
});
|
||||
|
||||
test("stopping keeps what streamed and says so; a 402 explains itself before leaving", () => {
|
||||
assert.match(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\);/);
|
||||
assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*window\.setTimeout\(\(\) => \{\s*window\.location\.assign\(membershipHref\("rectification"\)\);\s*\}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS\);/);
|
||||
});
|
||||
|
||||
test("the choice card confirms the tap and the board's first state shows the declared minute", () => {
|
||||
// BUG-508
|
||||
assert.match(choiceCard, /<Check aria-hidden="true" \/>/);
|
||||
assert.match(choiceCard, /已选择/);
|
||||
assert.match(choiceCard, /rectification-choice-card__pending/);
|
||||
assert.match(choiceCard, /InlineSpinner size=\{12\}/);
|
||||
assert.match(styles, /\.rectification-choice-card__selected \{/);
|
||||
assert.match(styles, /\.rectification-choice-card__pending \{/);
|
||||
// BUG-509
|
||||
assert.match(board, /declaredTime: string \| null;/);
|
||||
assert.match(board, /rectificationBoardEmptyCopy\(declaredTime\)/);
|
||||
assert.match(board, /rectificationBoardPeekCopy\(result, declaredTime\)/);
|
||||
assert.doesNotMatch(board, /补充经历后,这里会显示当前本命宫位和换升时刻/);
|
||||
assert.match(chat, /\$\{candidateResult \? "" : " is-board-empty"\}/);
|
||||
assert.match(styles, /\.rectification-workspace\.is-board-empty \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
|
||||
// The empty modifier is declared before `.is-compact`, so the single-column layout still wins.
|
||||
assert.ok(styles.indexOf(".rectification-workspace.is-board-empty {") < styles.indexOf(".rectification-workspace.is-compact {"));
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "../src/lib/home-bootstrap.ts";
|
||||
import {
|
||||
declaredBirthTime,
|
||||
hydrateRectificationCase,
|
||||
parsePersistedRectificationTurns,
|
||||
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
rectificationAdoptingLabel,
|
||||
rectificationBoardEmptyCopy,
|
||||
rectificationConversationState,
|
||||
rectificationInitialLiveLabel,
|
||||
rectificationQuestionGapState,
|
||||
rectificationQuestionRetryActive,
|
||||
type RectificationQuestionGapInput,
|
||||
} from "../src/lib/rectification-surface-state.ts";
|
||||
|
||||
const gapBase: RectificationQuestionGapInput = {
|
||||
liveQuestionVisible: false,
|
||||
questionMissing: true,
|
||||
questionLoadFailed: false,
|
||||
busy: false,
|
||||
readonly: false,
|
||||
regenerating: false,
|
||||
snapshotLoaded: true,
|
||||
resumableCase: true,
|
||||
retryAttempts: 0,
|
||||
};
|
||||
|
||||
test("conversation state: readonly and busy win, then opening, then empty", () => {
|
||||
const base = { messageCount: 0, busy: false, readonly: false, shouldStartOpening: false, openingStarted: false };
|
||||
assert.equal(rectificationConversationState({ ...base, readonly: true }), "readonly");
|
||||
assert.equal(rectificationConversationState({ ...base, busy: true }), "busy");
|
||||
assert.equal(rectificationConversationState({ ...base, messageCount: 3 }), "conversation");
|
||||
assert.equal(rectificationConversationState({ ...base, shouldStartOpening: true }), "opening");
|
||||
assert.equal(rectificationConversationState({ ...base, openingStarted: true }), "opening");
|
||||
// A resumed Case with no turns and no server-started opening: the reader needs a way to begin (BUG-507).
|
||||
assert.equal(rectificationConversationState(base), "empty");
|
||||
});
|
||||
|
||||
test("question gap: a live question inside a message means no gap", () => {
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, liveQuestionVisible: true, questionMissing: false }), "idle");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, liveQuestionVisible: true }), "idle");
|
||||
});
|
||||
|
||||
test("question gap: preparing while retries remain, then unavailable with a reload", () => {
|
||||
assert.equal(rectificationQuestionGapState(gapBase), "preparing");
|
||||
// The server names a question that no settled message carries live yet.
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, questionMissing: false }), "preparing");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, retryAttempts: 5 }), "unavailable");
|
||||
// The server itself said the question could not be loaded: no point retrying on a timer.
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionLoadFailed: true }), "unavailable");
|
||||
assert.equal(rectificationQuestionRetryActive("preparing"), true);
|
||||
assert.equal(rectificationQuestionRetryActive("unavailable"), false);
|
||||
assert.equal(rectificationQuestionRetryActive("idle"), false);
|
||||
});
|
||||
|
||||
test("question gap: a snapshot that never arrived is a gap the same retries fill", () => {
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, snapshotLoaded: false }), "preparing");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, snapshotLoaded: false, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
|
||||
});
|
||||
|
||||
test("question gap: nothing is shown while busy, readonly, regenerating, or for non-resumable cases", () => {
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, busy: true }), "idle");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, readonly: true }), "idle");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, regenerating: true }), "idle");
|
||||
assert.equal(rectificationQuestionGapState({ ...gapBase, resumableCase: false }), "idle");
|
||||
});
|
||||
|
||||
test("live-row labels follow the action that started the turn", () => {
|
||||
assert.equal(rectificationInitialLiveLabel("opening"), "正在读取你的出生资料,准备第一个问题…");
|
||||
assert.equal(rectificationInitialLiveLabel("message"), "正在处理…");
|
||||
assert.equal(rectificationInitialLiveLabel("read_only", "正在记录本次选择…"), "正在记录本次选择…");
|
||||
assert.equal(rectificationInitialLiveLabel("read_only"), "正在处理…");
|
||||
assert.equal(rectificationAdoptingLabel("04:53"), "正在采用 04:53…");
|
||||
});
|
||||
|
||||
test("board copy before any candidate shows the declared minute, never invented data", () => {
|
||||
assert.deepEqual(rectificationBoardEmptyCopy("05:10"), {
|
||||
clock: "05:10",
|
||||
lines: ["填报出生时间 05:10", "回答几个问题后,这里会显示宫位随时间的变化。"],
|
||||
});
|
||||
assert.deepEqual(rectificationBoardEmptyCopy(null), {
|
||||
clock: null,
|
||||
lines: ["补充经历后,这里会显示当前本命宫位和换升时刻。"],
|
||||
});
|
||||
assert.equal(declaredBirthTime({ reportedTime: "5:10", time: "" }), "5:10");
|
||||
assert.equal(declaredBirthTime({ reportedTime: "", time: "14:30" }), "14:30");
|
||||
assert.equal(declaredBirthTime({ reportedTime: "", time: "" }), null);
|
||||
assert.equal(declaredBirthTime({ reportedTime: "凌晨", time: "" }), null);
|
||||
});
|
||||
|
||||
test("persisted turns keep their question, offer and tool activities through the parser", () => {
|
||||
const turns = parsePersistedRectificationTurns([
|
||||
{
|
||||
id: "t1",
|
||||
role: "assistant",
|
||||
text: "先看事业。",
|
||||
status: "completed",
|
||||
question: { focus_id: "f1", kind: "choice" },
|
||||
offer_result_id: "r1",
|
||||
receipt: {
|
||||
status: "ready",
|
||||
phases: ["compute"],
|
||||
tools: ["rectification-compare-candidates"],
|
||||
methods: ["d9-navamsa", "d10-dashamsa"],
|
||||
tool_activities: [{ tool: "rectification-compare-candidates", status: "completed", methods: ["d9-navamsa"], started_at: null, elapsed_ms: 12 }],
|
||||
},
|
||||
},
|
||||
{ id: "t2", role: "user", text: "2014 年毕业。" },
|
||||
"junk",
|
||||
]);
|
||||
assert.equal(turns.length, 3);
|
||||
assert.equal(turns[0]?.offer_result_id, "r1");
|
||||
assert.deepEqual(turns[0]?.question, { focus_id: "f1", kind: "choice" });
|
||||
assert.deepEqual(turns[0]?.receipt?.methods, ["d9-navamsa", "d10-dashamsa"]);
|
||||
assert.equal(turns[0]?.receipt?.tool_activities?.[0]?.tool, "rectification-compare-candidates");
|
||||
assert.equal(turns[1]?.role, "user");
|
||||
assert.equal(turns[1]?.status, "completed");
|
||||
assert.equal(turns[1]?.receipt, null);
|
||||
assert.equal(turns[2]?.id, "");
|
||||
assert.deepEqual(parsePersistedRectificationTurns(null), []);
|
||||
});
|
||||
|
||||
test("hydration reads turns and snapshot from one Case read and is one constant with the home reveal budget", async () => {
|
||||
assert.equal(RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, BOOTSTRAP_PREPARE_TIMEOUT_MS);
|
||||
const calls: string[] = [];
|
||||
const fetchImpl = (async (input: RequestInfo | URL) => {
|
||||
calls.push(String(input));
|
||||
return new Response(JSON.stringify({
|
||||
turns: [{ id: "t1", role: "assistant", text: "hi", status: "completed" }],
|
||||
current_question: { kind: "choice", prompt: "哪一年?", focus_id: "f1", question_id: "q1" },
|
||||
case: { status: "collecting" },
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
const hydration = await hydrateRectificationCase("case-1", "session-1", { fetchImpl, timeoutMs: 1_000 });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0] ?? "", /\/api\/rectification\/cases\/case-1\?sessionId=session-1$/);
|
||||
assert.equal(hydration.complete, true);
|
||||
assert.equal(hydration.turns.length, 1);
|
||||
assert.equal((hydration.snapshot?.case as { status?: unknown } | undefined)?.status, "collecting");
|
||||
});
|
||||
|
||||
test("hydration resolves incomplete on failure and when the deadline passes first", async () => {
|
||||
const failing = (async () => new Response("nope", { status: 500 })) as typeof fetch;
|
||||
const failed = await hydrateRectificationCase("case-1", "session-1", { fetchImpl: failing, timeoutMs: 1_000 });
|
||||
assert.deepEqual(failed, { turns: [], snapshot: null, complete: false });
|
||||
|
||||
let fire: (() => void) | undefined;
|
||||
const never = (() => new Promise<Response>(() => {})) as typeof fetch;
|
||||
const late = hydrateRectificationCase("case-1", "session-1", {
|
||||
fetchImpl: never,
|
||||
timeoutMs: 4_000,
|
||||
setTimeoutImpl: (callback) => {
|
||||
fire = callback;
|
||||
return 1;
|
||||
},
|
||||
clearTimeoutImpl: () => {},
|
||||
});
|
||||
assert.ok(fire);
|
||||
fire?.();
|
||||
assert.deepEqual(await late, { turns: [], snapshot: null, complete: false });
|
||||
});
|
||||
Reference in New Issue
Block a user