Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fec000f79d | |||
| ef11f6e335 | |||
| 91b8b33aa6 |
@@ -1,5 +1,12 @@
|
||||
# BLOCKED
|
||||
|
||||
## 生时校正会话面:消除空白假死与交互摩擦(2026-09-02,分支 `codex/rectification-ux-20260902`)
|
||||
|
||||
- **浏览器级手工验收未做:执行环境无登录态、无 Chrome。** 任务书任务 0 的六条手工项与任务 1 的整轮录屏(开场 → 3 道选择题 → 候选 → 采用)都做不了;已按任务书把它们写进 `docs/testing/staging-manual-walkthrough-20260901.md` 第 8 节,交给有真实会话的人。本轮的替代证据:`tests/rectification-surface-state.test.ts`(纯函数四态、hydration 超时/失败、文案)与 `tests/rectification-surface-contract.test.ts`(源码锁:一次揭幕、无重挂、续接同一行、空态、板首态)。
|
||||
- **1.3「若快照 API 提供填报时间宫位表则直接渲染」未做:API 不提供。** `frontend/src/app/api/rectification/cases/[caseId]/route.ts` 的 `dossierResponse` 只带 `accepted_time` / `confirmed_time`,grep `natal|declared` 无命中;按任务书只做文案与收窄,不造数据。
|
||||
- **书面偏差:hydration 不是 `Promise.allSettled([turns, snapshot])`,而是一次请求。** turns 与快照来自同一个 `GET /api/rectification/cases/:id` 读取,两个并行请求会重复;`hydrateRectificationCase` 用 `Promise.race` 对一次读取套 4 秒上限,语义(并行拉完再切换、超时仍揭幕)不变,契约锁改锁 `hydrateRectificationCase` + `RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS`。
|
||||
- **书面偏差:turns 后到的填充与重试计数复位不用 `useEffect`。** `npm run lint` 的 `react-hooks` 规则拦截 effect 内同步 setState;改为 React 文档的"渲染中按上一 prop 调整 state"模式与事件处理器内复位,行为等价。
|
||||
|
||||
## 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 一并落地。
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# PROGRESS · 生时校正会话面:消除空白假死与交互摩擦(2026-09-02)
|
||||
|
||||
工作树:`.worktrees/rectification-ux-20260902`
|
||||
分支:`codex/rectification-ux-20260902`
|
||||
基线:`origin/staging` @ `4dc0c8c7`(`codex/streaming-ux-20260901` 的 merge commit;任务书前置条件满足)。
|
||||
执行时 `TASK-rectification-walkthrough-polish-20260902.md` 与 `TASK-unified-loading-20260902.md` 均未落地(远端无对应分支):B.2/D.2 按任务书由本轮承担,没有 `question.ready` 事件,0.4 走"完成后立即拉一次 + 定时重试"的分支。
|
||||
|
||||
未改服务端与 SQL。未改 `.gitea/workflows/**`。未合并 staging,未碰 main。未手写 `useCallback` / `useMemo`(`rectification-agentic-chat.tsx` 既有的原样保留,新增的 `refetchQuestion` / `reloadQuestion` / `startOpeningManually` 是普通函数)。BUG 编号开工时确认远端最大号为 478,本轮 479–483。
|
||||
|
||||
| 任务 | 状态 | commit | BUG |
|
||||
| --- | --- | --- | --- |
|
||||
| 0.1 入口有反馈不转圈 | 完成 | `91b8b33a` | BUG-479 |
|
||||
| 0.2 一次揭幕、面板不重挂 | 完成 | `91b8b33a` | BUG-479 |
|
||||
| 0.3 问题槽只有生成中态、在对话流内 | 完成 | `91b8b33a` | BUG-479 |
|
||||
| 0.4 「等待服务端更新」→ live 行 + 重试 + 重新加载 | 完成 | `91b8b33a` | BUG-479 |
|
||||
| 0.5 选择题/采用不留缝不闪卡 | 完成 | `91b8b33a` | BUG-480 |
|
||||
| 0.6 空 turns 会话的起点 | 完成 | `91b8b33a` | BUG-481 |
|
||||
| 0.7 停止后的文案 | 完成(现状不足,已补) | `91b8b33a` | — |
|
||||
| 1.1 选择卡确认感 | 完成 | `ef11f6e3` | BUG-482 |
|
||||
| 1.2 用户选择回显 | 完成(在 `91b8b33a`,与 `send` 重写同一处) | `91b8b33a` | BUG-482 |
|
||||
| 1.3 盘面首态 | 完成(文案 + 收窄;宫位表 API 不提供,未造数据) | `ef11f6e3` | BUG-483 |
|
||||
| 1.4 开场 live 文案 | 完成(在 `91b8b33a`) | `91b8b33a` | — |
|
||||
| 1.5 402 跳转前提示 | 完成(在 `91b8b33a`) | `91b8b33a` | — |
|
||||
| 2 DESIGN.md | 完成 | 本 docs commit | — |
|
||||
|
||||
1.2 / 1.4 / 1.5 的改动都落在 `send` 与 `submitStructuredChoice` 的重写里,无法从任务 0 的 commit 里干净拆出,所以随 `91b8b33a` 提交。
|
||||
|
||||
## 六段空白与死角:各自的消除证据
|
||||
|
||||
| 空白 | 消除方式 | 证据(契约 / 纯函数用例) |
|
||||
| --- | --- | --- |
|
||||
| 1 首页卡片无反馈 | `rectificationCardLabel` 在 `rectificationLoading` 时为「正在打开…」;卡片 `aria-busy` + `data-opening` + `cursor: progress`;侧栏目标行「打开中」 | `rectification-surface-contract` → "entry feedback is static…" |
|
||||
| 2 侧栏进入:普通对话 → 空白 → 重挂 | `selectSession` 对未打开的校正会话不再先 `setActiveSessionId`/写 URL;hook 在 `/cases/open` 后 `hydrateRectificationCase`(一次 Case 读取取 turns + 快照,4 秒上限)再一次性切换;面板 key 去掉 `-ready/-loading` | 同上 → "…revealed once…";`rectification-surface-state` → "hydration reads turns and snapshot from one Case read" / "…resolves incomplete…" |
|
||||
| 3 第一轮结束整面重挂 | key 只剩 `${sessionId}-${caseId}`;后到的 turns 经 prop 更新只填空 transcript(渲染中按上一 prop 调整) | 同上(`doesNotMatch(page, /"ready" : "loading"/)`、`seededTurns` 锁) |
|
||||
| 4 快照回来前问题槽空 | 快照随揭幕到位(`initialSnapshot` 初始化 state,`caseSnapshotLoaded` 初值 `initialSnapshot !== null`);面板不再自拉 | 同上(`useState(initialSnapshot !== null)`、无挂载 fetch) |
|
||||
| 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…" |
|
||||
| 文案 「等待服务端更新」 | `rectificationQuestionSlotState` 四态:`preparing`(时间线 live 行「正在准备下一个问题…」+ 2s 定时重拉 ≤2 次)→ `unavailable`(「没有拿到下一个问题。」+ 44px「重新加载」) | `rectification-surface-state` → "question slot: a gap is preparing…";契约 → "…never tells the reader to wait for the server" |
|
||||
|
||||
## 改动文件
|
||||
|
||||
- 新建:`frontend/src/lib/rectification-surface-state.ts`(常量、hydration、turn 解析、两个纯状态函数、板空态文案、填报时间派生)、`frontend/tests/rectification-surface-state.test.ts`(9 用例)、`frontend/tests/rectification-surface-contract.test.ts`(8 用例)。
|
||||
- 改动:`use-rectification-surface.ts`、`use-session-management.ts`、`page.tsx`(state 2 处、hook 参数、卡片文案、面板 key/props、侧栏 prop、import)、`app-sidebar.tsx`、`sidebar-session-row.tsx`、`starter-home.tsx`、`conversational-birth-time-rectification.tsx`(props)、`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 节)、`BLOCKED.md`。
|
||||
|
||||
## 1.3 核实
|
||||
|
||||
`frontend/src/app/api/rectification/cases/[caseId]/route.ts` 的 `dossierResponse` 只带 `accepted_time` / `confirmed_time` / `candidate_range`,`grep -n 'natal\|declared\|birth_time\|profile'` 无命中 → 快照 API 不提供填报时间的宫位表。按任务书只做文案与收窄,`declaredTime` 由 `page.tsx` 从 profile(`reportedTime || time`,须为 `H:MM`)派生。
|
||||
|
||||
## 与任务书的书面偏差(均登记 BLOCKED.md)
|
||||
|
||||
1. hydration 是一次 `GET /api/rectification/cases/:id`(turns 与快照同一端点)套 4 秒 `Promise.race`,不是 `Promise.allSettled([turns, snapshot])`;语义不变。契约锁改锁 `hydrateRectificationCase` + `RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS`。unified-loading 落地时应把它的 4 秒常量与 `RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS` 合一。
|
||||
2. turns 后到的填充与重试计数复位不用 `useEffect`(`npm run lint` 的 `react-hooks/set-state-in-effect` 拦):前者是渲染中按上一 prop 调整 state,后者在 `applyCaseSnapshot`(有问题到达)与 `setPending(true)` 里复位。
|
||||
3. 选择卡 pending 行用 `InlineSpinner` + shimmer 文案而不是嵌一个 `ConsultationTimelineLiveRow`:卡片内是 `<p role="status">`,与时间线 live 行同一形态(同 spinner、同 `agent-activity-status__text`)。
|
||||
|
||||
## 被触碰的既有断言(红线 4,每处源码上方均注明原值与错因)
|
||||
|
||||
| 文件 | 原断言 | 现在 | 理由 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tests/rectification-agentic-entry.test.ts` :85、:194 | `key={…-${rectificationTurns.length > 0 ? "ready" : "loading"}}` | key 只含 session/Case;`doesNotMatch /"ready" : "loading"/`;`initialSnapshot={rectificationSnapshot}` | 锁的正是重挂缺陷(BUG-479) |
|
||||
| 同上 :93 | `page` 含 turn 解析的 `methods: Array.isArray(…)` | 改读 `rectification-surface-state.ts`,`page` 锁 `parsePersistedRectificationTurns(payload?.turns)` | 解析器搬家,形状不变(BUG-479) |
|
||||
| 同上 :221–:224 | `await loadCaseSnapshot(); … choiceContinuationPending.current = true;`、effect 里 `void send("read_only", "")`、`doesNotMatch` 同链 `await send` | 锁 `await loadCaseSnapshot(); … await send("read_only", "", { reuseAssistantRenderKey…, label: adoptingLabel })`,`doesNotMatch /choiceContinuationPending/` | 锁的是 effect 中转本身(BUG-480) |
|
||||
| 同上 :504 | workspace className 以 ` is-board-open` 结尾 | 追加 `${candidateResult ? "" : " is-board-empty"}` | 空板收窄(BUG-483) |
|
||||
| 同上 :557 | `match(chat, /isStructuredChoiceUserText/)` | `doesNotMatch` + `userVisibleChoiceLine(answeredCard, optionId)` | 过滤掉的正是用户回显(BUG-482) |
|
||||
| `tests/rectification-answer-choice.test.ts` :1123 | `send\("read_only", ""\)` | `await send("read_only", "", { reuseAssistantRenderKey…, label: recordingLabel })` | 续接参数(BUG-480) |
|
||||
| `tests/rectification-spoken-collect.test.ts` :134–:138 | `showMissingQuestion = Boolean(caseSnapshotLoaded…currentQuestion === null` + 「当前没有可回答的问题,正在等待服务端更新」 | 锁 `rectificationQuestionSlotState({… snapshotLoaded: caseSnapshotLoaded, … resumableCase,`;`doesNotMatch /等待服务端更新/` | 锁的正是开发者文案(BUG-479) |
|
||||
|
||||
未改任何其它既有断言;`rectification-answer-choice` :583 的 `isStructuredChoiceUserText` 纯函数用例原样保留(函数仍存在,只是不再用于过滤渲染)。
|
||||
|
||||
## 命令输出
|
||||
|
||||
`./node_modules/.bin/tsc --noEmit` → **0**(任务 0、任务 1 后各一次)。
|
||||
|
||||
`./node_modules/.bin/eslint` 触及 13 个文件 → **0 error**,42 warning 全在 `page.tsx`(与 `origin/staging` 同文件既有 `react-hooks` warning;本轮在 `page.tsx` 只加了 2 个 state、1 个派生值、4 行 props,未新增 warning)。开发中一度因 effect 内 setState 报 2 个 error,已改(见偏差 2)。
|
||||
|
||||
Python quick gate 4 个文件(`test_daily_and_rectification_entrypoints` / `test_session_management_entrypoints` / `test_birth_time_journey_contract` / `test_supabase_user_data_contract`)→ **24 passed**。
|
||||
|
||||
`./node_modules/.bin/tsx --test tests/*.test.ts`(基线在同一 worktree 的 `4dc0c8c7` 干净状态上跑):
|
||||
|
||||
| | 基线 `4dc0c8c7` | 现在 |
|
||||
| --- | ---: | ---: |
|
||||
| tests | 2486 | 2503 |
|
||||
| pass | 2452 | 2469 |
|
||||
| fail | 24 | 24 |
|
||||
| skipped | 10 | 10 |
|
||||
|
||||
失败清单与基线逐条比对:`comm -3` 差集为空,同一集合。24 条失败全是本机无 Docker / PostgreSQL / 部署夹具的数据库、Better Auth、staging workflow、live sync 类既有缺口(与前两轮 PROGRESS 同一组)。
|
||||
|
||||
聚焦 26 套件(本轮 2 个新文件 + 校正/会话/composer/滚动/样式契约)→ 248 / 248。
|
||||
|
||||
`./node_modules/.bin/next build` → **0**。`✓ Compiled successfully in 49s`、`Finished TypeScript in 49s`、`Generating static pages (62/62)`,`/` 仍是 `○ (Static)`。5 条既有 `skill-package-registry.ts` filesystem-tracing warning,未失败。
|
||||
|
||||
## 未做与原因
|
||||
|
||||
见 `BLOCKED.md` 顶部:浏览器级手工验收(六条手工项 + 整轮录屏)无登录态无 Chrome,已写进 `docs/testing/staging-manual-walkthrough-20260901.md` 第 8 节;1.3 的宫位表 API 不提供;两条书面偏差。
|
||||
@@ -7362,3 +7362,83 @@
|
||||
- 相关记录:BUG-477
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-479 | 生时校正会话面在打开、恢复与第一轮结束时各有一段空白,且整块重挂
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`page.tsx`、`use-rectification-surface.ts`、`use-session-management.ts`、`rectification-agentic-chat.tsx`、`starter-home.tsx`、`sidebar-session-row.tsx`、`app-sidebar.tsx`、`rectification-surface-state.ts`(新)
|
||||
- 用户现象:首页点「生时校正」卡片无任何反馈;从侧栏点开已有校正会话先闪一帧普通对话、再整块空白、再重挂;新建校正第一轮回答结束时整个面板闪一下、滚动归零;恢复会话后问题槽在快照回来前空着;问题槽出现「正在等待服务端更新」的开发者文案。
|
||||
- 触发条件:任意进入校正面;任意校正第一轮完成。
|
||||
- 根因:面板 key 含 `rectificationTurns.length > 0 ? "ready" : "loading"`,turns 到达即卸载重挂;`rectificationSurfaceOpen` 要求 `activeSession.id === rectificationSessionId`,而 `selectSession` 先切会话再异步 open;面板挂载后自己再拉一次快照;入口卡只 `disabled`。
|
||||
- 修复:`/cases/open` 返回后先 `hydrateRectificationCase`(一次 Case 读取同时取 turns 与快照,4 秒上限)再一次性切换会话;`selectSession` 对未打开的校正会话不再先 `setActiveSessionId`;面板 key 只保留 `${sessionId}-${caseId}`,turns/快照作为初始 state,后到的 turns 经 prop 更新只填空 transcript;卸载时中止流与快照读;入口卡与侧栏行给静态「正在打开…」/「打开中」;问题槽改为纯函数四态(`rectification-surface-state.ts`),缝隙期显示时间线 live 行并定时重拉,两次后给「重新加载」按钮,旧文案删除。
|
||||
- 验证:`tests/rectification-surface-contract.test.ts`、`tests/rectification-surface-state.test.ts`、`rectification-agentic-entry`(key 锁改写)、`rectification-spoken-collect`(文案锁改写)、`chat-session-url`。
|
||||
- 防复发:校正面一个会话只挂载一次;turns 与快照只能是 prop/state 更新;任何等待必须是时间线 live 行或问题槽 live 行,不得出现「等待服务端更新」类文案;揭幕后不得再出现非生成中的 spinner。
|
||||
- 相关记录:BUG-480、BUG-481、BUG-478
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-480 | 选择题点击与采用候选之间有空帧、下一题卡片闪现,采用时只有按钮文案在变
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`rectification-agentic-chat.tsx`
|
||||
- 用户现象:点完选项后「正在记录本次选择」行消失一帧,下一题卡片闪一下又没了,再出现新的「正在处理」行;点「采用此时间」只看到按钮变灰变成「正在采用…」,对话区静止数秒。
|
||||
- 触发条件:任意会继续追问的结构化选择;任意采用候选。
|
||||
- 根因:`submitStructuredChoice` 的 `willContinue` 分支删掉 thinking 行、置 `choiceContinuationPending`,由下一次 effect 再 `send("read_only")` 并重新追加一行;中间 `busy` 掉回 false,`showLiveChoiceCard` 对刚装进的新卡片为真。`acceptCandidate` 不在 transcript 里放任何 live 行。
|
||||
- 修复:`send` 增加 `continuation: { reuseAssistantRenderKey, label }`,续接同一条 live 行并跳过 busy 守卫;选择与采用都在同一个 async 链里 `await loadCaseSnapshot()` 后直接 `await send("read_only", …)`,`busy` 全程为 true;`choiceContinuationPending` 与对应 effect 删除;采用时先追加「正在采用 HH:MM…」行。
|
||||
- 验证:`tests/rectification-surface-contract.test.ts`、`rectification-agentic-entry`(采用链锁改写)、`rectification-answer-choice`(`send("read_only", "")` 锁改写)。
|
||||
- 防复发:续接的回合复用已有 live 行,不得删行再由 effect 追加;`busy` 在整条链上不得中途回落。
|
||||
- 相关记录:BUG-479
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-481 | 开场失败或未持久化的校正会话再进来永远空白,没有任何起点
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`rectification-agentic-chat.tsx`、`rectification-surface-state.ts`
|
||||
- 用户现象:从侧栏进入一个没有任何记录的旧校正会话,画面空白,输入框可用但用户不知道该先说什么。
|
||||
- 触发条件:服务端对 resumed case 一律返回 `should_start_opening=false`,且该 case 的 turns 为空。
|
||||
- 根因:面板只在 `shouldStartOpening` 为真时自动发开场,其它情况不显示任何 CTA。
|
||||
- 修复:`rectificationConversationState` 判定 `empty` 时显示「这段校正还没有开始。」与「开始提问」按钮,调用已有的 `send("opening", "")`;服务端已抑制重复开场,客户端 `openingStarted` 守卫。不改服务端与 SQL。
|
||||
- 验证:`tests/rectification-surface-state.test.ts`、`tests/rectification-surface-contract.test.ts`。
|
||||
- 防复发:面板任何"无内容"状态都必须给出下一步动作。
|
||||
- 相关记录:BUG-479
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-482 | 用户的选择不回显、选中无确认感
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`rectification-agentic-chat.tsx`、`rectification-choice-card.tsx`、`globals.css`
|
||||
- 用户现象:点完选项,transcript 里没有一条"我选了 B",刷新后更看不到自己答过什么;卡片选中后没有对勾,等待时只是鼠标变沙漏。
|
||||
- 触发条件:任意结构化选择。
|
||||
- 根因:答过的卡贴在 assistant 消息下但不产生用户行;服务端返回的 `userMessage`(`applied.userDisplay`)客户端未用;持久化的结构化选择 turn 被 `isStructuredChoiceUserText` 过滤掉;卡片 pending 只改 `cursor: wait`。
|
||||
- 修复:点击即以 `userVisibleChoiceLine`(停止用 `stop_label`)追加用户气泡,成功后以服务端 `userMessage` 校正文本,失败则连同 live 行一起移除;`messagesFromTurns` 保留结构化选择 turn;卡片选中项显示 `Check` + 「已选择」,pending 时卡顶一行 `InlineSpinner` + 「正在记录…」。
|
||||
- 验证:`tests/rectification-surface-contract.test.ts`、`rectification-agentic-entry`(`isStructuredChoiceUserText` 锁改写)。
|
||||
- 防复发:用户的每个动作在 transcript 里都要有对应的用户行;等待态必须有可见进度。
|
||||
- 相关记录:BUG-480
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-483 | 盘面首态是一整块空面板
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`rectification-board.tsx`、`rectification-board-model.ts`、`rectification-surface-state.ts`、`page.tsx`、`globals.css`
|
||||
- 用户现象:第一阶段右侧 18–22.5rem 的面板只有一句「补充经历后,这里会显示当前本命宫位和换升时刻。」;移动端 peek 是「补充经历后会在这里更新」。用户明明填过出生时间。
|
||||
- 触发条件:任何候选结果出现之前。
|
||||
- 根因:面板没有拿到填报时间;快照 API 不含填报时间对应的宫位表(已核实 `cases/[caseId]/route.ts` 只有 accepted/confirmed time)。
|
||||
- 修复:面板新增 `declaredTime`(`page.tsx` 从 profile 派生),无结果时时钟位显示填报时间,正文两行「填报出生时间 HH:MM」「回答几个问题后,这里会显示宫位随时间的变化。」;peek 为「当前盘面 · 填报 HH:MM」;`is-board-empty` 把板列收为 `minmax(16rem, 18rem)`。不造宫位数据。
|
||||
- 验证:`tests/rectification-surface-state.test.ts`、`tests/rectification-surface-contract.test.ts`、`rectification-board-model`。
|
||||
- 防复发:面板空态必须显示已知的用户数据,不得只放一句说明。
|
||||
- 相关记录:BUG-479
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -56,3 +56,22 @@
|
||||
---
|
||||
|
||||
四轮改造:BUG-464(消息权威化)、BUG-465(会话 URL)、BUG-466(云端唯一真相)、拆页两批。自动化侧每轮均已验收(tsc / 2400+ 条测试 / 构建 / SQL 幂等),此清单覆盖的是自动化够不着的真浏览器行为。
|
||||
|
||||
## 8. 生时校正面:一次揭幕与无缝续接(BUG-479–483)
|
||||
|
||||
1. 首页点「生时校正」卡片。
|
||||
- ✅ 预期:卡片脚注立即变成「正在打开…」(不转圈),随后**一次性**出现完整面板:历史消息、问题槽、右侧盘面同时就位;不会先出现一个空面板再填内容。
|
||||
2. 在普通对话里,从侧栏点一个已有的校正会话。
|
||||
- ✅ 预期:旧画面保持不动,侧栏该行短暂显示「打开中」,然后直接切到校正面板;**不闪**一帧普通对话,**不出现**空白面板。
|
||||
3. 新建一段校正,等第一条引导说完。
|
||||
- ✅ 预期:第一轮结束的瞬间画面不闪、滚动不归零、时间线开合状态保留。
|
||||
4. 连续答两道选择题。
|
||||
- ✅ 预期:点完选项立刻在卡上看到对勾「已选择」、卡顶一行「正在记录…」,transcript 里出现你选的那句作为你的气泡,后面一条「正在记录本次选择…」的进行中行**一直在**,直到下一题出现;中间没有空帧,下一题卡片不会闪现又消失。
|
||||
5. 出现候选时间后点「采用此时间」。
|
||||
- ✅ 预期:transcript 末尾出现「正在采用 HH:MM…」进行中行,同一条行一直到后续回复出现。
|
||||
6. 找一个当初开场失败的旧校正会话(记录为空)进入。
|
||||
- ✅ 预期:看到「这段校正还没有开始。」和「开始提问」按钮,点后正常开场。
|
||||
7. 右侧盘面在没有候选前。
|
||||
- ✅ 预期:头部时钟位显示你填报的出生时间,正文是「填报出生时间 HH:MM」两行;面板比有结果时窄一些。
|
||||
8. 流式回答中点停止。
|
||||
- ✅ 预期:已生成内容保留,提示「已停止,已生成的内容保留;本次不会扣点。」,不是「暂时不可用」。
|
||||
|
||||
+27
-1
@@ -200,6 +200,32 @@ 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 question slot and a house board. 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 or sidebar row) reads the turns and the snapshot in one Case request before the switch, under the same 4-second budget as the home reveal; 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 question slot, the transcript's trailing live row and the composer show:
|
||||
|
||||
| State | Question slot | Trailing live row | Composer |
|
||||
|---|---|---|---|
|
||||
| `opening` | empty | “正在读取你的出生资料,准备第一个问题…” then tool labels | disabled, stop visible |
|
||||
| `resuming` | whatever the snapshot already holds | none | enabled |
|
||||
| `empty` | empty | none; the transcript shows “这段校正还没有开始。” and one primary action “开始提问” | enabled |
|
||||
| `waiting-question` | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | none | enabled |
|
||||
| `waiting-question`, retries spent | “没有拿到下一个问题。” and a secondary 44px “重新加载” | none | enabled |
|
||||
| `choice-live` | the choice card | none | enabled, placeholder points at the card |
|
||||
| `choice-pending` | the answered card, `Check` + “已选择” on the tap, a top row “正在记录…” | the same live row from “正在记录本次选择…” through the follow-up turn | disabled, stop visible |
|
||||
| `candidates` | empty | none | enabled |
|
||||
| `accepted` | empty until the next question | “正在采用 HH:MM…” through the follow-up turn, then “用这个时间看盘” | disabled during the chain |
|
||||
| `confirmed` | empty | none; “已确认校正时间:HH:MM” | enabled |
|
||||
| `readonly` | hidden | none; “该校正已结束,只能查看历史。” and “再次校正” | disabled |
|
||||
| `failed` / stopped | unchanged | the row settles with what streamed; notice “已停止,已生成的内容保留;本次不会扣点。” | enabled |
|
||||
|
||||
- **Rules:** the question slot is the last entry of the transcript, indented like an assistant entry, never a widget below the cards. A reader's tap is echoed as their own line (server wording when it differs), live and after a refresh alike. 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.
|
||||
- **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 opening notes set `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.
|
||||
@@ -405,7 +431,7 @@ or user IDs.
|
||||
| 行内 / 局部等待 | 出生地解析、两个会话面时间线的 live 步、兜底活动面板的 live 行、个人报告列表与详情 | `InlineSpinner`(`inline-spin`) | 0.8s linear | `animation: none`,收成静止圆点,不要半圈圆弧 |
|
||||
| 流式生成中 | 引导语打字、时间线 summary 与 live 行的文案 | `onboarding-caret` / `agent-activity-shimmer` | 700ms steps / 1.6s linear | 保持现有全局降级 |
|
||||
|
||||
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
|
||||
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。校正面所有等待复用行内等待:问题槽的「正在准备下一个问题…」与选择卡的「正在记录…」都是时间线 live 行的同一形态;进入校正面之前的等待只用静态文案(「正在打开…」/「打开中」),不转圈。
|
||||
|
||||
今日星语首次拉取是行内等待,但不用 spinner、也不用透明度呼吸:卡片用静态占位文案(`aria-busy` 仍保留)。轨道环消失后不得再换一套动效继续等。
|
||||
|
||||
|
||||
@@ -2624,6 +2624,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 (BUG-483). */
|
||||
.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);
|
||||
@@ -2926,6 +2931,60 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.rectification-question-slot__recover {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
justify-items: start;
|
||||
}
|
||||
.rectification-message-wrap > .rectification-question-slot:empty { display: none; }
|
||||
/* A Case with nothing to show and no automatic opening: one line and one way to start (BUG-481). */
|
||||
.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 question slot's own live step reuses the timeline row; no summary above it. */
|
||||
.consultation-run-timeline__list.is-standalone { margin-top: 0; }
|
||||
/* Static "opening" notes: no spinner after the reveal (unified-loading ruling). */
|
||||
.session-opening-note {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 500;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.product-entrypoint-card[data-opening="true"],
|
||||
.product-entrypoint-card[data-opening="true"] .product-entrypoint-hitarea { cursor: progress; }
|
||||
.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);
|
||||
|
||||
@@ -25,6 +25,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,
|
||||
@@ -289,6 +294,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 [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
|
||||
@@ -406,7 +413,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;
|
||||
@@ -474,7 +484,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,
|
||||
});
|
||||
|
||||
@@ -1671,6 +1682,7 @@ export default function Home() {
|
||||
sessions={sidebarSessions}
|
||||
charts={sidebarCharts}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
openingSessionId={rectificationOpeningSessionId}
|
||||
account={sidebarAccount}
|
||||
accountMenuOpen={accountMenuOpen}
|
||||
accountTriggerRef={accountTrigger}
|
||||
@@ -1887,12 +1899,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 same marker, spinner and
|
||||
* shimmer label as a timeline row, for a surface that is waiting on the agent
|
||||
* between turns (the rectification question slot). There is no second waiting
|
||||
* vocabulary; this is the timeline row itself.
|
||||
*/
|
||||
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<{
|
||||
@@ -25,6 +26,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;
|
||||
|
||||
@@ -48,10 +48,29 @@ import {
|
||||
isPublicRectificationTool,
|
||||
} from "@/lib/rectification-agentic/v9/public-receipt";
|
||||
import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic";
|
||||
import {
|
||||
parsePersistedRectificationTurns,
|
||||
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,
|
||||
rectificationAdoptingLabel,
|
||||
rectificationConversationState,
|
||||
rectificationInitialLiveLabel,
|
||||
rectificationQuestionSlotState,
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
import {
|
||||
CHOICE_ACTION,
|
||||
STOP_ACTION,
|
||||
isStructuredChoiceUserText,
|
||||
userVisibleChoiceLine,
|
||||
shouldContinueAfterStructuredChoice,
|
||||
stableChoiceActionKey,
|
||||
type ChoiceOptionId,
|
||||
@@ -68,6 +87,7 @@ import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anc
|
||||
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,
|
||||
@@ -172,6 +192,8 @@ type RectificationAgenticChatProps = Readonly<{
|
||||
readonly: boolean;
|
||||
shouldStartOpening: boolean;
|
||||
initialTurns: readonly PersistedTurn[];
|
||||
initialSnapshot: RectificationCaseSnapshotPayload | null;
|
||||
declaredTime: string | null;
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
@@ -265,7 +287,9 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
|
||||
}];
|
||||
}
|
||||
if (isIncompleteRunBanner(turn.text ?? "")) return [];
|
||||
if (isStructuredChoiceUserText(turn.text)) return [];
|
||||
// A structured choice the reader tapped is persisted as a user turn and is
|
||||
// shown as one: the reader can see what they answered after a refresh, the
|
||||
// same as the live echo (BUG-482).
|
||||
return [{
|
||||
role: "user",
|
||||
text: turn.text ?? "",
|
||||
@@ -275,6 +299,31 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
|
||||
});
|
||||
}
|
||||
|
||||
type CaseSnapshotState = Readonly<{
|
||||
candidate: CandidateResult;
|
||||
question: CurrentQuestionModel | null;
|
||||
choice: ChoiceCardModel | null;
|
||||
caseStatus: RectificationCaseStatus | null;
|
||||
savedTime: string | null;
|
||||
savedStatus: "accepted" | "confirmed" | null;
|
||||
}>;
|
||||
|
||||
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
|
||||
// from parsing agent text or hidden sentinels.
|
||||
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),
|
||||
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,
|
||||
@@ -282,6 +331,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
readonly,
|
||||
shouldStartOpening,
|
||||
initialTurns,
|
||||
initialSnapshot,
|
||||
declaredTime,
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
@@ -297,23 +348,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
headerSlot,
|
||||
} = props;
|
||||
|
||||
// Turns and snapshot were read before this surface mounted (one reveal,
|
||||
// BUG-479); both are initial state, and later arrivals are prop updates,
|
||||
// never a remount.
|
||||
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
|
||||
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 [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 [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);
|
||||
@@ -359,11 +414,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
|
||||
const setPending = useCallback((value: boolean) => {
|
||||
setBusy(value);
|
||||
// A new turn starting also ends the question slot's retry cycle.
|
||||
if (value) setQuestionRetryAttempts(0);
|
||||
onPendingChange?.(value);
|
||||
}, [onPendingChange]);
|
||||
|
||||
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
|
||||
// from parsing agent text or hidden sentinels.
|
||||
const applyCaseSnapshot = useCallback((payload: {
|
||||
latest_result?: unknown;
|
||||
current_question?: unknown;
|
||||
@@ -373,62 +428,83 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
accepted_time?: unknown;
|
||||
confirmed_time?: unknown;
|
||||
};
|
||||
turns?: unknown;
|
||||
} | null) => {
|
||||
if (!payload) return;
|
||||
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
|
||||
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
|
||||
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
|
||||
const nextCaseStatus = isRectificationCaseStatus(payload.case?.status)
|
||||
? payload.case.status
|
||||
: null;
|
||||
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
|
||||
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
|
||||
setCandidateResult(nextCandidate);
|
||||
const next = caseSnapshotState(payload);
|
||||
if (!next) return;
|
||||
const nextQuestion = next.question;
|
||||
setCandidateResult(next.candidate);
|
||||
setCurrentQuestion(nextQuestion);
|
||||
setChoiceCard(nextChoice);
|
||||
setCaseStatus(nextCaseStatus);
|
||||
// A question arriving ends the slot's retry cycle.
|
||||
if (nextQuestion !== null) setQuestionRetryAttempts(0);
|
||||
setChoiceCard(next.choice);
|
||||
setCaseStatus(next.caseStatus);
|
||||
setCaseSnapshotLoaded(true);
|
||||
if (confirmedTime) {
|
||||
setSavedTime(confirmedTime);
|
||||
setSavedStatus("confirmed");
|
||||
} else if (acceptedTime) {
|
||||
setSavedTime(acceptedTime);
|
||||
setSavedStatus("accepted");
|
||||
if (next.savedStatus && next.savedTime) {
|
||||
setSavedTime(next.savedTime);
|
||||
setSavedStatus(next.savedStatus);
|
||||
}
|
||||
// A late hydration (the reveal deadline passed first) lands here: the
|
||||
// Case read carries the turns too, so an empty transcript fills from it.
|
||||
const turns = parsePersistedRectificationTurns(payload?.turns);
|
||||
if (turns.length > 0) {
|
||||
setMessages((current) => (current.length === 0 ? messagesFromTurns(turns) : current));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Snapshot reads after mount (turn completion, question-slot retries) are
|
||||
// abortable so none of them lands after the surface has gone.
|
||||
const snapshotAbort = useRef<AbortController | null>(null);
|
||||
const loadCaseSnapshot = useCallback(async () => {
|
||||
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;
|
||||
applyCaseSnapshot(await response.json().catch(() => null));
|
||||
} catch {
|
||||
// Snapshot refresh is best-effort; the durable Case remains on the server.
|
||||
} 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) 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
|
||||
* (BUG-480). 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);
|
||||
|
||||
@@ -436,19 +512,25 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
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 initialLabel = rectificationInitialLiveLabel(action, continuation?.label);
|
||||
const initialActivity: AgentActivityView = {
|
||||
phase: "evidence-validation",
|
||||
label: initialLabel,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
|
||||
setMessages((current) => [
|
||||
...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(),
|
||||
} },
|
||||
]);
|
||||
setMessages((current) => (continuation
|
||||
? current.map((message) => (message.renderKey === assistantRenderKey
|
||||
? { ...message, text: "", state: "thinking", activityTrace: emptyActivityTrace(), activity: initialActivity }
|
||||
: message))
|
||||
: [
|
||||
...current,
|
||||
...(action === "message"
|
||||
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
|
||||
: []),
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: initialActivity },
|
||||
]));
|
||||
setDraft("");
|
||||
|
||||
let raw = "";
|
||||
@@ -456,11 +538,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
let activityReceiptState = createRectificationActivityReceiptState();
|
||||
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
|
||||
let completedTurnId: string | undefined;
|
||||
let currentActivity: AgentActivityView | undefined = {
|
||||
phase: "evidence-validation",
|
||||
label: "正在处理…",
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
let currentActivity: AgentActivityView | undefined = initialActivity;
|
||||
// Every stream event lands in `frames`; it commits at most once per animation
|
||||
// frame and releases text at a steady pace. The loop below never calls
|
||||
// setMessages for a live turn directly except on `attempt.reset`.
|
||||
@@ -507,7 +585,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return;
|
||||
}
|
||||
if (response.status === 402) {
|
||||
window.location.assign(membershipHref("rectification"));
|
||||
// Say why before the page changes underneath the reader (task 1.5).
|
||||
setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE);
|
||||
window.setTimeout(() => {
|
||||
window.location.assign(membershipHref("rectification"));
|
||||
}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS);
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) setError("请先登录。");
|
||||
@@ -694,6 +776,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? caught.name === "AbortError"
|
||||
: caught instanceof Error && caught.name === "AbortError";
|
||||
if (aborted) {
|
||||
// Stopped by the reader: what streamed stays in place and the notice
|
||||
// says so; nothing here is an outage (task 0.7).
|
||||
if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE);
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
if (raw.trim() || hasActivityReceipt(completedReceipt)) {
|
||||
@@ -763,6 +848,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
keyCounter.current += 1;
|
||||
const turnKey = keyCounter.current;
|
||||
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
|
||||
const echoRenderKey = `v9-choice-echo-${turnKey}`;
|
||||
// The tap is echoed as the reader's own line, right after the answered
|
||||
// card and before the working row, so the transcript reads as a
|
||||
// conversation and survives a refresh the same way (BUG-482).
|
||||
const echoText = optionId === "stop" ? answeredCard.stop_label : userVisibleChoiceLine(answeredCard, optionId);
|
||||
const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer;
|
||||
setMessages((current) => [
|
||||
...current.map((message) => (
|
||||
offeringRenderKey && message.renderKey === offeringRenderKey
|
||||
@@ -772,6 +863,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
: message
|
||||
)),
|
||||
{ role: "user", text: echoText, renderKey: echoRenderKey, state: "settled" },
|
||||
{
|
||||
role: "assistant",
|
||||
text: "",
|
||||
@@ -780,7 +872,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
activityTrace: emptyActivityTrace(),
|
||||
activity: {
|
||||
phase: "evidence-validation",
|
||||
label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer,
|
||||
label: recordingLabel,
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
@@ -808,7 +900,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
setMessages((current) => current.flatMap((message) => {
|
||||
if (message.renderKey === assistantRenderKey) return [];
|
||||
if (message.renderKey === assistantRenderKey || message.renderKey === echoRenderKey) return [];
|
||||
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
|
||||
return [{ ...message, choiceAttachment: undefined }];
|
||||
}
|
||||
@@ -822,12 +914,23 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
setError(payload?.message || payload?.error || `请求失败(${response.status})`);
|
||||
return;
|
||||
}
|
||||
const serverEcho = typeof payload?.userMessage === "string" && payload.userMessage.trim()
|
||||
? payload.userMessage.trim()
|
||||
: echoText;
|
||||
if (serverEcho !== echoText) {
|
||||
setMessages((current) => current.map((message) => (
|
||||
message.renderKey === echoRenderKey ? { ...message, text: serverEcho } : message
|
||||
)));
|
||||
}
|
||||
const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload);
|
||||
onCompleted?.();
|
||||
await loadCaseSnapshot();
|
||||
if (willContinue) {
|
||||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
choiceContinuationPending.current = true;
|
||||
// The same working row carries straight into the follow-up turn: no
|
||||
// frame without a live row, and no next card flashing in between
|
||||
// (BUG-480). `busy` stays true across the whole chain.
|
||||
onMessagesChange?.([{ role: "user", text: serverEcho }]);
|
||||
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
|
||||
} else {
|
||||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||||
? payload.narration.trim()
|
||||
@@ -841,12 +944,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
: message));
|
||||
onMessagesChange?.([
|
||||
{ role: "user", text: serverEcho },
|
||||
{ role: "assistant", text: narration },
|
||||
]);
|
||||
}
|
||||
} catch {
|
||||
setMessages((current) => current.flatMap((message) => {
|
||||
if (message.renderKey === assistantRenderKey) return [];
|
||||
if (message.renderKey === assistantRenderKey || message.renderKey === echoRenderKey) return [];
|
||||
if (offeringRenderKey && message.renderKey === offeringRenderKey) {
|
||||
return [{ ...message, choiceAttachment: undefined }];
|
||||
}
|
||||
@@ -868,16 +972,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?.();
|
||||
@@ -885,15 +984,43 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
if (readonly || openingStarted.current || !shouldStartOpening) return;
|
||||
openingStarted.current = true;
|
||||
setOpeningRequested(true);
|
||||
onOpeningConsumed?.();
|
||||
void send("opening", "");
|
||||
}, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]);
|
||||
|
||||
// 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 (BUG-481).
|
||||
function startOpeningManually() {
|
||||
if (readonly || busy || openingStarted.current) return;
|
||||
openingStarted.current = true;
|
||||
setOpeningRequested(true);
|
||||
void send("opening", "");
|
||||
}
|
||||
|
||||
const acceptCandidate = useCallback(async (candidateId: string) => {
|
||||
if (!candidateResult || acceptingCandidateId || busy || readonly) return;
|
||||
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 (BUG-480).
|
||||
const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? "";
|
||||
const adoptingLabel = rectificationAdoptingLabel(candidateTime);
|
||||
keyCounter.current += 1;
|
||||
const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`;
|
||||
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`,
|
||||
@@ -941,14 +1068,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, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
|
||||
|
||||
async function copyMessage(message: RenderMessage) {
|
||||
try {
|
||||
@@ -1048,24 +1176,53 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? currentQuestion.prompt
|
||||
: null;
|
||||
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
|
||||
const showMissingQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion === null,
|
||||
);
|
||||
const showUnavailableQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
&& resumableCase
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& currentQuestion !== null
|
||||
&& ((currentQuestion.kind === "choice" && !choiceCard)
|
||||
|| (currentQuestion.kind === "collect_spoken" && !collectSpokenPrompt)),
|
||||
);
|
||||
// The slot has four visible states. A gap after a settled turn is
|
||||
// `preparing` (one live row, timed refetches) and then `unavailable` (a
|
||||
// reload button) — never bare copy telling the reader to wait (BUG-479).
|
||||
const questionSlot = rectificationQuestionSlotState({
|
||||
questionKind: currentQuestion?.kind ?? null,
|
||||
questionPrompt: currentQuestion?.prompt ?? null,
|
||||
hasChoiceCard: Boolean(choiceCard),
|
||||
choiceAnswered: Boolean(choiceCard && answeredQuestionIds.has(choiceCard.question_id)),
|
||||
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-slot recovery: the turn already refetched once on completion;
|
||||
// while the slot 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.
|
||||
const questionRetrying = questionSlot === "preparing";
|
||||
useVisibilityAwarePoll({
|
||||
enabled: questionRetrying,
|
||||
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();
|
||||
}
|
||||
|
||||
function submitChoice(key: ChoiceKey) {
|
||||
if (!choiceCard) return;
|
||||
void submitStructuredChoice(CHOICE_ACTION, key);
|
||||
@@ -1079,6 +1236,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const boardPeek = compactBoard && !boardOpen ? (
|
||||
<RectificationBoardPeek
|
||||
result={candidateResult}
|
||||
declaredTime={declaredTime}
|
||||
expanded={boardOpen}
|
||||
boardId={boardId}
|
||||
onOpen={toggleBoard}
|
||||
@@ -1088,7 +1246,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return (
|
||||
<div
|
||||
ref={workspace}
|
||||
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
|
||||
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}${candidateResult ? "" : " is-board-empty"}`}
|
||||
>
|
||||
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
|
||||
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
|
||||
@@ -1104,6 +1262,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"
|
||||
@@ -1188,34 +1352,39 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<section className="rectification-question-slot" aria-label="当前问题">
|
||||
{showLiveChoiceCard && choiceCard && (
|
||||
<RectificationChoiceCard
|
||||
key={`${choiceCard.question_id}:${choiceNonce}`}
|
||||
card={choiceCard}
|
||||
pending={busy}
|
||||
disabled={readonly}
|
||||
selectedKey=""
|
||||
onSelect={submitChoice}
|
||||
onStop={submitStop}
|
||||
/>
|
||||
)}
|
||||
{collectSpokenPrompt && !showLiveChoiceCard && !readonly && (
|
||||
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
|
||||
{collectSpokenPrompt}
|
||||
</p>
|
||||
)}
|
||||
{showMissingQuestion && (
|
||||
<p className="rectification-question-slot__status" role="status">
|
||||
当前没有可回答的问题,正在等待服务端更新。
|
||||
</p>
|
||||
)}
|
||||
{showUnavailableQuestion && (
|
||||
<p className="rectification-question-slot__status" role="status">
|
||||
当前问题暂时无法显示,请等待服务端更新。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{/* The question slot is the last entry of the transcript, indented like
|
||||
every assistant entry, never a widget hanging below the cards. */}
|
||||
<div className="rectification-message-wrap rectification-message-entry">
|
||||
<section className="rectification-question-slot" aria-label="当前问题">
|
||||
{showLiveChoiceCard && choiceCard && (
|
||||
<RectificationChoiceCard
|
||||
key={`${choiceCard.question_id}:${choiceNonce}`}
|
||||
card={choiceCard}
|
||||
pending={busy}
|
||||
disabled={readonly}
|
||||
selectedKey=""
|
||||
onSelect={submitChoice}
|
||||
onStop={submitStop}
|
||||
/>
|
||||
)}
|
||||
{collectSpokenPrompt && !showLiveChoiceCard && !readonly && (
|
||||
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
|
||||
{collectSpokenPrompt}
|
||||
</p>
|
||||
)}
|
||||
{questionSlot === "preparing" && (
|
||||
<ConsultationTimelineLiveRow id="question-preparing" label={RECTIFICATION_QUESTION_PREPARING_LABEL} />
|
||||
)}
|
||||
{questionSlot === "unavailable" && (
|
||||
<div className="rectification-question-slot__recover" role="status">
|
||||
<p className="rectification-question-slot__status">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
|
||||
<Button type="button" variant="secondary" onClick={reloadQuestion}>
|
||||
{RECTIFICATION_QUESTION_RELOAD_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{savedTime && savedStatus === "confirmed" && (
|
||||
<p className="rectification-saved" role="status">
|
||||
已确认校正时间:{savedTime}
|
||||
@@ -1292,6 +1461,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 { useEffect, useRef, useState } from "react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import type {
|
||||
ChoiceKey,
|
||||
RectificationChoiceCard as ChoiceCard,
|
||||
@@ -43,6 +45,14 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) {
|
||||
className={`rectification-choice-card${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 (BUG-482).
|
||||
<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 (BUG-479). */
|
||||
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
|
||||
|
||||
@@ -96,7 +96,12 @@ 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"
|
||||
aria-busy={rectificationLoading ? true : undefined}
|
||||
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,27 +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 }) => ({
|
||||
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"),
|
||||
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.
|
||||
}
|
||||
@@ -168,8 +163,12 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
rectificationOpenInFlight.current = true;
|
||||
setRectificationLoading(true);
|
||||
setRectificationOpeningSessionId(exactSessionId);
|
||||
setRectificationError("");
|
||||
|
||||
try {
|
||||
@@ -201,6 +200,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// One reveal: turns and snapshot are read before the surface mounts, so
|
||||
// the reader never sees an empty transcript or an empty question slot
|
||||
// while they load. Past the deadline the surface opens with what arrived
|
||||
// and its own retry path fills in the rest (BUG-479).
|
||||
const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, {
|
||||
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Merge the server-created session into the local list. The browser
|
||||
// never generates a Case id; it only mirrors the returned binding.
|
||||
const existing = sessions.find((session) => session.id === opened.sessionId);
|
||||
@@ -228,19 +235,20 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setRectificationTurns(hydration.turns);
|
||||
setRectificationSnapshot(hydration.snapshot);
|
||||
setRectificationSessionId(opened.sessionId);
|
||||
setRectificationCaseId(opened.caseId);
|
||||
setRectificationShouldStartOpening(opened.shouldStartOpening);
|
||||
setRectificationReadonly(
|
||||
opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status),
|
||||
);
|
||||
setRectificationTurns([]);
|
||||
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 {
|
||||
@@ -249,6 +257,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
} finally {
|
||||
sessionSelectionSource.current = "user";
|
||||
rectificationOpenInFlight.current = false;
|
||||
setRectificationOpeningSessionId(null);
|
||||
setRectificationLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +320,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
|
||||
// (BUG-479). 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("");
|
||||
@@ -333,7 +340,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);
|
||||
@@ -341,7 +348,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";
|
||||
|
||||
@@ -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 (BUG-483). Without one, say what fills it later.
|
||||
return { title: "当前盘面", detail: declaredTime ? `填报 ${declaredTime}` : "补充经历后会在这里更新" };
|
||||
}
|
||||
return { title: "当前盘面", detail: `${table.time} · 上升${table.lagna}` };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Pure state for the rectification chat surface: what the transcript, the
|
||||
* question slot and the entry card 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";
|
||||
|
||||
/**
|
||||
* 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 question slot's retry path fills the rest. Shared
|
||||
* with the home reveal budget: keep it equal to the unified-loading constant.
|
||||
*/
|
||||
export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = 4_000;
|
||||
|
||||
/** 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-slot 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;
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: 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"),
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
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 RectificationQuestionSlotState =
|
||||
| "choice-live"
|
||||
| "spoken-prompt"
|
||||
| "preparing"
|
||||
| "unavailable"
|
||||
| "idle";
|
||||
|
||||
export type RectificationQuestionSlotInput = Readonly<{
|
||||
questionKind: "choice" | "collect_spoken" | null;
|
||||
questionPrompt: string | null;
|
||||
hasChoiceCard: boolean;
|
||||
choiceAnswered: boolean;
|
||||
busy: boolean;
|
||||
readonly: boolean;
|
||||
regenerating: boolean;
|
||||
snapshotLoaded: boolean;
|
||||
resumableCase: boolean;
|
||||
retryAttempts: number;
|
||||
retryLimit?: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The four visible states of the question slot. A gap between a settled turn
|
||||
* and its next question is `preparing` while retries remain, and
|
||||
* `unavailable` — with a manual reload — once they run out. There is no
|
||||
* "waiting for the snapshot" state: the snapshot arrives with the reveal.
|
||||
*/
|
||||
export function rectificationQuestionSlotState(input: RectificationQuestionSlotInput): RectificationQuestionSlotState {
|
||||
const limit = input.retryLimit ?? RECTIFICATION_QUESTION_RETRY_LIMIT;
|
||||
if (input.readonly || input.busy || input.regenerating) return "idle";
|
||||
if (input.questionKind === "choice" && input.hasChoiceCard && !input.choiceAnswered) return "choice-live";
|
||||
if (input.questionKind === "collect_spoken" && input.questionPrompt) return "spoken-prompt";
|
||||
if (!input.snapshotLoaded || !input.resumableCase) return "idle";
|
||||
// An answered card that is still the current question means the next one
|
||||
// has not arrived yet: that is a gap too.
|
||||
const gap = input.questionKind === null
|
||||
|| (input.questionKind === "choice" && (!input.hasChoiceCard || input.choiceAnswered))
|
||||
|| (input.questionKind === "collect_spoken" && !input.questionPrompt);
|
||||
if (!gap) return "idle";
|
||||
return input.retryAttempts < limit ? "preparing" : "unavailable";
|
||||
}
|
||||
|
||||
/** Whether the question slot should run another timed snapshot refetch. */
|
||||
export function rectificationQuestionRetryActive(state: RectificationQuestionSlotState): 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;
|
||||
}
|
||||
@@ -82,9 +82,20 @@ 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"\}`\}/);
|
||||
// Former lock: key=`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`.
|
||||
// That suffix remounted the whole surface when the first turns arrived — an empty panel, then a
|
||||
// flash, then a second mount (BUG-479). Turns are hydrated before the reveal and later arrive as a
|
||||
// prop update; the key is the session/Case binding only.
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.doesNotMatch(page, /"ready" : "loading"/);
|
||||
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
|
||||
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
|
||||
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
|
||||
// Former lock read this parser out of the page surface (`use-rectification-surface.ts`). The
|
||||
// turn parser moved to `rectification-surface-state.ts` so the hook's hydration and the chat's
|
||||
// late-snapshot fill share one (BUG-479); the shape it produces is unchanged.
|
||||
const surfaceState = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
|
||||
assert.match(surfaceState, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
|
||||
assert.match(page, /parsePersistedRectificationTurns\(payload\?\.turns\)/);
|
||||
assert.match(component, /methods\?: readonly string\[\]/);
|
||||
});
|
||||
|
||||
@@ -191,7 +202,13 @@ 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"\}`\}/);
|
||||
// Former lock: key=`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.length > 0 ? "ready" : "loading"}`.
|
||||
// That suffix remounted the whole surface when the first turns arrived — an empty panel, then a
|
||||
// flash, then a second mount (BUG-479). Turns are hydrated before the reveal and later arrive as a
|
||||
// prop update; the key is the session/Case binding only.
|
||||
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
|
||||
assert.doesNotMatch(page, /"ready" : "loading"/);
|
||||
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
|
||||
assert.match(page, /initialTurns=\{rectificationTurns\}/);
|
||||
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
|
||||
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
|
||||
@@ -218,10 +235,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;/);
|
||||
// Former locks: `await loadCaseSnapshot(); … choiceContinuationPending.current = true;`, the
|
||||
// `if (!choiceContinuationPending.current || busy || readonly) return; … void send("read_only", "")`
|
||||
// effect, and a doesNotMatch on `await loadCaseSnapshot(); … await send("read_only", "")`. That
|
||||
// effect hop dropped the live row for a frame and let the next card flash in (BUG-480); the
|
||||
// follow-up turn now continues in the same async chain on the same row, still through the
|
||||
// durable case endpoint first.
|
||||
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, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", ""\);/);
|
||||
assert.doesNotMatch(chat, /choiceContinuationPending/);
|
||||
assert.match(chat, /rectificationAdoptingLabel\(candidateTime\)/);
|
||||
assert.doesNotMatch(chat, /action: "accept_candidate"/);
|
||||
const acceptRoute = readFileSync(
|
||||
new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url),
|
||||
@@ -501,7 +524,9 @@ 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" : ""\}`\}/);
|
||||
// Former lock: the workspace class expression ended at ` is-board-open`. The board now also
|
||||
// carries `is-board-empty` before any candidate exists, so the empty board takes less width (BUG-483).
|
||||
assert.match(chat, /className=\{`rectification-workspace\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}\$\{candidateResult \? "" : " is-board-empty"\}`\}/);
|
||||
});
|
||||
|
||||
test("rectification composer can stop a live agent run", () => {
|
||||
@@ -554,7 +579,11 @@ test("time-selection cards use server adoption state and stay mutually exclusive
|
||||
assert.match(choiceCardComponent, /selectedKey/);
|
||||
assert.match(choiceCardComponent, /is-answered/);
|
||||
assert.match(chat, /showLiveChoiceCard = Boolean\(/);
|
||||
assert.match(chat, /isStructuredChoiceUserText/);
|
||||
// Former lock: `isStructuredChoiceUserText` in the chat — it filtered the reader's tapped choice out
|
||||
// of the persisted transcript, so after a refresh nobody could see what they had answered (BUG-482).
|
||||
// The tap is now echoed as the reader's line, live and persisted alike.
|
||||
assert.doesNotMatch(chat, /isStructuredChoiceUserText/);
|
||||
assert.match(chat, /userVisibleChoiceLine\(answeredCard, optionId\)/);
|
||||
assert.match(chat, /submitStructuredChoice\(CHOICE_ACTION/);
|
||||
assert.match(chat, /submitStructuredChoice\(STOP_ACTION/);
|
||||
assert.match(route, /answer_choice/);
|
||||
|
||||
@@ -1120,7 +1120,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", ""\)/);
|
||||
// Former lock: `send("read_only", "")` with no third argument. The follow-up turn now reuses the
|
||||
// live row the tap put in place (`{ reuseAssistantRenderKey, label }`) so nothing disappears
|
||||
// between the tap and the next question (BUG-480).
|
||||
assert.match(chat, /await 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
|
||||
|
||||
@@ -131,11 +131,15 @@ test("collect_spoken current_question renders the prompt in the question slot",
|
||||
|
||||
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/);
|
||||
// Former locks: `const showMissingQuestion = Boolean(caseSnapshotLoaded … resumableCase … currentQuestion === null`
|
||||
// and the copy 「当前没有可回答的问题,正在等待服务端更新」. That copy told the reader to wait with no
|
||||
// action and no end (BUG-479); the gap is now a pure slot state — `preparing` with timed refetches,
|
||||
// then `unavailable` with a reload — still only for resumable cases with a loaded snapshot.
|
||||
assert.match(chat, /const questionSlot = rectificationQuestionSlotState\(\{[\s\S]*snapshotLoaded: caseSnapshotLoaded,[\s\S]*resumableCase,/);
|
||||
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
|
||||
assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
|
||||
assert.doesNotMatch(chat, /等待服务端更新/);
|
||||
assert.match(chat, /readonly && \(/);
|
||||
assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/);
|
||||
assert.doesNotMatch(chat, /questionSlot[\s\S]*caseStatus.*TERMINAL/);
|
||||
});
|
||||
|
||||
test("choice_card still renders through the existing choice-card branch", () => {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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 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-479: 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\);/);
|
||||
// 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\)\);/);
|
||||
assert.doesNotMatch(chat, /useEffect\(\(\) => \{\s*if \(initialTurns\.length === 0\) return;/);
|
||||
// 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("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, /aria-busy=\{rectificationLoading \? true : undefined\}/);
|
||||
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.
|
||||
const spinnerUses = chat.match(/InlineSpinner/g) ?? [];
|
||||
assert.equal(spinnerUses.length, 0);
|
||||
assert.match(timeline, /export function ConsultationTimelineLiveRow/);
|
||||
assert.match(chat, /<ConsultationTimelineLiveRow id="question-preparing" label=\{RECTIFICATION_QUESTION_PREPARING_LABEL\} \/>/);
|
||||
});
|
||||
|
||||
test("the question slot has four states, sits in the transcript flow, and never tells the reader to wait for the server", () => {
|
||||
assert.doesNotMatch(chat, /等待服务端更新/);
|
||||
assert.doesNotMatch(chat, /showMissingQuestion|showUnavailableQuestion/);
|
||||
assert.match(chat, /const questionSlot = rectificationQuestionSlotState\(\{/);
|
||||
assert.match(chat, /questionSlot === "preparing" && \(/);
|
||||
assert.match(chat, /questionSlot === "unavailable" && \(/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
|
||||
assert.match(chat, /RECTIFICATION_QUESTION_RELOAD_LABEL/);
|
||||
assert.match(chat, /useVisibilityAwarePoll\(\{\s*enabled: questionRetrying,\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: wrapped like every other entry, after the message loop, before the saved-time line.
|
||||
const slotIndex = chat.indexOf('<div className="rectification-message-wrap rectification-message-entry">\n <section className="rectification-question-slot"');
|
||||
assert.ok(slotIndex > chat.indexOf("{messages.map((message) => {"));
|
||||
assert.ok(slotIndex < chat.indexOf("{savedTime &&"));
|
||||
});
|
||||
|
||||
test("a tapped choice or an adopted candidate keeps one live row through the follow-up turn", () => {
|
||||
// BUG-480: 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, /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*choiceContinuationPending/);
|
||||
});
|
||||
|
||||
test("an empty Case with no automatic opening offers a way to start", () => {
|
||||
// BUG-481
|
||||
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; the live-row label follows the action", () => {
|
||||
assert.match(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\);/);
|
||||
assert.match(chat, /const initialLabel = rectificationInitialLiveLabel\(action, continuation\?\.label\);/);
|
||||
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 tap is echoed as the reader's line and the card confirms the selection", () => {
|
||||
// BUG-482
|
||||
assert.match(chat, /\{ role: "user", text: echoText, renderKey: echoRenderKey, state: "settled" \}/);
|
||||
assert.match(chat, /typeof payload\?\.userMessage === "string"/);
|
||||
assert.doesNotMatch(chat, /isStructuredChoiceUserText/);
|
||||
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 \{/);
|
||||
});
|
||||
|
||||
test("the board's first state shows the declared minute and takes less width", () => {
|
||||
// BUG-483
|
||||
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,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
declaredBirthTime,
|
||||
hydrateRectificationCase,
|
||||
parsePersistedRectificationTurns,
|
||||
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||||
rectificationAdoptingLabel,
|
||||
rectificationBoardEmptyCopy,
|
||||
rectificationConversationState,
|
||||
rectificationInitialLiveLabel,
|
||||
rectificationQuestionRetryActive,
|
||||
rectificationQuestionSlotState,
|
||||
type RectificationQuestionSlotInput,
|
||||
} from "../src/lib/rectification-surface-state.ts";
|
||||
|
||||
const slotBase: RectificationQuestionSlotInput = {
|
||||
questionKind: null,
|
||||
questionPrompt: null,
|
||||
hasChoiceCard: false,
|
||||
choiceAnswered: 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-481).
|
||||
assert.equal(rectificationConversationState(base), "empty");
|
||||
});
|
||||
|
||||
test("question slot: a live choice card and a spoken prompt render as themselves", () => {
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: true }), "choice-live");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: true, choiceAnswered: true }), "preparing");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "collect_spoken", questionPrompt: "哪年上的大学?" }), "spoken-prompt");
|
||||
});
|
||||
|
||||
test("question slot: a gap is preparing while retries remain, then unavailable with a reload", () => {
|
||||
assert.equal(rectificationQuestionSlotState(slotBase), "preparing");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "choice", hasChoiceCard: false }), "preparing");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, questionKind: "collect_spoken", questionPrompt: null }), "preparing");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT }), "unavailable");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, retryAttempts: 5 }), "unavailable");
|
||||
assert.equal(rectificationQuestionRetryActive("preparing"), true);
|
||||
assert.equal(rectificationQuestionRetryActive("unavailable"), false);
|
||||
assert.equal(rectificationQuestionRetryActive("choice-live"), false);
|
||||
});
|
||||
|
||||
test("question slot: nothing is shown while busy, readonly, regenerating, or for non-resumable cases", () => {
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, busy: true }), "idle");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, readonly: true }), "idle");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, regenerating: true }), "idle");
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, resumableCase: false }), "idle");
|
||||
// No snapshot at all (hydration failed and no retry has answered yet): nothing to say about a question.
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, snapshotLoaded: false }), "idle");
|
||||
// Readonly hides even a live card.
|
||||
assert.equal(rectificationQuestionSlotState({ ...slotBase, readonly: true, questionKind: "choice", hasChoiceCard: true }), "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.equal(rectificationBoardEmptyCopy(null).clock, null);
|
||||
assert.equal(rectificationBoardEmptyCopy(null).lines.length, 1);
|
||||
assert.equal(declaredBirthTime({ reportedTime: "05:10", time: "05:10" }), "05:10");
|
||||
assert.equal(declaredBirthTime({ reportedTime: "", time: "7:05" }), "7:05");
|
||||
assert.equal(declaredBirthTime({ reportedTime: "", time: "" }), null);
|
||||
assert.equal(declaredBirthTime({ time: "凌晨" }), null);
|
||||
});
|
||||
|
||||
test("persisted turns parse with receipts and tolerate junk", () => {
|
||||
const turns = parsePersistedRectificationTurns([
|
||||
{ id: "t1", role: "user", text: "2014年毕业", status: "completed" },
|
||||
{ id: "t2", role: "assistant", text: "记下了", status: "completed", receipt: { status: "ok", phases: ["a"], tools: ["x"], methods: ["d9-navamsa"], skill_name: "s", skill_version: "1" } },
|
||||
{ id: 3, role: "other", text: 5 },
|
||||
]);
|
||||
assert.equal(turns.length, 3);
|
||||
assert.equal(turns[0]?.role, "user");
|
||||
assert.equal(turns[1]?.receipt?.methods?.[0], "d9-navamsa");
|
||||
assert.equal(turns[2]?.role, "assistant");
|
||||
assert.equal(turns[2]?.text, null);
|
||||
assert.deepEqual(parsePersistedRectificationTurns(undefined), []);
|
||||
});
|
||||
|
||||
function fakeResponse(body: unknown, ok = true) {
|
||||
return { ok, json: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
test("hydration reads turns and snapshot from one Case read", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await hydrateRectificationCase("case-1", "session-1", {
|
||||
timeoutMs: 1_000,
|
||||
fetchImpl: async (input) => {
|
||||
calls.push(String(input));
|
||||
return fakeResponse({
|
||||
turns: [{ id: "t1", role: "assistant", text: "你好", status: "completed" }],
|
||||
current_question: { kind: "choice", prompt: "哪一个?" },
|
||||
case: { status: "collecting", accepted_time: null, confirmed_time: null },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0] ?? "", /\/api\/rectification\/cases\/case-1\?sessionId=session-1$/);
|
||||
assert.equal(result.complete, true);
|
||||
assert.equal(result.turns.length, 1);
|
||||
assert.equal((result.snapshot?.current_question as { kind?: string })?.kind, "choice");
|
||||
});
|
||||
|
||||
test("hydration resolves incomplete, not rejected, on a failed read or a passed deadline", async () => {
|
||||
const failed = await hydrateRectificationCase("case-1", "session-1", {
|
||||
timeoutMs: 1_000,
|
||||
fetchImpl: async () => fakeResponse({ error: "nope" }, false),
|
||||
});
|
||||
assert.deepEqual(failed, { turns: [], snapshot: null, complete: false });
|
||||
|
||||
const thrown = await hydrateRectificationCase("case-1", "session-1", {
|
||||
timeoutMs: 1_000,
|
||||
fetchImpl: async () => { throw new Error("network"); },
|
||||
});
|
||||
assert.equal(thrown.complete, false);
|
||||
|
||||
let fire: (() => void) | undefined;
|
||||
const late = hydrateRectificationCase("case-1", "session-1", {
|
||||
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
fetchImpl: () => new Promise(() => {}),
|
||||
setTimeoutImpl: (callback, delayMs) => {
|
||||
assert.equal(delayMs, RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS);
|
||||
fire = callback;
|
||||
return 1;
|
||||
},
|
||||
clearTimeoutImpl: () => {},
|
||||
});
|
||||
assert.ok(fire);
|
||||
fire?.();
|
||||
const timedOut = await late;
|
||||
assert.equal(timedOut.complete, false);
|
||||
assert.deepEqual(timedOut.turns, []);
|
||||
assert.equal(RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, 4_000);
|
||||
});
|
||||
Reference in New Issue
Block a user