diff --git a/PROGRESS-contract-repairs-20260901.md b/PROGRESS-contract-repairs-20260901.md new file mode 100644 index 00000000..02b13db2 --- /dev/null +++ b/PROGRESS-contract-repairs-20260901.md @@ -0,0 +1,71 @@ +# PROGRESS · 小刀轮:孤儿契约红修复 + popstate 副作用对齐(2026-09-01) + +工作树:`.worktrees/contract-repairs-20260901` +分支:`codex/contract-repairs-20260901`(跟踪 `origin/staging`) +基线:`origin/staging` @ `a6148af8`(任务书提交;产品代码与第一批 `54269fcf` 相同,另加两份任务书) + +不要写成 `PROGRESS.md`。小刀轮与第二批在同一工作树落地(第二批任务书写「小刀轮落地后的 origin/staging」;小刀当时尚未推送,故未另开 `home-split-batch2` 工作树)。交付为快进推 `origin/staging`。 + +开工后 `origin/staging` 又快进了 4 个无关文档提交(`ff97de5e`…`8b7eda9f`),未并入本树。 + +## 任务 1 · 归档契约 + +文件:`tests/test_session_management_entrypoints.py::test_archiving_never_calls_the_delete_endpoint` + +| 项 | 原值 | 新值 | +| --- | --- | --- | +| 读取面 | `PAGE.read_text()`(仅 `page.tsx`) | `_home_surface()`:`page.tsx` + 第一批抽出文件 + 若存在则拼接 `hooks/use-session-management.ts`、`hooks/use-consultation-run.ts` | +| 切片 | `function toggleArchivedSession` → `async function shareSession` | 同左(两函数第二批一起搬进 session hook,切片标记仍相邻) | +| 正断言 | `"setArchivedSessionIds" in archive_action` | `'writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update")' in archive_action` 且 `"archived_at: nextArchivedAt" in archive_action` | +| 负断言 | `"/api/sessions/" not in archive_action` | `'method: "DELETE"' not in archive_action` 且 `"DELETE" not in archive_action` | +| PATCH | (无) | `chat-session-write-contract.ts` 含 `method: mode === "create" ? "POST" : "PATCH"` | + +同文件 `test_chat_history_management_actions_are_exposed` 的读取面从 `PAGE + STYLES + SESSION_ROW` 改为 `_home_surface() + STYLES + SESSION_ROW`,断言 token 未改。`test_chat_session_delete_is_server_controlled_and_granted` 仍读路由/迁移。 + +`CORE_PYTEST_TARGETS`:加入 `tests/test_session_management_entrypoints.py`,注释「Locks archive as PATCH archived_at, never HTTP DELETE (data-safety).」 + +## 任务 2 · 引导旅程契约 + +文件:`tests/test_birth_time_journey_contract.py::test_web_onboarding_uses_the_deterministic_free_journey` + +| 项 | 原值 | 新值 | +| --- | --- | --- | +| page 读取面 | `page.tsx` + `home-profile.ts` | `_home_surface()`(同上,含可选 hook 文件) | +| mastra 读取面 | `frontend/src/mastra/index.ts` | `_mastra_consultation_surface()` = `index.ts` + `consultation-workflow.ts`(`index.ts` 只再导出;token 在 workflow) | +| 正断言 | `'entry_mode: entryMode' in mastra` | 不变(现码 `consultation-workflow.ts`) | +| 负断言 | `'entry_mode: "direct_chart"' not in mastra` | 不变(schema 默认值是 `entryMode: z.enum(...).default("direct_chart")`,不是 `entry_mode: "direct_chart"`;`consultation-tools.ts` 未拼进此读取面) | + +白名单决定:**加入** `CORE_PYTEST_TARGETS`。整文件只读 SQL 迁移与 TS 源码,无 subprocess、无网络、无 Docker。注释「Pure source/SQL regex for the birth-time journey; no runtime services.」 + +## 任务 3 · popstate 副作用对齐 + +`applySessionPopStateRef`(现位于 `use-session-management.ts`,小刀改完后原样搬): + +| 项 | 原值 | 新值 | +| --- | --- | --- | +| `requestedId` | `query.present ? query.sessionId : fallbackId`(无 `?c=` 时直接等于列表首项,空 id 分支实际上只有「列表为空」才会进) | `query.sessionId`(无 `?c=` 时进空 id 分支) | +| 空 id 分支 | `setActiveSessionId(""); return;` | 有 `fallbackId`:`sessionSelectionSource.current = "history"; selectSession(fallbackId);`;否则维持 `setActiveSessionId("")` | + +有会话时 back 到 `/` 仍走 `selectSession`(清草稿/提示、切绑定星盘),来源保持 history,不二次 `pushState`。无会话时行为不变。 + +`frontend/tests/chat-session-url.test.ts`: + +- 原 P1 合同「popstate reuses selectSession without a second push」未改断言;切片里 `writeSessionUrl(..., "push")` 仍只有 `selectSession` 内那一处。 +- 新增「popstate to a missing session query reuses selectSession side effects for the default chat」,锁空 id 分支源码形态,且 popstate 切片内 `writeSessionUrl(..., "push")` 次数为 0。 + +`frontend/tests/home-surface.ts` 同步加入可选 hook 文件(`existsSync`),避免第二批把 `selectSession` / popstate 搬走后本文件立刻再红。 + +## 前瞻(第二批) + +两份 Python 合同与 `chat-session-url.test.ts` 都走拼接读取。第二批把 `toggleArchivedSession` / `applySessionPopStateRef` 搬进 `use-session-management.ts` 后,hook 文件进可选清单即可。切片标记保持相邻。 + +## 验证 + +- `./node_modules/.bin/tsc --noEmit`:通过(小刀后与第二批抽取后各跑一次)。 +- `python3.12 -m pytest tests/test_session_management_entrypoints.py tests/test_birth_time_journey_contract.py -q`:12 passed(3 + 9)。 +- 连同 `tests/test_supabase_user_data_contract.py` `tests/test_daily_and_rectification_entrypoints.py`:24 passed。 +- `tsx --test tests/chat-session-url.test.ts`:12 pass / 0 fail / 0 skipped。 +- 前端全量 `npm test`:2428 pass / 0 fail / 0 skipped(小刀新增 1 条 popstate 源码合同;相对第一批 2427)。 +- `./node_modules/.bin/next build --webpack`:`┌ ○ /` Static。首屏 JS gzip-9 = 501104 B(第一批后 501315 B,−0.04%)。 + +未改 `.gitea/workflows/**`。未动数据库。未提升 `main`。 diff --git a/PROGRESS-home-split-batch2-20260901.md b/PROGRESS-home-split-batch2-20260901.md new file mode 100644 index 00000000..4b95eb05 --- /dev/null +++ b/PROGRESS-home-split-batch2-20260901.md @@ -0,0 +1,98 @@ +# PROGRESS · 拆分首页巨石组件·第二批:聊天主链路(2026-09-01) + +工作树:`.worktrees/contract-repairs-20260901` +分支:`codex/contract-repairs-20260901` +基线:小刀轮未推送,故未另开 `codex/home-split-batch2-20260901`;产品代码从 `a6148af8` + 小刀轮改动继续。 + +不要写成 `PROGRESS.md`。交付为快进推 `origin/staging`。 + +React Compiler 仍关(`BLOCKED.md` 2026-08-17)。未引入 context/store。无手写 `useCallback`/`useMemo`。自定义 hook 按任务书决策记录 1 允许。`site-styles` import 仍在 `page.tsx` 顶。`const BirthTimeRectification = dynamic(...)` 未动。 + +## 度量 + +| 项 | 拆前(第一批后 3505 / 小刀后约 3510) | 拆后 | 说明 | +| --- | ---: | ---: | --- | +| `frontend/src/app/page.tsx` 行数 | 3505 | 2445 | 目标 ≤2600,达标 | +| `useState(` Home / session hook / consult hook | 26 / — / — | 26 / 0 / 0 | 独占 state 未下移,见 hook 顺序 | +| `useRef(` Home / session / consult | 19 / — / — | 19 / 0 / 0 | 同上 | +| `useEffect(` Home / session / consult | 20 / — / — | 20 / 0 / 0 | 拉取/恢复 effect 仍在 Home | +| `/` 路由 | `○ Static` | `○ Static` | webpack `next build --webpack` | +| 首屏 JS gzip -9 | 第一批后 501315 B | 501104 B = 489.4 KB | −211 B / **−0.04%**(±2% 内) | +| 前端测试 | 小刀后 2428 | 2428 | 见验证 | + +剩余未达标块(第三批):onboarding/profile(`refreshAccount`…`signOut`)、rectification 胶水(`refreshRectificationEntrySummary`…`handleRectificationMessagesChange`)、合盘草稿、composer `submit`。 + +## Hook 调用顺序(红线 1) + +搬前 `Home` 内真正的 React hook 序列(`a6148af8`): + +`useRouter` → `useState`×26 → `useRef`×19 → `useBirthTimeGuidedJourney` → `useEffect`×8 → `useConversationScrollAnchor` → `useEffect`×12 + +咨询/会话的 `useState`/`useRef` **不是连续块**:中间夹着校正会话、引导草稿、星盘库、合盘等。若只把「引擎独占」的那几项抠进 hook,会改变 `Home` 里 hook 的相对次序。 + +因此本轮两个新 hook **内部零 `useState`/`useRef`/`useEffect`**,只搬函数体与 `.current =` 赋值。串接后 `useState`/`useRef`/`useEffect` 次序与搬前逐一相同;新增的只是两个无内部 hook 的调用点: + +| 调用点 | 位置 | 原因 | +| --- | --- | --- | +| `useSessionManagement(...)` | `useBirthTimeGuidedJourney` 之后、**第一条 `useEffect` 之前** | 紧随其后的 effect 调用 `ensureSessionMessages` | +| `useConsultationRun(...)` | `useConversationScrollAnchor` 之后、其余 `useEffect` 之前 | `send` 需要 `conversationAnchor.anchorToLatest()` | + +依赖方向:session hook 先返回 `persistSession` / `updateSession` / `startNewChat` / `continueInNewChat`,由 Home 传入 consult hook。两文件 **不得、也没有** 互相 import。 + +`setDraft` / `setDraftTheme` / `setDraftEntrypoint` / `refreshAccount` / `openAccountDialog` / `openRectificationFromHomepage` / `openRectificationSession` 仍是 `Home` 内 **function declaration**(JS 提升),所以可以出现在 hook 调用点之上的参数列表里。这不是 React hook 顺序问题。 + +核对方式:对 `export default function Home` 之后的 `useRouter|useState|useRef|useEffect|useBirthTimeGuidedJourney|useConversationScrollAnchor|useSessionManagement|useConsultationRun` 计数;两 hook 文件内上述符号计数为 0。 + +## 任务 1–2 · 搬家 + +新文件(函数体与 `a6148af8` 的 `page.tsx` 逐行一致;允许差异仅缩进、`export`、经参数/返回值改道的标识符。popstate 以小刀轮为准,不回退到 `a6148af8`): + +| 文件 | 行 | 内容 | +| --- | ---: | --- | +| `frontend/src/hooks/use-session-management.ts` | 443 | `updateSession` … `selectSessionModel` + 小刀修过的 `applySessionPopStateRef.current` | +| `frontend/src/hooks/use-consultation-run.ts` | 1073 | `restoreConsultationRecovery` + recovery/replay 赋值、`requestCancellation` / `confirmCancellation` / `stopResponse` / `completeConsultationInterface` / `send` / `regenerateLatestAnswer` | + +函数体公证(对 `a6148af8` 去缩进后逐行): + +`updateSession` 3、`persistSession` 25、`ensureSessionMessages` 24、`continueInNewChat` 7、`renameSession` 11、`deleteSession` 20、`togglePinnedSession` 10、`toggleArchivedSession` 22、`shareSession` 28、`startNewChat` 33、`startConsultationAfterRectification` 5、`selectSession` 28、`selectSessionModel` 52、`restoreConsultationRecovery` 53、`requestCancellation` 27、`confirmCancellation` 19、`stopResponse` 129、`completeConsultationInterface` 9、`send` 567、`regenerateLatestAnswer` 23;`consultationRecoveryCheck.current` / `consultationReplay.current` 赋值块亦相同。 + +改名清单:**零**(无业务标识符重命名)。 + +`applySessionPopStateRef` 相对 `a6148af8` 的差异即小刀轮任务 3,原样搬进 session hook。 + +## 合同测试路径(同一 token,只换读取面) + +拼接件 `frontend/tests/home-surface.ts` / Python `_home_surface()`:第一批文件 + 可选 `use-session-management.ts` / `use-consultation-run.ts`(`existsSync` / `Path.exists()`)。hook 落地后自动拼进。 + +| 测试 | 原路径 | 新路径 | +| --- | --- | --- | +| `sidebar-contract.test.ts`「keeps the page session selection callback…」 | `page.tsx` 正则 `selectSession` | `homeSurface`(函数已搬走;断言内容未改) | +| `consultation-birth-time-mode.test.ts` homepage `consultationMode:` | `page.tsx` | `homeSurface`(token 在 `send`) | +| `tests/test_supabase_user_data_contract.py` | 已是 `_home_surface()` | 可选 hook 列入清单(`persistSession(completedSession)` 等在 consult hook) | +| `rectification-agentic-entry.test.ts` refreshAccount 切片 | `function updateSession` 作终点 | `function openAccountDialog`(`updateSession` 已搬走;若终点落在拼接后的 hook 文件,切片会吞进仍留在 page 的 `persistProfile`/`setProfileDraft`。断言内容未改) | +| `profile-state.test.ts` 同一切片 | 同上 | 同上(该文件仍只读 `page.tsx`,终点改为仍相邻的 `openAccountDialog`) | +| `chat-stream-layout.test.ts` 活动/ndjson/`regenerateLatestAnswer` | `page.tsx` | `homeSurface`(token 在 `send` / consult hook) | +| `evidence-audit-panel.test.ts` workflow receipt headers | `page.tsx` | `homeSurface` | +| `chat-notice-and-scroll-contract.test.ts` send 切片 | `page.tsx` 的 `updateSession(sessionId, () => userSession)` | `homeSurface`(仅该条;JSX/poll 仍读 `page.tsx`) | + +其余已走 `homeSurface` 的聊天主链路测试(`chat-session-url` / `consultation-recovery` / `consultation-entrypoint` / `composer-isolation` / `chat-session-authority` 等)无需再改:切片标记仍在 hook 文件内且相邻。 + +锁 JSX/仍留在 `page.tsx` 的测试继续只读 `page.tsx`(`site-style-isolation`、`rectification-lazy-loading`、`chat-notice-and-scroll` 的 poll timer、profile/onboarding 等)。 + +小刀轮两个 Python 文件保持全绿(见小刀 PROGRESS)。 + +## 验证 + +- `./node_modules/.bin/tsc --noEmit`:通过。 +- 聚焦合同:`consultation-recovery` / `chat-session-*` / `composer-isolation` / `consultation-entrypoint` / `starter-questions` / `chat-navigation-a11y` / `settings-mvp` / `sidebar-contract` / `consultation-birth-time-mode` / `rectification-agentic-entry` / `profile-state` 全绿。 +- 全量 `npm test`:2428 pass / 0 fail / 0 skipped。 +- `./node_modules/.bin/next build --webpack`:`┌ ○ /` Static。首屏 JS gzip-9 501104 B vs 第一批后 501315 B(−211 B / −0.04%)。 +- 行为抽查:无登录态浏览器点选会话 / 发送咨询。由合同测试覆盖抽取正确性;popstate 行为变化已在小刀轮用源码合同锁住。 + +未改 `.gitea/workflows/**`。未动数据库。未提升 `main`。未重开 React Compiler。 + +## 明确未做 + +- onboarding/profile 簇与 rectification 胶水(第三批)。 +- 未把 streaming/pending 等 state 下移进 hook(为保住 hook 顺序)。 +- 未修任何行为(popstate 已在小刀轮完成)。 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index be8183af..1b0271d0 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -76,7 +76,9 @@ import { } from "@/lib/birth-time-consultation-consent"; import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode"; import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey"; +import { useConsultationRun } from "@/hooks/use-consultation-run"; import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor"; +import { useSessionManagement } from "@/hooks/use-session-management"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-announcement"; import { @@ -420,6 +422,59 @@ export default function Home() { : rectificationError; const onboardingFingerprint = onboardingProfileFingerprint(profile); + const { + updateSession, + persistSession, + ensureSessionMessages, + continueInNewChat, + renameSession, + deleteSession, + togglePinnedSession, + toggleArchivedSession, + shareSession, + startNewChat, + startConsultationAfterRectification, + selectSession, + selectSessionModel, + } = useSessionManagement({ + account, + accountId, + activeChartId, + activeSession, + activeSessionId, + activeSessionIdRef, + applySessionPopStateRef, + cancellationPending, + chartLibrary, + creatingSession, + modelCatalog, + modelPersistence, + modelSelectionVersions, + modelSyncFailures, + pendingSessionId, + profile, + rectificationSessionId, + sessionDetailInFlight, + sessionSelectionSource, + sessions, + sessionsRef, + setActiveChartId, + setActiveSessionId, + setBirthTimeConsultationConsent, + setCreatingSession, + setDraft, + setDraftEntrypoint, + setDraftTheme, + setRectificationError, + setRequestError, + setSessionDetailLoadingId, + setSessionFullPrompt, + setSessions, + uiPreview, + visibleSessions, + openRectificationSession, + }); + useEffect(() => { activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); @@ -603,6 +658,65 @@ export default function Home() { !rectificationSurfaceOpen && !starterHomeVisible, activeSessionId, ); + const { + restoreConsultationRecovery, + stopResponse, + send, + regenerateLatestAnswer, + } = useConsultationRun({ + account, + activeSession, + activeSessionIdRef, + birthTimeConsultationConsent, + cancellationFeedbackRequest, + cancellationInFlight, + cancellationPending, + cancellationRequests, + composerInput, + consultationRecoveryCheck, + consultationRecoveryWakeup, + consultationReplay, + consultationReplayStarted, + consultationStatusMissingCount, + conversationAnchor, + isLoading, + modelCatalog, + onboardingJustCompleted, + pendingConsultation, + pendingSessionId, + profile, + router, + sessions, + setAccount, + setActiveSessionId, + setCancellationPending, + setConsultationPhase, + setDraft, + setDraftEntrypoint, + setDraftTheme, + setMessageFeedback, + setOnboardingJustCompleted, + setPendingRequestId, + setPendingSessionId, + setProfileNotice, + setReplyOutcome, + setRequestError, + setSessionFullPrompt, + setSessions, + setStreamingReply, + startGreeting, + stoppedRequestAwaitingSettlement, + stoppedSessionPersistence, + uiPreview, + uiPreviewMode, + persistSession, + updateSession, + startNewChat, + continueInNewChat, + refreshAccount, + openAccountDialog, + openRectificationFromHomepage, + }); const jumpToLatestVisible = !rectificationSurfaceOpen && !starterHomeVisible && !conversationAnchor.anchored @@ -620,72 +734,6 @@ export default function Home() { draftEntrypoint.current = entrypoint; } - function restoreConsultationRecovery( - session: ChatSession, - requestId: string, - stored?: StoredPendingConsultation | null, - ) { - if (pendingConsultation.current) return; - const lastMessage = session.messages.at(-1); - const storedQuestion = stored?.question?.trim() ?? ""; - const lastIsQuestion = lastMessage?.role === "user" - && (!storedQuestion || lastMessage.text === storedQuestion); - const question = lastIsQuestion && lastMessage ? lastMessage.text : storedQuestion; - const optimisticSession = lastIsQuestion || !question - ? session - : { - ...session, - title: session.messages.length === 0 && isGenericSessionTitle(session.title) - ? resolveSessionTitle(question, undefined, { - entrypoint: stored?.entrypoint, - theme: stored?.theme ?? session.theme, - existingTitles: sessions.filter((item) => item.id !== session.id).map((item) => item.title), - }) - : session.title, - theme: stored?.theme ?? session.theme, - messages: [...session.messages, { role: "user" as const, text: question }], - updatedAt: timestamp(), - }; - const previousSession = optimisticSession.messages.at(-1)?.role === "user" - ? { ...optimisticSession, messages: optimisticSession.messages.slice(0, -1) } - : optimisticSession; - if (optimisticSession !== session) updateSession(session.id, () => optimisticSession); - pendingConsultation.current = { - requestId, - sessionId: session.id, - question, - entrypoint: stored?.entrypoint ?? null, - theme: stored?.theme ?? session.theme, - previousSession, - optimisticSession, - previousOnboardingState: false, - controller: new AbortController(), - cancelled: false, - phase: "recovering", - partialReply: "", - }; - setPendingSessionId(session.id); - setPendingRequestId(requestId); - setActiveSessionId(session.id); - setConsultationPhase("recovering"); - setStreamingReply({ sessionId: session.id, text: "" }); - setComposerNotice(navigator.onLine - ? "回答仍在后台生成,正在自动恢复。" - : "网络已断开,回答仍在后台生成;联网后会自动恢复。"); - } - - consultationRecoveryCheck.current = () => { - consultationRecoveryWakeup.current(); - if (pendingConsultation.current || uiPreview.current) return; - void fetchActiveConsultationStatus() - .then((status) => { - if (status?.status !== "reserved") return; - const session = sessions.find((item) => item.id === status.sessionId); - if (session) restoreConsultationRecovery(session, status.requestId); - }) - .catch(() => undefined); - }; - useEffect(() => { const controller = new AbortController(); const bootstrapTimeout = window.setTimeout(() => { @@ -1276,308 +1324,6 @@ export default function Home() { } } - function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) { - setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); - } - - async function persistSession(session: ChatSession, mode: "create" | "update" = "update") { - if (!account) throw new Error("账户尚未加载完成"); - if (process.env.NODE_ENV === "development" && uiPreview.current) return; - const values = mode === "create" - ? { - title: session.title, - theme: session.theme, - model_id: session.modelId, - messages: [] as const, - session_type: session.sessionType, - rectification_case_id: session.rectificationCaseId, - chart_profile_id: session.chartProfileId, - chart_profile_name: session.chartProfileName, - chart_profile_role: session.chartProfileRole, - } - : { - title: session.title, - theme: session.theme, - model_id: session.modelId, - chart_profile_id: session.chartProfileId, - chart_profile_name: session.chartProfileName, - chart_profile_role: session.chartProfileRole, - }; - await writeChatSession(session.id, values, mode); - } - - async function ensureSessionMessages(sessionId: string) { - if (!sessionId || uiPreview.current) return; - if (pendingSessionId === sessionId) return; - if (sessionDetailInFlight.current.has(sessionId)) return; - const known = sessionsRef.current.find((session) => session.id === sessionId); - if (known?.messagesHydrated || known?.sessionType === "birth_time_rectification") return; - sessionDetailInFlight.current.add(sessionId); - setSessionDetailLoadingId(sessionId); - try { - const detailed = await fetchSessionDetail(sessionId, modelCatalog); - if (!detailed) return; - setSessions((existing) => { - const live = existing.find((session) => session.id === sessionId); - if (live?.messagesHydrated) return existing; - return mergeHydratedSession(existing, detailed); - }); - } catch (caught) { - if (caught instanceof LoginRedirectError) return; - setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录"); - } finally { - sessionDetailInFlight.current.delete(sessionId); - setSessionDetailLoadingId((currentId) => currentId === sessionId ? null : currentId); - } - } - - async function continueInNewChat(prompt: { question: string; theme: Theme }) { - setSessionFullPrompt(null); - const created = await startNewChat(); - if (!created) return; - setDraft(prompt.question); - setDraftTheme(prompt.theme); - } - - async function renameSession(session: ChatSession) { - const title = window.prompt("重命名聊天记录", session.title)?.trim(); - if (!title || title === session.title) return; - const nextSession = { ...session, title, updatedAt: timestamp() }; - updateSession(session.id, () => nextSession); - try { - await persistSession(nextSession); - } catch (caught) { - setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败"); - } - } - - async function deleteSession(session: ChatSession) { - if (!account) return; - const previousSessions = sessions; - const nextSessions = sessions.filter((item) => item.id !== session.id); - 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"); - } - try { - const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); - const payload = await response.json().catch(() => null) as { error?: string } | null; - if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败"); - } catch (caught) { - setSessions(previousSessions); - setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败"); - } - } - - function togglePinnedSession(sessionId: string) { - const session = sessions.find((item) => item.id === sessionId); - if (!session) return; - const nextPinned = !session.pinned; - updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned })); - void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => { - updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned })); - setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败"); - }); - } - - function toggleArchivedSession(sessionId: string) { - const session = sessions.find((item) => item.id === sessionId); - if (!session) return; - const restoring = Boolean(session.archivedAt); - const previousActiveId = activeSessionId; - 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"); - } - setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。"); - void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => { - updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt })); - if (!restoring && previousActiveId === sessionId) { - setActiveSessionId(previousActiveId); - if (!uiPreview.current) writeSessionUrl(previousActiveId || null, "replace"); - } - setComposerNotice(caught instanceof Error ? caught.message : "归档同步失败"); - }); - } - - async function shareSession(session: ChatSession) { - const sharePayload = { - share_payload_version: 1, - exported_at: new Date().toISOString(), - title: session.title, - theme: session.theme, - message_count: session.messages.length, - messages: session.messages.map((message) => ({ role: message.role, text: message.text })), - }; - const reportMarkdown = consultationReportMarkdown({ title: session.title, messages: session.messages }); - const transcript = [ - `Jyotisha 对话:${session.title}`, - "", - ...session.messages.map((message) => `${message.role === "user" ? "我" : "Jyotisha"}:${message.text}`), - "", - "---- Markdown 报告 ----", - reportMarkdown, - "", - "---- JSON 分享包 ----", - JSON.stringify(sharePayload, null, 2), - ].join("\n"); - try { - await navigator.clipboard.writeText(transcript); - setComposerNotice("已复制当前聊天,可粘贴转发。"); - } catch { - setComposerNotice("无法访问剪贴板,请手动复制聊天内容。"); - } - } - - async function startNewChat(): Promise { - if (!account || !modelCatalog || creatingSession) return null; - const nextSession = { - ...createSession(modelCatalog.defaultModelId), - ...chartSnapshotForSession(activeChartId, chartLibrary, profile), - }; - const previousSessionId = activeSession?.id ?? ""; - const previousHref = `${window.location.pathname}${window.location.search}`; - setCreatingSession(true); - setSessions((current) => [nextSession, ...current]); - setActiveSessionId(nextSession.id); - if (!uiPreview.current) writeSessionUrl(nextSession.id, "push"); - setDraft(""); - setDraftTheme(null); - setDraftEntrypoint(null); - setComposerNotice(""); - setRequestError(null); - try { - await persistSession(nextSession, "create"); - return nextSession; - } catch (caught) { - setSessions((current) => current.filter((session) => session.id !== nextSession.id)); - setActiveSessionId(previousSessionId); - if (!uiPreview.current) window.history.replaceState(null, "", previousHref); - setRequestError({ - sessionId: previousSessionId, - message: caught instanceof Error ? caught.message : "新对话未能保存到云端。", - }); - return null; - } finally { - setCreatingSession(false); - } - } - - async function startConsultationAfterRectification() { - await startNewChat(); - setDraft("请用刚才采用的代表性出生时间看盘。"); - setComposerNotice("已用刚才采用的时间作为当前排盘。这还不是唯一分钟确认。"); - } - - function selectSession(sessionId: string) { - const nextSession = sessions.find((session) => session.id === sessionId); - setActiveSessionId(sessionId); - setDraft(""); - setDraftEntrypoint(null); - setComposerNotice(""); - if (nextSession?.chartProfileId) { - const boundChart = chartLibrary.find((record) => record.id === nextSession.chartProfileId); - if (boundChart && boundChart.id !== activeChartId && accountId) { - setActiveChartId(boundChart.id); - localStorage.setItem(activeChartStorageKey(accountId), boundChart.id); - } - } - if (nextSession?.sessionType === "birth_time_rectification") { - setRectificationError(""); - if (nextSession.id !== rectificationSessionId) { - // 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); - } - } else { - void ensureSessionMessages(sessionId); - } - if (!uiPreview.current && sessionSelectionSource.current === "user") { - writeSessionUrl(sessionId, "push"); - } - sessionSelectionSource.current = "user"; - } - - applySessionPopStateRef.current = (search: string) => { - if (uiPreview.current) return; - const listed = sessionsRef.current; - const query = parseSessionUrlQuery(search); - const fallbackId = listed[0]?.id ?? ""; - const requestedId = query.present ? query.sessionId : fallbackId; - if (query.present && (!requestedId || !listed.some((session) => session.id === requestedId))) { - writeSessionUrl(null, "replace"); - sessionSelectionSource.current = "history"; - if (fallbackId) selectSession(fallbackId); - else setActiveSessionId(""); - setComposerNotice(SESSION_MISSING_NOTICE); - return; - } - if (!requestedId) { - setActiveSessionId(""); - return; - } - sessionSelectionSource.current = "history"; - selectSession(requestedId); - }; - - async function selectSessionModel(modelId: string) { - const userId = account?.user.id; - if (!activeSession || !modelCatalog || !userId || pendingSessionId || cancellationPending || creatingSession) return; - const selectedModel = modelCatalog.models.find((model) => model.id === modelId); - const retryingFailedSync = activeSession.modelId === modelId && modelSyncFailures.current.has(activeSession.id); - if (!selectedModel || (activeSession.modelId === modelId && !retryingFailedSync)) return; - - const nextSession: ChatSession = retryingFailedSync - ? activeSession - : { ...activeSession, modelId, updatedAt: timestamp() }; - const selectionVersion = (modelSelectionVersions.current.get(nextSession.id) ?? 0) + 1; - modelSelectionVersions.current.set(nextSession.id, selectionVersion); - if (!retryingFailedSync) updateSession(activeSession.id, () => nextSession); - setRequestError(null); - setComposerNotice(""); - - try { - await modelPersistence.current.enqueue(nextSession.id, () => persistSessionModelSelection( - async ({ values, sessionId }) => { - if (process.env.NODE_ENV === "development" && uiPreview.current) { - return { found: true, error: null }; - } - try { - await patchSessionModel(sessionId, values.model_id); - return { found: true, error: null }; - } catch (error) { - return { - found: false, - error: error instanceof Error ? error.message : "模型选择暂时无法同步到云端。", - }; - } - }, - userId, - nextSession.id, - modelId, - )); - if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return; - modelSelectionVersions.current.delete(nextSession.id); - modelSyncFailures.current.delete(nextSession.id); - } catch (caught) { - if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return; - modelSelectionVersions.current.delete(nextSession.id); - modelSyncFailures.current.add(nextSession.id); - if (activeSessionIdRef.current === nextSession.id) { - setComposerNotice(`已在当前页面选择 ${selectedModel.label},但云端同步失败;再次选择当前模型即可重试。`); - } - setRequestError({ - sessionId: nextSession.id, - message: caught instanceof Error ? caught.message : "模型选择暂时无法同步到云端。", - }); - } - } function openAccountDialog(dialog: AccountDialog, returnTarget: HTMLButtonElement | null = accountTrigger.current) { dialogReturnTarget.current = returnTarget ?? accountTrigger.current; @@ -2149,761 +1895,6 @@ export default function Home() { closeAccountDialog(); } - async function requestCancellation(requestId: string) { - const existing = cancellationRequests.current.get(requestId); - if (existing) return existing; - - const cancellation = (async () => { - const response = await fetch("/api/consult/cancel", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ requestId }), - keepalive: true, - }); - const payload: unknown = await response.json().catch(() => null); - if (!response.ok) { - throw new CancellationResponseError( - response.status, - payloadMessage(payload, "暂时无法确认点数已退回"), - ); - } - if (!payload || typeof payload !== "object") return; - const credits = "credits" in payload ? payload.credits : null; - if (typeof credits === "number") { - setAccount((current) => current ? { ...current, credits } : current); - } - })(); - cancellationRequests.current.set(requestId, cancellation); - return cancellation; - } - - async function confirmCancellation(requestId: string, sessionId: string, confirmedNotice: string) { - try { - await requestCancellation(requestId); - if (cancellationFeedbackRequest.current === requestId && activeSessionIdRef.current === sessionId) { - setComposerNotice(confirmedNotice); - } - } catch (error) { - if (cancellationFeedbackRequest.current === requestId && activeSessionIdRef.current === sessionId) { - setComposerNotice(error instanceof CancellationResponseError && error.status === 409 - ? "回答已完成结算,本次已计费;问题仍保留在输入框。" - : "问题已放回输入框;暂时无法确认点数状态,请稍后在账户中核对。"); - setRequestError((current) => current?.sessionId === sessionId ? current : { - sessionId, - message: error instanceof Error ? error.message : "暂时无法确认点数状态。", - }); - } - void refreshAccount(); - } - } - - async function stopResponse() { - const pending = pendingConsultation.current; - if (!pending || pending.cancelled) return; - - setReplyOutcome({ sessionId: pending.sessionId, phase: "stopped", replyOrdinal: 0 }); - const isPreview = process.env.NODE_ENV === "development" && uiPreview.current; - if (pending.phase !== "undo" && !isPreview) { - stoppedRequestAwaitingSettlement.current = pending.requestId; - cancellationInFlight.current = true; - setCancellationPending(true); - } - pendingConsultation.current = { ...pending, cancelled: true }; - pending.controller.abort(); - - if (pending.partialReply) { - const stoppedSession: ChatSession = { - ...pending.optimisticSession, - messages: [...pending.optimisticSession.messages, { role: "assistant", text: pending.partialReply }], - updatedAt: timestamp(), - }; - if (isPreview) { - updateSession(pending.sessionId, () => stoppedSession); - setStreamingReply(null); - setPendingSessionId(null); - setConsultationPhase(null); - setRequestError(null); - setComposerNotice("已停止回答,现有内容已保留。"); - if (pendingConsultation.current?.requestId === pending.requestId) { - pendingConsultation.current = null; - } - return; - } - - setComposerNotice("正在停止回答并申请退回本次点数…"); - try { - await requestCancellation(pending.requestId); - } catch (error) { - cancellationRequests.current.delete(pending.requestId); - stoppedRequestAwaitingSettlement.current = null; - cancellationInFlight.current = false; - setCancellationPending(false); - pendingConsultation.current = { - ...pending, - controller: new AbortController(), - cancelled: false, - phase: "recovering", - }; - setPendingSessionId(pending.sessionId); - setConsultationPhase("recovering"); - setStreamingReply({ sessionId: pending.sessionId, text: pending.partialReply }); - setRequestError(null); - - if (error instanceof CancellationResponseError && error.status === 409) { - setComposerNotice("回答已完成,正在恢复服务端完整内容。"); - try { - const status = await fetchConsultationStatus(pending.sessionId, pending.requestId); - if (status.status === "completed") { - const detailed = await fetchSessionDetail(pending.sessionId, modelCatalog); - if (detailed) { - setSessions((current) => mergeHydratedSession(current, detailed)); - setActiveSessionId((current) => current || detailed.id); - } - pendingConsultation.current = null; - setPendingSessionId(null); - setConsultationPhase(null); - setStreamingReply(null); - setComposerNotice("回答已恢复,已显示在对话区末尾。"); - void refreshAccount(); - return; - } - } catch { - // The recovery poll retries status and session reload. - } - } else { - setComposerNotice("停止请求尚未确认,正在自动恢复后台回答。"); - } - window.setTimeout(() => consultationRecoveryWakeup.current(), 0); - return; - } - - updateSession(pending.sessionId, () => stoppedSession); - setStreamingReply(null); - setPendingSessionId(null); - setConsultationPhase(null); - setRequestError(null); - setComposerNotice("已停止回答,现有内容已保留,本次点数已退回。"); - cancellationRequests.current.delete(pending.requestId); - stoppedRequestAwaitingSettlement.current = null; - cancellationInFlight.current = false; - setCancellationPending(false); - if (pendingConsultation.current?.requestId === pending.requestId) { - pendingConsultation.current = null; - } - void refreshAccount(); - return; - } - - updateSession(pending.sessionId, () => pending.previousSession); - setOnboardingJustCompleted(pending.previousOnboardingState); - setDraft(pending.question); - setDraftTheme(pending.theme); - setDraftEntrypoint(pending.entrypoint); - setStreamingReply(null); - setPendingSessionId(null); - setConsultationPhase(null); - setRequestError(null); - cancellationFeedbackRequest.current = pending.requestId; - setComposerNotice("已停止,问题已放回输入框,正在确认点数…"); - window.requestAnimationFrame(() => composerInput.current?.focus()); - - if (pending.phase === "undo" || isPreview) { - if (pendingConsultation.current?.requestId === pending.requestId) { - pendingConsultation.current = null; - } - setComposerNotice("已停止,问题已放回输入框,本次未扣点。"); - return; - } - - await confirmCancellation( - pending.requestId, - pending.sessionId, - "已停止,问题已放回输入框,本次未扣点。", - ); - cancellationRequests.current.delete(pending.requestId); - stoppedRequestAwaitingSettlement.current = null; - cancellationInFlight.current = false; - setCancellationPending(false); - if (pendingConsultation.current?.requestId === pending.requestId) pendingConsultation.current = null; - } - - function completeConsultationInterface(requestId: string) { - if (pendingConsultation.current?.requestId !== requestId) return; - pendingConsultation.current = null; - if (consultationReplayStarted.current === requestId) consultationReplayStarted.current = null; - setStreamingReply(null); - setPendingSessionId(null); - setPendingRequestId(null); - setConsultationPhase(null); - } - - async function send( - text: string, - requestedTheme?: Theme, - entrypoint: ConsultationEntrypoint | null = null, - consentGrantedForRequest: ConsultationBirthTimeMode | null = null, - targetSessionId: string | null = null, - options: { - resumeRequestId?: string; - sessionOverride?: ChatSession; - restoreOnFailure?: ChatSession; - } = {}, - ): Promise { - const originalQuestion = text; - const question = text.trim(); - const consultEntrypoint = entrypoint - ?? (isRectificationHandoffQuestion(question) - ? "birth_time_rectification" as const - : isGeneralDailyFortuneQuestion(question) - ? "daily_starlanguage" as const - : null); - const resumeRequestId = options.resumeRequestId; - const resuming = Boolean(resumeRequestId); - const liveSession = targetSessionId - ? sessions.find((session) => session.id === targetSessionId) - : activeSession; - const currentSession = options.sessionOverride ?? liveSession; - if (!question || !currentSession || !modelCatalog || !account) return false; - const rollbackSession = options.restoreOnFailure ?? liveSession ?? currentSession; - if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false; - if (resuming) { - const pending = pendingConsultation.current; - if (!pending - || pending.requestId !== resumeRequestId - || pending.sessionId !== currentSession.id - || pending.cancelled) return false; - } - - if (!isProfileComplete(profile)) { - openAccountDialog("profile"); - setProfileNotice("请先补充出生资料,才能进行星盘计算。"); - return false; - } - - if (consultEntrypoint === "birth_time_rectification") { - const previousUserQuestion = [...currentSession.messages] - .reverse() - .find((message) => message.role === "user") - ?.text - .trim() ?? null; - const pendingQuestion = previousUserQuestion && !isRectificationHandoffQuestion(previousUserQuestion) - ? previousUserQuestion - : null; - await openRectificationFromHomepage(pendingQuestion); - setDraft(""); - setDraftTheme(null); - setDraftEntrypoint(null); - return false; - } - - const birthPlace = selectedBirthPlace(profile); - if (!birthPlace) return false; - - const theme = requestedTheme ?? currentSession.theme; - const sessionId = currentSession.id; - const consentForDecision = consentGrantedForRequest === "unverified_birth_time" - ? grantBirthTimeConsultationConsent( - birthTimeConsultationConsent, - sessionId, - "unverified_birth_time", - ) - : birthTimeConsultationConsent; - const initialConsultationRoute = resolveBirthTimeConsultationRoute( - profile, - consentForDecision, - sessionId, - ); - const consultationRoute = initialConsultationRoute.kind === "choice" - ? { kind: "consult" as const, mode: "general_no_birth_time" as const, time: null } - : initialConsultationRoute; - - if (account.credits <= 0 && !account.activeSubscription) { - router.push(membershipHref("insufficient-credits")); - return false; - } - - const [year, month, day] = profile.date.split("-").map(Number); - const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? []; - - const lastMessage = currentSession.messages.at(-1); - const questionAlreadyPresent = lastMessage?.role === "user" && lastMessage.text === question; - const preservedMessages = questionAlreadyPresent - ? currentSession.messages - : (onboardingJustCompleted && currentSession.messages.length === 0 - ? completedOnboardingTranscript(profile, startGreeting) - : currentSession.messages); - const userSession: ChatSession = { - ...currentSession, - title: currentSession.messages.length === 0 && isGenericSessionTitle(currentSession.title) - ? resolveSessionTitle(question, undefined, { - entrypoint: consultEntrypoint, - theme, - existingTitles: sessions.filter((item) => item.id !== currentSession.id).map((item) => item.title), - }) - : currentSession.title, - theme, - messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }], - updatedAt: questionAlreadyPresent ? currentSession.updatedAt : timestamp(), - messagesHydrated: true, - }; - const requestId = resumeRequestId ?? globalThis.crypto.randomUUID(); - const controller = resuming && pendingConsultation.current - ? pendingConsultation.current.controller - : new AbortController(); - const previousOnboardingState = onboardingJustCompleted; - cancellationFeedbackRequest.current = null; - setRequestError(null); - setReplyOutcome(null); - if (!resuming) { - setComposerNotice(""); - consultationStatusMissingCount.current = 0; - consultationReplayStarted.current = null; - setPendingSessionId(sessionId); - setPendingRequestId(requestId); - setConsultationPhase("undo"); - pendingConsultation.current = { - requestId, - sessionId, - question: originalQuestion, - entrypoint: consultEntrypoint, - theme, - previousSession: rollbackSession, - optimisticSession: userSession, - previousOnboardingState, - controller, - cancelled: false, - phase: "undo", - partialReply: "", - }; - try { - sessionStorage.setItem(pendingConsultationStorageKey, JSON.stringify({ - sessionId, - requestId, - question: originalQuestion, - theme, - entrypoint: consultEntrypoint, - })); - } catch { - // Private-mode storage must not block send. - } - setOnboardingJustCompleted(false); - updateSession(sessionId, () => userSession); - conversationAnchor.anchorToLatest(); - setDraft(""); - setDraftTheme(null); - setDraftEntrypoint(null); - } - - if (!resuming && process.env.NODE_ENV === "development" && uiPreview.current) { - setStreamingReply({ sessionId, text: "" }); - if (uiPreviewMode.current === "partial") { - const partialReply = "已开始查看事业方向与关键时间,先给你一个阶段性的判断。"; - if (pendingConsultation.current?.requestId === requestId) { - pendingConsultation.current = { - ...pendingConsultation.current, - phase: "streaming", - partialReply, - }; - } - setConsultationPhase("streaming"); - setStreamingReply({ sessionId, text: partialReply }); - } - await new Promise((resolve) => window.setTimeout(resolve, uiPreviewMode.current === "streaming" || uiPreviewMode.current === "partial" ? 15_000 : 800)); - if (controller.signal.aborted) { - if (pendingConsultation.current?.requestId === requestId) pendingConsultation.current = null; - return false; - } - const previewReply = parseAgentReply([ - "这是本地交互预览。正式对话会结合你的星盘证据继续分析。", - "", - ].join("\n")); - const previewSession: ChatSession = { - ...userSession, - title: userSession.title, - messages: [...userSession.messages, { - role: "assistant", - text: previewReply.text, - }], - updatedAt: timestamp(), - }; - updateSession(sessionId, () => previewSession); - completeConsultationInterface(requestId); - return true; - } - - if (!resuming) { - await waitForUndoWindow(controller.signal); - if (controller.signal.aborted) return false; - } - if (!resuming || !questionAlreadyPresent) { - if (resuming && !questionAlreadyPresent) updateSession(sessionId, () => userSession); - } - if (pendingConsultation.current?.requestId === requestId) { - pendingConsultation.current = { - ...pendingConsultation.current, - question: originalQuestion, - entrypoint: consultEntrypoint, - theme, - optimisticSession: userSession, - phase: "streaming", - }; - setConsultationPhase("streaming"); - } - setStreamingReply({ sessionId, text: "", timeline: [] }); - let latestPartialReply = ""; - let thinkingSections: PublicThinkingSection[] = []; - let streamedThinking = ""; - let timelineState = emptyConsultationTimeline(); - try { - const response = await fetch("/api/consult", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - requestId, - sessionId: currentSession.id, - modelId: currentSession.modelId, - name: profile.name, - consultationMode: consultationRoute.mode, - entrypoint: consultEntrypoint ?? undefined, - ...(consultationRoute.mode === "general_no_birth_time" || consultationRoute.mode === "declared_birth_window" ? {} : { - year, - month, - day, - hour, - minute, - city: birthPlace.label, - lat: birthPlace.lat, - lon: birthPlace.lon, - tz: birthPlace.tz, - entryMode: "direct_chart" as const, - }), - theme, - question, - history: currentSession.messages.slice(-12).map((message) => ({ - role: message.role, - text: message.text.slice(0, 4000), - })), - }), - signal: controller.signal, - }); - if (!response.ok) { - const contentType = response.headers.get("content-type") ?? ""; - const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() }; - if (response.status === 401) window.location.assign("/login"); - if (response.status === 402) router.push(membershipHref("insufficient-credits")); - throw new ConsultationResponseError( - response.status, - payloadMessage(errorPayload, "服务暂时不可用"), - payloadCode(errorPayload), - ); - } - if (!response.body) { - throw new ConsultationResponseError(502, "浏览器未收到可读取的回答流"); - } - let techniqueTruth = response.headers.get("x-jyotish-technique-truth") ?? "unknown"; - let workflowReceipt: AgentExecutionReceipt["workflow"] = { - route: response.headers.get("x-jyotish-workflow-route") ?? "unknown", - status: response.headers.get("x-jyotish-workflow-status") ?? "unknown", - preciseTiming: response.headers.get("x-jyotish-precise-timing") ?? "unknown", - missingLayers: (response.headers.get("x-jyotish-missing-layers") ?? "none") - .split(",") - .map((item) => item.trim()) - .filter((item) => item && item !== "none"), - }; - let agentExecutionReceipt: AgentExecutionReceipt | undefined; - let runCompleted = false; - let truncatedFailure: Extract | undefined; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let answer = ""; - const updateStreamingAnswer = (activity?: AgentActivityView) => { - const partialReply = parseAgentReply(answer).text; - latestPartialReply = partialReply; - thinkingSections = applyThinkingSectionProgress(thinkingSections, partialReply); - setStreamingReply((current) => ({ - sessionId, - text: partialReply, - thinkingText: streamedThinking.trim() || undefined, - thinkingSections: thinkingSections.length ? thinkingSections : undefined, - timeline: timelineState.rows, - activity: activity - ? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity) - : current?.sessionId === sessionId ? current.activity : undefined, - })); - if (partialReply && pendingConsultation.current?.requestId === requestId) { - pendingConsultation.current = { ...pendingConsultation.current, partialReply }; - } - }; - const updateActivity = (event: ConsultationAgentPublicEvent) => { - let activity: AgentActivityView | undefined; - if (event.type === "skill.started") { - activity = { phase: "loading-method", label: CONSULTATION_LOADING_METHOD_LABEL }; - } else if (event.type === "skill.completed" || event.type === "tool.started") { - activity = { - phase: "chart-calculation", - label: CONSULTATION_CHART_CALCULATION_LABEL, - completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]), - }; - } else if (event.type === "activity") { - activity = { - phase: event.phase, - label: event.label, - completedTrail: event.phase === "evidence-validation" - ? activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL]) - : activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]), - }; - } else if (event.type === "tool.completed") { - activity = { - phase: "evidence-validation", - label: CONSULTATION_EVIDENCE_VALIDATION_LABEL, - completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL]), - }; - } else if (event.type === "answer.delta") { - activity = { phase: "answer-composition", label: CONSULTATION_COMPOSING_LABEL }; - } - if (activity) updateStreamingAnswer(activity); - }; - - if ((response.headers.get("content-type") ?? "").includes("application/x-ndjson")) { - const parser = createNdjsonParser((event) => { - timelineState = reduceConsultationTimeline(timelineState, event); - if (event.type === "answer.delta") answer += event.text; - if (event.type === "thinking.delta" && typeof event.text === "string") { - streamedThinking += event.text; - setStreamingReply((current) => ({ - sessionId, - text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text, - thinkingText: streamedThinking.trim() || undefined, - thinkingSections: current?.sessionId === sessionId ? current.thinkingSections : thinkingSections, - timeline: timelineState.rows, - activity: current?.sessionId === sessionId ? current.activity : undefined, - })); - } - if (event.type === "thinking.section") { - thinkingSections = applyThinkingSectionProgress( - upsertThinkingSection(thinkingSections, { - id: event.id, - title: event.title, - heading: event.heading, - steps: event.steps, - }), - parseAgentReply(answer).text, - ); - setStreamingReply((current) => ({ - sessionId, - text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text, - thinkingText: streamedThinking.trim() || undefined, - thinkingSections, - timeline: timelineState.rows, - activity: current?.sessionId === sessionId ? current.activity : undefined, - })); - } - if (event.type === "run.completed") { - runCompleted = true; - agentExecutionReceipt = event.receipt; - workflowReceipt = event.receipt.workflow; - techniqueTruth = event.receipt.techniqueTruth ?? "unknown"; - } - if (event.type === "run.failed") { - if (event.code === "answer_truncated") { - truncatedFailure = event; - if (event.receipt) { - agentExecutionReceipt = event.receipt; - workflowReceipt = event.receipt.workflow; - techniqueTruth = event.receipt.techniqueTruth ?? techniqueTruth; - } - return; - } - throw new ConsultationResponseError(502, event.message); - } - updateActivity(event); - }); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - parser.push(decoder.decode(value, { stream: true })); - } - parser.finish(decoder.decode()); - if (truncatedFailure) { - const reply = parseAgentReply(answer); - if (!reply.text) throw new ConsultationResponseError(502, truncatedFailure.message); - const truncatedSession: ChatSession = { - ...userSession, - title: userSession.title, - messages: [...userSession.messages, { - role: "assistant", - text: reply.text, - ...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}), - ...(thinkingSections.length ? { thinkingSections } : {}), - techniqueTruth, - workflowReceipt, - agentExecutionReceipt, - }], - updatedAt: timestamp(), - }; - updateSession(sessionId, () => truncatedSession); - setStreamingReply(null); - setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 }); - setComposerNotice(truncatedFailure.message); - completeConsultationInterface(requestId); - void refreshAccount(); - return true; - } - if (!runCompleted && !truncatedFailure) { - throw new ConsultationResponseError( - 502, - thinkingSections.length - ? "这次还没有生成可显示的回答。思考步骤已保留,可以直接继续问。" - : "Agent 回答未完成,本次不会保存为成功咨询。", - ); - } - } else { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - answer += decoder.decode(value, { stream: true }); - updateStreamingAnswer(); - } - answer += decoder.decode(); - } - if (controller.signal.aborted) return Boolean(latestPartialReply); - const reply = parseAgentReply(answer); - if (!reply.text) { - throw thinkingSections.length - ? new ConsultationResponseError(502, "这次还没有生成可显示的回答。思考步骤已保留,可以直接继续问。") - : new Error("Agent 没有返回可显示的回答,请重试。"); - } - - const completedTitle = reply.title && !isGenericSessionTitle(reply.title) - ? resolveSessionTitle(question, reply.title, { - entrypoint: consultEntrypoint, - theme, - existingTitles: sessions.filter((item) => item.id !== sessionId).map((item) => item.title), - }) - : userSession.title; - const completedSession: ChatSession = { - ...userSession, - title: completedTitle, - messages: [...userSession.messages, { - role: "assistant", - text: reply.text, - ...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}), - ...(thinkingSections.length ? { thinkingSections } : {}), - techniqueTruth, - workflowReceipt, - agentExecutionReceipt, - }], - updatedAt: timestamp(), - }; - updateSession(sessionId, () => completedSession); - try { - await persistSession(completedSession); - } catch (error) { - setComposerNotice(error instanceof Error ? error.message : "回答已生成,但云端同步暂时失败。"); - } - setReplyOutcome({ - sessionId, - phase: "completed", - replyOrdinal: completedSession.messages.filter((message) => message.role === "assistant").length, - }); - completeConsultationInterface(requestId); - void refreshAccount(); - return true; - } catch (caught) { - const cancelled = controller.signal.aborted; - const ownsInterface = pendingConsultation.current?.requestId === requestId; - const partialReply = latestPartialReply; - if (!cancelled && ownsInterface && pendingConsultation.current && caught instanceof ConsultationResponseError) { - if (caught.code === "session_full") { - updateSession(sessionId, () => rollbackSession); - setOnboardingJustCompleted(previousOnboardingState); - setSessionFullPrompt({ question: originalQuestion, theme }); - setComposerNotice("这段对话已写满,开个新对话继续吧", { - label: "开新对话", - onClick: () => { - void continueInNewChat({ question: originalQuestion, theme }); - }, - }); - completeConsultationInterface(requestId); - return false; - } - if (caught.message === "request_conflict") { - pendingConsultation.current = { - ...pendingConsultation.current, - phase: "recovering", - partialReply, - }; - setConsultationPhase("recovering"); - setRequestError(null); - setComposerNotice("回答仍在后台生成,正在自动恢复。"); - return Boolean(partialReply); - } - const reserveDidNotCommit = caught.status === 400 - || caught.status === 401 - || caught.status === 402 - || caught.status === 409; - if (reserveDidNotCommit) { - updateSession(sessionId, () => rollbackSession); - setOnboardingJustCompleted(previousOnboardingState); - if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) { - setDraft(originalQuestion); - setDraftTheme(theme); - setDraftEntrypoint(consultEntrypoint); - } - } - setRequestError({ sessionId, message: caught.message }); - setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 }); - setComposerNotice(caught.message); - const restore = options.restoreOnFailure; - if (restore) { - updateSession(sessionId, () => restore); - } else if (!reserveDidNotCommit && (thinkingSections.length || latestPartialReply || streamedThinking.trim())) { - const failedSession: ChatSession = { - ...userSession, - messages: [...userSession.messages, { - role: "assistant", - text: latestPartialReply, - ...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}), - ...(thinkingSections.length ? { thinkingSections } : {}), - }], - updatedAt: timestamp(), - }; - updateSession(sessionId, () => failedSession); - } - completeConsultationInterface(requestId); - return false; - } - if (!cancelled && ownsInterface && pendingConsultation.current) { - pendingConsultation.current = { - ...pendingConsultation.current, - phase: "recovering", - partialReply, - }; - setConsultationPhase("recovering"); - setRequestError(null); - setComposerNotice(navigator.onLine - ? "连接中断,回答仍在后台生成,正在自动恢复。" - : "网络已断开,回答仍在后台生成;联网后会自动恢复。"); - } - return Boolean(partialReply); - } finally { - cancellationRequests.current.delete(requestId); - const pending = pendingConsultation.current; - if (pending?.requestId !== requestId || pending.phase !== "recovering") { - completeConsultationInterface(requestId); - } - if (stoppedRequestAwaitingSettlement.current === requestId) { - const persistence = stoppedSessionPersistence.current.get(requestId); - if (persistence) { - await persistence; - stoppedSessionPersistence.current.delete(requestId); - } - stoppedRequestAwaitingSettlement.current = null; - cancellationInFlight.current = false; - setCancellationPending(false); - } - } - } async function copyAssistantMessage(renderKey: string, text: string) { try { @@ -2917,57 +1908,6 @@ export default function Home() { } } - function regenerateLatestAnswer(renderKey: string) { - const session = activeSession; - if (!session || isLoading || cancellationPending || pendingConsultation.current) return; - const last = session.messages.at(-1); - if (last?.role !== "assistant" || last.text.trim() === "") return; - const previous = session.messages.at(-2); - if (previous?.role !== "user" || previous.text.trim() === "") return; - if (`message-${session.messages.length - 1}` !== renderKey) return; - const sessionOverride: ChatSession = { - ...session, - messages: session.messages.slice(0, -1), - updatedAt: timestamp(), - }; - setMessageFeedback((current) => { - const next = { ...current }; - delete next[`${session.id}:${renderKey}`]; - return next; - }); - void send(previous.text, session.theme, null, null, session.id, { - sessionOverride, - restoreOnFailure: session, - }); - } - - consultationReplay.current = () => { - const pending = pendingConsultation.current; - if (!pending || pending.cancelled || pending.phase !== "recovering" || !pending.question.trim()) return; - if (consultationReplayStarted.current === pending.requestId) return; - consultationReplayStarted.current = pending.requestId; - setComposerNotice("后台尚未开始本次咨询,正在重新发起…"); - void send( - pending.question, - pending.theme, - pending.entrypoint, - null, - pending.sessionId, - { resumeRequestId: pending.requestId }, - ).then((started) => { - if (started || pendingConsultation.current?.requestId !== pending.requestId) return; - pendingConsultation.current = null; - setPendingSessionId(null); - setPendingRequestId(null); - setConsultationPhase(null); - setStreamingReply(null); - setRequestError({ - sessionId: pending.sessionId, - message: "后台未找到本次咨询请求,请重新发送。", - }); - setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。"); - }); - }; function submit(event: FormEvent) { event.preventDefault(); diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts new file mode 100644 index 00000000..160fb1fa --- /dev/null +++ b/frontend/src/hooks/use-consultation-run.ts @@ -0,0 +1,1073 @@ +"use client"; + +import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react"; + +import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; +import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle } from "@/lib/agent-reply"; +import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode"; +import { + GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION, + isGeneralDailyFortuneQuestion, + isRectificationHandoffQuestion, + type ConsultationEntrypoint, +} from "@/lib/consultation-entrypoint"; +import { + grantBirthTimeConsultationConsent, + resolveBirthTimeConsultationRoute, + type BirthTimeConsultationConsentState, +} from "@/lib/birth-time-consultation-consent"; +import { nextActivityView, activityCompletedTrail, type AgentActivityView } from "@/lib/chat-message-view"; +import { + emptyConsultationTimeline, + reduceConsultationTimeline, +} from "@/lib/consultation-run-timeline"; +import { createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "@/lib/consultation-agent-events"; +import { + applyThinkingSectionProgress, + upsertThinkingSection, + type PublicThinkingSection, +} from "@/lib/consultation-thinking-plan"; +import { + CONSULTATION_CHART_CALCULATION_LABEL, + CONSULTATION_COMPOSING_LABEL, + CONSULTATION_DONE_CHART_LABEL, + CONSULTATION_DONE_SKILL_LABEL, + CONSULTATION_EVIDENCE_VALIDATION_LABEL, + CONSULTATION_LOADING_METHOD_LABEL, +} from "@/lib/consultation-activity-labels"; +import { + BALANCE_CHANGED_EVENT, + BALANCE_SYNC_KEY, + membershipHref, +} from "@/lib/membership"; +import { + CancellationResponseError, + ConsultationResponseError, + ConsultationStatusError, + fetchActiveConsultationStatus, + fetchConsultationStatus, + fetchSessionDetail, + friendlyError, + LoginRedirectError, + mergeHydratedSession, + payloadCode, + payloadMessage, + waitForUndoWindow, +} from "@/lib/home-cloud-sync"; +import { completedOnboardingTranscript, isProfileComplete, selectedBirthPlace } from "@/lib/home-profile"; +import { pendingConsultationStorageKey, timestamp } from "@/lib/home-types"; +import type { + Account, + AccountDialog, + ChatSession, + ConsultationStatus, + Message, + PendingConsultation, + Profile, + ReplyOutcome, + RequestError, + StoredPendingConsultation, + StreamingReply, + Theme, +} from "@/lib/home-types"; +import type { ChatMessageFeedback } from "@/components/chat-message-actions"; +import type { PublicLanguageModelCatalog } from "@/lib/public-models"; + +type ConversationAnchor = { + readonly anchored: boolean; + readonly anchorToLatest: () => void; +}; + +export type ConsultationRunParams = { + account: Account | null; + activeSession: ChatSession | undefined; + activeSessionIdRef: MutableRefObject; + birthTimeConsultationConsent: BirthTimeConsultationConsentState; + cancellationFeedbackRequest: MutableRefObject; + cancellationInFlight: MutableRefObject; + cancellationPending: boolean; + cancellationRequests: MutableRefObject>>; + composerInput: RefObject; + consultationRecoveryCheck: MutableRefObject<() => void>; + consultationRecoveryWakeup: MutableRefObject<() => void>; + consultationReplay: MutableRefObject<() => void>; + consultationReplayStarted: MutableRefObject; + consultationStatusMissingCount: MutableRefObject; + conversationAnchor: ConversationAnchor; + isLoading: boolean; + modelCatalog: PublicLanguageModelCatalog | null; + onboardingJustCompleted: boolean; + pendingConsultation: MutableRefObject; + pendingSessionId: string | null; + profile: Profile; + router: { push: (href: string) => void }; + sessions: ChatSession[]; + setAccount: Dispatch>; + setActiveSessionId: Dispatch>; + setCancellationPending: Dispatch>; + setConsultationPhase: Dispatch>; + setDraft: (value: string) => void; + setDraftEntrypoint: (entrypoint: ConsultationEntrypoint | null) => void; + setDraftTheme: (theme: Theme | null) => void; + setMessageFeedback: Dispatch>>; + setOnboardingJustCompleted: Dispatch>; + setPendingRequestId: Dispatch>; + setPendingSessionId: Dispatch>; + setProfileNotice: Dispatch>; + setReplyOutcome: Dispatch>; + setRequestError: Dispatch>; + setSessionFullPrompt: Dispatch>; + setSessions: Dispatch>; + setStreamingReply: Dispatch>; + startGreeting: string; + stoppedRequestAwaitingSettlement: MutableRefObject; + stoppedSessionPersistence: MutableRefObject>>; + uiPreview: MutableRefObject; + uiPreviewMode: MutableRefObject; + persistSession: (session: ChatSession, mode?: "create" | "update") => Promise; + updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void; + startNewChat: () => Promise; + continueInNewChat: (prompt: { question: string; theme: Theme }) => Promise; + refreshAccount: () => Promise; + openAccountDialog: (dialog: AccountDialog, returnTarget?: HTMLButtonElement | null) => void; + openRectificationFromHomepage: (pendingConsultationQuestion?: string | null) => Promise; +}; + +export function useConsultationRun(params: ConsultationRunParams) { + const { + account, + activeSession, + activeSessionIdRef, + birthTimeConsultationConsent, + cancellationFeedbackRequest, + cancellationInFlight, + cancellationPending, + cancellationRequests, + composerInput, + consultationRecoveryCheck, + consultationRecoveryWakeup, + consultationReplay, + consultationReplayStarted, + consultationStatusMissingCount, + conversationAnchor, + isLoading, + modelCatalog, + onboardingJustCompleted, + pendingConsultation, + pendingSessionId, + profile, + router, + sessions, + setAccount, + setActiveSessionId, + setCancellationPending, + setConsultationPhase, + setDraft, + setDraftEntrypoint, + setDraftTheme, + setMessageFeedback, + setOnboardingJustCompleted, + setPendingRequestId, + setPendingSessionId, + setProfileNotice, + setReplyOutcome, + setRequestError, + setSessionFullPrompt, + setSessions, + setStreamingReply, + startGreeting, + stoppedRequestAwaitingSettlement, + stoppedSessionPersistence, + uiPreview, + uiPreviewMode, + persistSession, + updateSession, + startNewChat, + continueInNewChat, + refreshAccount, + openAccountDialog, + openRectificationFromHomepage, + } = params; + + function restoreConsultationRecovery( + session: ChatSession, + requestId: string, + stored?: StoredPendingConsultation | null, + ) { + if (pendingConsultation.current) return; + const lastMessage = session.messages.at(-1); + const storedQuestion = stored?.question?.trim() ?? ""; + const lastIsQuestion = lastMessage?.role === "user" + && (!storedQuestion || lastMessage.text === storedQuestion); + const question = lastIsQuestion && lastMessage ? lastMessage.text : storedQuestion; + const optimisticSession = lastIsQuestion || !question + ? session + : { + ...session, + title: session.messages.length === 0 && isGenericSessionTitle(session.title) + ? resolveSessionTitle(question, undefined, { + entrypoint: stored?.entrypoint, + theme: stored?.theme ?? session.theme, + existingTitles: sessions.filter((item) => item.id !== session.id).map((item) => item.title), + }) + : session.title, + theme: stored?.theme ?? session.theme, + messages: [...session.messages, { role: "user" as const, text: question }], + updatedAt: timestamp(), + }; + const previousSession = optimisticSession.messages.at(-1)?.role === "user" + ? { ...optimisticSession, messages: optimisticSession.messages.slice(0, -1) } + : optimisticSession; + if (optimisticSession !== session) updateSession(session.id, () => optimisticSession); + pendingConsultation.current = { + requestId, + sessionId: session.id, + question, + entrypoint: stored?.entrypoint ?? null, + theme: stored?.theme ?? session.theme, + previousSession, + optimisticSession, + previousOnboardingState: false, + controller: new AbortController(), + cancelled: false, + phase: "recovering", + partialReply: "", + }; + setPendingSessionId(session.id); + setPendingRequestId(requestId); + setActiveSessionId(session.id); + setConsultationPhase("recovering"); + setStreamingReply({ sessionId: session.id, text: "" }); + setComposerNotice(navigator.onLine + ? "回答仍在后台生成,正在自动恢复。" + : "网络已断开,回答仍在后台生成;联网后会自动恢复。"); + } + + consultationRecoveryCheck.current = () => { + consultationRecoveryWakeup.current(); + if (pendingConsultation.current || uiPreview.current) return; + void fetchActiveConsultationStatus() + .then((status) => { + if (status?.status !== "reserved") return; + const session = sessions.find((item) => item.id === status.sessionId); + if (session) restoreConsultationRecovery(session, status.requestId); + }) + .catch(() => undefined); + }; + async function requestCancellation(requestId: string) { + const existing = cancellationRequests.current.get(requestId); + if (existing) return existing; + + const cancellation = (async () => { + const response = await fetch("/api/consult/cancel", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestId }), + keepalive: true, + }); + const payload: unknown = await response.json().catch(() => null); + if (!response.ok) { + throw new CancellationResponseError( + response.status, + payloadMessage(payload, "暂时无法确认点数已退回"), + ); + } + if (!payload || typeof payload !== "object") return; + const credits = "credits" in payload ? payload.credits : null; + if (typeof credits === "number") { + setAccount((current) => current ? { ...current, credits } : current); + } + })(); + cancellationRequests.current.set(requestId, cancellation); + return cancellation; + } + + async function confirmCancellation(requestId: string, sessionId: string, confirmedNotice: string) { + try { + await requestCancellation(requestId); + if (cancellationFeedbackRequest.current === requestId && activeSessionIdRef.current === sessionId) { + setComposerNotice(confirmedNotice); + } + } catch (error) { + if (cancellationFeedbackRequest.current === requestId && activeSessionIdRef.current === sessionId) { + setComposerNotice(error instanceof CancellationResponseError && error.status === 409 + ? "回答已完成结算,本次已计费;问题仍保留在输入框。" + : "问题已放回输入框;暂时无法确认点数状态,请稍后在账户中核对。"); + setRequestError((current) => current?.sessionId === sessionId ? current : { + sessionId, + message: error instanceof Error ? error.message : "暂时无法确认点数状态。", + }); + } + void refreshAccount(); + } + } + + async function stopResponse() { + const pending = pendingConsultation.current; + if (!pending || pending.cancelled) return; + + setReplyOutcome({ sessionId: pending.sessionId, phase: "stopped", replyOrdinal: 0 }); + const isPreview = process.env.NODE_ENV === "development" && uiPreview.current; + if (pending.phase !== "undo" && !isPreview) { + stoppedRequestAwaitingSettlement.current = pending.requestId; + cancellationInFlight.current = true; + setCancellationPending(true); + } + pendingConsultation.current = { ...pending, cancelled: true }; + pending.controller.abort(); + + if (pending.partialReply) { + const stoppedSession: ChatSession = { + ...pending.optimisticSession, + messages: [...pending.optimisticSession.messages, { role: "assistant", text: pending.partialReply }], + updatedAt: timestamp(), + }; + if (isPreview) { + updateSession(pending.sessionId, () => stoppedSession); + setStreamingReply(null); + setPendingSessionId(null); + setConsultationPhase(null); + setRequestError(null); + setComposerNotice("已停止回答,现有内容已保留。"); + if (pendingConsultation.current?.requestId === pending.requestId) { + pendingConsultation.current = null; + } + return; + } + + setComposerNotice("正在停止回答并申请退回本次点数…"); + try { + await requestCancellation(pending.requestId); + } catch (error) { + cancellationRequests.current.delete(pending.requestId); + stoppedRequestAwaitingSettlement.current = null; + cancellationInFlight.current = false; + setCancellationPending(false); + pendingConsultation.current = { + ...pending, + controller: new AbortController(), + cancelled: false, + phase: "recovering", + }; + setPendingSessionId(pending.sessionId); + setConsultationPhase("recovering"); + setStreamingReply({ sessionId: pending.sessionId, text: pending.partialReply }); + setRequestError(null); + + if (error instanceof CancellationResponseError && error.status === 409) { + setComposerNotice("回答已完成,正在恢复服务端完整内容。"); + try { + const status = await fetchConsultationStatus(pending.sessionId, pending.requestId); + if (status.status === "completed") { + const detailed = await fetchSessionDetail(pending.sessionId, modelCatalog); + if (detailed) { + setSessions((current) => mergeHydratedSession(current, detailed)); + setActiveSessionId((current) => current || detailed.id); + } + pendingConsultation.current = null; + setPendingSessionId(null); + setConsultationPhase(null); + setStreamingReply(null); + setComposerNotice("回答已恢复,已显示在对话区末尾。"); + void refreshAccount(); + return; + } + } catch { + // The recovery poll retries status and session reload. + } + } else { + setComposerNotice("停止请求尚未确认,正在自动恢复后台回答。"); + } + window.setTimeout(() => consultationRecoveryWakeup.current(), 0); + return; + } + + updateSession(pending.sessionId, () => stoppedSession); + setStreamingReply(null); + setPendingSessionId(null); + setConsultationPhase(null); + setRequestError(null); + setComposerNotice("已停止回答,现有内容已保留,本次点数已退回。"); + cancellationRequests.current.delete(pending.requestId); + stoppedRequestAwaitingSettlement.current = null; + cancellationInFlight.current = false; + setCancellationPending(false); + if (pendingConsultation.current?.requestId === pending.requestId) { + pendingConsultation.current = null; + } + void refreshAccount(); + return; + } + + updateSession(pending.sessionId, () => pending.previousSession); + setOnboardingJustCompleted(pending.previousOnboardingState); + setDraft(pending.question); + setDraftTheme(pending.theme); + setDraftEntrypoint(pending.entrypoint); + setStreamingReply(null); + setPendingSessionId(null); + setConsultationPhase(null); + setRequestError(null); + cancellationFeedbackRequest.current = pending.requestId; + setComposerNotice("已停止,问题已放回输入框,正在确认点数…"); + window.requestAnimationFrame(() => composerInput.current?.focus()); + + if (pending.phase === "undo" || isPreview) { + if (pendingConsultation.current?.requestId === pending.requestId) { + pendingConsultation.current = null; + } + setComposerNotice("已停止,问题已放回输入框,本次未扣点。"); + return; + } + + await confirmCancellation( + pending.requestId, + pending.sessionId, + "已停止,问题已放回输入框,本次未扣点。", + ); + cancellationRequests.current.delete(pending.requestId); + stoppedRequestAwaitingSettlement.current = null; + cancellationInFlight.current = false; + setCancellationPending(false); + if (pendingConsultation.current?.requestId === pending.requestId) pendingConsultation.current = null; + } + + function completeConsultationInterface(requestId: string) { + if (pendingConsultation.current?.requestId !== requestId) return; + pendingConsultation.current = null; + if (consultationReplayStarted.current === requestId) consultationReplayStarted.current = null; + setStreamingReply(null); + setPendingSessionId(null); + setPendingRequestId(null); + setConsultationPhase(null); + } + + async function send( + text: string, + requestedTheme?: Theme, + entrypoint: ConsultationEntrypoint | null = null, + consentGrantedForRequest: ConsultationBirthTimeMode | null = null, + targetSessionId: string | null = null, + options: { + resumeRequestId?: string; + sessionOverride?: ChatSession; + restoreOnFailure?: ChatSession; + } = {}, + ): Promise { + const originalQuestion = text; + const question = text.trim(); + const consultEntrypoint = entrypoint + ?? (isRectificationHandoffQuestion(question) + ? "birth_time_rectification" as const + : isGeneralDailyFortuneQuestion(question) + ? "daily_starlanguage" as const + : null); + const resumeRequestId = options.resumeRequestId; + const resuming = Boolean(resumeRequestId); + const liveSession = targetSessionId + ? sessions.find((session) => session.id === targetSessionId) + : activeSession; + const currentSession = options.sessionOverride ?? liveSession; + if (!question || !currentSession || !modelCatalog || !account) return false; + const rollbackSession = options.restoreOnFailure ?? liveSession ?? currentSession; + if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false; + if (resuming) { + const pending = pendingConsultation.current; + if (!pending + || pending.requestId !== resumeRequestId + || pending.sessionId !== currentSession.id + || pending.cancelled) return false; + } + + if (!isProfileComplete(profile)) { + openAccountDialog("profile"); + setProfileNotice("请先补充出生资料,才能进行星盘计算。"); + return false; + } + + if (consultEntrypoint === "birth_time_rectification") { + const previousUserQuestion = [...currentSession.messages] + .reverse() + .find((message) => message.role === "user") + ?.text + .trim() ?? null; + const pendingQuestion = previousUserQuestion && !isRectificationHandoffQuestion(previousUserQuestion) + ? previousUserQuestion + : null; + await openRectificationFromHomepage(pendingQuestion); + setDraft(""); + setDraftTheme(null); + setDraftEntrypoint(null); + return false; + } + + const birthPlace = selectedBirthPlace(profile); + if (!birthPlace) return false; + + const theme = requestedTheme ?? currentSession.theme; + const sessionId = currentSession.id; + const consentForDecision = consentGrantedForRequest === "unverified_birth_time" + ? grantBirthTimeConsultationConsent( + birthTimeConsultationConsent, + sessionId, + "unverified_birth_time", + ) + : birthTimeConsultationConsent; + const initialConsultationRoute = resolveBirthTimeConsultationRoute( + profile, + consentForDecision, + sessionId, + ); + const consultationRoute = initialConsultationRoute.kind === "choice" + ? { kind: "consult" as const, mode: "general_no_birth_time" as const, time: null } + : initialConsultationRoute; + + if (account.credits <= 0 && !account.activeSubscription) { + router.push(membershipHref("insufficient-credits")); + return false; + } + + const [year, month, day] = profile.date.split("-").map(Number); + const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? []; + + const lastMessage = currentSession.messages.at(-1); + const questionAlreadyPresent = lastMessage?.role === "user" && lastMessage.text === question; + const preservedMessages = questionAlreadyPresent + ? currentSession.messages + : (onboardingJustCompleted && currentSession.messages.length === 0 + ? completedOnboardingTranscript(profile, startGreeting) + : currentSession.messages); + const userSession: ChatSession = { + ...currentSession, + title: currentSession.messages.length === 0 && isGenericSessionTitle(currentSession.title) + ? resolveSessionTitle(question, undefined, { + entrypoint: consultEntrypoint, + theme, + existingTitles: sessions.filter((item) => item.id !== currentSession.id).map((item) => item.title), + }) + : currentSession.title, + theme, + messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }], + updatedAt: questionAlreadyPresent ? currentSession.updatedAt : timestamp(), + messagesHydrated: true, + }; + const requestId = resumeRequestId ?? globalThis.crypto.randomUUID(); + const controller = resuming && pendingConsultation.current + ? pendingConsultation.current.controller + : new AbortController(); + const previousOnboardingState = onboardingJustCompleted; + cancellationFeedbackRequest.current = null; + setRequestError(null); + setReplyOutcome(null); + if (!resuming) { + setComposerNotice(""); + consultationStatusMissingCount.current = 0; + consultationReplayStarted.current = null; + setPendingSessionId(sessionId); + setPendingRequestId(requestId); + setConsultationPhase("undo"); + pendingConsultation.current = { + requestId, + sessionId, + question: originalQuestion, + entrypoint: consultEntrypoint, + theme, + previousSession: rollbackSession, + optimisticSession: userSession, + previousOnboardingState, + controller, + cancelled: false, + phase: "undo", + partialReply: "", + }; + try { + sessionStorage.setItem(pendingConsultationStorageKey, JSON.stringify({ + sessionId, + requestId, + question: originalQuestion, + theme, + entrypoint: consultEntrypoint, + })); + } catch { + // Private-mode storage must not block send. + } + setOnboardingJustCompleted(false); + updateSession(sessionId, () => userSession); + conversationAnchor.anchorToLatest(); + setDraft(""); + setDraftTheme(null); + setDraftEntrypoint(null); + } + + if (!resuming && process.env.NODE_ENV === "development" && uiPreview.current) { + setStreamingReply({ sessionId, text: "" }); + if (uiPreviewMode.current === "partial") { + const partialReply = "已开始查看事业方向与关键时间,先给你一个阶段性的判断。"; + if (pendingConsultation.current?.requestId === requestId) { + pendingConsultation.current = { + ...pendingConsultation.current, + phase: "streaming", + partialReply, + }; + } + setConsultationPhase("streaming"); + setStreamingReply({ sessionId, text: partialReply }); + } + await new Promise((resolve) => window.setTimeout(resolve, uiPreviewMode.current === "streaming" || uiPreviewMode.current === "partial" ? 15_000 : 800)); + if (controller.signal.aborted) { + if (pendingConsultation.current?.requestId === requestId) pendingConsultation.current = null; + return false; + } + const previewReply = parseAgentReply([ + "这是本地交互预览。正式对话会结合你的星盘证据继续分析。", + "", + ].join("\n")); + const previewSession: ChatSession = { + ...userSession, + title: userSession.title, + messages: [...userSession.messages, { + role: "assistant", + text: previewReply.text, + }], + updatedAt: timestamp(), + }; + updateSession(sessionId, () => previewSession); + completeConsultationInterface(requestId); + return true; + } + + if (!resuming) { + await waitForUndoWindow(controller.signal); + if (controller.signal.aborted) return false; + } + if (!resuming || !questionAlreadyPresent) { + if (resuming && !questionAlreadyPresent) updateSession(sessionId, () => userSession); + } + if (pendingConsultation.current?.requestId === requestId) { + pendingConsultation.current = { + ...pendingConsultation.current, + question: originalQuestion, + entrypoint: consultEntrypoint, + theme, + optimisticSession: userSession, + phase: "streaming", + }; + setConsultationPhase("streaming"); + } + setStreamingReply({ sessionId, text: "", timeline: [] }); + let latestPartialReply = ""; + let thinkingSections: PublicThinkingSection[] = []; + let streamedThinking = ""; + let timelineState = emptyConsultationTimeline(); + try { + const response = await fetch("/api/consult", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + requestId, + sessionId: currentSession.id, + modelId: currentSession.modelId, + name: profile.name, + consultationMode: consultationRoute.mode, + entrypoint: consultEntrypoint ?? undefined, + ...(consultationRoute.mode === "general_no_birth_time" || consultationRoute.mode === "declared_birth_window" ? {} : { + year, + month, + day, + hour, + minute, + city: birthPlace.label, + lat: birthPlace.lat, + lon: birthPlace.lon, + tz: birthPlace.tz, + entryMode: "direct_chart" as const, + }), + theme, + question, + history: currentSession.messages.slice(-12).map((message) => ({ + role: message.role, + text: message.text.slice(0, 4000), + })), + }), + signal: controller.signal, + }); + if (!response.ok) { + const contentType = response.headers.get("content-type") ?? ""; + const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() }; + if (response.status === 401) window.location.assign("/login"); + if (response.status === 402) router.push(membershipHref("insufficient-credits")); + throw new ConsultationResponseError( + response.status, + payloadMessage(errorPayload, "服务暂时不可用"), + payloadCode(errorPayload), + ); + } + if (!response.body) { + throw new ConsultationResponseError(502, "浏览器未收到可读取的回答流"); + } + let techniqueTruth = response.headers.get("x-jyotish-technique-truth") ?? "unknown"; + let workflowReceipt: AgentExecutionReceipt["workflow"] = { + route: response.headers.get("x-jyotish-workflow-route") ?? "unknown", + status: response.headers.get("x-jyotish-workflow-status") ?? "unknown", + preciseTiming: response.headers.get("x-jyotish-precise-timing") ?? "unknown", + missingLayers: (response.headers.get("x-jyotish-missing-layers") ?? "none") + .split(",") + .map((item) => item.trim()) + .filter((item) => item && item !== "none"), + }; + let agentExecutionReceipt: AgentExecutionReceipt | undefined; + let runCompleted = false; + let truncatedFailure: Extract | undefined; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let answer = ""; + const updateStreamingAnswer = (activity?: AgentActivityView) => { + const partialReply = parseAgentReply(answer).text; + latestPartialReply = partialReply; + thinkingSections = applyThinkingSectionProgress(thinkingSections, partialReply); + setStreamingReply((current) => ({ + sessionId, + text: partialReply, + thinkingText: streamedThinking.trim() || undefined, + thinkingSections: thinkingSections.length ? thinkingSections : undefined, + timeline: timelineState.rows, + activity: activity + ? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity) + : current?.sessionId === sessionId ? current.activity : undefined, + })); + if (partialReply && pendingConsultation.current?.requestId === requestId) { + pendingConsultation.current = { ...pendingConsultation.current, partialReply }; + } + }; + const updateActivity = (event: ConsultationAgentPublicEvent) => { + let activity: AgentActivityView | undefined; + if (event.type === "skill.started") { + activity = { phase: "loading-method", label: CONSULTATION_LOADING_METHOD_LABEL }; + } else if (event.type === "skill.completed" || event.type === "tool.started") { + activity = { + phase: "chart-calculation", + label: CONSULTATION_CHART_CALCULATION_LABEL, + completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]), + }; + } else if (event.type === "activity") { + activity = { + phase: event.phase, + label: event.label, + completedTrail: event.phase === "evidence-validation" + ? activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL]) + : activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]), + }; + } else if (event.type === "tool.completed") { + activity = { + phase: "evidence-validation", + label: CONSULTATION_EVIDENCE_VALIDATION_LABEL, + completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL]), + }; + } else if (event.type === "answer.delta") { + activity = { phase: "answer-composition", label: CONSULTATION_COMPOSING_LABEL }; + } + if (activity) updateStreamingAnswer(activity); + }; + + if ((response.headers.get("content-type") ?? "").includes("application/x-ndjson")) { + const parser = createNdjsonParser((event) => { + timelineState = reduceConsultationTimeline(timelineState, event); + if (event.type === "answer.delta") answer += event.text; + if (event.type === "thinking.delta" && typeof event.text === "string") { + streamedThinking += event.text; + setStreamingReply((current) => ({ + sessionId, + text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text, + thinkingText: streamedThinking.trim() || undefined, + thinkingSections: current?.sessionId === sessionId ? current.thinkingSections : thinkingSections, + timeline: timelineState.rows, + activity: current?.sessionId === sessionId ? current.activity : undefined, + })); + } + if (event.type === "thinking.section") { + thinkingSections = applyThinkingSectionProgress( + upsertThinkingSection(thinkingSections, { + id: event.id, + title: event.title, + heading: event.heading, + steps: event.steps, + }), + parseAgentReply(answer).text, + ); + setStreamingReply((current) => ({ + sessionId, + text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text, + thinkingText: streamedThinking.trim() || undefined, + thinkingSections, + timeline: timelineState.rows, + activity: current?.sessionId === sessionId ? current.activity : undefined, + })); + } + if (event.type === "run.completed") { + runCompleted = true; + agentExecutionReceipt = event.receipt; + workflowReceipt = event.receipt.workflow; + techniqueTruth = event.receipt.techniqueTruth ?? "unknown"; + } + if (event.type === "run.failed") { + if (event.code === "answer_truncated") { + truncatedFailure = event; + if (event.receipt) { + agentExecutionReceipt = event.receipt; + workflowReceipt = event.receipt.workflow; + techniqueTruth = event.receipt.techniqueTruth ?? techniqueTruth; + } + return; + } + throw new ConsultationResponseError(502, event.message); + } + updateActivity(event); + }); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + parser.push(decoder.decode(value, { stream: true })); + } + parser.finish(decoder.decode()); + if (truncatedFailure) { + const reply = parseAgentReply(answer); + if (!reply.text) throw new ConsultationResponseError(502, truncatedFailure.message); + const truncatedSession: ChatSession = { + ...userSession, + title: userSession.title, + messages: [...userSession.messages, { + role: "assistant", + text: reply.text, + ...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}), + ...(thinkingSections.length ? { thinkingSections } : {}), + techniqueTruth, + workflowReceipt, + agentExecutionReceipt, + }], + updatedAt: timestamp(), + }; + updateSession(sessionId, () => truncatedSession); + setStreamingReply(null); + setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 }); + setComposerNotice(truncatedFailure.message); + completeConsultationInterface(requestId); + void refreshAccount(); + return true; + } + if (!runCompleted && !truncatedFailure) { + throw new ConsultationResponseError( + 502, + thinkingSections.length + ? "这次还没有生成可显示的回答。思考步骤已保留,可以直接继续问。" + : "Agent 回答未完成,本次不会保存为成功咨询。", + ); + } + } else { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + answer += decoder.decode(value, { stream: true }); + updateStreamingAnswer(); + } + answer += decoder.decode(); + } + if (controller.signal.aborted) return Boolean(latestPartialReply); + const reply = parseAgentReply(answer); + if (!reply.text) { + throw thinkingSections.length + ? new ConsultationResponseError(502, "这次还没有生成可显示的回答。思考步骤已保留,可以直接继续问。") + : new Error("Agent 没有返回可显示的回答,请重试。"); + } + + const completedTitle = reply.title && !isGenericSessionTitle(reply.title) + ? resolveSessionTitle(question, reply.title, { + entrypoint: consultEntrypoint, + theme, + existingTitles: sessions.filter((item) => item.id !== sessionId).map((item) => item.title), + }) + : userSession.title; + const completedSession: ChatSession = { + ...userSession, + title: completedTitle, + messages: [...userSession.messages, { + role: "assistant", + text: reply.text, + ...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}), + ...(thinkingSections.length ? { thinkingSections } : {}), + techniqueTruth, + workflowReceipt, + agentExecutionReceipt, + }], + updatedAt: timestamp(), + }; + updateSession(sessionId, () => completedSession); + try { + await persistSession(completedSession); + } catch (error) { + setComposerNotice(error instanceof Error ? error.message : "回答已生成,但云端同步暂时失败。"); + } + setReplyOutcome({ + sessionId, + phase: "completed", + replyOrdinal: completedSession.messages.filter((message) => message.role === "assistant").length, + }); + completeConsultationInterface(requestId); + void refreshAccount(); + return true; + } catch (caught) { + const cancelled = controller.signal.aborted; + const ownsInterface = pendingConsultation.current?.requestId === requestId; + const partialReply = latestPartialReply; + if (!cancelled && ownsInterface && pendingConsultation.current && caught instanceof ConsultationResponseError) { + if (caught.code === "session_full") { + updateSession(sessionId, () => rollbackSession); + setOnboardingJustCompleted(previousOnboardingState); + setSessionFullPrompt({ question: originalQuestion, theme }); + setComposerNotice("这段对话已写满,开个新对话继续吧", { + label: "开新对话", + onClick: () => { + void continueInNewChat({ question: originalQuestion, theme }); + }, + }); + completeConsultationInterface(requestId); + return false; + } + if (caught.message === "request_conflict") { + pendingConsultation.current = { + ...pendingConsultation.current, + phase: "recovering", + partialReply, + }; + setConsultationPhase("recovering"); + setRequestError(null); + setComposerNotice("回答仍在后台生成,正在自动恢复。"); + return Boolean(partialReply); + } + const reserveDidNotCommit = caught.status === 400 + || caught.status === 401 + || caught.status === 402 + || caught.status === 409; + if (reserveDidNotCommit) { + updateSession(sessionId, () => rollbackSession); + setOnboardingJustCompleted(previousOnboardingState); + if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) { + setDraft(originalQuestion); + setDraftTheme(theme); + setDraftEntrypoint(consultEntrypoint); + } + } + setRequestError({ sessionId, message: caught.message }); + setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 }); + setComposerNotice(caught.message); + const restore = options.restoreOnFailure; + if (restore) { + updateSession(sessionId, () => restore); + } else if (!reserveDidNotCommit && (thinkingSections.length || latestPartialReply || streamedThinking.trim())) { + const failedSession: ChatSession = { + ...userSession, + messages: [...userSession.messages, { + role: "assistant", + text: latestPartialReply, + ...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}), + ...(thinkingSections.length ? { thinkingSections } : {}), + }], + updatedAt: timestamp(), + }; + updateSession(sessionId, () => failedSession); + } + completeConsultationInterface(requestId); + return false; + } + if (!cancelled && ownsInterface && pendingConsultation.current) { + pendingConsultation.current = { + ...pendingConsultation.current, + phase: "recovering", + partialReply, + }; + setConsultationPhase("recovering"); + setRequestError(null); + setComposerNotice(navigator.onLine + ? "连接中断,回答仍在后台生成,正在自动恢复。" + : "网络已断开,回答仍在后台生成;联网后会自动恢复。"); + } + return Boolean(partialReply); + } finally { + cancellationRequests.current.delete(requestId); + const pending = pendingConsultation.current; + if (pending?.requestId !== requestId || pending.phase !== "recovering") { + completeConsultationInterface(requestId); + } + if (stoppedRequestAwaitingSettlement.current === requestId) { + const persistence = stoppedSessionPersistence.current.get(requestId); + if (persistence) { + await persistence; + stoppedSessionPersistence.current.delete(requestId); + } + stoppedRequestAwaitingSettlement.current = null; + cancellationInFlight.current = false; + setCancellationPending(false); + } + } + } + + function regenerateLatestAnswer(renderKey: string) { + const session = activeSession; + if (!session || isLoading || cancellationPending || pendingConsultation.current) return; + const last = session.messages.at(-1); + if (last?.role !== "assistant" || last.text.trim() === "") return; + const previous = session.messages.at(-2); + if (previous?.role !== "user" || previous.text.trim() === "") return; + if (`message-${session.messages.length - 1}` !== renderKey) return; + const sessionOverride: ChatSession = { + ...session, + messages: session.messages.slice(0, -1), + updatedAt: timestamp(), + }; + setMessageFeedback((current) => { + const next = { ...current }; + delete next[`${session.id}:${renderKey}`]; + return next; + }); + void send(previous.text, session.theme, null, null, session.id, { + sessionOverride, + restoreOnFailure: session, + }); + } + + consultationReplay.current = () => { + const pending = pendingConsultation.current; + if (!pending || pending.cancelled || pending.phase !== "recovering" || !pending.question.trim()) return; + if (consultationReplayStarted.current === pending.requestId) return; + consultationReplayStarted.current = pending.requestId; + setComposerNotice("后台尚未开始本次咨询,正在重新发起…"); + void send( + pending.question, + pending.theme, + pending.entrypoint, + null, + pending.sessionId, + { resumeRequestId: pending.requestId }, + ).then((started) => { + if (started || pendingConsultation.current?.requestId !== pending.requestId) return; + pendingConsultation.current = null; + setPendingSessionId(null); + setPendingRequestId(null); + setConsultationPhase(null); + setStreamingReply(null); + setRequestError({ + sessionId: pending.sessionId, + message: "后台未找到本次咨询请求,请重新发送。", + }); + setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。"); + }); + }; + return { + restoreConsultationRecovery, + requestCancellation, + confirmCancellation, + stopResponse, + completeConsultationInterface, + send, + regenerateLatestAnswer, + }; +} diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts new file mode 100644 index 00000000..e2a368df --- /dev/null +++ b/frontend/src/hooks/use-session-management.ts @@ -0,0 +1,443 @@ +"use client"; + +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; + +import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; +import { writeChatSession } from "@/lib/chat-session-write-contract"; +import { + SESSION_MISSING_NOTICE, + parseSessionUrlQuery, + writeSessionUrl, +} from "@/lib/chat-session-url"; +import { consultationReportMarkdown } from "@/lib/consultation-report-export"; +import { + clearBirthTimeConsultationConsent, + type BirthTimeConsultationConsentState, +} from "@/lib/birth-time-consultation-consent"; +import { + activeChartStorageKey, + createSession, + fetchSessionDetail, + LoginRedirectError, + mergeHydratedSession, + patchSessionModel, +} from "@/lib/home-cloud-sync"; +import { chartSnapshotForSession } from "@/lib/home-profile"; +import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; +import { + persistSessionModelSelection, + type SessionModelPersistenceQueue, +} from "@/lib/session-model-persistence"; +import type { PublicLanguageModelCatalog } from "@/lib/public-models"; +import { + timestamp, + type Account, + type ChartLibraryRecord, + type ChatSession, + type Profile, + type RequestError, + type Theme, +} from "@/lib/home-types"; + +export type SessionManagementParams = { + account: Account | null; + accountId: string | undefined; + activeChartId: string; + activeSession: ChatSession | undefined; + activeSessionId: string; + activeSessionIdRef: MutableRefObject; + applySessionPopStateRef: MutableRefObject<(search: string) => void>; + cancellationPending: boolean; + chartLibrary: ChartLibraryRecord[]; + creatingSession: boolean; + modelCatalog: PublicLanguageModelCatalog | null; + modelPersistence: MutableRefObject; + modelSelectionVersions: MutableRefObject>; + modelSyncFailures: MutableRefObject>; + pendingSessionId: string | null; + profile: Profile; + rectificationSessionId: string | null; + sessionDetailInFlight: MutableRefObject>; + sessionSelectionSource: MutableRefObject<"user" | "history">; + sessions: ChatSession[]; + sessionsRef: MutableRefObject; + setActiveChartId: Dispatch>; + setActiveSessionId: Dispatch>; + setBirthTimeConsultationConsent: Dispatch>; + setCreatingSession: Dispatch>; + setDraft: (value: string) => void; + setDraftEntrypoint: (entrypoint: ConsultationEntrypoint | null) => void; + setDraftTheme: (theme: Theme | null) => void; + setRectificationError: Dispatch>; + setRequestError: Dispatch>; + setSessionDetailLoadingId: Dispatch>; + setSessionFullPrompt: Dispatch>; + setSessions: Dispatch>; + uiPreview: MutableRefObject; + visibleSessions: ChatSession[]; + openRectificationSession: (exactSessionId: string) => Promise | void; +}; + +export function useSessionManagement(params: SessionManagementParams) { + const { + account, + accountId, + activeChartId, + activeSession, + activeSessionId, + activeSessionIdRef, + applySessionPopStateRef, + cancellationPending, + chartLibrary, + creatingSession, + modelCatalog, + modelPersistence, + modelSelectionVersions, + modelSyncFailures, + pendingSessionId, + profile, + rectificationSessionId, + sessionDetailInFlight, + sessionSelectionSource, + sessions, + sessionsRef, + setActiveChartId, + setActiveSessionId, + setBirthTimeConsultationConsent, + setCreatingSession, + setDraft, + setDraftEntrypoint, + setDraftTheme, + setRectificationError, + setRequestError, + setSessionDetailLoadingId, + setSessionFullPrompt, + setSessions, + uiPreview, + visibleSessions, + openRectificationSession, + } = params; + + function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) { + setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); + } + + async function persistSession(session: ChatSession, mode: "create" | "update" = "update") { + if (!account) throw new Error("账户尚未加载完成"); + if (process.env.NODE_ENV === "development" && uiPreview.current) return; + const values = mode === "create" + ? { + title: session.title, + theme: session.theme, + model_id: session.modelId, + messages: [] as const, + session_type: session.sessionType, + rectification_case_id: session.rectificationCaseId, + chart_profile_id: session.chartProfileId, + chart_profile_name: session.chartProfileName, + chart_profile_role: session.chartProfileRole, + } + : { + title: session.title, + theme: session.theme, + model_id: session.modelId, + chart_profile_id: session.chartProfileId, + chart_profile_name: session.chartProfileName, + chart_profile_role: session.chartProfileRole, + }; + await writeChatSession(session.id, values, mode); + } + + async function ensureSessionMessages(sessionId: string) { + if (!sessionId || uiPreview.current) return; + if (pendingSessionId === sessionId) return; + if (sessionDetailInFlight.current.has(sessionId)) return; + const known = sessionsRef.current.find((session) => session.id === sessionId); + if (known?.messagesHydrated || known?.sessionType === "birth_time_rectification") return; + sessionDetailInFlight.current.add(sessionId); + setSessionDetailLoadingId(sessionId); + try { + const detailed = await fetchSessionDetail(sessionId, modelCatalog); + if (!detailed) return; + setSessions((existing) => { + const live = existing.find((session) => session.id === sessionId); + if (live?.messagesHydrated) return existing; + return mergeHydratedSession(existing, detailed); + }); + } catch (caught) { + if (caught instanceof LoginRedirectError) return; + setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录"); + } finally { + sessionDetailInFlight.current.delete(sessionId); + setSessionDetailLoadingId((currentId) => currentId === sessionId ? null : currentId); + } + } + + async function continueInNewChat(prompt: { question: string; theme: Theme }) { + setSessionFullPrompt(null); + const created = await startNewChat(); + if (!created) return; + setDraft(prompt.question); + setDraftTheme(prompt.theme); + } + + async function renameSession(session: ChatSession) { + const title = window.prompt("重命名聊天记录", session.title)?.trim(); + if (!title || title === session.title) return; + const nextSession = { ...session, title, updatedAt: timestamp() }; + updateSession(session.id, () => nextSession); + try { + await persistSession(nextSession); + } catch (caught) { + setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败"); + } + } + + async function deleteSession(session: ChatSession) { + if (!account) return; + const previousSessions = sessions; + const nextSessions = sessions.filter((item) => item.id !== session.id); + 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"); + } + try { + const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); + const payload = await response.json().catch(() => null) as { error?: string } | null; + if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败"); + } catch (caught) { + setSessions(previousSessions); + setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败"); + } + } + + function togglePinnedSession(sessionId: string) { + const session = sessions.find((item) => item.id === sessionId); + if (!session) return; + const nextPinned = !session.pinned; + updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned })); + void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => { + updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned })); + setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败"); + }); + } + + function toggleArchivedSession(sessionId: string) { + const session = sessions.find((item) => item.id === sessionId); + if (!session) return; + const restoring = Boolean(session.archivedAt); + const previousActiveId = activeSessionId; + 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"); + } + setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。"); + void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => { + updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt })); + if (!restoring && previousActiveId === sessionId) { + setActiveSessionId(previousActiveId); + if (!uiPreview.current) writeSessionUrl(previousActiveId || null, "replace"); + } + setComposerNotice(caught instanceof Error ? caught.message : "归档同步失败"); + }); + } + + async function shareSession(session: ChatSession) { + const sharePayload = { + share_payload_version: 1, + exported_at: new Date().toISOString(), + title: session.title, + theme: session.theme, + message_count: session.messages.length, + messages: session.messages.map((message) => ({ role: message.role, text: message.text })), + }; + const reportMarkdown = consultationReportMarkdown({ title: session.title, messages: session.messages }); + const transcript = [ + `Jyotisha 对话:${session.title}`, + "", + ...session.messages.map((message) => `${message.role === "user" ? "我" : "Jyotisha"}:${message.text}`), + "", + "---- Markdown 报告 ----", + reportMarkdown, + "", + "---- JSON 分享包 ----", + JSON.stringify(sharePayload, null, 2), + ].join("\n"); + try { + await navigator.clipboard.writeText(transcript); + setComposerNotice("已复制当前聊天,可粘贴转发。"); + } catch { + setComposerNotice("无法访问剪贴板,请手动复制聊天内容。"); + } + } + + async function startNewChat(): Promise { + if (!account || !modelCatalog || creatingSession) return null; + const nextSession = { + ...createSession(modelCatalog.defaultModelId), + ...chartSnapshotForSession(activeChartId, chartLibrary, profile), + }; + const previousSessionId = activeSession?.id ?? ""; + const previousHref = `${window.location.pathname}${window.location.search}`; + setCreatingSession(true); + setSessions((current) => [nextSession, ...current]); + setActiveSessionId(nextSession.id); + if (!uiPreview.current) writeSessionUrl(nextSession.id, "push"); + setDraft(""); + setDraftTheme(null); + setDraftEntrypoint(null); + setComposerNotice(""); + setRequestError(null); + try { + await persistSession(nextSession, "create"); + return nextSession; + } catch (caught) { + setSessions((current) => current.filter((session) => session.id !== nextSession.id)); + setActiveSessionId(previousSessionId); + if (!uiPreview.current) window.history.replaceState(null, "", previousHref); + setRequestError({ + sessionId: previousSessionId, + message: caught instanceof Error ? caught.message : "新对话未能保存到云端。", + }); + return null; + } finally { + setCreatingSession(false); + } + } + + async function startConsultationAfterRectification() { + await startNewChat(); + setDraft("请用刚才采用的代表性出生时间看盘。"); + setComposerNotice("已用刚才采用的时间作为当前排盘。这还不是唯一分钟确认。"); + } + + function selectSession(sessionId: string) { + const nextSession = sessions.find((session) => session.id === sessionId); + setActiveSessionId(sessionId); + setDraft(""); + setDraftEntrypoint(null); + setComposerNotice(""); + if (nextSession?.chartProfileId) { + const boundChart = chartLibrary.find((record) => record.id === nextSession.chartProfileId); + if (boundChart && boundChart.id !== activeChartId && accountId) { + setActiveChartId(boundChart.id); + localStorage.setItem(activeChartStorageKey(accountId), boundChart.id); + } + } + if (nextSession?.sessionType === "birth_time_rectification") { + setRectificationError(""); + if (nextSession.id !== rectificationSessionId) { + // 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); + } + } else { + void ensureSessionMessages(sessionId); + } + if (!uiPreview.current && sessionSelectionSource.current === "user") { + writeSessionUrl(sessionId, "push"); + } + sessionSelectionSource.current = "user"; + } + + applySessionPopStateRef.current = (search: string) => { + if (uiPreview.current) return; + const listed = sessionsRef.current; + const query = parseSessionUrlQuery(search); + const fallbackId = listed[0]?.id ?? ""; + const requestedId = query.sessionId; + if (query.present && (!requestedId || !listed.some((session) => session.id === requestedId))) { + writeSessionUrl(null, "replace"); + sessionSelectionSource.current = "history"; + if (fallbackId) selectSession(fallbackId); + else setActiveSessionId(""); + setComposerNotice(SESSION_MISSING_NOTICE); + return; + } + if (!requestedId) { + if (fallbackId) { + sessionSelectionSource.current = "history"; + selectSession(fallbackId); + } else { + setActiveSessionId(""); + } + return; + } + sessionSelectionSource.current = "history"; + selectSession(requestedId); + }; + + async function selectSessionModel(modelId: string) { + const userId = account?.user.id; + if (!activeSession || !modelCatalog || !userId || pendingSessionId || cancellationPending || creatingSession) return; + const selectedModel = modelCatalog.models.find((model) => model.id === modelId); + const retryingFailedSync = activeSession.modelId === modelId && modelSyncFailures.current.has(activeSession.id); + if (!selectedModel || (activeSession.modelId === modelId && !retryingFailedSync)) return; + + const nextSession: ChatSession = retryingFailedSync + ? activeSession + : { ...activeSession, modelId, updatedAt: timestamp() }; + const selectionVersion = (modelSelectionVersions.current.get(nextSession.id) ?? 0) + 1; + modelSelectionVersions.current.set(nextSession.id, selectionVersion); + if (!retryingFailedSync) updateSession(activeSession.id, () => nextSession); + setRequestError(null); + setComposerNotice(""); + + try { + await modelPersistence.current.enqueue(nextSession.id, () => persistSessionModelSelection( + async ({ values, sessionId }) => { + if (process.env.NODE_ENV === "development" && uiPreview.current) { + return { found: true, error: null }; + } + try { + await patchSessionModel(sessionId, values.model_id); + return { found: true, error: null }; + } catch (error) { + return { + found: false, + error: error instanceof Error ? error.message : "模型选择暂时无法同步到云端。", + }; + } + }, + userId, + nextSession.id, + modelId, + )); + if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return; + modelSelectionVersions.current.delete(nextSession.id); + modelSyncFailures.current.delete(nextSession.id); + } catch (caught) { + if (modelSelectionVersions.current.get(nextSession.id) !== selectionVersion) return; + modelSelectionVersions.current.delete(nextSession.id); + modelSyncFailures.current.add(nextSession.id); + if (activeSessionIdRef.current === nextSession.id) { + setComposerNotice(`已在当前页面选择 ${selectedModel.label},但云端同步失败;再次选择当前模型即可重试。`); + } + setRequestError({ + sessionId: nextSession.id, + message: caught instanceof Error ? caught.message : "模型选择暂时无法同步到云端。", + }); + } + } + return { + updateSession, + persistSession, + ensureSessionMessages, + continueInNewChat, + renameSession, + deleteSession, + togglePinnedSession, + toggleArchivedSession, + shareSession, + startNewChat, + startConsultationAfterRectification, + selectSession, + selectSessionModel, + }; +} diff --git a/frontend/tests/chat-notice-and-scroll-contract.test.ts b/frontend/tests/chat-notice-and-scroll-contract.test.ts index bbfa3f6b..4252e637 100644 --- a/frontend/tests/chat-notice-and-scroll-contract.test.ts +++ b/frontend/tests/chat-notice-and-scroll-contract.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { noticeTone } from "../src/lib/chat-notice.ts"; import { nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts"; +import { homeSurface } from "./home-surface.ts"; const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8"); @@ -86,7 +87,7 @@ test("scrolls to the newest turn on intentional jumps", () => { assert.match(anchorSource, /const anchored = anchor\.key === resetKey \? anchor\.anchored : true/); // And: sending a question re-anchors before the optimistic turn renders. - const sendBlock = sourceBetween(pageSource, " updateSession(sessionId, () => userSession);", " setDraft(\"\");"); + const sendBlock = sourceBetween(homeSurface, " updateSession(sessionId, () => userSession);", " setDraft(\"\");"); assert.match(sendBlock, /conversationAnchor\.anchorToLatest\(\)/); }); diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts index c12747f9..55603071 100644 --- a/frontend/tests/chat-session-url.test.ts +++ b/frontend/tests/chat-session-url.test.ts @@ -150,6 +150,22 @@ test("user session switches push history; popstate reuses selectSession without assert.match(page, /window\.addEventListener\("popstate", onPopState\)/); }); +test("popstate to a missing session query reuses selectSession side effects for the default chat", () => { + const pop = sourceBetween( + page, + "applySessionPopStateRef.current =", + "async function selectSessionModel", + ); + // Former empty-id branch: `if (!requestedId) { setActiveSessionId(""); return; }` + // Back to `/` with a listed chat now runs the same selectSession side effects + // as a sidebar click, still tagged history so it does not push a second URL. + assert.match( + pop, + /if \(!requestedId\) \{\n if \(fallbackId\) \{\n sessionSelectionSource\.current = "history";\n selectSession\(fallbackId\);\n \} else \{\n setActiveSessionId\(""\);\n \}\n return;/, + ); + assert.equal((pop.match(/writeSessionUrl\([^)]*, "push"\)/g) ?? []).length, 0); +}); + test("creating and leaving a session keep the address bar in sync", () => { const startNewChat = sourceBetween(page, "async function startNewChat()", "function selectSession("); assert.match(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/); diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts index 684e7e9b..8fc45254 100644 --- a/frontend/tests/chat-stream-layout.test.ts +++ b/frontend/tests/chat-stream-layout.test.ts @@ -3,8 +3,7 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { chatMessageViews } from "../src/lib/chat-message-view.ts"; - -const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +import { homeSurface as pageSource } from "./home-surface.ts"; const transcriptSource = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8"); const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8"); diff --git a/frontend/tests/consultation-birth-time-mode.test.ts b/frontend/tests/consultation-birth-time-mode.test.ts index d820eeb0..810bbda7 100644 --- a/frontend/tests/consultation-birth-time-mode.test.ts +++ b/frontend/tests/consultation-birth-time-mode.test.ts @@ -11,6 +11,7 @@ import { } from "../src/lib/consultation-birth-time-mode.ts"; import { getGeneralJyotishAgent } from "../src/mastra/index.ts"; import type { ResolvedLanguageModel } from "../src/mastra/model.ts"; +import { homeSurface } from "./home-surface.ts"; test("general-no-birth-time is an explicit server mode that never runs a chart workflow", () => { const source = readFileSync(new URL("../src/lib/consultation-birth-time-mode.ts", import.meta.url), "utf8"); @@ -144,7 +145,7 @@ test("consult route validates mode before billing and general mode uses no chart }); test("homepage sends explicit modes and never routes an unverified minute through the retired questionnaire", () => { - const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const page = homeSurface; const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); assert.match(page, /consultationMode:/); diff --git a/frontend/tests/evidence-audit-panel.test.ts b/frontend/tests/evidence-audit-panel.test.ts index fe8436fe..1ae96f05 100644 --- a/frontend/tests/evidence-audit-panel.test.ts +++ b/frontend/tests/evidence-audit-panel.test.ts @@ -2,10 +2,11 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { homeSurface as pageSource } from "./home-surface.ts"; + const rowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8"); const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8"); const panelSource = readFileSync(new URL("../src/components/evidence-audit-panel.tsx", import.meta.url), "utf8"); -const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); test("assistant messages fold the user-facing Technique Audit Table and never remount the internal panel", () => { diff --git a/frontend/tests/home-surface.ts b/frontend/tests/home-surface.ts index 887f8628..d7b08166 100644 --- a/frontend/tests/home-surface.ts +++ b/frontend/tests/home-surface.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; const homeSurfaceFiles = [ "../src/app/page.tsx", @@ -12,6 +12,14 @@ const homeSurfaceFiles = [ "../src/components/starter-home.tsx", ] as const; -export const homeSurface = homeSurfaceFiles +const optionalHomeHookFiles = [ + "../src/hooks/use-session-management.ts", + "../src/hooks/use-consultation-run.ts", +] as const; + +export const homeSurface = [ + ...homeSurfaceFiles, + ...optionalHomeHookFiles.filter((relativePath) => existsSync(new URL(relativePath, import.meta.url))), +] .map((relativePath) => readFileSync(new URL(relativePath, import.meta.url), "utf8")) .join("\n"); diff --git a/frontend/tests/profile-state.test.ts b/frontend/tests/profile-state.test.ts index c1d0eb91..cde3e4f3 100644 --- a/frontend/tests/profile-state.test.ts +++ b/frontend/tests/profile-state.test.ts @@ -41,7 +41,7 @@ test("account refresh preserves an equivalent normalized profile instead of repl const refreshAccountStart = pageSource.indexOf("async function refreshAccount()"); const refreshAccountSource = pageSource.slice( refreshAccountStart, - pageSource.indexOf("function updateSession", refreshAccountStart), + pageSource.indexOf("function openAccountDialog", refreshAccountStart), ); assert.match( diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 50663c8c..667fa878 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -173,7 +173,7 @@ test("account rehydration normalizes persisted ISO birth dates before completene test("candidate acceptance refreshes the profile result without overwriting an open draft", () => { const refresh = page.slice( page.indexOf("async function refreshAccount"), - page.indexOf("function updateSession"), + page.indexOf("function openAccountDialog"), ); assert.match(refresh, /const nextProfile = readProfile\(latest\.profile\)/); assert.match( diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index fec3468f..b49de341 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; import { cssDeclarations } from "./css-contract-test-support.ts"; +import { homeSurface } from "./home-surface.ts"; const projectFile = (path: string) => new URL(`../${path}`, import.meta.url); const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8"); @@ -275,8 +276,7 @@ test("blocks the provider mobile Escape action behind layered account UI", () => }); test("keeps the page session selection callback free of request locks", () => { - const page = readProjectFile("src/app/page.tsx"); - const selectSession = page.match(/function selectSession\(sessionId: string\) \{([\s\S]*?)\n \}/); + const selectSession = homeSurface.match(/function selectSession\(sessionId: string\) \{([\s\S]*?)\n \}/); assert.ok(selectSession); assert.match(selectSession[1], /setActiveSessionId\(sessionId\)/); diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 3fceaacb..3fe04928 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -65,6 +65,10 @@ CORE_PYTEST_TARGETS = [ "tests/test_rectification_*.py", # This file regexes frontend source. Home-split and other page.tsx moves must keep it green. "tests/test_supabase_user_data_contract.py", + # Locks archive as PATCH archived_at, never HTTP DELETE (data-safety). + "tests/test_session_management_entrypoints.py", + # Pure source/SQL regex for the birth-time journey; no runtime services. + "tests/test_birth_time_journey_contract.py", ] RUNTIME_TRUTH_PYTEST_TARGETS = [ diff --git a/tests/test_birth_time_journey_contract.py b/tests/test_birth_time_journey_contract.py index 245628a3..5dc01b7b 100644 --- a/tests/test_birth_time_journey_contract.py +++ b/tests/test_birth_time_journey_contract.py @@ -126,18 +126,39 @@ def test_rectification_cases_are_owner_scoped_and_auditable() -> None: assert "grant delete" not in sql -def test_web_onboarding_uses_the_deterministic_free_journey() -> None: - page = "".join( +def _home_surface() -> str: + files = [ + FRONTEND / "src" / "app" / "page.tsx", + FRONTEND / "src" / "lib" / "home-types.ts", + FRONTEND / "src" / "lib" / "home-profile.ts", + FRONTEND / "src" / "lib" / "home-cloud-sync.ts", + FRONTEND / "src" / "components" / "birth-location-fields.tsx", + FRONTEND / "src" / "components" / "profile-fields.tsx", + FRONTEND / "src" / "components" / "onboarding-chat-message.tsx", + FRONTEND / "src" / "components" / "chart-library-panel.tsx", + FRONTEND / "src" / "components" / "starter-home.tsx", + FRONTEND / "src" / "hooks" / "use-session-management.ts", + FRONTEND / "src" / "hooks" / "use-consultation-run.ts", + ] + return "".join(path.read_text(encoding="utf-8") for path in files if path.exists()) + + +def _mastra_consultation_surface() -> str: + return "".join( path.read_text(encoding="utf-8") for path in ( - FRONTEND / "src" / "app" / "page.tsx", - FRONTEND / "src" / "lib" / "home-profile.ts", + FRONTEND / "src" / "mastra" / "index.ts", + FRONTEND / "src" / "mastra" / "consultation-workflow.ts", ) ) + + +def test_web_onboarding_uses_the_deterministic_free_journey() -> None: + page = _home_surface() route = ( FRONTEND / "src" / "app" / "api" / "birth-time-journey" / "route.ts" ).read_text(encoding="utf-8") - mastra = (FRONTEND / "src" / "mastra" / "index.ts").read_text(encoding="utf-8") + mastra = _mastra_consultation_surface() assert " str: + parts = [path.read_text(encoding="utf-8") for path in HOME_SURFACE_FILES] + parts.extend(path.read_text(encoding="utf-8") for path in OPTIONAL_HOME_HOOKS if path.exists()) + return "".join(parts) def test_chat_history_management_actions_are_exposed() -> None: - source = PAGE.read_text(encoding="utf-8") + STYLES.read_text(encoding="utf-8") + SESSION_ROW.read_text(encoding="utf-8") + source = _home_surface() + STYLES.read_text(encoding="utf-8") + SESSION_ROW.read_text(encoding="utf-8") for expected in ( "renameSession", "deleteSession", @@ -50,9 +73,15 @@ def test_chat_session_delete_is_server_controlled_and_granted() -> None: def test_archiving_never_calls_the_delete_endpoint() -> None: - source = PAGE.read_text(encoding="utf-8") + source = _home_surface() + write_contract = SESSION_WRITE_CONTRACT.read_text(encoding="utf-8") start = source.index("function toggleArchivedSession") end = source.index("async function shareSession", start) archive_action = source[start:end] - assert "setArchivedSessionIds" in archive_action - assert "/api/sessions/" not in archive_action + # Former tokens: setArchivedSessionIds and no `/api/sessions/` in the slice. + # Archive is now a metadata PATCH; the write helper uses PATCH for update. + assert 'writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update")' in archive_action + assert "archived_at: nextArchivedAt" in archive_action + assert 'method: "DELETE"' not in archive_action + assert "DELETE" not in archive_action + assert 'method: mode === "create" ? "POST" : "PATCH"' in write_contract diff --git a/tests/test_supabase_user_data_contract.py b/tests/test_supabase_user_data_contract.py index b583a893..adb12d0a 100644 --- a/tests/test_supabase_user_data_contract.py +++ b/tests/test_supabase_user_data_contract.py @@ -52,8 +52,16 @@ HOME_SURFACE_FILES = ( ) +OPTIONAL_HOME_HOOKS = ( + _FRONTEND_SRC / "hooks" / "use-session-management.ts", + _FRONTEND_SRC / "hooks" / "use-consultation-run.ts", +) + + def _home_surface() -> str: - return "".join(path.read_text(encoding="utf-8") for path in HOME_SURFACE_FILES) + parts = [path.read_text(encoding="utf-8") for path in HOME_SURFACE_FILES] + parts.extend(path.read_text(encoding="utf-8") for path in OPTIONAL_HOME_HOOKS if path.exists()) + return "".join(parts) SESSION_CREATE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "sessions" / "route.ts"