diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e65f03..5b7304d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-17 — 校正会话上不再出现可用的普通输入框 + +在生时校正会话里,普通对话的输入框不再能用:打开校正面之前是禁用的「正在打开生时校正…」;打开失败时可以点「重新打开生时校正」。打在那里的字不会再发到普通咨询。Skill 版本不变。 + ## 2026-09-17 — 星盘页、星历页去掉三句说明 打开星盘后,Tab 行下面不再写「主盘直接算 · 分盘按需 · 不消耗点数」。基础信息里也不再写「下面是词条式释义,不是对你个人的判断。」星历页底部不再写「这一页是天象本身,不是对你的判断。」盘还是主盘先算、分盘点到才算、不扣点;行星卡仍是度数和星宿;星历仍只写天象事实,不是运势判断。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index eda81136..f78a682b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-session-composer-guard-20260917.md b/docs/tasks/PROGRESS-rectification-session-composer-guard-20260917.md new file mode 100644 index 00000000..43b282aa --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-session-composer-guard-20260917.md @@ -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`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index c8c9d6b0..2212ac2c 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -106,7 +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` | — | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 待领取 | — | +| `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` | ### 聊天主链路与首页 diff --git a/docs/testing/rectification-session-composer-guard-20260917.md b/docs/testing/rectification-session-composer-guard-20260917.md new file mode 100644 index 00000000..bca1fb13 --- /dev/null +++ b/docs/testing/rectification-session-composer-guard-20260917.md @@ -0,0 +1,24 @@ +# 真机清单 · 校正会话普通输入框守卫(2026-09-17) + +执行环境无登录态、无 Chrome。下列条目留给有真实账号的人在 staging 走查。截图不要带姓名、出生资料或会话正文。 + +## 刷新后立刻打字 + +1. 打开一条进行中的生时校正会话。 +2. 刷新页面。揭幕后立刻在底部输入框打字。 +3. **看:** 输入框是禁用的,占位「正在打开生时校正…」。没有转圈。 +4. 几秒后校正面出现。刚才打的字还在草稿里(不要发到普通咨询)。 + +## 打开失败后重新打开 + +1. 断网(或开发者工具离线)。 +2. 点侧栏一条校正会话。 +3. **看:** 错误文案 + 「重新打开生时校正」按钮。普通输入框仍禁用。 +4. 恢复网络,点按钮。 +5. **看:** 校正面打开。 + +## 删除后落到校正会话 + +1. 当前是一条普通对话,侧栏下一条(或第一条剩下的)是校正会话。 +2. 删除当前普通对话。 +3. **看:** 不会出现可用的普通输入框。要么打开校正面,要么输入框保持禁用。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 211b9208..9a894e4d 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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. diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 40974467..85d68154 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -66,6 +66,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 | | 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 | | 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 | +| (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 | ## 服务端探针 → Agent 题干 diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index f473b000..bdad37a5 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -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: "请刷新会话后重新发送,本次不会扣点。" }, diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index d72cc0c7..5bf12403 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 && ( )} + {rectificationComposerReopen && activeSession ? void reopenRectificationSession(activeSession.id)} /> : null} void; +}) { + return ( +
+

{message}

+ +
+ ); +} diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index ec0aa22f..f3f65e1b 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -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; openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void; openRectificationFromHomepage: (pendingConsultationQuestion?: string | null) => Promise; + openRectificationSession: (exactSessionId: string) => Promise | 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); diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts index ea1b4af7..f8cc3678 100644 --- a/frontend/src/hooks/use-rectification-surface.ts +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -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, diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index fc19b6ba..da129656 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -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"); diff --git a/frontend/src/lib/rectification-session-composer-guard.ts b/frontend/src/lib/rectification-session-composer-guard.ts new file mode 100644 index 00000000..da5a8196 --- /dev/null +++ b/frontend/src/lib/rectification-session-composer-guard.ts @@ -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( + 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; +} diff --git a/frontend/tests/chat-session-authority.test.ts b/frontend/tests/chat-session-authority.test.ts index 1f7efd51..bc196527 100644 --- a/frontend/tests/chat-session-authority.test.ts +++ b/frontend/tests/chat-session-authority.test.ts @@ -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/); diff --git a/frontend/tests/rectification-session-composer-guard.test.ts b/frontend/tests/rectification-session-composer-guard.test.ts new file mode 100644 index 00000000..c8ff0825 --- /dev/null +++ b/frontend/tests/rectification-session-composer-guard.test.ts @@ -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"\)/); +}); diff --git a/frontend/tests/rectification-surface-contract.test.ts b/frontend/tests/rectification-surface-contract.test.ts index ea7b7036..c12d1676 100644 --- a/frontend/tests/rectification-surface-contract.test.ts +++ b/frontend/tests/rectification-surface-contract.test.ts @@ -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\}/);