Compare commits

...
Author SHA1 Message Date
jesse-ux 6d81062be9 fix(web): 校正会话上不再出现可用的普通输入框
Independent Staging Quality Gate / validate (push) Canceled after 5m37s
Independent Staging Quality Gate / publish (push) Canceled after 0s
send() 对校正会话不发 /api/consult,草稿保留并打开校正面。
普通输入框只有禁用态。咨询接口把类型不对改成 409 session_not_consultation。
删除和回退不再直接切到校正会话。
2026-09-17 20:06:25 +08:00
Jesse_ChenandClaude Fable 5.1 f6db3c7709 docs(tasks): 会话列表单改为常驻外壳 layout 方案
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-17 12:03:47 +00:00
Jesse_ChenandClaude Fable 5.1 8eb1de1fa1 docs(tasks): 校正会话输入框守卫单 + 会话列表单一数据源单
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-17 11:34:09 +00:00
19 changed files with 662 additions and 37 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-17 — 校正会话上不再出现可用的普通输入框
在生时校正会话里,普通对话的输入框不再能用:打开校正面之前是禁用的「正在打开生时校正…」;打开失败时可以点「重新打开生时校正」。打在那里的字不会再发到普通咨询。Skill 版本不变。
## 2026-09-17 — 星盘页、星历页去掉三句说明
打开星盘后,Tab 行下面不再写「主盘直接算 · 分盘按需 · 不消耗点数」。基础信息里也不再写「下面是词条式释义,不是对你个人的判断。」星历页底部不再写「这一页是天象本身,不是对你的判断。」盘还是主盘先算、分盘点到才算、不扣点;行星卡仍是度数和星宿;星历仍只写天象事实,不是运势判断。Skill 版本不变。
+32
View File
@@ -12034,3 +12034,35 @@
- 相关记录:BUG-214、BUG-286、BUG-922
- 复发自:无
- 修复版本:待发布
## BUG-924 | 校正会话上普通输入框可用,问题发到 `/api/consult`
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`use-consultation-run.ts` `send()`、`page.tsx` 普通 `ChatComposer`、`use-session-management.ts` 回退路径
- 用户现象:在生时校正会话里打了一个普通问题,请求打到咨询接口,提示「咨询会话不存在」。
- 触发条件:校正会话已激活但校正面未开(刷新揭幕超时、打开失败不重试、删除或回退落到列表第一条校正会话)。
- 根因:发送链路以「校正面没开」推断「当前是普通会话」;`send()` 不看 `sessionType`。BUG-505 只覆盖了 `selectSession`。
- 修复:`send()` 对校正会话不发 `/api/consult`,保留草稿并打开校正面。普通输入框在校正会话上只有禁用态。删除 / popstate / `sessions[0]` 回退不再直接 `setActiveSessionId` 到校正会话。
- 验证:`rectification-session-composer-guard.test.ts`、`rectification-surface-contract.test.ts`。
- 防复发:校正会话不得发出 `/api/consult`;普通输入框在校正会话上不得可用。
- 相关记录:BUG-505、BUG-925
- 复发自:无
- 修复版本:待发布
## BUG-925 | 咨询接口把校正会话和「会话不存在」都回 404
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`api/consult/route.ts`、`use-consultation-run.ts` 错误映射
- 用户现象:校正会话误发咨询时只看到「咨询会话不存在」。
- 触发条件:`POST /api/consult` 的 `sessionId` 指向 `session_type !== consultation`。
- 根因:查不到与类型不对共用一个 404。
- 修复:查不到仍 404「咨询会话不存在」;类型不对 409 `session_not_consultation`。前端收到该码不显示错误、不扣点,改走打开校正面。
- 验证:`chat-session-authority.test.ts`、`rectification-session-composer-guard.test.ts`。
- 防复发:不得把两种失败再合成一个 404。
- 相关记录:BUG-924
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,33 @@
# PROGRESS · 校正会话输入框守卫(2026-09-17)
工作树:`.worktrees/rectification-session-composer-guard-20260917`
分支:`codex/rectification-session-composer-guard-20260917`
基线:`origin/staging` @ `8eb1de1f`
本机 Windows。
Skill **未 bump**。未改 Python、迁移、Skill、校正面组件、校正 Agent。
## 任务状态
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| T1 send() 会话类型守卫 BUG-924 | 完成 | 校正会话不发 `/api/consult`,保留草稿,打开校正面;pending 指向校正会话则丢弃 |
| T4 错误码 BUG-925 | 完成 | 查不到 404;类型不对 409 `session_not_consultation`;前端不显示错误、走 T1 |
| T2 普通输入框禁用态 | 完成 | placeholder「正在打开生时校正…」;失败时「重新打开生时校正」 |
| T3 回退走 selectSession | 完成 | `activateFallbackSession`;popstate 用 `fallbackSessionId`;`sessions[0]` 改为第一条非校正 |
| T5 记录 | 完成 | BUG-924/925、CHANGELOG、DESIGN、VOICE、真人清单 |
## 让步
`/api/consult/cancel` 与 `status` 按 requestId 工作,没有 session_type 判断,未改。
## 测试
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error / 116 warning(与基线同;本轮文件 0 条) |
| 定向 | composer-guard / surface-contract / chat-session-authority / bootstrap-reveal / home-shell-growth **34 passed / 0 failed** |
| `page.tsx` 行数 | 开工 1835 → 现在 ≤ 1835 |
无登录态、无 Chrome:走查见 `docs/testing/rectification-session-composer-guard-20260917.md`。
+2
View File
@@ -106,6 +106,7 @@
| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过**。§1d 已记 **产品决策 (b)** | 已验收 | `5113d457`(BUG-915~917,产品拍板 (b) 风格题不计分)。Claude 独立验收:生产路径测试(receipt 不含自建探针)答题 applied / GET 出卡 / idle 不误伤;合并候选门禁 tsc 0 / lint 0 error / npm test 3427 条 31 红与基线逐条相同 / `/` Static / 首屏 gzip 620,107→620,193(+0.01%)/ pytest 63 绿 / 快速门 Python 798 绿。真机清单 `docs/testing/rectification-dead-d9-choice-fix-20260916.md` |
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` |
| `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 待验收 | `codex/mobile-viewport-scroll-lock-20260917` |
| `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 待验收 | `codex/rectification-session-composer-guard-20260917` |
### 聊天主链路与首页
@@ -130,6 +131,7 @@
| `TASK-rectification-p0-fix-20260915.md` | `PROGRESS-rectification-p0-fix-20260915.md` | **验收修复单**:`f51e494c` 六条缺陷全部实现且方式正确,但 `page.tsx` 从 1951 涨到 1964 行,撞了 `chart-view-route.test.ts` 的 `<= 1951` 上限(AGENTS.md §6 增长冻结)。全量 fail 32→33,就这一条。门禁红很可能是 staging 停在 `2d7698ea`、6 个提交未部署的原因。修法是把 BUG-705 的十来行接线搬出 page.tsx,不放宽上限 | 待验收 | `codex/rectification-p0-fix-20260915` |
| `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal` 的 `dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` |
| `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 待验收 | `codex/consult-followup-tool-contract-20260917` |
| `TASK-session-list-single-source-20260917.md` | — | 会话列表一处数据源:本地 PG 兼容层 `order()` 只保留最后一键,`/api/sessions` 实际按 `id` 排、与游标不一致;`/` 与次级页两份数据源、`/` 每次回来重启动(产品拍板:首页与三个次级页进同一路由组,侧栏外壳与列表 provider 常驻 layout,不做 store);空「新对话」落库堆积(首页 50 条里 28 条);标题类别在后、同名靠墙钟 HH:MM。串行在 composer-guard 单之后。BUG 段 926 起 | 待领取 | — |
### 个人报告
@@ -0,0 +1,112 @@
# 任务书 · 校正会话上露出普通输入框,发问打到 `/api/consult` 报「咨询会话不存在」(2026-09-17)
## 0. 基线
- 基线 commit:`eea90926`(`origin/staging` head;staging 已部署 `dc2f2a16`,两者之间只有文档)。
- 分支:`codex/rectification-session-composer-guard-20260917`,`git worktree add -b codex/rectification-session-composer-guard-20260917 .worktrees/rectification-session-composer-guard-20260917 origin/staging`。
- 范围:前端 `use-consultation-run.ts`、`use-session-management.ts`、`use-rectification-surface.ts`、`page.tsx`(只允许减行)、`api/consult/route.ts` 的一处错误码,以及对应测试。不动 Python、不动迁移、不动 Skill、不动校正 Agent 与校正面组件。
- 串行:与 `TASK-session-list-single-source-20260917.md` 同天改 `use-session-management.ts`。**本单先做**;那一单从本单合入后的 staging 起分支。
- BUG 段:**BUG-924 起**(基线 `docs/BUG_HISTORY.md` 最大号 BUG-923,开工时再核对一次)。
## 1. 事故实证
产品负责人 2026-09-17 19:1x(Asia/Shanghai)在 staging 桌面 Chrome 上,从一条生时校正会话里输入了一个感情问题,浏览器发出 `POST /api/consult`,服务端返回:
```json
{"error":"咨询会话不存在","message":"请重新进入咨询。"}
```
用产品负责人的登录态只读核对 staging(不含任何身份信息):
| 项目 | 结果 |
| --- | --- |
| 请求体 | `sessionId` 指向一条会话,`history: []`,`entryMode: direct_chart`,`consultationMode: verified_chart` |
| `GET /api/sessions/<id>` | 200,`session_type = birth_time_rectification`,`rectification_case_id = null`(所有校正会话都是 null,绑定在案例表里,正常),`messages = 0`,标题「9月17日 · 生时校正」 |
| `GET /api/rectification/cases/entry-summary` | 有一个 `collecting_evidence` 的可续案例,`lastActivityAt` 与该会话 `updated_at` 同一毫秒 |
| `GET /api/rectification/cases/<caseId>?sessionId=<id>` | 200,3 轮(开场 + 一问一答,都在 11:11–11:12 UTC 完成),`next_user_action = ask_method_followup` |
| `POST /api/rectification/cases/open`(intent=session) | 200,`disposition = resumed`,Skill 10.0.29,0.9 s |
结论:校正会话与案例都健康,服务端能正常打开;是前端在这条会话激活时渲染了**普通** `ChatComposer`,把问题发到了普通咨询接口。
代码定位(按符号):
- `frontend/src/app/api/consult/route.ts` `POST`:读 `chat_sessions` 后 `if (!chatSession || chatSession.session_type !== "consultation")` 统一回 404「咨询会话不存在」。判断正确,但没有独立错误码,前端无法区分「不存在」与「类型不对」。
- `frontend/src/app/page.tsx`:`activeRectificationSession = activeSession?.sessionType === "birth_time_rectification"`;`rectificationSurfaceOpen = activeRectificationSession && activeSession.id === rectificationSessionId`。校正面只在 `rectificationSurfaceOpen && rectificationCaseId` 时挂载;否则渲染普通 `ChatComposer`,其 `inputDisabled` / `submitBlocked` 只看 `rectificationSurfaceOpen`,**不看 `activeRectificationSession`、`rectificationLoading`、`rectificationError`**。
- `frontend/src/hooks/use-consultation-run.ts` `send()`:只对 `consultEntrypoint === "birth_time_rectification"`(题目文案像校正交接)转去 `openRectificationFromHomepage`;对 `currentSession.sessionType` **没有任何判断**,直接把 `currentSession.id` 发到 `/api/consult`。
- 让「校正会话激活但校正面未开」成立的三条路径:
1. `page.tsx` 的自动打开 effect(注释「A rectification session named in `?c=`…」):刷新、深链、从 `/chart` 等次级页侧栏 `Link` 回 `/?c=<校正会话>` 都走它。揭幕由 `bootstrapRevealDelayMs` 最多等 `BOOTSTRAP_PREPARE_TIMEOUT_MS`(4 s);打开校正面要 `open` + `hydrateRectificationCase` 两次往返(本次实测各 0.9 s / 1.3 s),网络稍慢就先揭幕成普通对话,输入框可用。
2. 同一个 effect 在 `rectificationError` 非空时直接 `return`,不再重试;`openRectificationCase` 的 `catch` 只 `assignOpenError`,不重置 `activeSessionId`。打开失败一次,普通输入框就永久留在校正会话上。
3. `use-session-management.ts` `deleteSession` 的 `fallbackId = nextSessions[0]?.id`、`applySessionPopStateRef` 的 `fallbackId = listed[0]?.id`,以及 `page.tsx` 的 `activeSession = sessions.find(...) ?? sessions[0]`:三处都可能直接落到列表第一条;而 `openRectificationCase` 用 `[merged, ...current]` 把刚打开的校正会话插在第一位。回退时不经过 `selectSession` 的「校正会话先打开再切换」逻辑(BUG-505 的修复只覆盖了 `selectSession`)。
Bug 历史检索:BUG-505(进校正面先闪普通对话)修的是 `selectSession` 一条路径,防复发措施仍在;本单是同类漏洞的另外三条路径,**不是复发**,但关联 BUG-505。BUG-621(历史校正打不开)已排除:`open` 实测 200。
## 2. 根因
普通咨询的发送链路以「校正面没开」推断「当前是普通会话」,而「校正面没开」还有三种别的含义(正在打开、打开失败、回退落上去的)。会话类型这个事实在前端从未作为发送前置条件检查,服务端又只用一句「咨询会话不存在」兜底,用户看到的提示与实际状态无关。
## 3. 决策记录
- 产品负责人 2026-09-17 授权出本单(「可以」)。
- 口径:**校正会话上永远不出现普通输入框的可用态**。用户在校正会话上打的字,只能进校正面的输入框;校正面还没开时,普通输入框只能是「正在打开生时校正」的禁用态,打开失败时给一个「重新打开」动作。
- 不推翻 BUG-505 的一次揭幕合同(`hydrateRectificationCase` 4 s 上限、面板一个会话只挂一次);不推翻 AGENTS.md §6「揭幕后不得出现 spinner / 骨架 / 正在加载」——禁用态占位文案不算加载动画,但不得加 spinner。
- 不改校正面组件、不改校正 Agent、不改 Skill。
- `page.tsx` 不得增长;新逻辑进 hook 或 lib。
## 4. 硬红线
1. `send()` 对 `sessionType === "birth_time_rectification"` 的会话不得发出 `/api/consult`,无论入口(表单提交、`onFollowUp`、排队草稿、刷新恢复重放)。
2. 既有测试断言不得静默弱化;改任何断言写「原值 / 新值 / 原因」三栏。
3. 不顺手改会话列表排序、标题、缓存(那是另一单)。
4. Bug 历史与进度记录不得写入会话 ID、案例 ID、用户 ID、出生资料、Cookie。
## 5. 任务分解
### T1 `send()` 加会话类型守卫(BUG-924)
- `use-consultation-run.ts` `send()`:在 `if (!question || !currentSession …) return false` 之后、扣点与撤回窗口之前,若 `currentSession.sessionType === "birth_time_rectification"`:不发 `/api/consult`,把用户输入保留在草稿(`setDraft(originalQuestion)`,不清空),调用 `openRectificationSession(currentSession.id)`,`return false`。
- 刷新恢复重放(`restoreConsultationRecovery` → `send(..., { resumeRequestId })`)同样受守卫约束;pending 存储里若指向校正会话,直接清掉并不重放。
- 验收:
- 新测试(`frontend/tests/` 下按既有 hook 合同测试的写法):给定 active 会话为校正类型、校正面未开,提交一条普通问题 → `fetch` 未被调用到 `/api/consult`,`openRectificationSession` 被调用且参数为该会话 id,草稿仍是原文。
- 既有 `rectification-surface-contract`、`chat-session-authority` 全绿。
### T2 普通输入框在校正会话上只有禁用态(BUG-924)
- `page.tsx` 的 `ChatComposer`:`inputDisabled` 与 `submitBlocked` 增加 `activeRectificationSession` 条件;`placeholder` 在该状态下为「正在打开生时校正…」;`rectificationError` 非空且属于当前会话时,`ChatComposer` 上方(沿用现有 `error-message` 位置)显示错误文案与一个「重新打开生时校正」按钮,点击调用 `openRectificationSession(activeSession.id)`。这些派生值请放进现有 hook(`use-rectification-surface.ts` 或 `use-session-management.ts`)返回,`page.tsx` 只消费。
- 自动打开 effect:`rectificationError` 非空时仍不自动重试(避免循环),但重新打开按钮点击后先清 `rectificationError`。
- 验收:
- `home-bootstrap-reveal` 或 `rectification-surface-contract` 加断言:prepare 超时揭幕、校正面未开时,composer `disabled` 为真、placeholder 为「正在打开生时校正…」;`rectificationError` 状态下出现「重新打开生时校正」。
- `frontend/DESIGN.md` 侧栏/输入框合同补一句:校正会话上普通输入框只有禁用态。
### T3 三条回退路径统一走 `selectSession`(BUG-924)
- `deleteSession`、`applySessionPopStateRef`(两处 `fallbackId`)、以及 `activeSession ?? sessions[0]` 的兜底:回退目标若是校正会话,必须经 `selectSession` 的延迟切换(先 `openRectificationSession` 再 `setActiveSessionId`);不得直接 `setActiveSessionId` 到校正会话。`sessions[0]` 的兜底改为「第一条非校正会话,没有则空」。
- 验收:新测试覆盖「删除当前会话、列表第一条是校正会话」与「popstate 回到无参 URL、列表第一条是校正会话」两个场景:`activeSessionId` 不直接变成校正会话 id,`openRectificationSession` 被调用。
### T4 服务端错误码与前端映射(BUG-925)
- `api/consult/route.ts`:`session_type !== "consultation"` 时返回 `409 { error: "这是生时校正会话", code: "session_not_consultation", message: "请在生时校正里继续。" }`;真正查不到仍是 404「咨询会话不存在」。`/api/consult/cancel`、status 若有同样判断一并对齐。
- `use-consultation-run.ts`:收到 `session_not_consultation` 时不显示错误,不扣点(本来就在预留前),走 T1 同一条打开链路。
- 验收:`chat-session-authority` 加一条:校正会话 POST `/api/consult` → 409 + `session_not_consultation`;普通不存在 id → 404。
### T5 记录
- `docs/BUG_HISTORY.md` 新增 BUG-924、BUG-925,关联 BUG-505;`CHANGELOG.md` 一句「校正会话上不再出现可用的普通输入框」;`docs/tasks/PROGRESS-rectification-session-composer-guard-20260917.md`;`docs/tasks/README.md` 状态板行改状态。
- `docs/testing/` 加真机条目:① 在校正会话里刷新,揭幕后立刻打字 → 输入框禁用、几秒后校正面出现、草稿不丢;② 断网状态下点侧栏校正会话 → 错误文案 + 重新打开按钮,联网后点按钮可开;③ 删除当前普通会话且列表第一条是校正会话 → 不出现普通输入框。
## 6. 让步顺序
T1 > T4 > T2 > T3。T1 一项就能挡住误发;T3 若牵动 `use-session-management.ts` 过大,可只做 `deleteSession` 与 `sessions[0]` 兜底,`popstate` 写进 `BLOCKED.md`。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-session-composer-guard-20260917 .worktrees/rectification-session-composer-guard-20260917 origin/staging
cd .worktrees/rectification-session-composer-guard-20260917/frontend
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20 # 记下基线失败清单
grep -n "^## BUG-" ../docs/BUG_HISTORY.md | tail -1 # 核对 BUG 起点
```
## 8. 验收口径
`tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 失败清单与基线逐条一致、新增测试全绿;`next build` 后 `/` 仍 Static;首屏 gzip ±2%;`page.tsx` 行数 ≤ 开工时。无 Docker 的 DB 套件失败照基线记录。
@@ -0,0 +1,132 @@
# 任务书 · 会话列表一处数据源、服务端排序修正、空会话不入列、标题规则重定(2026-09-17)
## 0. 基线
- 基线 commit:`eea90926`(`origin/staging` head)。**串行在 `TASK-rectification-session-composer-guard-20260917.md` 之后**:那一单改 `use-session-management.ts` 的回退路径,本单从它合入后的 staging 起分支,开工时以当时的 `origin/staging` 为准。
- 分支:`codex/session-list-single-source-20260917`,`git worktree add -b codex/session-list-single-source-20260917 .worktrees/session-list-single-source-20260917 origin/staging`。
- 范围:`frontend/src/lib/db/local-postgres-client-core.ts`(排序)、`api/sessions` 两个路由、`frontend/src/app/` 路由组重排(`page.tsx` 与 `(secondary)/*` 移入 `(app)/`)、新建 `lib/session-list-context.tsx` / `hooks/use-session-list.ts`、`hooks/use-session-management.ts`、删除 `lib/sidebar-data-cache.ts` / `hooks/use-sidebar-data.ts`、`components/app-sidebar.tsx` / `components/sidebar-session-row.tsx`、`lib/agent-reply.ts` 的标题函数、`lib/home-profile.ts` 的侧栏标题/副标题、`lib/session-groups.ts`,以及 `page.tsx`(只允许减行)。不动 Python、不动 Skill。**T3 若选服务端过滤方案不动表结构**;若产品另批清理迁移再动表并真跑 `npm run test:db`。
- BUG 段:**BUG-926 起**(上一单占 924–925;开工时核对 `docs/BUG_HISTORY.md` 最大号)。
## 1. 事故实证
产品负责人 2026-09-17 反馈:`/`(新建对话)、`/chart`、`/ephemeris`、`/reports` 四处的侧栏会话列表不是同一份,每换一页都重新读一次;列表内容还一直在变;排序和标题规则看不出哪个是哪个。
用产品负责人登录态只读核对 staging(`GET /api/sessions?limit=50`,不含身份信息):
| 项目 | 结果 |
| --- | --- |
| 返回顺序 | **严格按 `id` 降序**,`updated_at` 不单调 |
| `nextCursor` | `2026-09-03T07:09:23Z,<id>`,按 `updated_at` 构造 |
| 首页 50 条里最新的 `updated_at` | 2026-09-17 01:35Z;当天 11:12Z 新建的校正会话**不在首页** |
| 首页 50 条标题分布 | 「新对话」28 条、「生时校正」4 条、「9月8日 · 生时校正」2 条、「9月14日 · 生时校正」2 条、其余各 1 |
| 各接口耗时 | sessions 0.83 s、account 0.87 s |
代码定位(按符号):
- **服务端排序丢了第一键。** `frontend/src/lib/db/local-postgres-client-core.ts` 的 `order(column, options)` 写 `this.ordering = { column, ascending }`,只保存**最后一次**调用;SQL 生成处 `if (this.ordering) sql += " order by …"` 只有一个键。`api/sessions/route.ts` `GET` 链式调用 `.order("updated_at", desc).order("id", desc)`,实际只按 `id` 排。游标 `sessionCursorFilter` 却按 `updated_at, id` 过滤,于是首页不是最近 40 条、翻页与首页重叠或漏掉、每次进入看到的集合都不同。同一缺陷还吃掉了 `api/payment/packages/route.ts` 的 `.order("sort_order").order("created_at")`,套餐顺序变成按创建时间。
- **两份数据源。** `/` 由 `page.tsx` 启动 effect 自己拉 `fetchSessions()`(默认 40 条)+ 详情,存进 `Home()` 的 `sessions` state,再经 `visibleSessions`(过滤掉空的普通会话)→ `sidebarSessions` 喂给侧栏;`(secondary)/layout.tsx` 用 `useSidebarData()` 另拉 `/api/sessions?limit=40` + `/api/account`,进 `sidebar-data-cache.ts` 的 60 s 模块缓存,**不做空会话过滤**。`/` 在 `app/` 里不在 `(secondary)` 路由组,离开 `/` 即卸载 `Home()`,回来重跑整个启动(账户、目录、列表、详情、entry-summary、今日星语);`invalidateSidebarCache()` 在 `/` 的每次写之后清空次级页缓存。BUG-745 修掉的是次级页之间的重挂,`/` ↔ 次级页之间没有修。
- **空会话堆积。** `page.tsx` 启动时 `starterHomeLandingNeedsConsultation` 为真(落点缺失或是校正会话)就 `createSession` + `writeChatSession(create)` 立即落库;`startNewChat` 也是点了就落库。用户从没发过问题的「新对话」因此在库里累积(首页 50 条里 28 条),`/` 用 `visibleSessions` 把它们藏起来,次级页原样显示,两边列表自然不一样。
- **标题规则。** `resolveSessionTitle`:普通会话在第一次回答前叫「新对话」,之后取模型标题或问题前 14 字;校正/今日节奏用 `datedSessionTitle` 生成「M月D日 · 生时校正」;同名靠 `uniquifySessionTitle` 追加「HH:MM」(**新建时的墙钟**,不是会话时间)。旧数据还有无日期的「生时校正」。侧栏行只有标题一行,`/` 的副标题只在他人盘时显示,次级页根本没有副标题字段。结果是一列「新对话 / 9月14日 · 生时校正 / 9月14日 · 生时校正 02:14」。
- 服务端 `updated_at` 口径(BUG-553:只由对话活动推进,元数据 PATCH 不写)**仍在**,不是本单问题;客户端 `sortSessions` 置顶 + `updatedAt` 倒序也正确,只是服务端给的集合本身不对。
Bug 历史检索:BUG-553(列表只按置顶排)、BUG-557(标题守卫 0 行)、BUG-699(打开旧校正会话改今天标题)、BUG-744~746(侧栏统一、次级页重挂、折叠状态)防复发措施均在,本单四条都是新问题;BUG-745 的「每跳一次重拉列表」在 `/` ↔ 次级页方向属**未覆盖**,关联但非复发。
## 2. 根因
1. 本地 PostgreSQL 兼容层的 `order()` 不支持多键,服务端列表从来没有按最近活动排过;游标分页因此与排序不一致。
2. 会话列表没有一个跨路由的所有者:`Home()` 与 `(secondary)` 各拉各的,`/` 又是每次进入从零启动。
3. 会话在用户还没说一句话时就落库,而两处列表对空会话的处理不一致。
4. 标题只承载「叫什么」,不承载「哪一类、哪一天、谁的盘」,同名去重又用墙钟时间。
## 3. 决策记录
- 产品负责人 2026-09-17 授权:列表必须共用一份;排序与标题规则重构。以下具体口径由 Claude 定,产品负责人如不同意在验收时改:
- **排序**:置顶在前;组内按 `updated_at` 倒序;`updated_at` 仍只由对话活动推进(BUG-553 不变)。服务端与客户端同一口径。
- **哪些会话入列**:只列**有内容**的会话——普通会话至少一条消息;校正会话一律入列(它一打开就有开场轮)。当前正在用的空会话只在 `/` 本地显示,不入服务端列表。
- **标题**:普通会话 = 模型标题,否则第一问前 14 字;第一次回答落库前显示「新对话」但不入列。校正会话 = 「生时校正 · M月D日」;今日节奏 = 「今日节奏 · M月D日」——**类别在前,日期在后**,扫一眼能分类。同日多条不再加墙钟「HH:MM」,改为副标题区分。
- **副标题**(两处侧栏都显示一行小字):`M月D日 HH:MM`(会话创建时间,Asia/Shanghai)+「 · <盘主名>」(仅他人盘)。校正会话再补状态词:进行中 / 已交付 / 已结束(从 entry-summary 或列表字段取;取不到就不显示,不得猜)。
- **存量数据**:旧标题不批量改(BUG-699 已做过一次校正会话标题修复,且手改标题不能动);只对未来新建生效。空会话**不删**,只是不入列;若产品负责人另批清理,另开迁移单。
- 产品负责人 2026-09-17 拍板:**侧栏外壳提到路由组 layout,首页也进去,侧栏在四个页面之间不卸载;不做模块级 store**。这推翻 BUG-745 防复发里「layout 不传 `controls`」与 `TASK-sidebar-unify-20260916` D1「次级页侧栏只读、管理操作留在首页」两条:管理操作里只依赖列表的部分(改名、置顶、归档、删除、翻页)上移到 provider,四页都能用;依赖首页状态的(新建、选会话、校正打开)仍由首页注册。BUG-553 / BUG-699 的防复发不动。
- `page.tsx` 不得增长(本单应明显减少);`Home()` 的乐观更新与回滚层保留,只是列表 state 的所有者换成 provider。
## 4. 硬红线
1. 兼容层 `order()` 改多键后,所有既有单键调用行为不变;有 SQL 文本级测试。
2. 服务端列表排序键与 `sessionCursorFilter` 的键必须一致(`updated_at desc, id desc`),有测试锁住。
3. 元数据 PATCH 仍不写 `updated_at`(BUG-553);打开已有会话不改 `title` / `updatedAt`(BUG-699,`session-open-preserves-identity.test.ts` 必须保持绿)。
4. `app/` 下 `SidebarProvider` 只在 `(app)/layout.tsx` 出现一次(BUG-745 口径升级);侧栏源码不得出现 `window.location`;`/login`、`/admin*` 不得带侧栏。
5. 既有测试断言不得静默弱化;改断言写「原值 / 新值 / 原因」三栏。测试总数不低于开工时。
6. 无 Docker 时 `npm run test:db` 失败照基线记录,不得写成通过。
## 5. 任务分解
### T1 兼容层多键排序 + 列表接口排序契约(BUG-926)
- `local-postgres-client-core.ts`:`ordering` 改为数组,`order()` 追加;SQL 生成 `order by a desc, b desc`。
- `api/sessions/route.ts` 两处查询(分页与置顶)保持 `.order("updated_at", desc).order("id", desc)`;`api/payment/packages/route.ts` 不改代码,验证顺序恢复为 `sort_order, created_at`。
- 验收:
- `frontend/tests/local-postgres-query-value.test.ts`(或新建 `local-postgres-order.test.ts`):两次 `order()` 生成的 SQL 含 `order by "updated_at" desc, "id" desc`;单次 `order()` 输出不变。
- `frontend/tests/session-cursor.test.ts` 或 `chat-session-authority.test.ts` 加断言:列表路由源码的排序键序列与 `sessionCursorFilter` 的键一致。
- 有 Docker 时 `npm run test:db` 加一条:插入三条不同 `updated_at` 的会话,`GET /api/sessions?limit=2` 返回最新两条且 `nextCursor` 翻页拿到第三条、无重叠。无 Docker 写 `BLOCKED.md`。
### T2 应用外壳提到路由组 layout,首页也进去,侧栏在四个页面之间不卸载(BUG-927)
产品负责人 2026-09-17 拍板:**不做 store,做常驻外壳**。侧栏、会话列表、账户摘要只有一个所有者,就是 layout;四个页面切换只换中间内容。
- **路由组**:新建 `frontend/src/app/(app)/`;`app/page.tsx` 移到 `(app)/page.tsx`,`(secondary)/chart|ephemeris|reports|reports/[reportId]` 移到 `(app)/` 下,`(secondary)` 目录删除。URL 不变;`/login`、`/admin*`、`error` / `not-found` 留在外面,不得带侧栏。每页自己的 `metadata` / `dynamic` 声明原样保留(`reports` 的 `force-dynamic` 是页级的,不影响 `/` 的 Static)。
- **外壳 layout**:`(app)/layout.tsx`(client)= `SessionListProvider` + `SidebarProvider` + `AppSidebar` + `SidebarInset`。首页现在 `page.tsx` 里的 `<SidebarProvider escapeBlocked=…><main className="chat-app">…<AppSidebar …/>` 整段搬到这里;首页只剩 `SidebarInset` 里的内容。
- **`SessionListProvider`**(新文件 `frontend/src/lib/session-list-context.tsx` + `frontend/src/hooks/use-session-list.ts`)拥有:`sessions: ChatSession[]` 与 `setSessions`、分页游标、归档视图开关、账户摘要(侧栏页脚用)、`signedOut`。签入后每个标签页只拉一次列表;暴露一个 `ready` Promise 给首页启动用。只依赖 `sessions` + fetch 的元数据操作(`renameSession`、`togglePinnedSession`、`toggleArchivedSession`、`deleteSession`、`loadMoreSessions`、`toggleArchivedView`)从 `use-session-management.ts` 搬到 provider;401 时清空并标 `signedOut`。
- **首页改造**:`const [sessions, setSessions] = useState<ChatSession[]>([])` 改为从 provider 取;启动 effect 不再自己 `fetchSessions()`,改 `await ready`,其余(账户、目录、详情、落点选择、entry-summary、今日星语)不变。`use-session-management.ts` 保留选会话、新建、换模型、校正会话延迟切换这些需要首页状态的部分,参数表相应缩短。
- **侧栏控制注册**:首页独有的控制(新建对话、选会话、正在打开 / 打开失败的行状态、账户菜单开关、`escapeBlocked`)通过 provider 的 `registerShellControls()` 在首页挂载时注册、卸载时清空;layout 把注册到的 `controls` 传给 `AppSidebar`。另外三页没注册:会话行仍是 `Link` 到 `/?c=<id>`,但改名、置顶、归档、删除、翻页这些 provider 自带的操作在四页都可用;新建对话在三页上是 `Link` 到 `/`。高亮只在 `/` 上有。
- **删除**:`hooks/use-sidebar-data.ts`、`lib/sidebar-data-cache.ts` 及 `tests/sidebar-data-cache.test.ts`(改写成 provider 测试,不是删掉测试数量)。
- **合同测试改写**(每条写三栏「原值 / 新值 / 原因」):
- `sidebar-contract.test.ts` 「the same component renders read-only when `/` is not the one mounting it」:原值断言次级 layout 不传 `controls`;新值断言 layout 只传 `registerShellControls` 注册到的 `controls`,未注册即只读。
- `sidebar-contract.test.ts` 第 313 行附近对 `page.tsx` 的 `<SidebarProvider escapeBlocked={accountMenuOpen || modalOpen}>` 正则:搬到对 `(app)/layout.tsx` 的断言,`escapeBlocked` 来自注册值。
- BUG-745 的防复发「layout 里 `<SidebarProvider>` 恰好一次、且不传 `controls`」改为「`(app)/layout.tsx` 里恰好一次、`app/` 其它地方零次;`controls` 只来自注册」。`chart-page-view`、`ephemeris-page`、`personal-report-view` 三个测试里对 `(secondary)` 路径的引用改到 `(app)`。
- 验收:
- `next build`:`/` 仍 `○ Static`;`/chart`、`/ephemeris`、`/reports` 的渲染方式与基线一致。
- 新测试:provider 挂载后 `/api/sessions` 恰好一次;首页有 provider 列表时启动不再请求 `/api/sessions`(mock fetch 计数);在 `/` 改名后 provider 里的行立即更新;401 清空。
- `page.tsx` 行数明显下降(外壳段 + 列表 state + 六个元数据操作的调用点都走了),进度记录写出前后行数。
- 真机清单(`docs/testing/`):DevTools Network 观察 `/` → `/chart` → `/ephemeris` → `/reports` → `/`:`/api/sessions` 与 `/api/account` 全程各一次;侧栏 DOM 节点在四页之间不重建(Elements 面板选中侧栏节点,切页后仍是同一节点);改名、置顶、删除后另外三页立即一致;`/login` 与 `/admin` 无侧栏。
### T3 空会话不入列,且延迟落库(BUG-928)
- 服务端:`api/sessions` `GET` 排除 `session_type = 'consultation' AND messages = '[]'`(保留校正会话);置顶查询同样处理。`GET /api/sessions/<id>` 不变(详情仍可读)。
- 客户端:`startNewChat` 与启动时的落点会话改为**本地创建、不立即落库**,`ChatSession` 加 `persisted: boolean`;`send()` 在撤回窗口结束、`POST /api/consult` 之前若 `!persisted` 先 `writeChatSession(create)`(失败则回滚并提示,沿用 `startNewChat` 现有回滚文案)。校正会话仍由 `/cases/open` 服务端创建,不受影响。
- `visibleSessions` 的过滤搬进 provider 的派生值(当前空会话只在 `/` 激活时可见),四页读同一个派生结果。
- 让步:若延迟落库牵动 `?c=` 深链、刷新恢复(`pendingConsultationStorageKey`)过多,可只做服务端过滤 + 启动时**复用**已有空会话而不再新建,把延迟落库写进 `BLOCKED.md`。
- 验收:
- `chat-session-authority.test.ts`:列表路由过滤条件存在;详情路由不过滤。
- 新测试:连点三次「新建对话」不发 `POST /api/sessions`;发出第一问前恰好一次 `POST /api/sessions`;`?c=<本地未落库 id>` 刷新后不报「会话不存在」而是回到落点。
- 有 Docker:`test:db` 加一条空会话不出现在列表。
### T4 标题与副标题规则重定(BUG-929)
- `agent-reply.ts`:`datedSessionTitle` 改为「<类别> · M月D日」;`uniquifySessionTitle` 删除(同名允许,靠副标题区分);`isGenericSessionTitle` 的前缀正则同步改成类别在前,并兼容旧「M月D日 · 生时校正」格式(旧标题仍判为 generic,行为不变)。
- 列表接口增加 `created_at`(表里已有;只是没选出来)。`toSidebarSession` 与 `sidebarSessions` 共用一个 `lib/session-sidebar-row.ts`:`title`、`subtitle = 「M月D日 HH:MM」+「 · 盘主」`、校正状态词。`sidebar-session-row.tsx` 副标题已支持,无需新组件;两处侧栏必须走同一个映射函数。
- 分组(今天 / 昨天 / 最近 7 天 / 30 天 / 更早)保留,按 `updated_at`。
- `frontend/DESIGN.md` Sidebar shell 一节写明标题与副标题规则;`frontend/docs/VOICE.md` 对照「生时校正 · 」「今日节奏 · 」措辞。
- 验收:`agent-reply.test.ts` 更新(写三栏);新测试锁映射函数:同一行输入在 `/` 与次级页产出相同 title/subtitle;`session-open-preserves-identity.test.ts` 保持绿。
### T5 记录
- `docs/BUG_HISTORY.md` 新增 BUG-926~929(926 关联 packages 路由;927 关联 BUG-745;928 关联 BUG-553;929 关联 BUG-699);`CHANGELOG.md`;`docs/tasks/PROGRESS-session-list-single-source-20260917.md`;`docs/tasks/README.md` 状态板行;`BLOCKED.md`(无 Docker 项)。
## 6. 让步顺序
T1 > T2 > T3(服务端过滤部分)> T4 > T3(延迟落库部分)。T1 一项独立可发。T2 内部若元数据操作上移牵动过大,允许第一步只做「路由组 + layout 常驻 + provider 拥有列表 + 首页注册全部控制」,元数据操作上移写进 `BLOCKED.md` 作第二步;但外壳常驻与列表单一所有者不得让步。
## 7. 开工前置命令
```bash
git fetch origin --prune
git log -1 --format='%h %s' origin/staging # 确认上一单已合入
git worktree add -b codex/session-list-single-source-20260917 .worktrees/session-list-single-source-20260917 origin/staging
cd .worktrees/session-list-single-source-20260917/frontend
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20 # 记下基线失败清单与测试总数
grep -n "^## BUG-" ../docs/BUG_HISTORY.md | tail -1
```
## 8. 验收口径
`tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 失败清单与基线逐条一致、新增测试全绿、总数不降;`next build` 后 `/` 仍 Static;首屏 gzip ±2%;`page.tsx` 行数 ≤ 开工时;有 Docker 则 `npm run test:db` 通过,无 Docker 写 `BLOCKED.md`。部署后用 DevTools 复核 T2 的「一次 `/api/sessions`」、侧栏节点不重建,与 `GET /api/sessions` 的 `updated_at` 单调递减。
@@ -0,0 +1,24 @@
# 真机清单 · 校正会话普通输入框守卫(2026-09-17)
执行环境无登录态、无 Chrome。下列条目留给有真实账号的人在 staging 走查。截图不要带姓名、出生资料或会话正文。
## 刷新后立刻打字
1. 打开一条进行中的生时校正会话。
2. 刷新页面。揭幕后立刻在底部输入框打字。
3. **看:** 输入框是禁用的,占位「正在打开生时校正…」。没有转圈。
4. 几秒后校正面出现。刚才打的字还在草稿里(不要发到普通咨询)。
## 打开失败后重新打开
1. 断网(或开发者工具离线)。
2. 点侧栏一条校正会话。
3. **看:** 错误文案 + 「重新打开生时校正」按钮。普通输入框仍禁用。
4. 恢复网络,点按钮。
5. **看:** 校正面打开。
## 删除后落到校正会话
1. 当前是一条普通对话,侧栏下一条(或第一条剩下的)是校正会话。
2. 删除当前普通对话。
3. **看:** 不会出现可用的普通输入框。要么打开校正面,要么输入框保持禁用。
+1 -1
View File
@@ -218,7 +218,7 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
- **Structure:** a column, not a row — warm canvas field on top, the composer's own bottom row beneath it. The row carries the model picker on the left and the send/stop control on the right, with the character count inline beside it.
- **No strip below the composer.** `.composer-footer` used to sit under the field at a permanent `min-height: 44px`, holding the model picker and a count that only appears near the cap — so most of the time it was 44px of empty chrome on every screen. The row is inside the box now and only renders what it has.
- **States:** default, hover, focus with deep-brown ring, disabled only for structural reasons (readonly, session loading, onboarding, the other surface open), invalid. Generating does not disable the textarea; the send control becomes stop.
- **States:** default, hover, focus with deep-brown ring, disabled only for structural reasons (readonly, session loading, onboarding, the other surface open, **a rectification session whose surface is not yet open**), invalid. Generating does not disable the textarea; the send control becomes stop. On a rectification session the ordinary composer is only a disabled field with placeholder「正在打开生时校正…」; if opening failed, a「重新打开生时校正」button sits above it. No spinner.
- **Queue:** one pending card (`.composer-queue`) sits above the field while an answer is in flight. Copy: “已排队,回答结束后发出”, plus the text and a 44px “撤回”. A second Enter appends to the same card with a newline. Successful settlement sends it; stop, failure, or recovery puts the text back in the field. The send button is disabled while a card is waiting. No spinner.
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot; nothing is stacked below it.
+1
View File
@@ -66,6 +66,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 |
| 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 |
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 |
| (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 |
## 服务端探针 → Agent 题干
+11 -1
View File
@@ -305,12 +305,22 @@ export async function POST(request: Request) {
{ status: 503 },
);
}
if (!chatSession || chatSession.session_type !== "consultation") {
if (!chatSession) {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
if (chatSession.session_type !== "consultation") {
return NextResponse.json(
{
error: "这是生时校正会话",
code: "session_not_consultation",
message: "请在生时校正里继续。",
},
{ status: 409 },
);
}
if (!chatSession.model_id || chatSession.model_id !== parsed.data.modelId) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
+23 -28
View File
@@ -21,6 +21,11 @@ import {
rectificationEntryLabels,
resolveRectificationEntryAction,
} from "@/lib/rectification-entry";
import {
dropRectificationStoredPending,
ordinaryComposerPlaceholder,
rectificationOrdinaryComposerReopen,
} from "@/lib/rectification-session-composer-guard";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import {
declaredBirthTime,
@@ -34,6 +39,7 @@ import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-tr
import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
import { ChatComposer } from "@/components/chat-composer";
import { RectificationComposerReopen } from "@/components/rectification-composer-reopen";
import { JumpToLatestButton } from "@/components/jump-to-latest-button";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
@@ -166,7 +172,6 @@ import {
type Profile,
type ReplyOutcome,
type RequestError,
type StoredPendingConsultation,
type StreamingReply,
type Theme,
} from "@/lib/home-types";
@@ -376,10 +381,12 @@ export default function Home() {
onEditBirthTimeDetails: () => editDeclaredBirthTimeDetails(),
});
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
const activeSession = sessions.find((session) => session.id === activeSessionId)
?? sessions.find((session) => session.sessionType !== "birth_time_rectification");
const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification";
const rectificationSurfaceOpen = activeRectificationSession
&& activeSession.id === rectificationSessionId;
const rectificationSurfaceOpen = Boolean(activeRectificationSession && activeSession && activeSession.id === rectificationSessionId);
const rectificationComposerLocked = Boolean(activeRectificationSession && !rectificationSurfaceOpen);
const rectificationComposerReopen = rectificationOrdinaryComposerReopen({ activeRectificationSession, error: rectificationError, errorSessionId: rectificationErrorSessionId, sessionId: activeSession?.id });
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
const isLoading = pendingSessionId === activeSession?.id;
const productEntrypointsDisabled = !hydrated
@@ -460,10 +467,8 @@ export default function Home() {
});
const {
setRectificationHeaderSlot,
rectificationPanel,
resumeRectificationSession,
openRectificationFromHomepage,
setRectificationHeaderSlot, rectificationPanel, resumeRectificationSession,
openRectificationFromHomepage, openRectificationSession, reopenRectificationSession,
} = useRectificationSurface({
account, activeChartId, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
persistSession, profile, rectification, setRectification,
@@ -699,7 +704,7 @@ export default function Home() {
&& presetMessageFinished
&& !rectificationSurfaceOpen
&& Boolean(activeSession?.messagesHydrated)
&& !activeSession.messages.length;
&& !activeSession?.messages.length;
const sessionMessagesLoading = Boolean(
activeSession
&& activeSession.sessionType === "consultation"
@@ -766,8 +771,7 @@ export default function Home() {
startNewChat,
continueInNewChat,
refreshAccount,
openAccountDialog,
openRectificationFromHomepage,
openAccountDialog, openRectificationFromHomepage, openRectificationSession,
});
const jumpToLatestVisible = !rectificationSurfaceOpen
&& !starterHomeVisible
@@ -939,13 +943,11 @@ export default function Home() {
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
let reservedConsultation: ConsultationStatus | null = null;
const storedPending: StoredPendingConsultation | null = readStoredPendingConsultation(
sessionStorage.getItem(pendingConsultationStorageKey),
nextSessions.map((session) => session.id),
const storedPending = dropRectificationStoredPending(
readStoredPendingConsultation(sessionStorage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)),
nextSessions,
);
if (!storedPending && sessionStorage.getItem(pendingConsultationStorageKey)) {
sessionStorage.removeItem(pendingConsultationStorageKey);
}
if (!storedPending) sessionStorage.removeItem(pendingConsultationStorageKey);
if (storedPending) {
try {
@@ -1747,22 +1749,15 @@ export default function Home() {
{jumpToLatestVisible && (
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
)}
{rectificationComposerReopen && activeSession ? <RectificationComposerReopen message={rectificationErrorMessage} onReopen={() => void reopenRectificationSession(activeSession.id)} /> : null}
<ChatComposer
inputRef={composerInput}
inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
placeholder={!account
? "正在读取账户…"
: !profileComplete
? onboardingStep === "name"
? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"
: "请先完成上方资料"
: account.credits === 0 && !account.activeSubscription
? "余额不足,可在账户与点数中补充"
: "例如:未来半年是否适合换工作?"}
placeholder={ordinaryComposerPlaceholder({ locked: rectificationComposerLocked, hasAccount: Boolean(account), profileComplete, onboardingName: onboardingStep === "name", presetMessageFinished, creditsEmpty: Boolean(account && account.credits === 0 && !account.activeSubscription) })}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
inputDisabled={sessionMessagesLoading || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
inputDisabled={sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
submitBlocked={Boolean(pendingSessionId) || Boolean(queuedDraft) || sessionMessagesLoading || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitBlocked={Boolean(pendingSessionId) || Boolean(queuedDraft) || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
stopVisible={isLoading}
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
@@ -0,0 +1,18 @@
import { RECTIFICATION_REOPEN_LABEL } from "@/lib/rectification-session-composer-guard";
export function RectificationComposerReopen({
message,
onReopen,
}: {
message: string;
onReopen: () => void;
}) {
return (
<div className="error-message" role="alert">
<p>{message}</p>
<button type="button" className="button-secondary" onClick={onReopen}>
{RECTIFICATION_REOPEN_LABEL}
</button>
</div>
);
}
@@ -62,6 +62,10 @@ import {
} from "@/lib/home-cloud-sync";
import { completedOnboardingTranscript, isProfileComplete, selectedBirthPlace } from "@/lib/home-profile";
import { pendingConsultationStorageKey, timestamp } from "@/lib/home-types";
import {
consultSendBlockedByRectificationSession,
SESSION_NOT_CONSULTATION_CODE,
} from "@/lib/rectification-session-composer-guard";
import type {
Account,
AccountDialog,
@@ -139,6 +143,7 @@ export type ConsultationRunParams = {
refreshAccount: () => Promise<void>;
openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void;
openRectificationFromHomepage: (pendingConsultationQuestion?: string | null) => Promise<void>;
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
};
export function useConsultationRun(params: ConsultationRunParams) {
@@ -196,6 +201,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
refreshAccount,
openAccountDialog,
openRectificationFromHomepage,
openRectificationSession,
} = params;
const queued = useQueuedMessage();
@@ -219,6 +225,14 @@ export function useConsultationRun(params: ConsultationRunParams) {
if (pendingConsultation.current) return;
const lastMessage = session.messages.at(-1);
const storedQuestion = stored?.question?.trim() ?? "";
if (consultSendBlockedByRectificationSession(session)) {
try {
sessionStorage.removeItem(pendingConsultationStorageKey);
} catch {
// Private-mode storage must not block recovery.
}
return;
}
const lastIsQuestion = lastMessage?.role === "user"
&& (!storedQuestion || lastMessage.text === storedQuestion);
const question = lastIsQuestion && lastMessage ? lastMessage.text : storedQuestion;
@@ -499,6 +513,26 @@ export function useConsultationRun(params: ConsultationRunParams) {
: activeSession;
const currentSession = options.sessionOverride ?? liveSession;
if (!question || !currentSession || !modelCatalog || !account) return false;
if (consultSendBlockedByRectificationSession(currentSession)) {
if (resuming) {
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
setStreamingReply(null);
try {
sessionStorage.removeItem(pendingConsultationStorageKey);
} catch {
// Private-mode storage must not block the guard.
}
}
setDraft(originalQuestion);
setDraftTheme(null);
setDraftEntrypoint(null);
await openRectificationSession(currentSession.id);
setDraft(originalQuestion);
return false;
}
const rollbackSession = options.restoreOnFailure ?? liveSession ?? currentSession;
if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false;
if (resuming) {
@@ -960,6 +994,17 @@ export function useConsultationRun(params: ConsultationRunParams) {
const ownsInterface = pendingConsultation.current?.requestId === requestId;
const partialReply = latestPartialReply;
if (!cancelled && ownsInterface && pendingConsultation.current && caught instanceof ConsultationResponseError) {
if (caught.code === SESSION_NOT_CONSULTATION_CODE) {
updateSession(sessionId, () => rollbackSession);
setOnboardingJustCompleted(previousOnboardingState);
completeConsultationInterface(requestId);
setDraft(originalQuestion);
setDraftTheme(null);
setDraftEntrypoint(null);
await openRectificationSession(currentSession.id);
setDraft(originalQuestion);
return false;
}
if (caught.code === "session_full") {
updateSession(sessionId, () => rollbackSession);
setOnboardingJustCompleted(previousOnboardingState);
@@ -335,6 +335,12 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
await openRectificationCase("session", exactSessionId, null);
}
async function reopenRectificationSession(exactSessionId: string) {
setRectificationError("");
setRectificationErrorSessionId(null);
await openRectificationSession(exactSessionId);
}
async function startNewRectification() {
await openRectificationCase("new", null, null);
}
@@ -404,6 +410,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
openRectificationCase,
openRectificationFromHomepage,
openRectificationSession,
reopenRectificationSession,
startNewRectification,
handleRectificationProfileIncomplete,
handleRectificationMessagesChange,
+20 -7
View File
@@ -12,6 +12,7 @@ import {
writeSessionUrl,
} from "@/lib/chat-session-url";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import { fallbackSessionId } from "@/lib/rectification-session-composer-guard";
import { sortSessions } from "@/lib/session-groups";
import { invalidateSidebarCache } from "@/lib/sidebar-data-cache";
import {
@@ -235,9 +236,7 @@ export function useSessionManagement(params: SessionManagementParams) {
setSessions(nextSessions);
setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id));
if (activeSessionId === session.id) {
const fallbackId = nextSessions[0]?.id ?? "";
setActiveSessionId(fallbackId);
if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace");
activateFallbackSession(nextSessions);
}
try {
const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" });
@@ -269,9 +268,7 @@ export function useSessionManagement(params: SessionManagementParams) {
const nextArchivedAt = restoring ? null : new Date().toISOString();
updateSession(sessionId, (current) => ({ ...current, archivedAt: nextArchivedAt }));
if (!restoring && activeSessionId === sessionId) {
const fallbackId = visibleSessions.find((item) => item.id !== sessionId)?.id ?? "";
setActiveSessionId(fallbackId);
if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace");
activateFallbackSession(visibleSessions.filter((item) => item.id !== sessionId));
}
setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。");
void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").then(invalidateSidebarCache).catch((caught) => {
@@ -392,6 +389,22 @@ export function useSessionManagement(params: SessionManagementParams) {
sessionSelectionSource.current = "user";
}
function activateFallbackSession(candidates: ChatSession[]) {
const fallbackId = fallbackSessionId(candidates);
if (!fallbackId) {
setActiveSessionId("");
if (!uiPreview.current) writeSessionUrl(null, "replace");
return;
}
const fallback = candidates.find((session) => session.id === fallbackId);
if (fallback?.sessionType === "birth_time_rectification") {
selectSession(fallbackId);
return;
}
setActiveSessionId(fallbackId);
if (!uiPreview.current) writeSessionUrl(fallbackId, "replace");
}
async function loadMoreSessions() {
if (!beginSessionPageLoad(loadMoreInFlight, sessionsCursor)) return;
try {
@@ -431,7 +444,7 @@ export function useSessionManagement(params: SessionManagementParams) {
if (uiPreview.current) return;
const listed = sessionsRef.current;
const query = parseSessionUrlQuery(search);
const fallbackId = listed[0]?.id ?? "";
const fallbackId = fallbackSessionId(listed);
const requestedId = query.sessionId;
if (query.present && !requestedId) {
writeSessionUrl(null, "replace");
@@ -0,0 +1,67 @@
export const RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER = "正在打开生时校正…";
export const RECTIFICATION_REOPEN_LABEL = "重新打开生时校正";
export const SESSION_NOT_CONSULTATION_CODE = "session_not_consultation";
export function isRectificationChatSession(
session: { sessionType?: string } | null | undefined,
): boolean {
return session?.sessionType === "birth_time_rectification";
}
/** First non-rectification session, else the first session, else empty. */
export function fallbackSessionId<T extends { id: string; sessionType: string }>(
sessions: readonly T[],
): string {
return sessions.find((session) => session.sessionType !== "birth_time_rectification")?.id
?? sessions[0]?.id
?? "";
}
export function consultSendBlockedByRectificationSession(
session: { sessionType?: string } | null | undefined,
): boolean {
return isRectificationChatSession(session);
}
export function dropRectificationStoredPending<
S extends { sessionId: string },
T extends { id: string; sessionType: string },
>(
stored: S | null,
sessions: readonly T[],
): S | null {
if (!stored) return null;
const session = sessions.find((item) => item.id === stored.sessionId);
if (session?.sessionType === "birth_time_rectification") return null;
return stored;
}
export function ordinaryComposerPlaceholder(input: {
locked: boolean;
hasAccount: boolean;
profileComplete: boolean;
onboardingName: boolean;
presetMessageFinished: boolean;
creditsEmpty: boolean;
}): string {
if (input.locked) return RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER;
if (!input.hasAccount) return "正在读取账户…";
if (!input.profileComplete) {
if (!input.onboardingName) return "请先完成上方资料";
return input.presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…";
}
if (input.creditsEmpty) return "余额不足,可在账户与点数中补充";
return "例如:未来半年是否适合换工作?";
}
export function rectificationOrdinaryComposerReopen(input: {
activeRectificationSession: boolean;
error: string;
errorSessionId: string | null;
sessionId: string | undefined;
}): boolean {
if (!input.activeRectificationSession || !input.error || input.error === "profile_incomplete") {
return false;
}
return !input.errorSessionId || input.errorSessionId === input.sessionId;
}
@@ -35,6 +35,19 @@ test("session list GET omits messages while detail GET returns them", () => {
assert.match(page, /messagesHydrated: messagesPresent/);
});
test("consult POST on a rectification session is 409, a missing id is 404", () => {
// 原值:!chatSession || session_type !== consultation 一律 404「咨询会话不存在」
// 新值:查不到 404;类型不对 409 session_not_consultation
// 原因:BUG-925 前端无法区分不存在与类型不对
assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*status: 404/);
assert.match(consultRoute, /code: "session_not_consultation"/);
assert.match(consultRoute, /status: 409/);
assert.doesNotMatch(
consultRoute,
/if \(!chatSession \|\| chatSession\.session_type !== "consultation"\)/,
);
});
test("consult reads stored history and never applies the client history field", () => {
assert.match(consultRoute, /history: z\s*\.array\([\s\S]*?\)\s*\.max\(20\)\s*\.optional\(\)\s*\.default\(\[\]\)/);
assert.match(consultRoute, /const storedHistory = historyWindow\.tail/);
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { homeSurface as page } from "./home-surface.ts";
import {
consultSendBlockedByRectificationSession,
dropRectificationStoredPending,
fallbackSessionId,
RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER,
RECTIFICATION_REOPEN_LABEL,
rectificationOrdinaryComposerReopen,
SESSION_NOT_CONSULTATION_CODE,
} from "../src/lib/rectification-session-composer-guard.ts";
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const run = read("../src/hooks/use-consultation-run.ts");
const sessions = read("../src/hooks/use-session-management.ts");
const consultRoute = read("../src/app/api/consult/route.ts");
const pageSource = read("../src/app/page.tsx");
const sendFn = run.slice(run.indexOf("async function send("), run.indexOf('await fetch("/api/consult"'));
test("a rectification session blocks consult send and keeps the draft", () => {
assert.equal(consultSendBlockedByRectificationSession({ sessionType: "birth_time_rectification" }), true);
assert.equal(consultSendBlockedByRectificationSession({ sessionType: "consultation" }), false);
assert.match(sendFn, /consultSendBlockedByRectificationSession\(currentSession\)/);
assert.match(sendFn, /setDraft\(originalQuestion\)/);
assert.match(sendFn, /openRectificationSession\(currentSession\.id\)/);
assert.doesNotMatch(sendFn, /fetch\("\/api\/consult"/);
});
test("pending consult replay pointing at a rectification session is dropped", () => {
assert.equal(
dropRectificationStoredPending(
{ sessionId: "r1" },
[{ id: "r1", sessionType: "birth_time_rectification" }],
),
null,
);
assert.deepEqual(
dropRectificationStoredPending(
{ sessionId: "c1" },
[{ id: "c1", sessionType: "consultation" }],
),
{ sessionId: "c1" },
);
assert.match(run, /if \(consultSendBlockedByRectificationSession\(session\)\) \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
});
test("consult POST splits missing session from wrong session type", () => {
assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*咨询会话不存在[\s\S]*status: 404/);
assert.match(
consultRoute,
/if \(chatSession\.session_type !== "consultation"\) \{[\s\S]*session_not_consultation[\s\S]*status: 409/,
);
assert.match(consultRoute, /这是生时校正会话/);
assert.match(consultRoute, /请在生时校正里继续。/);
assert.equal(SESSION_NOT_CONSULTATION_CODE, "session_not_consultation");
assert.match(run, /caught\.code === SESSION_NOT_CONSULTATION_CODE/);
});
test("ordinary composer is locked on a rectification session until the surface opens", () => {
assert.equal(RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER, "正在打开生时校正…");
assert.equal(RECTIFICATION_REOPEN_LABEL, "重新打开生时校正");
assert.match(pageSource, /inputDisabled=\{[^}]*activeRectificationSession/);
assert.match(pageSource, /submitBlocked=\{[^}]*activeRectificationSession/);
assert.match(pageSource, /ordinaryComposerPlaceholder\(/);
assert.match(pageSource, /RectificationComposerReopen/);
assert.equal(
rectificationOrdinaryComposerReopen({
activeRectificationSession: true,
error: "生时校正会话暂时无法打开,请稍后重试。",
errorSessionId: "s1",
sessionId: "s1",
}),
true,
);
assert.equal(
rectificationOrdinaryComposerReopen({
activeRectificationSession: true,
error: "",
errorSessionId: null,
sessionId: "s1",
}),
false,
);
});
test("delete and popstate fallbacks do not setActiveSessionId onto a rectification session", () => {
assert.equal(
fallbackSessionId([
{ id: "r1", sessionType: "birth_time_rectification" },
{ id: "c1", sessionType: "consultation" },
]),
"c1",
);
assert.equal(
fallbackSessionId([{ id: "r1", sessionType: "birth_time_rectification" }]),
"r1",
);
assert.match(sessions, /function activateFallbackSession/);
assert.match(sessions, /if \(fallback\?\.sessionType === "birth_time_rectification"\) \{\s*selectSession\(fallbackId\);/);
assert.match(sessions, /if \(activeSessionId === session.id\) \{\s*activateFallbackSession\(nextSessions\);/);
assert.match(sessions, /const fallbackId = fallbackSessionId\(listed\);/);
assert.match(page, /sessions.find\(\(session\) => session.sessionType !== "birth_time_rectification"\)/);
});
@@ -65,6 +65,17 @@ test("a rectification session named in the URL is hydrated before the reveal; ba
assert.match(sessions, /sessionSelectionSource\.current = "history";\s*selectSession\(requestedId\);/);
});
test("an ordinary composer on a rectification session is disabled until the surface opens", () => {
// 原值:inputDisabled / submitBlocked 只看 rectificationSurfaceOpen
// 新值:另加 activeRectificationSession;placeholder 为「正在打开生时校正…」
// 原因:BUG-924 校正面未开时普通输入框仍可用,问题被发到 /api/consult
assert.match(page, /inputDisabled=\{[^}]*activeRectificationSession/);
assert.match(page, /ordinaryComposerPlaceholder\(/);
assert.match(page, /RectificationComposerReopen/);
assert.match(page, /reopenRectificationSession/);
assert.doesNotMatch(page, /正在打开生时校正[\s\S]*InlineSpinner/);
});
test("entry feedback is static: card and sidebar row say 正在打开, and nothing spins after the reveal", () => {
assert.match(page, /const rectificationCardLabel = rectificationLoading\s*\? RECTIFICATION_OPENING_LABEL/);
assert.match(starter, /data-opening=\{rectificationLoading \? "true" : undefined\}/);